From df5d5a1850159ef2fdbf825f30ea97effcd58dcf Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Tue, 15 Dec 2015 16:30:38 +0100 Subject: [PATCH 001/722] wrapping pytest.main() call in the runtests.py; fixing the script exit code --- runtests.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/runtests.py b/runtests.py index b098d442c..852de9c22 100755 --- a/runtests.py +++ b/runtests.py @@ -1,4 +1,8 @@ #!/usr/bin/env python +import sys import pytest -pytest.main() + +# sys.exit() is required otherwise the wrapper exits +# with exit code 0, regardless the pytest.main() execution +sys.exit(pytest.main()) From 6c88b25d25ffdd99e9451e56fe077fc781fe4429 Mon Sep 17 00:00:00 2001 From: Doug Keen Date: Tue, 5 Jan 2016 12:16:40 -0800 Subject: [PATCH 002/722] Fix #340 by defaulting encoding var when request.encoding is None (which is a valid value, as documented: https://docs.djangoproject.com/en/1.9/ref/request-response/#django.http.HttpRequest.encoding) --- oauth2_provider/oauth2_validators.py | 3 ++- oauth2_provider/tests/test_oauth2_validators.py | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 25908d979..7cc6f3e18 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -7,6 +7,7 @@ from datetime import timedelta from django.utils import timezone +from django.conf import settings from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist from oauthlib.oauth2 import RequestValidator @@ -57,7 +58,7 @@ def _authenticate_basic_auth(self, request): return False try: - encoding = request.encoding + encoding = request.encoding or settings.DEFAULT_CHARSET or 'utf-8' except AttributeError: encoding = 'utf-8' diff --git a/oauth2_provider/tests/test_oauth2_validators.py b/oauth2_provider/tests/test_oauth2_validators.py index b18e4decf..851d2e70e 100644 --- a/oauth2_provider/tests/test_oauth2_validators.py +++ b/oauth2_provider/tests/test_oauth2_validators.py @@ -53,6 +53,12 @@ def test_authenticate_basic_auth(self): self.request.headers = {'HTTP_AUTHORIZATION': 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n'} self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_default_encoding(self): + self.request.encoding = None + # client_id:client_secret + self.request.headers = {'HTTP_AUTHORIZATION': 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n'} + self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_wrong_client_id(self): self.request.encoding = 'utf-8' # wrong_id:client_secret From 13ed73b8d1a7aa217153d80c4aa8b77d8841aa99 Mon Sep 17 00:00:00 2001 From: Bart Merenda Date: Tue, 15 Dec 2015 17:45:33 +0100 Subject: [PATCH 003/722] Removed unused imports --- oauth2_provider/compat.py | 2 +- oauth2_provider/compat_handlers.py | 1 + oauth2_provider/ext/rest_framework/__init__.py | 1 + oauth2_provider/management/commands/cleartokens.py | 2 +- oauth2_provider/tests/settings.py | 2 -- oauth2_provider/tests/test_application_views.py | 2 -- oauth2_provider/tests/test_authorization_code.py | 1 - oauth2_provider/tests/test_decorators.py | 1 - oauth2_provider/tests/test_implicit.py | 3 +-- oauth2_provider/tests/test_oauth2_backends.py | 1 - oauth2_provider/views/__init__.py | 1 + tox.ini | 1 - 12 files changed, 6 insertions(+), 12 deletions(-) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 3fca93610..d4c3fc231 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -2,7 +2,7 @@ The `compat` module provides support for backwards compatibility with older versions of django and python.. """ - +# flake8: noqa from __future__ import unicode_literals import django diff --git a/oauth2_provider/compat_handlers.py b/oauth2_provider/compat_handlers.py index 21859e80e..ce95a02eb 100644 --- a/oauth2_provider/compat_handlers.py +++ b/oauth2_provider/compat_handlers.py @@ -1,3 +1,4 @@ +# flake8: noqa # Django 1.9 drops the NullHandler since Python 2.7 includes it try: from logging import NullHandler diff --git a/oauth2_provider/ext/rest_framework/__init__.py b/oauth2_provider/ext/rest_framework/__init__.py index 00da0a1ce..bdc638818 100644 --- a/oauth2_provider/ext/rest_framework/__init__.py +++ b/oauth2_provider/ext/rest_framework/__init__.py @@ -1,2 +1,3 @@ +# flake8: noqa from .authentication import OAuth2Authentication from .permissions import TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope diff --git a/oauth2_provider/management/commands/cleartokens.py b/oauth2_provider/management/commands/cleartokens.py index 5b56d2bc1..48f70b822 100644 --- a/oauth2_provider/management/commands/cleartokens.py +++ b/oauth2_provider/management/commands/cleartokens.py @@ -1,4 +1,4 @@ -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from ...models import clear_expired diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index e144e8006..b7521bd4c 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -1,5 +1,3 @@ -import os - DEBUG = True TEMPLATE_DEBUG = DEBUG diff --git a/oauth2_provider/tests/test_application_views.py b/oauth2_provider/tests/test_application_views.py index ec94f6ab9..f5920c1ea 100644 --- a/oauth2_provider/tests/test_application_views.py +++ b/oauth2_provider/tests/test_application_views.py @@ -1,9 +1,7 @@ from __future__ import unicode_literals -import mock from django.core.urlresolvers import reverse from django.test import TestCase -from django.test.utils import override_settings from ..models import get_application_model from ..compat import get_user_model diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index cf87996a7..8ce8b8024 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -7,7 +7,6 @@ from django.test import TestCase, RequestFactory from django.core.urlresolvers import reverse -from django.test.utils import override_settings from django.utils import timezone from ..compat import urlparse, parse_qs, urlencode, get_user_model diff --git a/oauth2_provider/tests/test_decorators.py b/oauth2_provider/tests/test_decorators.py index b9e22bc93..babf574d1 100644 --- a/oauth2_provider/tests/test_decorators.py +++ b/oauth2_provider/tests/test_decorators.py @@ -1,4 +1,3 @@ -import json from datetime import timedelta from django.test import TestCase, RequestFactory diff --git a/oauth2_provider/tests/test_implicit.py b/oauth2_provider/tests/test_implicit.py index df038fd58..b3414ad7d 100644 --- a/oauth2_provider/tests/test_implicit.py +++ b/oauth2_provider/tests/test_implicit.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import mock from django.test import TestCase, RequestFactory from django.core.urlresolvers import reverse @@ -8,7 +7,7 @@ from ..compat import urlparse, parse_qs, urlencode, get_user_model from ..models import get_application_model from ..settings import oauth2_settings -from ..views import ProtectedResourceView, AuthorizationView +from ..views import ProtectedResourceView Application = get_application_model() diff --git a/oauth2_provider/tests/test_oauth2_backends.py b/oauth2_provider/tests/test_oauth2_backends.py index 399f9a4fd..5203e09cc 100644 --- a/oauth2_provider/tests/test_oauth2_backends.py +++ b/oauth2_provider/tests/test_oauth2_backends.py @@ -2,7 +2,6 @@ import mock from django.test import TestCase, RequestFactory -from django.test.utils import override_settings from ..backends import get_oauthlib_core from ..oauth2_backends import OAuthLibCore, JSONOAuthLibCore diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 257c86add..4f444f55d 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from .base import AuthorizationView, TokenView, RevokeTokenView from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ ApplicationDelete, ApplicationUpdate diff --git a/tox.ini b/tox.ini index 7a4bc12fe..e0560ef1f 100644 --- a/tox.ini +++ b/tox.ini @@ -37,5 +37,4 @@ commands = [flake8] max-line-length = 120 -ignore = F403,F401 exclude = docs,migrations,south_migrations,.tox From 53b80156333a5cc184f8d1be7d905fd352cc25cf Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 10 Mar 2016 15:26:07 +0100 Subject: [PATCH 004/722] removed django 1.6 compatibility settings --- oauth2_provider/tests/settings.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index b7521bd4c..1e38f8cc2 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -118,11 +118,3 @@ OAUTH2_PROVIDER = { '_SCOPES': ['example'] } - -import django - -if django.VERSION[:2] < (1, 6): - TEST_RUNNER = 'discover_runner.DiscoverRunner' - INSTALLED_APPS += ('discover_runner',) -else: - TEST_RUNNER = 'django.test.runner.DiscoverRunner' From 8201b81afe7eca0ee80453ccfd5bdbf550c25e12 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 10 Mar 2016 16:36:48 +0100 Subject: [PATCH 005/722] updated test matrix --- .travis.yml | 10 ++++------ tox.ini | 13 ++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/.travis.yml b/.travis.yml index b344a7538..ffc2ea3ee 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,7 @@ language: python -python: "2.7" +python: + - "3.5" + sudo: false env: @@ -18,14 +20,10 @@ env: - TOX_ENV=docs matrix: - # Python 3.5 not yet available on travis, watch this to see when it is. fast_finish: true - allow_failures: - - env: TOX_ENV=py35-django18 - - env: TOX_ENV=py35-django19 install: - - pip install tox + - pip install tox "virtualenv<14" - pip install coveralls script: diff --git a/tox.ini b/tox.ini index e0560ef1f..cd372938f 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,10 @@ testpaths=oauth2_provider [tox] envlist = - {py27}-django{17,18,19}, - {py32}-django{17,18}, - {py33}-django{17,18}, - {py34}-django{17,18,19}, + {py27}-django{18,19}, + {py32}-django{18}, + {py33}-django{18}, + {py34}-django{18,19}, {py35}-django{18,19}, docs, flake8 @@ -15,9 +15,8 @@ envlist = [testenv] commands=python runtests.py -q --cov oauth2_provider --cov-report= --cov-append deps = - django17: Django==1.7.11 - django18: Django==1.8.7 - django19: Django==1.9 + django18: Django==1.8.11 + django19: Django==1.9.4 coverage<4 -rrequirements/testing.txt From 6c5da583fd7c8fbf5e53beb8aa37dac1816e13d8 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 10 Mar 2016 16:40:44 +0100 Subject: [PATCH 006/722] removed django17 from .travis.yml --- .travis.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.travis.yml b/.travis.yml index ffc2ea3ee..8e318a6dc 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,14 +5,10 @@ python: sudo: false env: - - TOX_ENV=py27-django17 - TOX_ENV=py27-django18 - TOX_ENV=py27-django19 - - TOX_ENV=py32-django17 - TOX_ENV=py32-django18 - - TOX_ENV=py33-django17 - TOX_ENV=py33-django18 - - TOX_ENV=py34-django17 - TOX_ENV=py34-django18 - TOX_ENV=py34-django19 - TOX_ENV=py35-django18 From e8e7980e491c51a524ab8a99897d644b05dd231a Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 11 Mar 2016 15:02:23 +0100 Subject: [PATCH 007/722] removed old compatibility code for django < 1.8 --- oauth2_provider/backends.py | 4 ++- oauth2_provider/compat.py | 30 +------------------ oauth2_provider/models.py | 14 +++++---- .../application_confirm_delete.html | 3 +- .../oauth2_provider/application_detail.html | 3 +- .../oauth2_provider/application_form.html | 3 +- .../oauth2_provider/application_list.html | 3 +- .../application_registration_form.html | 3 +- .../oauth2_provider/authorized-tokens.html | 1 - oauth2_provider/templatetags/__init__.py | 0 oauth2_provider/templatetags/compat.py | 10 ------- .../tests/test_application_views.py | 3 +- oauth2_provider/tests/test_auth_backends.py | 6 ++-- .../tests/test_authorization_code.py | 5 ++-- .../tests/test_client_credential.py | 2 +- oauth2_provider/tests/test_decorators.py | 2 +- oauth2_provider/tests/test_implicit.py | 6 ++-- oauth2_provider/tests/test_models.py | 9 ++---- .../tests/test_oauth2_validators.py | 2 +- oauth2_provider/tests/test_password.py | 4 +-- oauth2_provider/tests/test_rest_framework.py | 8 ++--- oauth2_provider/tests/test_scopes.py | 6 ++-- .../tests/test_token_revocation.py | 5 ++-- oauth2_provider/tests/test_token_view.py | 3 +- 24 files changed, 47 insertions(+), 88 deletions(-) delete mode 100644 oauth2_provider/templatetags/__init__.py delete mode 100644 oauth2_provider/templatetags/compat.py diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index 3578fa0c5..b2e706b54 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -1,6 +1,8 @@ -from .compat import get_user_model +from django.contrib.auth import get_user_model + from .oauth2_backends import get_oauthlib_core + UserModel = get_user_model() OAuthLibCore = get_oauthlib_core() diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index d4c3fc231..f8888505f 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -1,13 +1,10 @@ """ The `compat` module provides support for backwards compatibility with older -versions of django and python.. +versions of django and python. """ # flake8: noqa from __future__ import unicode_literals -import django -from django.conf import settings - # urlparse in python3 has been renamed to urllib.parse try: from urlparse import urlparse, parse_qs, parse_qsl, urlunparse @@ -18,28 +15,3 @@ from urllib import urlencode, unquote_plus except ImportError: from urllib.parse import urlencode, unquote_plus - -# Django 1.5 add support for custom auth user model -if django.VERSION >= (1, 5): - AUTH_USER_MODEL = settings.AUTH_USER_MODEL -else: - AUTH_USER_MODEL = 'auth.User' - -try: - from django.contrib.auth import get_user_model -except ImportError: - from django.contrib.auth.models import User - get_user_model = lambda: User - -# Django's new application loading system -try: - from django.apps import apps - get_model = apps.get_model -except ImportError: - from django.db.models import get_model - -# Django 1.5 add the support of context variables for the url template tag -if django.VERSION >= (1, 5): - from django.template.defaulttags import url -else: - from django.templatetags.future import url diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index fd3cdf40d..f127f88cd 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -2,6 +2,8 @@ from datetime import timedelta +from django.apps import apps +from django.conf import settings from django.core.urlresolvers import reverse from django.db import models, transaction from django.utils import timezone @@ -11,7 +13,7 @@ from django.core.exceptions import ImproperlyConfigured from .settings import oauth2_settings -from .compat import AUTH_USER_MODEL, parse_qsl, urlparse, get_model +from .compat import parse_qsl, urlparse from .generators import generate_client_secret, generate_client_id from .validators import validate_uris @@ -57,7 +59,7 @@ class AbstractApplication(models.Model): client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) - user = models.ForeignKey(AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s") + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s") help_text = _("Allowed URIs list, space separated") redirect_uris = models.TextField(help_text=help_text, validators=[validate_uris], blank=True) @@ -146,7 +148,7 @@ class Grant(models.Model): * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional """ - user = models.ForeignKey(AUTH_USER_MODEL) + user = models.ForeignKey(settings.AUTH_USER_MODEL) code = models.CharField(max_length=255, db_index=True) # code comes from oauthlib application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) expires = models.DateTimeField() @@ -183,7 +185,7 @@ class AccessToken(models.Model): * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ - user = models.ForeignKey(AUTH_USER_MODEL, blank=True, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) token = models.CharField(max_length=255, db_index=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) expires = models.DateTimeField() @@ -252,7 +254,7 @@ class RefreshToken(models.Model): * :attr:`access_token` AccessToken instance this refresh token is bounded to """ - user = models.ForeignKey(AUTH_USER_MODEL) + user = models.ForeignKey(settings.AUTH_USER_MODEL) token = models.CharField(max_length=255, db_index=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) access_token = models.OneToOneField(AccessToken, @@ -276,7 +278,7 @@ def get_application_model(): except ValueError: e = "APPLICATION_MODEL must be of the form 'app_label.model_name'" raise ImproperlyConfigured(e) - app_model = get_model(app_label, model_name) + app_model = apps.get_model(app_label, model_name) if app_model is None: e = "APPLICATION_MODEL refers to model {0} that has not been installed" raise ImproperlyConfigured(e.format(oauth2_settings.APPLICATION_MODEL)) diff --git a/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html index b1d944f9e..35b961a0b 100644 --- a/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html +++ b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html @@ -1,7 +1,6 @@ {% extends "oauth2_provider/base.html" %} {% load i18n %} -{% load url from compat %} {% block content %}

{% trans "Are you sure to delete the application" %} {{ application.name }}?

@@ -16,4 +15,4 @@

{% trans "Are you sure to delete the applicatio

-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 833f9a581..736dc4605 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -1,7 +1,6 @@ {% extends "oauth2_provider/base.html" %} {% load i18n %} -{% load url from compat %} {% block content %}

{{ application.name }}

@@ -39,4 +38,4 @@

{{ application.name }}

{% trans "Delete" %}
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html index 5c08ff0aa..43926e134 100644 --- a/oauth2_provider/templates/oauth2_provider/application_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -1,7 +1,6 @@ {% extends "oauth2_provider/base.html" %} {% load i18n %} -{% load url from compat %} {% block content %}
@@ -40,4 +39,4 @@

-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index cb7c7c4eb..34b299a6c 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -1,7 +1,6 @@ {% extends "oauth2_provider/base.html" %} {% load i18n %} -{% load url from compat %} {% block content %}

{% trans "Your applications" %}

@@ -17,4 +16,4 @@

{% trans "Your applications" %}

{% trans "No applications defined" %}. {% trans "Click here" %} {% trans "if you want to register a new one" %}

{% endif %}
-{% endblock content %} \ No newline at end of file +{% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_registration_form.html b/oauth2_provider/templates/oauth2_provider/application_registration_form.html index 69bebb283..c22eca9ef 100644 --- a/oauth2_provider/templates/oauth2_provider/application_registration_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_registration_form.html @@ -1,10 +1,9 @@ {% extends "oauth2_provider/application_form.html" %} {% load i18n %} -{% load url from compat %} {% block app-form-title %}{% trans "Register a new application" %}{% endblock app-form-title %} {% block app-form-action-url %}{% url 'oauth2_provider:register' %}{% endblock app-form-action-url %} -{% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %} \ No newline at end of file +{% block app-form-back-url %}{% url "oauth2_provider:list" %}"{% endblock app-form-back-url %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html index f25069e61..2c6a028a8 100644 --- a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html +++ b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html @@ -1,7 +1,6 @@ {% extends "oauth2_provider/base.html" %} {% load i18n %} -{% load url from compat %} {% block content %}

{% trans "Tokens" %}

diff --git a/oauth2_provider/templatetags/__init__.py b/oauth2_provider/templatetags/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/oauth2_provider/templatetags/compat.py b/oauth2_provider/templatetags/compat.py deleted file mode 100644 index 8fbc8b0c4..000000000 --- a/oauth2_provider/templatetags/compat.py +++ /dev/null @@ -1,10 +0,0 @@ -from django import template - -from ..compat import url as url_compat - -register = template.Library() - - -@register.tag -def url(parser, token): - return url_compat(parser, token) diff --git a/oauth2_provider/tests/test_application_views.py b/oauth2_provider/tests/test_application_views.py index f5920c1ea..8cf22b9a8 100644 --- a/oauth2_provider/tests/test_application_views.py +++ b/oauth2_provider/tests/test_application_views.py @@ -1,10 +1,11 @@ from __future__ import unicode_literals +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.test import TestCase from ..models import get_application_model -from ..compat import get_user_model + Application = get_application_model() UserModel = get_user_model() diff --git a/oauth2_provider/tests/test_auth_backends.py b/oauth2_provider/tests/test_auth_backends.py index 53efc224d..d5abb1935 100644 --- a/oauth2_provider/tests/test_auth_backends.py +++ b/oauth2_provider/tests/test_auth_backends.py @@ -1,11 +1,11 @@ +from django.conf.global_settings import MIDDLEWARE_CLASSES +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser from django.test import TestCase, RequestFactory from django.test.utils import override_settings -from django.contrib.auth.models import AnonymousUser from django.utils.timezone import now, timedelta -from django.conf.global_settings import MIDDLEWARE_CLASSES from django.http import HttpResponse -from ..compat import get_user_model from ..models import get_application_model from ..models import AccessToken from ..backends import OAuth2Backend diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index 8ce8b8024..e9c7aae2f 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -5,11 +5,12 @@ import datetime import mock -from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import urlparse, parse_qs, urlencode, get_user_model +from ..compat import urlparse, parse_qs, urlencode from ..models import get_application_model, Grant, AccessToken, RefreshToken from ..settings import oauth2_settings from ..views import ProtectedResourceView diff --git a/oauth2_provider/tests/test_client_credential.py b/oauth2_provider/tests/test_client_credential.py index a0462ca8d..515cac59e 100644 --- a/oauth2_provider/tests/test_client_credential.py +++ b/oauth2_provider/tests/test_client_credential.py @@ -8,6 +8,7 @@ import urllib from django.core.urlresolvers import reverse +from django.contrib.auth import get_user_model from django.test import TestCase, RequestFactory from django.views.generic import View @@ -19,7 +20,6 @@ from ..settings import oauth2_settings from ..views import ProtectedResourceView from ..views.mixins import OAuthLibMixin -from ..compat import get_user_model from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_decorators.py b/oauth2_provider/tests/test_decorators.py index babf574d1..294497e1f 100644 --- a/oauth2_provider/tests/test_decorators.py +++ b/oauth2_provider/tests/test_decorators.py @@ -1,12 +1,12 @@ from datetime import timedelta +from django.contrib.auth import get_user_model from django.test import TestCase, RequestFactory from django.utils import timezone from ..decorators import protected_resource, rw_protected_resource from ..settings import oauth2_settings from ..models import get_application_model, AccessToken -from ..compat import get_user_model from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_implicit.py b/oauth2_provider/tests/test_implicit.py index b3414ad7d..25493ec61 100644 --- a/oauth2_provider/tests/test_implicit.py +++ b/oauth2_provider/tests/test_implicit.py @@ -1,10 +1,10 @@ from __future__ import unicode_literals - -from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory -from ..compat import urlparse, parse_qs, urlencode, get_user_model +from ..compat import urlparse, parse_qs, urlencode from ..models import get_application_model from ..settings import oauth2_settings from ..views import ProtectedResourceView diff --git a/oauth2_provider/tests/test_models.py b/oauth2_provider/tests/test_models.py index 563000207..89bad276f 100644 --- a/oauth2_provider/tests/test_models.py +++ b/oauth2_provider/tests/test_models.py @@ -1,18 +1,15 @@ from __future__ import unicode_literals -try: - from unittest import skipIf -except ImportError: - from django.utils.unittest.case import skipIf +from unittest import skipIf import django +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError from django.test import TestCase from django.test.utils import override_settings -from django.core.exceptions import ValidationError from django.utils import timezone from ..models import get_application_model, Grant, AccessToken, RefreshToken -from ..compat import get_user_model Application = get_application_model() diff --git a/oauth2_provider/tests/test_oauth2_validators.py b/oauth2_provider/tests/test_oauth2_validators.py index 851d2e70e..e4f7e1a82 100644 --- a/oauth2_provider/tests/test_oauth2_validators.py +++ b/oauth2_provider/tests/test_oauth2_validators.py @@ -1,3 +1,4 @@ +from django.contrib.auth import get_user_model from django.test import TestCase import mock @@ -5,7 +6,6 @@ from ..oauth2_validators import OAuth2Validator from ..models import get_application_model -from ..compat import get_user_model UserModel = get_user_model() AppModel = get_application_model() diff --git a/oauth2_provider/tests/test_password.py b/oauth2_provider/tests/test_password.py index a4fbdf424..72db69f37 100644 --- a/oauth2_provider/tests/test_password.py +++ b/oauth2_provider/tests/test_password.py @@ -2,13 +2,13 @@ import json -from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory from ..models import get_application_model from ..settings import oauth2_settings from ..views import ProtectedResourceView -from ..compat import get_user_model from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_rest_framework.py b/oauth2_provider/tests/test_rest_framework.py index 83c9dbe12..10ebdc403 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/oauth2_provider/tests/test_rest_framework.py @@ -1,19 +1,15 @@ +import unittest from datetime import timedelta from django.conf.urls import patterns, url, include +from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase from django.utils import timezone -try: - from django.utils import unittest -except ImportError: - import unittest - from .test_utils import TestCaseUtils from ..models import AccessToken, get_application_model from ..settings import oauth2_settings -from ..compat import get_user_model Application = get_application_model() diff --git a/oauth2_provider/tests/test_scopes.py b/oauth2_provider/tests/test_scopes.py index 36ef1032d..dfc1fdfd1 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/oauth2_provider/tests/test_scopes.py @@ -2,16 +2,18 @@ import json -from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory from .test_utils import TestCaseUtils -from ..compat import urlparse, parse_qs, get_user_model, urlencode +from ..compat import urlparse, parse_qs, urlencode from ..models import get_application_model, Grant, AccessToken from ..settings import oauth2_settings from ..views import ScopedProtectedResourceView, ReadWriteScopedResourceView + Application = get_application_model() UserModel = get_user_model() diff --git a/oauth2_provider/tests/test_token_revocation.py b/oauth2_provider/tests/test_token_revocation.py index ce8024fa9..868a3aa3b 100644 --- a/oauth2_provider/tests/test_token_revocation.py +++ b/oauth2_provider/tests/test_token_revocation.py @@ -2,11 +2,12 @@ import datetime -from django.test import TestCase, RequestFactory +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse +from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import urlencode, get_user_model +from ..compat import urlencode from ..models import get_application_model, AccessToken, RefreshToken from ..settings import oauth2_settings diff --git a/oauth2_provider/tests/test_token_view.py b/oauth2_provider/tests/test_token_view.py index 7e02a32b2..30c3fa020 100644 --- a/oauth2_provider/tests/test_token_view.py +++ b/oauth2_provider/tests/test_token_view.py @@ -2,12 +2,13 @@ import datetime +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import timezone from ..models import get_application_model, AccessToken -from ..compat import get_user_model + Application = get_application_model() UserModel = get_user_model() From 98b078db1e8bbebb6054f5eaece26fe35f778309 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 11 Mar 2016 15:29:26 +0100 Subject: [PATCH 008/722] removed south_migrations since support for django<1.8 was dropped --- .../south_migrations/0001_initial.py | 159 ------------------ .../south_migrations/0002_adding_indexes.py | 119 ------------- ...p_authorization__chg_field_accesstoken_.py | 121 ------------- oauth2_provider/south_migrations/__init__.py | 0 4 files changed, 399 deletions(-) delete mode 100644 oauth2_provider/south_migrations/0001_initial.py delete mode 100644 oauth2_provider/south_migrations/0002_adding_indexes.py delete mode 100644 oauth2_provider/south_migrations/0003_auto__add_field_application_skip_authorization__chg_field_accesstoken_.py delete mode 100644 oauth2_provider/south_migrations/__init__.py diff --git a/oauth2_provider/south_migrations/0001_initial.py b/oauth2_provider/south_migrations/0001_initial.py deleted file mode 100644 index e42b6b054..000000000 --- a/oauth2_provider/south_migrations/0001_initial.py +++ /dev/null @@ -1,159 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -try: - from django.contrib.auth import get_user_model -except ImportError: # django < 1.5 - from django.contrib.auth.models import User -else: - User = get_user_model() - -from oauth2_provider.models import get_application_model -ApplicationModel = get_application_model() - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding model 'Application' - db.create_table(u'oauth2_provider_application', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('client_id', self.gf('django.db.models.fields.CharField')(default='284250a821f74df67cb50b6c2b7fc95d39d0e4a9', unique=True, max_length=100)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (User._meta.app_label, User._meta.object_name)])), - ('redirect_uris', self.gf('django.db.models.fields.TextField')(blank=True)), - ('client_type', self.gf('django.db.models.fields.CharField')(max_length=32)), - ('authorization_grant_type', self.gf('django.db.models.fields.CharField')(max_length=32)), - ('client_secret', self.gf('django.db.models.fields.CharField')(default='89288b8343edef095b5fee98b4def28409cf4e064fcd26b00c555f51d8fdabfcaedbae8b9d6739080cf27d216e13cc85133d794c9cc1018e0d116c951f0b865e', max_length=255, blank=True)), - ('name', self.gf('django.db.models.fields.CharField')(max_length=255, blank=True)), - )) - db.send_create_signal(u'oauth2_provider', ['Application']) - - # Adding model 'Grant' - db.create_table(u'oauth2_provider_grant', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (User._meta.app_label, User._meta.object_name)])), - ('code', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('application', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)])), - ('expires', self.gf('django.db.models.fields.DateTimeField')()), - ('redirect_uri', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('scope', self.gf('django.db.models.fields.TextField')(blank=True)), - )) - db.send_create_signal(u'oauth2_provider', ['Grant']) - - # Adding model 'AccessToken' - db.create_table(u'oauth2_provider_accesstoken', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (User._meta.app_label, User._meta.object_name)])), - ('token', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('application', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)])), - ('expires', self.gf('django.db.models.fields.DateTimeField')()), - ('scope', self.gf('django.db.models.fields.TextField')(blank=True)), - )) - db.send_create_signal(u'oauth2_provider', ['AccessToken']) - - # Adding model 'RefreshToken' - db.create_table(u'oauth2_provider_refreshtoken', ( - (u'id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), - ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (User._meta.app_label, User._meta.object_name)])), - ('token', self.gf('django.db.models.fields.CharField')(max_length=255)), - ('application', self.gf('django.db.models.fields.related.ForeignKey')(to=orm["%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)])), - ('access_token', self.gf('django.db.models.fields.related.OneToOneField')(related_name='refresh_token', unique=True, to=orm['oauth2_provider.AccessToken'])), - )) - db.send_create_signal(u'oauth2_provider', ['RefreshToken']) - - - def backwards(self, orm): - # Deleting model 'Application' - db.delete_table(u'oauth2_provider_application') - - # Deleting model 'Grant' - db.delete_table(u'oauth2_provider_grant') - - # Deleting model 'AccessToken' - db.delete_table(u'oauth2_provider_accesstoken') - - # Deleting model 'RefreshToken' - db.delete_table(u'oauth2_provider_refreshtoken') - - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u"%s.%s" % (User._meta.app_label, User._meta.object_name): { - 'Meta': {'object_name': User.__name__}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'oauth2_provider.accesstoken': { - 'Meta': {'object_name': 'AccessToken'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u"%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name): { - 'Meta': {'object_name': ApplicationModel.__name__}, - 'authorization_grant_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - 'client_id': ('django.db.models.fields.CharField', [], {'default': "'30f17d266183cd455bc57ce8548a439db3491353'", 'unique': 'True', 'max_length': '100'}), - 'client_secret': ('django.db.models.fields.CharField', [], {'default': "'18e68df61ad8e1af355644ddf6a636b269b6309aafbd2a34d4f5ed6c5562e44c0792c5b2441571e85cbf8a85249dca5537dedb6fd6f60e134f4a60f3865c8395'", 'max_length': '255', 'blank': 'True'}), - 'client_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'redirect_uris': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.grant': { - 'Meta': {'object_name': 'Grant'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'code': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'redirect_uri': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.refreshtoken': { - 'Meta': {'object_name': 'RefreshToken'}, - 'access_token': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'refresh_token'", 'unique': 'True', 'to': u"orm['oauth2_provider.AccessToken']"}), - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - } - } - - complete_apps = ['oauth2_provider'] \ No newline at end of file diff --git a/oauth2_provider/south_migrations/0002_adding_indexes.py b/oauth2_provider/south_migrations/0002_adding_indexes.py deleted file mode 100644 index 6c65c86ab..000000000 --- a/oauth2_provider/south_migrations/0002_adding_indexes.py +++ /dev/null @@ -1,119 +0,0 @@ -# -*- coding: utf-8 -*- -import datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - -try: - from django.contrib.auth import get_user_model -except ImportError: # django < 1.5 - from django.contrib.auth.models import User -else: - User = get_user_model() - -from oauth2_provider.models import get_application_model -ApplicationModel = get_application_model() - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding index on 'RefreshToken', fields ['token'] - db.create_index(u'oauth2_provider_refreshtoken', ['token']) - - # Adding index on 'Grant', fields ['code'] - db.create_index(u'oauth2_provider_grant', ['code']) - - # Adding index on 'AccessToken', fields ['token'] - db.create_index(u'oauth2_provider_accesstoken', ['token']) - - - def backwards(self, orm): - # Removing index on 'AccessToken', fields ['token'] - db.delete_index(u'oauth2_provider_accesstoken', ['token']) - - # Removing index on 'Grant', fields ['code'] - db.delete_index(u'oauth2_provider_grant', ['code']) - - # Removing index on 'RefreshToken', fields ['token'] - db.delete_index(u'oauth2_provider_refreshtoken', ['token']) - - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u"%s.%s" % (User._meta.app_label, User._meta.object_name): { - 'Meta': {'object_name': User.__name__}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'oauth2_provider.accesstoken': { - 'Meta': {'object_name': 'AccessToken'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u"%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name): { - 'Meta': {'object_name': ApplicationModel.__name__}, - 'authorization_grant_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - 'client_id': ('django.db.models.fields.CharField', [], {'default': "'30f17d266183cd455bc57ce8548a439db3491353'", 'unique': 'True', 'max_length': '100'}), - 'client_secret': ('django.db.models.fields.CharField', [], {'default': "'18e68df61ad8e1af355644ddf6a636b269b6309aafbd2a34d4f5ed6c5562e44c0792c5b2441571e85cbf8a85249dca5537dedb6fd6f60e134f4a60f3865c8395'", 'max_length': '255', 'blank': 'True'}), - 'client_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'redirect_uris': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.grant': { - 'Meta': {'object_name': 'Grant'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'code': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'redirect_uri': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.refreshtoken': { - 'Meta': {'object_name': 'RefreshToken'}, - 'access_token': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'refresh_token'", 'unique': 'True', 'to': u"orm['oauth2_provider.AccessToken']"}), - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (User._meta.app_label, User._meta.object_name)}) - } - } - - complete_apps = ['oauth2_provider'] diff --git a/oauth2_provider/south_migrations/0003_auto__add_field_application_skip_authorization__chg_field_accesstoken_.py b/oauth2_provider/south_migrations/0003_auto__add_field_application_skip_authorization__chg_field_accesstoken_.py deleted file mode 100644 index 85f9d8a79..000000000 --- a/oauth2_provider/south_migrations/0003_auto__add_field_application_skip_authorization__chg_field_accesstoken_.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -from south.utils import datetime_utils as datetime -from south.db import db -from south.v2 import SchemaMigration -from django.db import models - - -try: - from django.contrib.auth import get_user_model -except ImportError: # django < 1.5 - from django.contrib.auth.models import User -else: - User = get_user_model() - -from oauth2_provider.models import get_application_model -ApplicationModel = get_application_model() - - -class Migration(SchemaMigration): - - def forwards(self, orm): - # Adding field 'Application.skip_authorization' - db.add_column(u'oauth2_provider_application', 'skip_authorization', - self.gf('django.db.models.fields.BooleanField')(default=False), - keep_default=False) - - - # Changing field 'AccessToken.user' - db.alter_column(u'oauth2_provider_accesstoken', 'user_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['%s.%s' % (User._meta.app_label, User._meta.object_name)], null=True)) - - def backwards(self, orm): - # Deleting field 'Application.skip_authorization' - db.delete_column(u'oauth2_provider_application', 'skip_authorization') - - - # User chose to not deal with backwards NULL issues for 'AccessToken.user' - raise RuntimeError("Cannot reverse this migration. 'AccessToken.user' and its values cannot be restored.") - - # The following code is provided here to aid in writing a correct migration - # Changing field 'AccessToken.user' - db.alter_column(u'oauth2_provider_accesstoken', 'user_id', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['%s.%s' % (User._meta.app_label, User._meta.object_name)])) - - models = { - u'auth.group': { - 'Meta': {'object_name': 'Group'}, - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), - 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) - }, - u'auth.permission': { - 'Meta': {'ordering': "(u'content_type__app_label', u'content_type__model', u'codename')", 'unique_together': "((u'content_type', u'codename'),)", 'object_name': 'Permission'}, - 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['contenttypes.ContentType']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) - }, - u'%s.%s' % (User._meta.app_label, User._meta.object_name): { - 'Meta': {'object_name': User.__name__}, - 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), - 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), - 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), - 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), - 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), - 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}), - 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) - }, - u'contenttypes.contenttype': { - 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, - 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) - }, - u'oauth2_provider.accesstoken': { - 'Meta': {'object_name': 'AccessToken'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']" % (User._meta.app_label, User._meta.object_name), 'null': 'True', 'blank': 'True'}) - }, - u"%s.%s" % (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name): { - 'Meta': {'object_name': ApplicationModel.__name__}, - 'authorization_grant_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - 'client_id': ('django.db.models.fields.CharField', [], {'default': "u'amXbsy974anVL3xgzY2dczL8SRMSXA5awkXyjtsY'", 'unique': 'True', 'max_length': '100', 'db_index': 'True'}), - 'client_secret': ('django.db.models.fields.CharField', [], {'default': "u'trXjdJB8EO7HPsZcPswIT1l0Zdg3W3AWDxXvh5Jj9rON2MAoRT6YVDSHqKFB76rIgD9X9YBxoY7jjT4Mj12UHc2BjCCXJI4nzx4qwEwoyZ7l6N88xiHaM6J5qXeWJ6e3'", 'max_length': '255', 'db_index': 'True', 'blank': 'True'}), - 'client_type': ('django.db.models.fields.CharField', [], {'max_length': '32'}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), - 'redirect_uris': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'skip_authorization': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']" % (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.grant': { - 'Meta': {'object_name': 'Grant'}, - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - 'code': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'expires': ('django.db.models.fields.DateTimeField', [], {}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'redirect_uri': ('django.db.models.fields.CharField', [], {'max_length': '255'}), - 'scope': ('django.db.models.fields.TextField', [], {'blank': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']" % (User._meta.app_label, User._meta.object_name)}) - }, - u'oauth2_provider.refreshtoken': { - 'Meta': {'object_name': 'RefreshToken'}, - 'access_token': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "u'refresh_token'", 'unique': 'True', 'to': u"orm['oauth2_provider.AccessToken']"}), - 'application': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']"% (ApplicationModel._meta.app_label, ApplicationModel._meta.object_name)}), - u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'token': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['%s.%s']" % (User._meta.app_label, User._meta.object_name)}) - } -} - -complete_apps = ['oauth2_provider'] diff --git a/oauth2_provider/south_migrations/__init__.py b/oauth2_provider/south_migrations/__init__.py deleted file mode 100644 index e69de29bb..000000000 From b751b080d7dc3d2e0d128b75a5a68c4a985d6e20 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 11 Mar 2016 15:47:46 +0100 Subject: [PATCH 009/722] removed django1.4 specific code --- oauth2_provider/models.py | 6 ++---- oauth2_provider/tests/test_mixins.py | 5 ----- oauth2_provider/tests/test_models.py | 3 --- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index f127f88cd..eaf73f9ae 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -126,10 +126,8 @@ def __str__(self): class Application(AbstractApplication): - pass - -# Add swappable like this to not break django 1.4 compatibility -Application._meta.swappable = 'OAUTH2_PROVIDER_APPLICATION_MODEL' + class Meta(AbstractApplication.Meta): + swappable = 'OAUTH2_PROVIDER_APPLICATION_MODEL' @python_2_unicode_compatible diff --git a/oauth2_provider/tests/test_mixins.py b/oauth2_provider/tests/test_mixins.py index 4cb6f203d..97695a526 100644 --- a/oauth2_provider/tests/test_mixins.py +++ b/oauth2_provider/tests/test_mixins.py @@ -3,7 +3,6 @@ from django.core.exceptions import ImproperlyConfigured from django.views.generic import View from django.test import TestCase, RequestFactory -from django.http import HttpResponse from oauthlib.oauth2 import Server @@ -100,10 +99,6 @@ class TestView(ProtectedResourceMixin, View): server_class = Server validator_class = OAuth2Validator - def options(self, request, *args, **kwargs): - """Django 1.4 doesn't provide a default options method""" - return HttpResponse() - request = self.request_factory.options("/fake-req") view = TestView.as_view() response = view(request) diff --git a/oauth2_provider/tests/test_models.py b/oauth2_provider/tests/test_models.py index 89bad276f..7bc191c2b 100644 --- a/oauth2_provider/tests/test_models.py +++ b/oauth2_provider/tests/test_models.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from unittest import skipIf - import django from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError @@ -109,7 +107,6 @@ def test_scopes_property(self): self.assertEqual(access_token2.scopes, {'write': 'Writing scope'}) -@skipIf(django.VERSION < (1, 5), "Behavior is broken on 1.4 and there is no solution") @override_settings(OAUTH2_PROVIDER_APPLICATION_MODEL='tests.TestApplication') class TestCustomApplicationModel(TestCase): def setUp(self): From 9b28c9e20cedeb8bc93c02326535151f6e670111 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 11 Mar 2016 15:48:40 +0100 Subject: [PATCH 010/722] fixed AppsRegistryNotReady errors in docs build --- docs/conf.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index 84880a19a..d9529ec62 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,9 +19,12 @@ here = os.path.abspath(os.path.dirname(__file__)) sys.path.insert(0, here) sys.path.insert(0, os.path.dirname(here)) -sys.path.insert(0, os.path.join(os.path.dirname(here), 'example')) os.environ['DJANGO_SETTINGS_MODULE'] = 'oauth2_provider.tests.settings' + +import django +django.setup() + import oauth2_provider # -- General configuration ----------------------------------------------------- From 167c6c3d54b06433ec6226171a781e475968b1c7 Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Thu, 17 Mar 2016 14:10:42 +0900 Subject: [PATCH 011/722] relax user constraint on AbstractApplication model --- oauth2_provider/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index eaf73f9ae..f87395002 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -59,7 +59,9 @@ class AbstractApplication(models.Model): client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s") + user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", + null=True, blank=True) + help_text = _("Allowed URIs list, space separated") redirect_uris = models.TextField(help_text=help_text, validators=[validate_uris], blank=True) From b2cbf2916c5cffb9cb756f3824657b19d195ede9 Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Thu, 17 Mar 2016 14:11:01 +0900 Subject: [PATCH 012/722] relax user constraint on AbstractApplication model --- .../migrations/0003_auto_20160316_1503.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 oauth2_provider/migrations/0003_auto_20160316_1503.py diff --git a/oauth2_provider/migrations/0003_auto_20160316_1503.py b/oauth2_provider/migrations/0003_auto_20160316_1503.py new file mode 100644 index 000000000..5dd05ddff --- /dev/null +++ b/oauth2_provider/migrations/0003_auto_20160316_1503.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models +from django.conf import settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0002_08_updates'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='user', + field=models.ForeignKey(related_name='oauth2_provider_application', blank=True, to=settings.AUTH_USER_MODEL, null=True), + ), + ] From 3074e8c3f62ab7a3ccc3db2bb426414402ba041a Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Fri, 18 Mar 2016 00:19:15 +0900 Subject: [PATCH 013/722] Update changelog --- AUTHORS | 1 + README.rst | 5 +++++ docs/changelog.rst | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/AUTHORS b/AUTHORS index 7b2c6b83f..3a91ba821 100644 --- a/AUTHORS +++ b/AUTHORS @@ -16,3 +16,4 @@ Hiroki Kiyohara Diego Garcia Bas van Oostveen Bart Merenda +Paul Oswald diff --git a/README.rst b/README.rst index 5eed1ba76..4b31264ed 100644 --- a/README.rst +++ b/README.rst @@ -87,6 +87,11 @@ Roadmap / Todo list (help wanted) Changelog --------- +Development +~~~~~~~~~~~ + +* #357: Support multiple-user clients by allowing User to be NULL for Applications + 0.10.0 [2015-12-14] ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/changelog.rst b/docs/changelog.rst index a9a4e5a24..1b24e4dfe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,12 @@ Changelog ========= +Development +~~~~~~~~~~~ + +* #357: Support multiple-user clients by allowing User to be NULL for Applications + + 0.10.0 [2015-12-14] ------------------ From 7cba4a5baa49005d449213b9738f427ceeb6af69 Mon Sep 17 00:00:00 2001 From: Emanuele Palazzetti Date: Mon, 21 Mar 2016 16:26:40 +0100 Subject: [PATCH 014/722] added more information about how to report security issues --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index 4b31264ed..f70ca4d15 100644 --- a/README.rst +++ b/README.rst @@ -34,6 +34,16 @@ Contributing We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the guidelines `_ and submit a PR. +Reporting security issues +------------------------- + +If you believe you've found an issue with security implications, please send a detailed description via email to **security@evonove.it**. +Mail sent to that address reaches the Django OAuth Toolkit core team, who can solve (or forward) the security issue as soon as possible. After +our acknowledge, we may decide to open a public discussion in our mailing list or issues tracker. + +Once you’ve submitted an issue via email, you should receive a response from the core team within 48 hours, and depending on the action to be +taken, you may receive further followup emails. + Requirements ------------ From 5513f110dbd0515089b25ea039d7605c77da9fde Mon Sep 17 00:00:00 2001 From: David Richfield Date: Fri, 22 Apr 2016 09:42:52 +0200 Subject: [PATCH 015/722] Copy-edit Minor grammar and style edits. --- docs/tutorial/tutorial_01.rst | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index fdb1c3edc..89edbfce5 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -8,7 +8,7 @@ You want to make your own :term:`Authorization Server` to issue access tokens to Start Your App -------------- During this tutorial you will make an XHR POST from a Heroku deployed app to your localhost instance. -Since the domain that will originate the request (the app on Heroku) is different than the destination domain (your local instance), +Since the domain that will originate the request (the app on Heroku) is different from the destination domain (your local instance), you will need to install the `django-cors-headers `_ app. These "cross-domain" requests are by default forbidden by web browsers unless you use `CORS `_. @@ -67,7 +67,7 @@ for details on using login templates. -As a final step, execute migrate command, start the internal server, and login with your credentials. +As a final step, execute the migrate command, start the internal server, and login with your credentials. Create an OAuth2 Client Application ----------------------------------- @@ -78,11 +78,11 @@ the API, subject to approval by its users. Let's register your application. Point your browser to http://localhost:8000/o/applications/ and add an Application instance. -`Client id` and `Client Secret` are automatically generated, you have to provide the rest of the informations: +`Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: * `User`: the owner of the Application (e.g. a developer, or the currently logged in user.) - * `Redirect uris`: Applications must register at least one redirection endpoint prior to utilizing the + * `Redirect uris`: Applications must register at least one redirection endpoint beofre using the authorization endpoint. The :term:`Authorization Server` will deliver the access token to the client only if the client specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `http://django-oauth-toolkit.herokuapp.com/consumer/exchange/` @@ -117,9 +117,9 @@ Authorize the Application +++++++++++++++++++++++++ When a user clicks the link, she is redirected to your (possibly local) :term:`Authorization Server`. If you're not logged in, you will be prompted for username and password. This is because the authorization -page is login protected by django-oauth-toolkit. Login, then you should see the (not so cute) form users can use to give +page is login protected by django-oauth-toolkit. Login, then you should see the (not so cute) form a user can use to give her authorization to the client application. Flag the *Allow* checkbox and click *Authorize*, you will be redirected -again on to the consumer service. +again to the consumer service. __ loginTemplate_ @@ -140,9 +140,9 @@ Refresh the token +++++++++++++++++ The page showing the access token retrieved from the :term:`Authorization Server` also let you make a POST request to the server itself to swap the refresh token for another, brand new access token. -Just fill in the missing form fields and click the Refresh button: if everything goes smooth you will see the access and +Just fill in the missing form fields and click the Refresh button: if everything goes smoothly you will see the access and refresh token change their values, otherwise you will likely see an error message. -When finished playing with your authorization server, take note of both the access and refresh tokens, we will use them +When you have finished playing with your authorization server, take note of both the access and refresh tokens, we will use them for the next part of the tutorial. So let's make an API and protect it with your OAuth2 tokens in the :doc:`part 2 of the tutorial `. From f28496c8b07086941909ad94cb22f1c5a89a7362 Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Fri, 13 May 2016 11:17:47 +0900 Subject: [PATCH 016/722] Small documentation fixes --- docs/advanced_topics.rst | 6 +++--- docs/contributing.rst | 4 ++-- docs/tutorial/tutorial_02.rst | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 5579e0c69..dd0468f2f 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -55,9 +55,9 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application Skip authorization form ======================= -Depending on the OAuth2 flow in use and the access token policy, users might be prompted for the -same authorization multiple times: sometimes this is acceptable or even desiderable but other it isn't. -To control DOT behaviour you can use `approval_prompt` parameter when hitting the authorization endpoint. +Depending on the OAuth2 flow in use and the access token policy, users might be prompted for the +same authorization multiple times: sometimes this is acceptable or even desirable but other times it isn't. +To control DOT behaviour you can use the `approval_prompt` parameter when hitting the authorization endpoint. Possible values are: * `force` - users are always prompted for authorization. diff --git a/docs/contributing.rst b/docs/contributing.rst index 5ebf257a3..6de828be3 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -47,7 +47,7 @@ of the pull request. Pull upstream changes into your fork regularly ============================================== -It's a good practice to pull upstream changes from master into your fork on a regular basis, infact if you work on +It's a good practice to pull upstream changes from master into your fork on a regular basis, in fact if you work on outdated code and your changes diverge too far from master, the pull request has to be rejected. To pull in upstream changes:: @@ -85,7 +85,7 @@ Add the tests! -------------- Whenever you add code, you have to add tests as well. We cannot accept untested code, so unless it is a peculiar -situation you previously discussed with the core commiters, if your pull request reduces the test coverage it will be +situation you previously discussed with the core committers, if your pull request reduces the test coverage it will be **immediately rejected**. Code conventions matter diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 98fa08314..214abdb74 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -51,7 +51,7 @@ Testing your API Time to make requests to your API. For a quick test, try accessing your app at the url `/api/hello` with your browser -and verify that it reponds with a `403` (in fact no `HTTP_AUTHORIZATION` header was provided). +and verify that it responds with a `403` (in fact no `HTTP_AUTHORIZATION` header was provided). You can test your API with anything that can perform HTTP requests, but for this tutorial you can use the online `consumer client `_. Just fill the form with the URL of the API endpoint (i.e. http://localhost:8000/api/hello if you're on localhost) and From 06378b58f060e9977ff04f97414c5f60429f149b Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Fri, 13 May 2016 11:19:17 +0900 Subject: [PATCH 017/722] Don't encourage adding the application urls without dealing security restrictions --- docs/tutorial/tutorial_02.rst | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 214abdb74..7ea8d98dd 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -34,15 +34,37 @@ URL this view will respond to: .. code-block:: python + from django.conf.urls import patterns, url + from oauth2_provider import views + from django.conf import settings from .views import ApiEndpoint urlpatterns = patterns( '', url(r'^admin/', include(admin.site.urls)), - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), # look ma, I'm a provider! - url(r'^api/hello', ApiEndpoint.as_view()), # and also a resource server! + + # OAuth2 provider endpoints + url(r'^o/authorize/$', views.AuthorizationView.as_view(), name="authorize"), + url(r'^o/token/$', views.TokenView.as_view(), name="token"), + url(r'^o/revoke-token/$', views.RevokeTokenView.as_view(), name="revoke-token"), + + url(r'^api/hello', ApiEndpoint.as_view()), # a resource endpoint ) + if settings.DEBUG: + # OAuth2 Application management views + + urlpatterns += patterns( + '', + url(r'^o/applications/$', views.ApplicationList.as_view(), name="application-list"), + url(r'^o/applications/register/$', views.ApplicationRegistration.as_view(), name="application-register"), + url(r'^o/applications/(?P\d+)/$', views.ApplicationDetail.as_view(), name="application-detail"), + url(r'^o/applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="application-delete"), + url(r'^o/applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="application-update"), + ) + +You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. + Since we inherit from `ProtectedResourceView`, we're done and our API is OAuth2 protected - for the sake of the lazy programmer. From e6fc1a9637f08e4d6d5fa39e10b2fddcc346614a Mon Sep 17 00:00:00 2001 From: Adam Chainz Date: Tue, 31 May 2016 18:52:44 +0100 Subject: [PATCH 018/722] Convert readthedocs link for their .org -> .io migration for hosted projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit As per [their blog post of the 27th April](https://blog.readthedocs.com/securing-subdomains/) ‘Securing subdomains’: > Starting today, Read the Docs will start hosting projects from subdomains on the domain readthedocs.io, instead of on readthedocs.org. This change addresses some security concerns around site cookies while hosting user generated data on the same domain as our dashboard. Test Plan: Manually visited all the links I’ve modified. --- CONTRIBUTING.rst | 2 +- README.rst | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 69be21a75..61d13273e 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -2,4 +2,4 @@ Contributing ============ Thanks for your interest! We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines `_ and submit a PR. +guidelines `_ and submit a PR. diff --git a/README.rst b/README.rst index f70ca4d15..0affb7238 100644 --- a/README.rst +++ b/README.rst @@ -32,7 +32,7 @@ Contributing ------------ We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines `_ and submit a PR. +guidelines `_ and submit a PR. Reporting security issues ------------------------- @@ -80,7 +80,7 @@ Notice that `oauth2_provider` namespace is mandatory. Documentation -------------- -The `full documentation `_ is on *Read the Docs*. +The `full documentation `_ is on *Read the Docs*. License ------- From fb5cda00dcfffc726257196139dfc5feef0ea6ae Mon Sep 17 00:00:00 2001 From: Paul Oswald Date: Wed, 1 Jun 2016 14:57:58 +0900 Subject: [PATCH 019/722] Define urls such that they are namespaced properly and forward-compatible with newer Django standards --- docs/tutorial/tutorial_02.rst | 52 ++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 22 deletions(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 7ea8d98dd..7b82e5264 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -35,33 +35,41 @@ URL this view will respond to: .. code-block:: python from django.conf.urls import patterns, url - from oauth2_provider import views + import oauth2_provider.views as oauth2_views from django.conf import settings from .views import ApiEndpoint - urlpatterns = patterns( - '', - url(r'^admin/', include(admin.site.urls)), - - # OAuth2 provider endpoints - url(r'^o/authorize/$', views.AuthorizationView.as_view(), name="authorize"), - url(r'^o/token/$', views.TokenView.as_view(), name="token"), - url(r'^o/revoke-token/$', views.RevokeTokenView.as_view(), name="revoke-token"), - - url(r'^api/hello', ApiEndpoint.as_view()), # a resource endpoint - ) + # OAuth2 provider endpoints + oauth2_endpoint_views = [ + url(r'^authorize/$', oauth2_views.AuthorizationView.as_view(), name="authorize"), + url(r'^token/$', oauth2_views.TokenView.as_view(), name="token"), + url(r'^revoke-token/$', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), + ] if settings.DEBUG: - # OAuth2 Application management views - - urlpatterns += patterns( - '', - url(r'^o/applications/$', views.ApplicationList.as_view(), name="application-list"), - url(r'^o/applications/register/$', views.ApplicationRegistration.as_view(), name="application-register"), - url(r'^o/applications/(?P\d+)/$', views.ApplicationDetail.as_view(), name="application-detail"), - url(r'^o/applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="application-delete"), - url(r'^o/applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="application-update"), - ) + # OAuth2 Application Management endpoints + oauth2_endpoint_views += [ + url(r'^applications/$', oauth2_views.ApplicationList.as_view(), name="list"), + url(r'^applications/register/$', oauth2_views.ApplicationRegistration.as_view(), name="register"), + url(r'^applications/(?P\d+)/$', oauth2_views.ApplicationDetail.as_view(), name="detail"), + url(r'^applications/(?P\d+)/delete/$', oauth2_views.ApplicationDelete.as_view(), name="delete"), + url(r'^applications/(?P\d+)/update/$', oauth2_views.ApplicationUpdate.as_view(), name="update"), + ] + + # OAuth2 Token Management endpoints + oauth2_endpoint_views += [ + url(r'^authorized-tokens/$', oauth2_views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + url(r'^authorized-tokens/(?P\d+)/delete/$', oauth2_views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete"), + ] + + urlpatterns = [ + # OAuth 2 endpoints: + url(r'^o/', include(oauth2_endpoint_views, namespace="oauth2_provider")), + + url(r'^admin/', include(admin.site.urls)), + url(r'^api/hello', ApiEndpoint.as_view()), # an example resource endpoint + ] You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. From da7e6ba0bde6d684b801343cfd6d9264104fd05c Mon Sep 17 00:00:00 2001 From: David Richfield Date: Sun, 5 Jun 2016 12:56:22 +0200 Subject: [PATCH 020/722] Typo: "beofre" -> "before" --- docs/tutorial/tutorial_01.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 89edbfce5..23304eb82 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -82,7 +82,7 @@ Point your browser to http://localhost:8000/o/applications/ and add an Applicati * `User`: the owner of the Application (e.g. a developer, or the currently logged in user.) - * `Redirect uris`: Applications must register at least one redirection endpoint beofre using the + * `Redirect uris`: Applications must register at least one redirection endpoint before using the authorization endpoint. The :term:`Authorization Server` will deliver the access token to the client only if the client specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `http://django-oauth-toolkit.herokuapp.com/consumer/exchange/` From 6fcb8b52a5861440af55a28cfa07d1654562f99a Mon Sep 17 00:00:00 2001 From: Jens Timmerman Date: Wed, 29 Jun 2016 18:29:56 +0200 Subject: [PATCH 021/722] added an IsAuthenticatedOrTokenHasScope Permission --- .../ext/rest_framework/permissions.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/ext/rest_framework/permissions.py index 559bbbc54..5a0982d60 100644 --- a/oauth2_provider/ext/rest_framework/permissions.py +++ b/oauth2_provider/ext/rest_framework/permissions.py @@ -2,7 +2,7 @@ from django.core.exceptions import ImproperlyConfigured -from rest_framework.permissions import BasePermission +from rest_framework.permissions import BasePermission, IsAuthenticated from ...settings import oauth2_settings @@ -29,7 +29,7 @@ def has_permission(self, request, view): return token.is_valid(required_scopes) - assert False, ('TokenHasScope requires either the' + assert False, ('TokenHasScope requires the' '`oauth2_provider.rest_framework.OAuth2Authentication` authentication ' 'class to be used.') @@ -84,3 +84,16 @@ def get_scopes(self, request, view): ] return required_scopes + + +class IsAuthenticatedOrTokenHasScope(BasePermission): + """ + The user is authenticated using some backend or the token has the right scope + This is usefull when combined with the DjangoModelPermissions to allow people browse the browsable api's + if they log in using the a non token bassed middleware, + and let them access the api's using a rest client with a token + """ + def has_permission(self, request, view): + is_authenticated = IsAuthenticated() + token_has_scope = TokenHasScope() + return is_authenticated.has_permission(request, view) or token_has_scope.has_permission(request, view) From 9c86cfac3426bc17e0aac41e3bbcb08f1f711e6d Mon Sep 17 00:00:00 2001 From: Jens Timmerman Date: Wed, 29 Jun 2016 18:40:38 +0200 Subject: [PATCH 022/722] import permission into rest_framework --- oauth2_provider/ext/rest_framework/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/ext/rest_framework/__init__.py b/oauth2_provider/ext/rest_framework/__init__.py index bdc638818..4b826720c 100644 --- a/oauth2_provider/ext/rest_framework/__init__.py +++ b/oauth2_provider/ext/rest_framework/__init__.py @@ -1,3 +1,4 @@ # flake8: noqa from .authentication import OAuth2Authentication from .permissions import TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope +from .permissions import IsAuthenticatedOrTokenHasScope From 233498faaacd446aa169983488e4e83c99c90ccf Mon Sep 17 00:00:00 2001 From: Jens Timmerman Date: Thu, 30 Jun 2016 12:01:03 +0200 Subject: [PATCH 023/722] added tests, fixed an error the tests revealed --- .../ext/rest_framework/permissions.py | 12 ++++- oauth2_provider/tests/test_rest_framework.py | 52 +++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/ext/rest_framework/permissions.py index 5a0982d60..71b2ac91d 100644 --- a/oauth2_provider/ext/rest_framework/permissions.py +++ b/oauth2_provider/ext/rest_framework/permissions.py @@ -3,6 +3,7 @@ from django.core.exceptions import ImproperlyConfigured from rest_framework.permissions import BasePermission, IsAuthenticated +from .authentication import OAuth2Authentication from ...settings import oauth2_settings @@ -89,11 +90,18 @@ def get_scopes(self, request, view): class IsAuthenticatedOrTokenHasScope(BasePermission): """ The user is authenticated using some backend or the token has the right scope + This only returns True if the user is authenticated, but not using a token + or using a token, and the token has the correct scope. + This is usefull when combined with the DjangoModelPermissions to allow people browse the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ def has_permission(self, request, view): - is_authenticated = IsAuthenticated() + is_authenticated = IsAuthenticated().has_permission(request, view) + oauth2authenticated = False + if is_authenticated: + oauth2authenticated = isinstance(request.successful_authenticator, OAuth2Authentication) + token_has_scope = TokenHasScope() - return is_authenticated.has_permission(request, view) or token_has_scope.has_permission(request, view) + return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) diff --git a/oauth2_provider/tests/test_rest_framework.py b/oauth2_provider/tests/test_rest_framework.py index 10ebdc403..a64f58cd1 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/oauth2_provider/tests/test_rest_framework.py @@ -19,7 +19,9 @@ try: from rest_framework import permissions from rest_framework.views import APIView + from rest_framework.test import force_authenticate, APIRequestFactory from ..ext.rest_framework import OAuth2Authentication, TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope + from ..ext.rest_framework import IsAuthenticatedOrTokenHasScope class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) @@ -37,6 +39,10 @@ class ScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasScope] required_scopes = ['scope1'] + class AuthenticatedOrScopedView(OAuth2View): + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ['scope1'] + class ReadWriteScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] @@ -51,6 +57,7 @@ class ResourceScopedView(OAuth2View): url(r'^oauth2-scoped-test/$', ScopedView.as_view()), url(r'^oauth2-read-write-test/$', ReadWriteScopedView.as_view()), url(r'^oauth2-resource-scoped-test/$', ResourceScopedView.as_view()), + url(r'^oauth2-authenticated-or-scoped-test/$', AuthenticatedOrScopedView.as_view()), ) rest_framework_installed = True @@ -105,6 +112,24 @@ def test_authentication_denied(self): response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) + @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + def test_authentication_or_scope_denied(self): + # user is not authenticated + # not a correct token + auth = self._create_authorization_header("fake-token") + response = self.client.get("/oauth2-authenticated-or-scoped-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) + # token doesn't have correct scope + auth = self._create_authorization_header(self.access_token.token) + + factory = APIRequestFactory() + request = factory.get("/oauth2-authenticated-or-scoped-test/") + request.auth = auth + force_authenticate(request, token=self.access_token) + response = AuthenticatedOrScopedView.as_view()(request) + # authenticated but wrong scope, this is 403, not 401 + self.assertEqual(response.status_code, 403) + @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') def test_scoped_permission_allow(self): self.access_token.scope = 'scope1' @@ -114,6 +139,33 @@ def test_scoped_permission_allow(self): response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) + @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + def test_authenticated_or_scoped_permission_allow(self): + self.access_token.scope = 'scope1' + self.access_token.save() + # correct token and correct scope + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-authenticated-or-scoped-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + auth = self._create_authorization_header("fake-token") + # incorrect token but authenticated + factory = APIRequestFactory() + request = factory.get("/oauth2-authenticated-or-scoped-test/") + request.auth = auth + force_authenticate(request, self.test_user) + response = AuthenticatedOrScopedView.as_view()(request) + self.assertEqual(response.status_code, 200) + + # correct token but not authenticated + request = factory.get("/oauth2-authenticated-or-scoped-test/") + request.auth = auth + self.access_token.scope = 'scope1' + self.access_token.save() + force_authenticate(request, token=self.access_token) + response = AuthenticatedOrScopedView.as_view()(request) + self.assertEqual(response.status_code, 200) + @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') def test_scoped_permission_deny(self): self.access_token.scope = 'scope2' From 46d136fb0d7529c45f915dbc465f0c50ab81c4c0 Mon Sep 17 00:00:00 2001 From: Jens Timmerman Date: Thu, 30 Jun 2016 18:05:36 +0200 Subject: [PATCH 024/722] added documentation, info to the readme, changelog and added myself to contributors --- AUTHORS | 1 + README.rst | 1 + docs/changelog.rst | 1 + docs/rest-framework/permissions.rst | 18 ++++++++++++++++++ 4 files changed, 21 insertions(+) diff --git a/AUTHORS b/AUTHORS index 3a91ba821..be13a5925 100644 --- a/AUTHORS +++ b/AUTHORS @@ -17,3 +17,4 @@ Diego Garcia Bas van Oostveen Bart Merenda Paul Oswald +Jens Timmerman diff --git a/README.rst b/README.rst index 0affb7238..8a204b4a4 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,7 @@ Changelog Development ~~~~~~~~~~~ +* #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications 0.10.0 [2015-12-14] diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b24e4dfe..95e5e3789 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog Development ~~~~~~~~~~~ +* #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index d22e8f4f7..629bf50d0 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -63,3 +63,21 @@ When the request's method is one of "non safe" methods, the access is allowed on required_scopes = ['music'] The `required_scopes` attribute is mandatory (you just need inform the resource scope). + + +IsAuthenticatedOrTokenHasScope +------------------------------ +The `TokenHasResourceScope` permission class allows the access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. +And also allows access to Authenticated users who are authenticated in django, but were not authenticated trought the OAuth2Authentication class. +This allows for protection of the api using scopes, but still let's users browse the full browseable API. +To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this wwith the DjangoModelPermission or the DjangoObjectPermission. + +For example: + +.. code-block:: python + + class SongView(views.APIView): + permission_classes = [IsAuthenticatedOrTokenHasScope, DjangoModelPermission] + required_scopes = ['music'] + +The `required_scopes` attribute is mandatory. From d41ff4cd36c95f39f33954470b29ad3a58ee53c9 Mon Sep 17 00:00:00 2001 From: Chathan Driehuys Date: Sat, 27 Aug 2016 17:32:49 -0400 Subject: [PATCH 025/722] Suggest django-cors-middleware Since `django-cors-headers` has been inactive since May 2015, the documentation should suggest using `django-cors-middleware`. This is a fork of `django-cors-headers` that has been kept up to date, so it supports django 1.10. --- docs/tutorial/tutorial_01.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 23304eb82..dfc855efe 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -9,14 +9,14 @@ Start Your App -------------- During this tutorial you will make an XHR POST from a Heroku deployed app to your localhost instance. Since the domain that will originate the request (the app on Heroku) is different from the destination domain (your local instance), -you will need to install the `django-cors-headers `_ app. +you will need to install the `django-cors-middleware `_ app. These "cross-domain" requests are by default forbidden by web browsers unless you use `CORS `_. -Create a virtualenv and install `django-oauth-toolkit` and `django-cors-headers`: +Create a virtualenv and install `django-oauth-toolkit` and `django-cors-middleware`: :: - pip install django-oauth-toolkit django-cors-headers + pip install django-oauth-toolkit django-cors-middleware Start a Django project, add `oauth2_provider` and `corsheaders` to the installed apps, and enable admin: From b3a8f4ad0983dc7cf357d731ad6ee0f68a8edf2c Mon Sep 17 00:00:00 2001 From: Nick Merrill Date: Thu, 11 Aug 2016 13:38:05 -0600 Subject: [PATCH 026/722] Fixed typo --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 8a204b4a4..9e38b07c5 100644 --- a/README.rst +++ b/README.rst @@ -154,7 +154,7 @@ Development * fixed ``get_application_model`` on Django 1.7 * fixed non rotating refresh tokens * #137: fixed base template -* customized ``client_secret`` lenght +* customized ``client_secret`` length * #38: create access tokens not bound to a user instance for *client credentials* flow 0.7.2 [2014-07-02] From a41dc7a4536bd04183246f92f64cfc0783961028 Mon Sep 17 00:00:00 2001 From: Irae Hueck Costa Date: Tue, 20 Sep 2016 15:17:04 +0200 Subject: [PATCH 027/722] Removed 404.html --- oauth2_provider/templates/404.html | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 oauth2_provider/templates/404.html diff --git a/oauth2_provider/templates/404.html b/oauth2_provider/templates/404.html deleted file mode 100644 index 84a2f974d..000000000 --- a/oauth2_provider/templates/404.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends 'oauth2_provider/base.html' %} - -{% block content %} -

Not Found

-

The requested URL {{ request_path }} was not found on this server.

-{% endblock content %} \ No newline at end of file From 42f1de9d2edc3f340f9ead24f6960e326fe3076a Mon Sep 17 00:00:00 2001 From: Zach Date: Wed, 13 Jul 2016 11:44:59 -0700 Subject: [PATCH 028/722] Update tutorial_01.rst --- docs/tutorial/tutorial_01.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index dfc855efe..f4f2444ed 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -128,7 +128,7 @@ you probably need to `setup your login template correctly`__. Exchange the token ++++++++++++++++++ -At this point your autorization server redirected the user to a special page on the consumer passing in an +At this point your authorization server redirected the user to a special page on the consumer passing in an :term:`Authorization Code`, a special token the consumer will use to obtain the final access token. This operation is usually done automatically by the client application during the request/response cycle, but we cannot make a POST request from Heroku to your localhost, so we proceed manually with this step. Fill the form with the From 86633e720019a7109f814dd69b53d922c089fc34 Mon Sep 17 00:00:00 2001 From: Aron Griffis Date: Sun, 10 Jul 2016 12:54:16 -0400 Subject: [PATCH 029/722] update pytest-cov and coverage to repair coveralls --- requirements/testing.txt | 1 - tox.ini | 10 +++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/requirements/testing.txt b/requirements/testing.txt index d444c6358..39b535c31 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -3,4 +3,3 @@ mock==1.0.1 pytest==2.8.3 pytest-django==2.9.1 pytest-xdist==1.13.1 -pytest-cov==2.2.0 diff --git a/tox.ini b/tox.ini index cd372938f..d820d90de 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,15 @@ commands=python runtests.py -q --cov oauth2_provider --cov-report= --cov-append deps = django18: Django==1.8.11 django19: Django==1.9.4 - coverage<4 + coverage==4.1 + pytest-cov==2.3.0 + -rrequirements/testing.txt + +[testenv:py32-django18] +# coverage-4.1 doesn't support python-3.2. +commands=python runtests.py -q +deps = + django18: Django==1.8.11 -rrequirements/testing.txt [testenv:docs] From 3a93f7bb92a0bc7b6a179d0c834ef1301b1f1cf5 Mon Sep 17 00:00:00 2001 From: Aron Griffis Date: Sun, 10 Jul 2016 12:55:14 -0400 Subject: [PATCH 030/722] noop tweak tox.ini envlist and test invocation --- tox.ini | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index d820d90de..b617d5288 100644 --- a/tox.ini +++ b/tox.ini @@ -4,16 +4,16 @@ testpaths=oauth2_provider [tox] envlist = - {py27}-django{18,19}, - {py32}-django{18}, - {py33}-django{18}, - {py34}-django{18,19}, - {py35}-django{18,19}, + py27-django{18,19}, + py32-django18, + py33-django18, + py34-django{18,19}, + py35-django{18,19}, docs, flake8 [testenv] -commands=python runtests.py -q --cov oauth2_provider --cov-report= --cov-append +commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = django18: Django==1.8.11 django19: Django==1.9.4 From 77584b8f1d5d984f8c22f4bbb5a45c82ad2b8152 Mon Sep 17 00:00:00 2001 From: Aron Griffis Date: Sun, 10 Jul 2016 13:19:25 -0400 Subject: [PATCH 031/722] use tox's existing support for TOXENV --- .travis.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8e318a6dc..f286c9d5c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,15 +5,15 @@ python: sudo: false env: - - TOX_ENV=py27-django18 - - TOX_ENV=py27-django19 - - TOX_ENV=py32-django18 - - TOX_ENV=py33-django18 - - TOX_ENV=py34-django18 - - TOX_ENV=py34-django19 - - TOX_ENV=py35-django18 - - TOX_ENV=py35-django19 - - TOX_ENV=docs + - TOXENV=py27-django18 + - TOXENV=py27-django19 + - TOXENV=py32-django18 + - TOXENV=py33-django18 + - TOXENV=py34-django18 + - TOXENV=py34-django19 + - TOXENV=py35-django18 + - TOXENV=py35-django19 + - TOXENV=docs matrix: fast_finish: true @@ -23,7 +23,7 @@ install: - pip install coveralls script: - - tox -e $TOX_ENV + - tox after_success: - coveralls From 147bc56467aa4b7cf44d9b6aad7406506b10f747 Mon Sep 17 00:00:00 2001 From: Aron Griffis Date: Sun, 10 Jul 2016 13:20:04 -0400 Subject: [PATCH 032/722] send to coveralls even when some tests fail --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index f286c9d5c..49029c999 100644 --- a/.travis.yml +++ b/.travis.yml @@ -25,5 +25,5 @@ install: script: - tox -after_success: +after_script: - coveralls From cce3ff898f627cde32a6e9dd5dbff1e4a0d4d9cd Mon Sep 17 00:00:00 2001 From: Girbons Date: Thu, 27 Oct 2016 23:39:38 +0200 Subject: [PATCH 033/722] updated django version on tox.ini --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index b617d5288..11ddb9f8e 100644 --- a/tox.ini +++ b/tox.ini @@ -15,8 +15,8 @@ envlist = [testenv] commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = - django18: Django==1.8.11 - django19: Django==1.9.4 + django18: Django==1.8.15 + django19: Django==1.9.10 coverage==4.1 pytest-cov==2.3.0 -rrequirements/testing.txt @@ -25,7 +25,7 @@ deps = # coverage-4.1 doesn't support python-3.2. commands=python runtests.py -q deps = - django18: Django==1.8.11 + django18: Django==1.8.15 -rrequirements/testing.txt [testenv:docs] From 707a6016a3023fe423ede53db707c55273b0f6d0 Mon Sep 17 00:00:00 2001 From: Peith Vergil Date: Fri, 28 Oct 2016 07:24:38 +0800 Subject: [PATCH 034/722] Use the OAuthLibCore object defined at the module level. --- oauth2_provider/backends.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index b2e706b54..25c0fed9c 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -15,8 +15,7 @@ class OAuth2Backend(object): def authenticate(self, **credentials): request = credentials.get('request') if request is not None: - oauthlib_core = get_oauthlib_core() - valid, r = oauthlib_core.verify_request(request, scopes=[]) + valid, r = OAuthLibCore.verify_request(request, scopes=[]) if valid: return r.user return None From d51a3513b939c56de0d4bc493345899baddf408d Mon Sep 17 00:00:00 2001 From: Alejandro Varas Date: Fri, 29 Apr 2016 11:57:30 +0200 Subject: [PATCH 035/722] Fixed "code-block" statement --- docs/settings.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/settings.rst b/docs/settings.rst index 4999c1c77..efba4d1ce 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -83,6 +83,7 @@ DEFAULT_SCOPES A list of scopes that should be returned by default. This is a subset of the keys of the SCOPES setting. By default this is set to '__all__' meaning that the whole set of SCOPES will be returned. + .. code-block:: python DEFAULT_SCOPES = ['read', 'write'] From 2978d0f4a0affa814070124a2f1898fbd152afd6 Mon Sep 17 00:00:00 2001 From: Jim Graham Date: Tue, 31 May 2016 09:35:00 -0400 Subject: [PATCH 036/722] Reuse refresh tokens Reuse refresh tokens if it's enabled. This is based on a code sample in #304 from https://github.com/evonove/django-oauth-toolkit/issues/304#issuecomment-175636716 For us, this has cleaned up a number of race conditions deleting and recreating tokens. --- oauth2_provider/oauth2_validators.py | 94 +++++++++--- .../tests/test_oauth2_validators.py | 140 +++++++++++++++++- 2 files changed, 209 insertions(+), 25 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 7cc6f3e18..d8eedc886 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -10,9 +10,11 @@ from django.conf import settings from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist +from django.db import transaction from oauthlib.oauth2 import RequestValidator from .compat import unquote_plus +from .exceptions import FatalClientError from .models import Grant, AccessToken, RefreshToken, get_application_model, AbstractApplication from .settings import oauth2_settings @@ -86,6 +88,9 @@ def _authenticate_basic_auth(self, request): if self._load_application(client_id, request) is None: log.debug("Failed basic auth: Application %s does not exist" % client_id) return False + elif request.client.client_id != client_id: + log.debug("Failed basic auth: wrong client id %s" % client_id) + return False elif request.client.client_secret != client_secret: log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False @@ -292,41 +297,88 @@ def save_authorization_code(self, client_id, code, request, *args, **kwargs): scope=' '.join(request.scopes)) g.save() + @transaction.atomic def save_bearer_token(self, token, request, *args, **kwargs): """ - Save access and refresh token, If refresh token is issued, remove old refresh tokens as - in rfc:`6` + Save access and refresh token, If refresh token is issued, remove or + reuse old refresh token as in rfc:`6` + + @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 """ - if request.refresh_token: - # remove used refresh token - try: - RefreshToken.objects.get(token=request.refresh_token).revoke() - except RefreshToken.DoesNotExist: - assert() # TODO though being here would be very strange, at least log the error + + if 'scope' not in token: + raise FatalClientError(u"Failed to renew access token: missing scope") expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + if request.grant_type == 'client_credentials': request.user = None + # This comes from OAuthLib: + # https://github.com/idan/oauthlib/blob/1.0.3/oauthlib/oauth2/rfc6749/tokens.py#L267 + # Its value is either a new random code; or if we are reusing + # refresh tokens, then it is the same value that the request passed in + # (stored in `request.refresh_token`) + refresh_token_code = token.get('refresh_token', None) + + if refresh_token_code: + # an instance of `RefreshToken` that matches the old refresh code. + # Set on the request in `validate_refresh_token` + refresh_token_instance = getattr(request, 'refresh_token_instance', None) + + # If we are to reuse tokens, and we can: do so + if not self.rotate_refresh_token(request) and \ + isinstance(refresh_token_instance, RefreshToken) and \ + refresh_token_instance.access_token: + + access_token = AccessToken.objects.select_for_update().get( + pk=refresh_token_instance.access_token.pk + ) + access_token.user = request.user + access_token.scope = token['scope'] + access_token.expires = expires + access_token.token = token['access_token'] + access_token.application = request.client + access_token.save() + + # else create fresh with access & refresh tokens + else: + # revoke existing tokens if possible + if isinstance(refresh_token_instance, RefreshToken): + try: + refresh_token_instance.revoke() + except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): + pass + else: + setattr(request, 'refresh_token_instance', None) + + access_token = self._create_access_token(expires, request, token) + + refresh_token = RefreshToken( + user=request.user, + token=refresh_token_code, + application=request.client, + access_token=access_token + ) + refresh_token.save() + + # No refresh token should be created, just access token + else: + self._create_access_token(expires, request, token) + + # TODO: check out a more reliable way to communicate expire time to oauthlib + token['expires_in'] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + + def _create_access_token(self, expires, request, token): access_token = AccessToken( user=request.user, scope=token['scope'], expires=expires, token=token['access_token'], - application=request.client) + application=request.client + ) access_token.save() - - if 'refresh_token' in token: - refresh_token = RefreshToken( - user=request.user, - token=token['refresh_token'], - application=request.client, - access_token=access_token - ) - refresh_token.save() - - # TODO check out a more reliable way to communicate expire time to oauthlib - token['expires_in'] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + return access_token def revoke_token(self, token, token_type_hint, request, *args, **kwargs): """ diff --git a/oauth2_provider/tests/test_oauth2_validators.py b/oauth2_provider/tests/test_oauth2_validators.py index e4f7e1a82..36a52f588 100644 --- a/oauth2_provider/tests/test_oauth2_validators.py +++ b/oauth2_provider/tests/test_oauth2_validators.py @@ -1,25 +1,31 @@ +from datetime import timedelta + from django.contrib.auth import get_user_model -from django.test import TestCase +from django.test import TransactionTestCase +from django.utils import timezone import mock from oauthlib.common import Request +from ..exceptions import FatalClientError from ..oauth2_validators import OAuth2Validator -from ..models import get_application_model +from ..models import get_application_model, AccessToken, RefreshToken UserModel = get_user_model() AppModel = get_application_model() -class TestOAuth2Validator(TestCase): +class TestOAuth2Validator(TransactionTestCase): def setUp(self): self.user = UserModel.objects.create_user("user", "test@user.com", "123456") self.request = mock.MagicMock(wraps=Request) - self.request.client = None + self.request.user = self.user + self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = AppModel.objects.create( client_id='client_id', client_secret='client_secret', user=self.user, client_type=AppModel.CLIENT_PUBLIC, authorization_grant_type=AppModel.GRANT_PASSWORD) + self.request.client = self.application def tearDown(self): self.application.delete() @@ -108,3 +114,129 @@ def test_client_authentication_required(self): def test_load_application_fails_when_request_has_no_client(self): self.assertRaises(AssertionError, self.validator.authenticate_client_id, 'client_id', {}) + + def test_rotate_refresh_token__is_true(self): + self.assertTrue(self.validator.rotate_refresh_token(mock.MagicMock())) + + def test_save_bearer_token__without_user__raises_fatal_client(self): + token = {} + + with self.assertRaises(FatalClientError): + self.validator.save_bearer_token(token, mock.MagicMock()) + + def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(self): + + rotate_token_function = mock.MagicMock() + rotate_token_function.return_value = False + self.validator.rotate_refresh_token = rotate_token_function + + access_token = AccessToken.objects.create( + token="123", + user=self.user, + expires=timezone.now() + timedelta(seconds=60), + application=self.application + ) + refresh_token = RefreshToken.objects.create( + access_token=access_token, + token="abc", + user=self.user, + application=self.application + ) + self.request.refresh_token_instance = refresh_token + token = { + "scope": "foo bar", + "refresh_token": "abc", + "access_token": "123", + } + + self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) + + self.validator.save_bearer_token(token, self.request) + + self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) + + def test_save_bearer_token__checks_to_rotate_tokens(self): + + rotate_token_function = mock.MagicMock() + rotate_token_function.return_value = False + self.validator.rotate_refresh_token = rotate_token_function + + access_token = AccessToken.objects.create( + token="123", + user=self.user, + expires=timezone.now() + timedelta(seconds=60), + application=self.application + ) + refresh_token = RefreshToken.objects.create( + access_token=access_token, + token="abc", + user=self.user, + application=self.application + ) + self.request.refresh_token_instance = refresh_token + token = { + "scope": "foo bar", + "refresh_token": "abc", + "access_token": "123", + } + + self.validator.save_bearer_token(token, self.request) + rotate_token_function.assert_called_once_with(self.request) + + def test_save_bearer_token__with_new_token__creates_new_tokens(self): + token = { + "scope": "foo bar", + "refresh_token": "abc", + "access_token": "123", + } + + self.assertEqual(0, RefreshToken.objects.count()) + self.assertEqual(0, AccessToken.objects.count()) + + self.validator.save_bearer_token(token, self.request) + + self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) + + def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_tokens(self): + access_token = AccessToken.objects.create( + token="123", + user=self.user, + expires=timezone.now() + timedelta(seconds=60), + application=self.application + ) + refresh_token = RefreshToken.objects.create( + access_token=access_token, + token="abc", + user=self.user, + application=self.application + ) + + self.request.refresh_token_instance = refresh_token + + token = { + "scope": "foo bar", + "refresh_token": "abc", + "access_token": "123", + } + + self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) + + self.validator.save_bearer_token(token, self.request) + + self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) + + def test_save_bearer_token__with_no_refresh_token__creates_new_access_token_only(self): + token = { + "scope": "foo bar", + "access_token": "123", + } + + self.validator.save_bearer_token(token, self.request) + + self.assertEqual(0, RefreshToken.objects.count()) + self.assertEqual(1, AccessToken.objects.count()) From c38f8d6a92c74d623ca42ced957af0670a89982f Mon Sep 17 00:00:00 2001 From: Jim Graham Date: Tue, 31 May 2016 12:04:54 -0400 Subject: [PATCH 037/722] fix unicode py 3 --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d8eedc886..38645ae5d 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -307,7 +307,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): """ if 'scope' not in token: - raise FatalClientError(u"Failed to renew access token: missing scope") + raise FatalClientError("Failed to renew access token: missing scope") expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) From 34a5263d30b4cd61b6945f856d77ffe483ec4edd Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 17 Nov 2016 22:49:58 +0100 Subject: [PATCH 038/722] Updated changelog and authors list --- AUTHORS | 2 ++ README.rst | 1 + docs/changelog.rst | 1 + 3 files changed, 4 insertions(+) diff --git a/AUTHORS b/AUTHORS index be13a5925..f20b36ea9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,3 +18,5 @@ Bas van Oostveen Bart Merenda Paul Oswald Jens Timmerman +Jim Graham +pySilver diff --git a/README.rst b/README.rst index 9e38b07c5..973a72a2e 100644 --- a/README.rst +++ b/README.rst @@ -102,6 +102,7 @@ Development * #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications +* #389: Reuse refresh tokens if enabled. 0.10.0 [2015-12-14] ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/changelog.rst b/docs/changelog.rst index 95e5e3789..1ad4e67bf 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -6,6 +6,7 @@ Development * #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications +* #389: Reuse refresh tokens if enabled. 0.10.0 [2015-12-14] From 911f7b828ec70ca02aab111a660412bd1e90a7c2 Mon Sep 17 00:00:00 2001 From: Bas ten Berge Date: Tue, 9 Aug 2016 21:37:56 +0200 Subject: [PATCH 039/722] Should fix #406 Should now work with old-style Middleware and the Django 1.10 new style --- oauth2_provider/middleware.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 33eab12d5..0984443ca 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,8 +1,18 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers +# bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style +# middleware working again +# More? +# https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware +try: + from django.utils.deprecation import MiddlewareMixin + middleware_parent_class = MiddlewareMixin +except ImportError: + middleware_parent_class = object -class OAuth2TokenMiddleware(object): + +class OAuth2TokenMiddleware(middleware_parent_class): """ Middleware for OAuth2 user authentication From ebe09b7c0e83f37aa8da92faaf34698d36e8bcd8 Mon Sep 17 00:00:00 2001 From: Bas ten Berge Date: Tue, 9 Aug 2016 21:45:03 +0200 Subject: [PATCH 040/722] Added notification concerning the migrations This should prevent others from running into #405 while trying to create both a swappable instance and swap it out at once --- docs/advanced_topics.rst | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index dd0468f2f..6e1d5ace1 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -43,6 +43,10 @@ Write something like this in your settings module:: OAUTH2_PROVIDER_APPLICATION_MODEL='your_app_name.MyApplication' +Be aware that, when you intend to swap the application model, you should create and run the +migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. +You'll run into models.E022 in Core system checks if you don't get the order right. + That's all, now Django OAuth Toolkit will use your model wherever an Application instance is needed. **Notice:** `OAUTH2_PROVIDER_APPLICATION_MODEL` is the only setting variable that is not namespaced, this From a23b11e3c0aa8786db47fa9ef7c2e904a2a759bf Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Thu, 27 Oct 2016 23:57:26 +0200 Subject: [PATCH 041/722] added support for django 1.10 in tox.ini --- tox.ini | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 11ddb9f8e..6dcdaff2d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,11 +4,11 @@ testpaths=oauth2_provider [tox] envlist = - py27-django{18,19}, + py27-django{18,19,110}, py32-django18, py33-django18, - py34-django{18,19}, - py35-django{18,19}, + py34-django{18,19,110}, + py35-django{18,19,110}, docs, flake8 @@ -17,6 +17,7 @@ commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = django18: Django==1.8.15 django19: Django==1.9.10 + django110: Django==1.10.2 coverage==4.1 pytest-cov==2.3.0 -rrequirements/testing.txt @@ -31,6 +32,7 @@ deps = [testenv:docs] basepython=python changedir=docs +whitelist_externals=make deps = sphinx south From 54c51b04a84fdd409518e3bdb33e53936bd4f373 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 28 Oct 2016 00:04:52 +0200 Subject: [PATCH 042/722] urlpatterns are now plain lists --- README.rst | 4 ++-- docs/install.rst | 4 ++-- docs/rest-framework/getting_started.rst | 6 +++--- docs/tutorial/tutorial_01.rst | 5 ++--- docs/tutorial/tutorial_02.rst | 2 +- docs/tutorial/tutorial_03.rst | 5 ++--- oauth2_provider/tests/test_rest_framework.py | 7 +++---- oauth2_provider/tests/urls.py | 4 ++-- oauth2_provider/urls.py | 12 ++++++------ 9 files changed, 23 insertions(+), 26 deletions(-) diff --git a/README.rst b/README.rst index 973a72a2e..021e5f211 100644 --- a/README.rst +++ b/README.rst @@ -72,10 +72,10 @@ Notice that `oauth2_provider` namespace is mandatory. .. code-block:: python - urlpatterns = patterns( + urlpatterns = [ ... url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), - ) + ] Documentation -------------- diff --git a/docs/install.rst b/docs/install.rst index adaf95f34..60e9d8fe6 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -19,10 +19,10 @@ If you need an OAuth2 provider you'll want to add the following to your urls.py .. code-block:: python - urlpatterns = patterns( + urlpatterns = [ ... url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), - ) + ] Sync your database ------------------ diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 58d9abb13..9fa8f873e 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -48,7 +48,7 @@ Here's our project's root `urls.py` module: .. code-block:: python - from django.conf.urls import url, patterns, include + from django.conf.urls import url, include from django.contrib.auth.models import User, Group from django.contrib import admin admin.autodiscover() @@ -91,11 +91,11 @@ Here's our project's root `urls.py` module: # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browseable API. - urlpatterns = patterns('', + urlpatterns = [ url(r'^', include(router.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), url(r'^admin/', include(admin.site.urls)), - ) + ] Also add the following to your `settings.py` module: diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index f4f2444ed..fb11db779 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -33,12 +33,11 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python - urlpatterns = patterns( - '', + urlpatterns = [ url(r'^admin/', include(admin.site.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), # ... - ) + ] Include the CORS middleware in your `settings.py`: diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 7b82e5264..326fa83e9 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -34,7 +34,7 @@ URL this view will respond to: .. code-block:: python - from django.conf.urls import patterns, url + from django.conf.urls import url import oauth2_provider.views as oauth2_views from django.conf import settings from .views import ApiEndpoint diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 210cc24cc..d49e286c8 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -65,11 +65,10 @@ To check everything works properly, mount the view above to some url: .. code-block:: python - urlpatterns = patterns( - '', + urlpatterns = [ url(r'^secret$', 'my.views.secret_page', name='secret'), '...', - ) + ] You should have an :term:`Application` registered at this point, if you don't, follow the steps in the previous tutorials to create one. Obtain an :term:`Access Token`, either following the OAuth2 diff --git a/oauth2_provider/tests/test_rest_framework.py b/oauth2_provider/tests/test_rest_framework.py index a64f58cd1..57e344309 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/oauth2_provider/tests/test_rest_framework.py @@ -1,7 +1,7 @@ import unittest from datetime import timedelta -from django.conf.urls import patterns, url, include +from django.conf.urls import url, include from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase @@ -50,15 +50,14 @@ class ResourceScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope] required_scopes = ['resource1'] - urlpatterns = patterns( - '', + urlpatterns = [ url(r'^oauth2/', include('oauth2_provider.urls')), url(r'^oauth2-test/$', OAuth2View.as_view()), url(r'^oauth2-scoped-test/$', ScopedView.as_view()), url(r'^oauth2-read-write-test/$', ReadWriteScopedView.as_view()), url(r'^oauth2-resource-scoped-test/$', ResourceScopedView.as_view()), url(r'^oauth2-authenticated-or-scoped-test/$', AuthenticatedOrScopedView.as_view()), - ) + ] rest_framework_installed = True except ImportError: diff --git a/oauth2_provider/tests/urls.py b/oauth2_provider/tests/urls.py index aa925826a..7695ca39a 100644 --- a/oauth2_provider/tests/urls.py +++ b/oauth2_provider/tests/urls.py @@ -4,7 +4,7 @@ admin.autodiscover() -urlpatterns = ( +urlpatterns = [ url(r'^admin/', include(admin.site.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), -) +] diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index ebcb9e0b6..2e3130e25 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -3,23 +3,23 @@ from . import views -urlpatterns = ( +urlpatterns = [ url(r'^authorize/$', views.AuthorizationView.as_view(), name="authorize"), url(r'^token/$', views.TokenView.as_view(), name="token"), url(r'^revoke_token/$', views.RevokeTokenView.as_view(), name="revoke-token"), -) +] # Application management views -urlpatterns += ( +urlpatterns += [ url(r'^applications/$', views.ApplicationList.as_view(), name="list"), url(r'^applications/register/$', views.ApplicationRegistration.as_view(), name="register"), url(r'^applications/(?P\d+)/$', views.ApplicationDetail.as_view(), name="detail"), url(r'^applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), url(r'^applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="update"), -) +] -urlpatterns += ( +urlpatterns += [ url(r'^authorized_tokens/$', views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), url(r'^authorized_tokens/(?P\d+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), -) +] From db25ce7b491f886b486fb967001daabb146bbf77 Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Thu, 17 Nov 2016 23:55:03 +0100 Subject: [PATCH 043/722] Fixed tests for Django 1.10 --- oauth2_provider/admin.py | 1 + oauth2_provider/middleware.py | 2 +- oauth2_provider/tests/settings.py | 28 +++++++++++++------- oauth2_provider/tests/test_models.py | 9 ++++++- oauth2_provider/tests/test_rest_framework.py | 4 +-- 5 files changed, 31 insertions(+), 13 deletions(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index d3c764adf..20bf1cb75 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -6,6 +6,7 @@ class RawIDAdmin(admin.ModelAdmin): raw_id_fields = ('user',) + Application = get_application_model() admin.site.register(Application, RawIDAdmin) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 0984443ca..3ea729a4b 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -3,7 +3,7 @@ # bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style # middleware working again -# More? +# More? # https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware try: from django.utils.deprecation import MiddlewareMixin diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index 1e38f8cc2..1bfc697db 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -1,6 +1,3 @@ -DEBUG = True -TEMPLATE_DEBUG = DEBUG - ADMINS = () MANAGERS = ADMINS @@ -40,10 +37,25 @@ # Make this unique, and don't share it with anybody. SECRET_KEY = "1234567890evonove" -TEMPLATE_LOADERS = ( - 'django.template.loaders.filesystem.Loader', - 'django.template.loaders.app_directories.Loader', -) +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'debug': True, + 'context_processors': [ + 'django.contrib.auth.context_processors.auth', + 'django.template.context_processors.debug', + 'django.template.context_processors.i18n', + 'django.template.context_processors.media', + 'django.template.context_processors.static', + 'django.template.context_processors.tz', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', @@ -55,8 +67,6 @@ ROOT_URLCONF = 'oauth2_provider.tests.urls' -TEMPLATE_DIRS = () - INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.contenttypes', diff --git a/oauth2_provider/tests/test_models.py b/oauth2_provider/tests/test_models.py index 7bc191c2b..022beefa7 100644 --- a/oauth2_provider/tests/test_models.py +++ b/oauth2_provider/tests/test_models.py @@ -122,7 +122,14 @@ def test_related_objects(self): # Django internals caches the related objects. if django.VERSION < (1, 8): del UserModel._meta._related_objects_cache - related_object_names = [ro.name for ro in UserModel._meta.get_all_related_objects()] + if django.VERSION < (1, 10): + related_object_names = [ro.name for ro in UserModel._meta.get_all_related_objects()] + else: + related_object_names = [ + f.name for f in UserModel._meta.get_fields() + if (f.one_to_many or f.one_to_one) + and f.auto_created and not f.concrete + ] self.assertNotIn('oauth2_provider:application', related_object_names) self.assertIn('tests%stestapplication' % (':' if django.VERSION < (1, 8) else '_'), related_object_names) diff --git a/oauth2_provider/tests/test_rest_framework.py b/oauth2_provider/tests/test_rest_framework.py index 57e344309..4bf62e14e 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/oauth2_provider/tests/test_rest_framework.py @@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase +from django.test.utils import override_settings from django.utils import timezone from .test_utils import TestCaseUtils @@ -71,9 +72,8 @@ class BaseTest(TestCaseUtils, TestCase): pass +@override_settings(ROOT_URLCONF=__name__) class TestOAuth2Authentication(BaseTest): - urls = 'oauth2_provider.tests.test_rest_framework' - def setUp(self): oauth2_settings._SCOPES = ['read', 'write', 'scope1', 'scope2', 'resource1'] From 4d18c5132f2e9e5fa02676a2c4665ae05e0399fb Mon Sep 17 00:00:00 2001 From: Silvano Cerza Date: Fri, 18 Nov 2016 00:14:46 +0100 Subject: [PATCH 044/722] Updated changelog and authors --- AUTHORS | 1 + README.rst | 1 + docs/changelog.rst | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index f20b36ea9..7aa65f47c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -20,3 +20,4 @@ Paul Oswald Jens Timmerman Jim Graham pySilver +Silvano Cerza diff --git a/README.rst b/README.rst index 021e5f211..3455e593e 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,7 @@ Changelog Development ~~~~~~~~~~~ +* #425: Added support for Django 1.10 * #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications * #389: Reuse refresh tokens if enabled. diff --git a/docs/changelog.rst b/docs/changelog.rst index 1ad4e67bf..6d8c55484 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,7 +4,8 @@ Changelog Development ~~~~~~~~~~~ -* #396: added an IsAuthenticatedOrTokenHasScope Permission +* #425: Added support for Django 1.10 +* #396: Added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications * #389: Reuse refresh tokens if enabled. From f31a8eae6f5b0ccfc395b1448c5402054733fe0d Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 18 Nov 2016 00:26:07 +0100 Subject: [PATCH 045/722] Added django 1.10 to travis --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 49029c999..d4480ba6e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,12 +7,15 @@ sudo: false env: - TOXENV=py27-django18 - TOXENV=py27-django19 + - TOXENV=py27-django110 - TOXENV=py32-django18 - TOXENV=py33-django18 - TOXENV=py34-django18 - TOXENV=py34-django19 + - TOXENV=py34-django110 - TOXENV=py35-django18 - TOXENV=py35-django19 + - TOXENV=py35-django110 - TOXENV=docs matrix: From 04a650888c15370357a2d755240ced026fe991b4 Mon Sep 17 00:00:00 2001 From: Federico Dolce Date: Wed, 16 Dec 2015 14:49:30 +0100 Subject: [PATCH 046/722] Fix issue 315 --- oauth2_provider/views/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index ea19f319a..a98ec0a82 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -115,7 +115,11 @@ def get(self, request, *args, **kwargs): # at this point we know an Application instance with such client_id exists in the database application = get_application_model().objects.get(client_id=credentials['client_id']) # TODO: cache it! kwargs['application'] = application - kwargs.update(credentials) + kwargs['client_id'] = credentials['client_id'] + kwargs['redirect_uri'] = credentials['redirect_uri'] + kwargs['response_type'] = credentials['response_type'] + kwargs['state'] = credentials['state'] + self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 form = self.get_form(self.get_form_class()) From 3b4f4728495923c852ae882078c5647c49f8fae6 Mon Sep 17 00:00:00 2001 From: Federico Dolce Date: Fri, 18 Nov 2016 00:13:44 +0100 Subject: [PATCH 047/722] Test added --- .../tests/test_authorization_code.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index e9c7aae2f..155eb0424 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -54,6 +54,28 @@ def tearDown(self): self.dev_user.delete() +class TestRegressionIssue315(BaseTest): + """ + Test to avoid regression for the issue 315: request object + was being reassigned when getting AuthorizationView + """ + + def test_request_is_not_overwritten(self): + self.client.login(username="test_user", password="123456") + query_string = urlencode({ + 'client_id': self.application.client_id, + 'response_type': 'code', + 'state': 'random_state_string', + 'scope': 'read write', + 'redirect_uri': 'http://example.it', + }) + url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert 'request' not in response.context_data + + class TestAuthorizationCodeView(BaseTest): def test_skip_authorization_completely(self): """ From 0757a8f5af693b90afd7eec95fe63b3f41dfdc70 Mon Sep 17 00:00:00 2001 From: Federico Dolce Date: Fri, 18 Nov 2016 00:31:03 +0100 Subject: [PATCH 048/722] Changelog updated --- AUTHORS | 1 + README.rst | 1 + docs/changelog.rst | 1 + 3 files changed, 3 insertions(+) diff --git a/AUTHORS b/AUTHORS index 7aa65f47c..bec289f5d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,3 +21,4 @@ Jens Timmerman Jim Graham pySilver Silvano Cerza +Federico Dolce diff --git a/README.rst b/README.rst index 3455e593e..19efa0263 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,7 @@ Changelog Development ~~~~~~~~~~~ +* #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 * #396: added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications diff --git a/docs/changelog.rst b/docs/changelog.rst index 6d8c55484..95c72f952 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog Development ~~~~~~~~~~~ +* #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 * #396: Added an IsAuthenticatedOrTokenHasScope Permission * #357: Support multiple-user clients by allowing User to be NULL for Applications From 2402be088e2b5117a0f9eba0e80f1e87a423d554 Mon Sep 17 00:00:00 2001 From: Benedikt Brandtner Date: Tue, 15 Nov 2016 14:12:57 +0100 Subject: [PATCH 049/722] updated oauthlib version to 1.1.2 --- requirements/base.txt | 2 +- setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 79d7461e5..38cc44a03 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ Sphinx==1.3.3 -oauthlib==1.0.3 +oauthlib==1.1.2 django-braces==1.8.1 six diff --git a/setup.py b/setup.py index 2114ab47d..309c52604 100644 --- a/setup.py +++ b/setup.py @@ -52,7 +52,7 @@ def get_version(package): install_requires=[ 'django>=1.7', 'django-braces>=1.8.1', - 'oauthlib==1.0.3', + 'oauthlib==1.1.2', 'six', ], zip_safe=False, From 6cf84e9b78ac04823d8fabf8f1d51652b8b895f7 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Mon, 21 Nov 2016 12:46:46 +0100 Subject: [PATCH 050/722] Fixed #427: updated django versions on tox to latest security release --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 6dcdaff2d..2e772343e 100644 --- a/tox.ini +++ b/tox.ini @@ -15,9 +15,9 @@ envlist = [testenv] commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = - django18: Django==1.8.15 - django19: Django==1.9.10 - django110: Django==1.10.2 + django18: Django==1.8.16 + django19: Django==1.9.11 + django110: Django==1.10.3 coverage==4.1 pytest-cov==2.3.0 -rrequirements/testing.txt @@ -26,7 +26,7 @@ deps = # coverage-4.1 doesn't support python-3.2. commands=python runtests.py -q deps = - django18: Django==1.8.15 + django18: Django==1.8.16 -rrequirements/testing.txt [testenv:docs] From 86ef077dbbea5bc66df32c5273f7fdc728dc2791 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Mon, 21 Nov 2016 14:53:10 +0100 Subject: [PATCH 051/722] Fixed #428: added CI tests against django development branch --- oauth2_provider/backends.py | 3 +-- tox.ini | 7 ++++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index 25c0fed9c..aa7e1ec2a 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -12,8 +12,7 @@ class OAuth2Backend(object): Authenticate against an OAuth2 access token """ - def authenticate(self, **credentials): - request = credentials.get('request') + def authenticate(self, request=None, **credentials): if request is not None: valid, r = OAuthLibCore.verify_request(request, scopes=[]) if valid: diff --git a/tox.ini b/tox.ini index 2e772343e..dd11d16a6 100644 --- a/tox.ini +++ b/tox.ini @@ -4,11 +4,11 @@ testpaths=oauth2_provider [tox] envlist = - py27-django{18,19,110}, + py27-django{18,19,110,master}, py32-django18, py33-django18, - py34-django{18,19,110}, - py35-django{18,19,110}, + py34-django{18,19,110,master}, + py35-django{18,19,110,master}, docs, flake8 @@ -18,6 +18,7 @@ deps = django18: Django==1.8.16 django19: Django==1.9.11 django110: Django==1.10.3 + djangomaster: https://github.com/django/django/archive/master.tar.gz coverage==4.1 pytest-cov==2.3.0 -rrequirements/testing.txt From bb70d857d675c5b9e7d8a02a657ba7b29d491ea9 Mon Sep 17 00:00:00 2001 From: Jim Graham Date: Wed, 25 May 2016 12:25:59 -0400 Subject: [PATCH 052/722] Add unique constraints to models' token codes Add a unique constraint to the token codes to avoid `objects.get() MultipleObjectsReturned` exceptions. --- .../migrations/0004_auto_20160525_1623.py | 29 +++++++++++++++++++ oauth2_provider/models.py | 6 ++-- 2 files changed, 32 insertions(+), 3 deletions(-) create mode 100644 oauth2_provider/migrations/0004_auto_20160525_1623.py diff --git a/oauth2_provider/migrations/0004_auto_20160525_1623.py b/oauth2_provider/migrations/0004_auto_20160525_1623.py new file mode 100644 index 000000000..5ada5dbb4 --- /dev/null +++ b/oauth2_provider/migrations/0004_auto_20160525_1623.py @@ -0,0 +1,29 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0003_auto_20160316_1503'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='token', + field=models.CharField(unique=True, max_length=255), + ), + migrations.AlterField( + model_name='grant', + name='code', + field=models.CharField(unique=True, max_length=255), + ), + migrations.AlterField( + model_name='refreshtoken', + name='token', + field=models.CharField(unique=True, max_length=255), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index f87395002..0025fcc85 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -149,7 +149,7 @@ class Grant(models.Model): * :attr:`scope` Required scopes, optional """ user = models.ForeignKey(settings.AUTH_USER_MODEL) - code = models.CharField(max_length=255, db_index=True) # code comes from oauthlib + code = models.CharField(max_length=255, unique=True) # code comes from oauthlib application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) expires = models.DateTimeField() redirect_uri = models.CharField(max_length=255) @@ -186,7 +186,7 @@ class AccessToken(models.Model): * :attr:`scope` Allowed scopes """ user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) - token = models.CharField(max_length=255, db_index=True) + token = models.CharField(max_length=255, unique=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) expires = models.DateTimeField() scope = models.TextField(blank=True) @@ -255,7 +255,7 @@ class RefreshToken(models.Model): bounded to """ user = models.ForeignKey(settings.AUTH_USER_MODEL) - token = models.CharField(max_length=255, db_index=True) + token = models.CharField(max_length=255, unique=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) access_token = models.OneToOneField(AccessToken, related_name='refresh_token') From 118f2094e5e05974b913a5024adcf14d80210a5e Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Mon, 21 Nov 2016 22:49:23 +0100 Subject: [PATCH 053/722] Added djangomaster envs to travis --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index d4480ba6e..fa77f4acb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,14 +8,17 @@ env: - TOXENV=py27-django18 - TOXENV=py27-django19 - TOXENV=py27-django110 + - TOXENV=py27-djangomaster - TOXENV=py32-django18 - TOXENV=py33-django18 - TOXENV=py34-django18 - TOXENV=py34-django19 - TOXENV=py34-django110 + - TOXENV=py34-djangomaster - TOXENV=py35-django18 - TOXENV=py35-django19 - TOXENV=py35-django110 + - TOXENV=py35-djangomaster - TOXENV=docs matrix: From 5c2c6a2e18652d14024a902554cd70ded12ab691 Mon Sep 17 00:00:00 2001 From: Alessandro De Angelis Date: Fri, 18 Nov 2016 00:45:02 +0100 Subject: [PATCH 054/722] Fixed issue #424 --- oauth2_provider/oauth2_validators.py | 5 +++++ oauth2_provider/settings.py | 2 +- oauth2_provider/tests/test_authorization_code.py | 13 +++++++------ 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 38645ae5d..d60fb080b 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -297,6 +297,11 @@ def save_authorization_code(self, client_id, code, request, *args, **kwargs): scope=' '.join(request.scopes)) g.save() + def rotate_refresh_token(self, request): + """ + """ + return oauth2_settings.ROTATE_REFRESH_TOKEN + @transaction.atomic def save_bearer_token(self, token, request, *args, **kwargs): """ diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 7fb78e211..63829028a 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -43,6 +43,7 @@ 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 60, 'ACCESS_TOKEN_EXPIRE_SECONDS': 36000, 'REFRESH_TOKEN_EXPIRE_SECONDS': None, + 'ROTATE_REFRESH_TOKEN': True, 'APPLICATION_MODEL': getattr(settings, 'OAUTH2_PROVIDER_APPLICATION_MODEL', 'oauth2_provider.Application'), 'REQUEST_APPROVAL_PROMPT': 'force', 'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'], @@ -154,5 +155,4 @@ def validate_setting(self, attr, val): if not val and attr in self.mandatory: raise AttributeError("OAuth2Provider setting: '%s' is mandatory" % attr) - oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index 155eb0424..0e3b652ad 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -707,13 +707,14 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): 'refresh_token': content['refresh_token'], 'scope': content['scope'], } + oauth2_settings.ROTATE_REFRESH_TOKEN = False - with mock.patch('oauthlib.oauth2.rfc6749.request_validator.RequestValidator.rotate_refresh_token', - return_value=False): - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) + response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + oauth2_settings.ROTATE_REFRESH_TOKEN = True def test_basic_auth_bad_authcode(self): """ From eec701d68faab3e7093958876a7200ebd76d6a73 Mon Sep 17 00:00:00 2001 From: Alessandro De Angelis Date: Thu, 24 Nov 2016 11:31:00 +0100 Subject: [PATCH 055/722] added docstring for rotate_refresh_token, removed unused import, chagelog modified --- AUTHORS | 1 + docs/changelog.rst | 1 + oauth2_provider/oauth2_validators.py | 1 + oauth2_provider/settings.py | 1 + oauth2_provider/tests/test_authorization_code.py | 1 - 5 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index bec289f5d..36008359c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,3 +22,4 @@ Jim Graham pySilver Silvano Cerza Federico Dolce +Alessandro De Angelis diff --git a/docs/changelog.rst b/docs/changelog.rst index 95c72f952..7a74aa87a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -4,6 +4,7 @@ Changelog Development ~~~~~~~~~~~ +* #424: Added a ROTATE_REFRESH_TOKEN setting to control whether refresh tokens are reused or not * #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 * #396: Added an IsAuthenticatedOrTokenHasScope Permission diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d60fb080b..1fd80bbfe 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -299,6 +299,7 @@ def save_authorization_code(self, client_id, code, request, *args, **kwargs): def rotate_refresh_token(self, request): """ + Checks if rotate refresh token is enabled """ return oauth2_settings.ROTATE_REFRESH_TOKEN diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 63829028a..4ba498531 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -155,4 +155,5 @@ def validate_setting(self, attr, val): if not val and attr in self.mandatory: raise AttributeError("OAuth2Provider setting: '%s' is mandatory" % attr) + oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index 0e3b652ad..05c16739f 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -3,7 +3,6 @@ import base64 import json import datetime -import mock from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse From 653b84e98a05acc3cb2c584acb454a2a20d97832 Mon Sep 17 00:00:00 2001 From: Alessandro De Angelis Date: Thu, 1 Dec 2016 22:11:23 +0100 Subject: [PATCH 056/722] added docs for rotate_refresh_token setting --- docs/settings.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index efba4d1ce..65251321b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -101,8 +101,11 @@ REFRESH_TOKEN_EXPIRE_SECONDS The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +ROTATE_REFRESH_TOKEN +~~~~~~~~~~~~~~~~~~~~ +When is set to `True` (default) a new refresh token is issued to the client when the client refresh an access token. + REQUEST_APPROVAL_PROMPT ~~~~~~~~~~~~~~~~~~~~~~~ Can be ``'force'`` or ``'auto'``. The strategy used to display the authorization form. Refer to :ref:`skip-auth-form`. - From a4e2d7b4ac4c3650097b66561c1505f0b298b940 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 1 Dec 2016 22:22:50 +0100 Subject: [PATCH 057/722] Fixed a typo --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 65251321b..ac8cfce3b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -103,7 +103,7 @@ the ``cleartokens`` management command. Check :ref:`cleartokens` management comm ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ -When is set to `True` (default) a new refresh token is issued to the client when the client refresh an access token. +When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. REQUEST_APPROVAL_PROMPT ~~~~~~~~~~~~~~~~~~~~~~~ From bee18f2550acaea12d8954f76a446eded43d7e66 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 1 Dec 2016 22:38:17 +0100 Subject: [PATCH 058/722] bumped version to 0.11.0 --- README.rst | 2 +- docs/changelog.rst | 2 +- oauth2_provider/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 19efa0263..2f5052271 100644 --- a/README.rst +++ b/README.rst @@ -97,7 +97,7 @@ Roadmap / Todo list (help wanted) Changelog --------- -Development +0.11.0 [2016-12-1] ~~~~~~~~~~~ * #315: AuthorizationView does not overwrite requests on get diff --git a/docs/changelog.rst b/docs/changelog.rst index 7a74aa87a..2c7cd828c 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,7 +1,7 @@ Changelog ========= -Development +0.11.0 [2016-12-1] ~~~~~~~~~~~ * #424: Added a ROTATE_REFRESH_TOKEN setting to control whether refresh tokens are reused or not diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index a13af8376..326f4a2f9 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.10.0' +__version__ = '0.11.0' __author__ = "Massimiliano Pippi & Federico Frenguelli" From be96a4c89cd5003186dbac73f54734ead0c18e4d Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Thu, 1 Dec 2016 22:46:38 +0100 Subject: [PATCH 059/722] Add setup.cfg to build a universal wheel --- setup.cfg | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 setup.cfg diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 000000000..2a9acf13d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 From cee6090086b414933698db5c71439bf0a84ac2c7 Mon Sep 17 00:00:00 2001 From: Alessandro De Angelis Date: Thu, 1 Dec 2016 23:18:25 +0100 Subject: [PATCH 060/722] dropped support for django 1.7 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 309c52604..925725cef 100644 --- a/setup.py +++ b/setup.py @@ -37,9 +37,9 @@ def get_version(package): "Programming Language :: Python :: 3.5", "Topic :: Software Development :: Libraries :: Python Modules", "Framework :: Django", - "Framework :: Django :: 1.7", "Framework :: Django :: 1.8", "Framework :: Django :: 1.9", + "Framework :: Django :: 1.10", ], keywords='django oauth oauth2 oauthlib', author="Federico Frenguelli, Massimiliano Pippi", From 71f15f652345f4d6879095e52ef33a99cc97b327 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 2 Dec 2016 10:53:31 +0100 Subject: [PATCH 061/722] Updated django versions to the latest bugfix release --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index dd11d16a6..5bd85e6e1 100644 --- a/tox.ini +++ b/tox.ini @@ -15,9 +15,9 @@ envlist = [testenv] commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = - django18: Django==1.8.16 - django19: Django==1.9.11 - django110: Django==1.10.3 + django18: Django==1.8.17 + django19: Django==1.9.12 + django110: Django==1.10.4 djangomaster: https://github.com/django/django/archive/master.tar.gz coverage==4.1 pytest-cov==2.3.0 @@ -27,7 +27,7 @@ deps = # coverage-4.1 doesn't support python-3.2. commands=python runtests.py -q deps = - django18: Django==1.8.16 + django18: Django==1.8.17 -rrequirements/testing.txt [testenv:docs] From d0f127e83269e9bc157f7963512a9ff12af240ae Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 2 Dec 2016 10:57:34 +0100 Subject: [PATCH 062/722] Removed Django 1.7 references in docs and requirements --- README.rst | 2 +- docs/index.rst | 2 +- requirements/project.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 2f5052271..c515b90b1 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,7 @@ Requirements ------------ * Python 2.7, 3.2, 3.3, 3.4, 3.5 -* Django 1.7, 1.8, 1.9 +* Django 1.8, 1.9, 1.10 Installation ------------ diff --git a/docs/index.rst b/docs/index.rst index de2c0f8b1..7464e954b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,7 +22,7 @@ Requirements ------------ * Python 2.7, 3.2, 3.3, 3.4, 3.5 -* Django 1.7, 1.8, 1.9 +* Django 1.8, 1.9, 1.10 Index ===== diff --git a/requirements/project.txt b/requirements/project.txt index f89e28c15..85af3c6f3 100644 --- a/requirements/project.txt +++ b/requirements/project.txt @@ -1,2 +1,2 @@ -r optional.txt -Django>=1.7 +Django>=1.8 From 2f503d44336d2b1f4357e843188da79c9f4438ba Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 10 Dec 2016 11:58:30 +0200 Subject: [PATCH 063/722] Drop Python 2.6 shims --- oauth2_provider/compat_handlers.py | 6 ------ oauth2_provider/settings.py | 7 ++----- oauth2_provider/tests/settings.py | 2 +- 3 files changed, 3 insertions(+), 12 deletions(-) delete mode 100644 oauth2_provider/compat_handlers.py diff --git a/oauth2_provider/compat_handlers.py b/oauth2_provider/compat_handlers.py deleted file mode 100644 index ce95a02eb..000000000 --- a/oauth2_provider/compat_handlers.py +++ /dev/null @@ -1,6 +0,0 @@ -# flake8: noqa -# Django 1.9 drops the NullHandler since Python 2.7 includes it -try: - from logging import NullHandler -except ImportError: - from django.utils.log import NullHandler diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 4ba498531..66ca78705 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -17,15 +17,12 @@ """ from __future__ import unicode_literals +import importlib import six from django.conf import settings from django.core.exceptions import ImproperlyConfigured -try: - # Available in Python 2.7+ - import importlib -except ImportError: - from django.utils import importlib + USER_SETTINGS = getattr(settings, 'OAUTH2_PROVIDER', None) diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index 1bfc697db..e62bdf799 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -108,7 +108,7 @@ }, 'null': { 'level': 'DEBUG', - 'class': 'oauth2_provider.compat_handlers.NullHandler', + 'class': 'logging.NullHandler', }, }, 'loggers': { From 0ff422a7e5e39f5b0a3844a289de242c16f34011 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 10 Dec 2016 12:13:49 +0200 Subject: [PATCH 064/722] Greatly improve model administration --- oauth2_provider/admin.py | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 20bf1cb75..60929c2e5 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -3,13 +3,34 @@ from .models import Grant, AccessToken, RefreshToken, get_application_model -class RawIDAdmin(admin.ModelAdmin): - raw_id_fields = ('user',) +class ApplicationAdmin(admin.ModelAdmin): + list_display = ("name", "user", "client_type", "authorization_grant_type") + list_filter = ("client_type", "authorization_grant_type", "skip_authorization") + radio_fields = { + "client_type": admin.HORIZONTAL, + "authorization_grant_type": admin.VERTICAL, + } + raw_id_fields = ("user", ) + + +class GrantAdmin(admin.ModelAdmin): + list_display = ("code", "application", "user", "expires") + raw_id_fields = ("user", ) + + +class AccessTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user", ) + + +class RefreshTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user", "access_token") Application = get_application_model() -admin.site.register(Application, RawIDAdmin) -admin.site.register(Grant, RawIDAdmin) -admin.site.register(AccessToken, RawIDAdmin) -admin.site.register(RefreshToken, RawIDAdmin) +admin.site.register(Application, ApplicationAdmin) +admin.site.register(Grant, GrantAdmin) +admin.site.register(AccessToken, AccessTokenAdmin) +admin.site.register(RefreshToken, RefreshTokenAdmin) From 0bcdbd770a31dbe360025b87858435b3180ab05a Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 10 Dec 2016 12:20:53 +0200 Subject: [PATCH 065/722] Separate the base views and management views in url router This allows users to selectively include the base urlpatterns and the management urlpatterns, or include them behind separate root urls. --- oauth2_provider/urls.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 2e3130e25..41ca174c3 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -3,23 +3,26 @@ from . import views -urlpatterns = [ + +base_urlpatterns = [ url(r'^authorize/$', views.AuthorizationView.as_view(), name="authorize"), url(r'^token/$', views.TokenView.as_view(), name="token"), url(r'^revoke_token/$', views.RevokeTokenView.as_view(), name="revoke-token"), ] -# Application management views -urlpatterns += [ + +management_urlpatterns = [ + # Application management views url(r'^applications/$', views.ApplicationList.as_view(), name="list"), url(r'^applications/register/$', views.ApplicationRegistration.as_view(), name="register"), url(r'^applications/(?P\d+)/$', views.ApplicationDetail.as_view(), name="detail"), url(r'^applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), url(r'^applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="update"), -] - -urlpatterns += [ + # Token management views url(r'^authorized_tokens/$', views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), url(r'^authorized_tokens/(?P\d+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] + + +urlpatterns = base_urlpatterns + management_urlpatterns From def9c35f2155ff6d76576732bf5edddce22864e8 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 10 Dec 2016 18:10:13 +0200 Subject: [PATCH 066/722] Drop deprecated 'scopes' query parameter from AllowForm This parameter was deprecated in 2014. As of Django 1.11, the query dict is now immutable, which causes a crash in this path. Rather than copying it, we can just drop the old shim. --- README.rst | 5 +++ oauth2_provider/forms.py | 7 ---- oauth2_provider/tests/test_scopes.py | 55 ---------------------------- 3 files changed, 5 insertions(+), 62 deletions(-) diff --git a/README.rst b/README.rst index c515b90b1..812585364 100644 --- a/README.rst +++ b/README.rst @@ -97,6 +97,11 @@ Roadmap / Todo list (help wanted) Changelog --------- +0.12.0 [Unreleased] +~~~~~~~~~~ + +* Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped + 0.11.0 [2016-12-1] ~~~~~~~~~~~ diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index a2b4d8f1c..484720223 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -8,10 +8,3 @@ class AllowForm(forms.Form): client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) - - def __init__(self, *args, **kwargs): - data = kwargs.get('data') - # backwards compatible support for plural `scopes` query parameter - if data and 'scopes' in data: - data['scope'] = data['scopes'] - return super(AllowForm, self).__init__(*args, **kwargs) diff --git a/oauth2_provider/tests/test_scopes.py b/oauth2_provider/tests/test_scopes.py index dfc1fdfd1..6d07f8355 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/oauth2_provider/tests/test_scopes.py @@ -66,61 +66,6 @@ def tearDown(self): self.dev_user.delete() -class TestScopesQueryParameterBackwardsCompatibility(BaseTest): - def setUp(self): - super(TestScopesQueryParameterBackwardsCompatibility, self).setUp() - oauth2_settings._SCOPES = ['read', 'write'] - oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] - - def test_scopes_query_parameter_is_supported_on_post(self): - """ - Tests support for plural `scopes` query parameter on POST requests. - - """ - self.client.login(username="test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scopes': 'read write', # using plural `scopes` - 'redirect_uri': 'http://example.it', - 'response_type': 'code', - 'allow': True, - } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() - - grant = Grant.objects.get(code=authorization_code) - self.assertEqual(grant.scope, "read write") - - def test_scopes_query_parameter_is_supported_on_get(self): - """ - Tests support for plural `scopes` query parameter on GET requests. - - """ - self.client.login(username="test_user", password="123456") - - query_string = urlencode({ - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scopes': 'read write', # using plural `scopes` - 'redirect_uri': 'http://example.it', - 'response_type': 'code', - }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form['scope'].value(), "read write") - - class TestScopesSave(BaseTest): def test_scopes_saved_in_grant(self): """ From 660a1e2dc8351228d3cd0df278a6851e0946208e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 15 Dec 2016 15:36:56 +0200 Subject: [PATCH 067/722] Document the ALLOWED_REDIRECT_URI_SCHEMES setting --- docs/settings.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index ac8cfce3b..dd6f297aa 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -32,6 +32,14 @@ The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients can cache the token for a reasonable amount of time. +ALLOWED_REDIRECT_URI_SCHEMES +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: ``["http", "https"]`` + +A list of schemes that the ``redirect_uri`` field will be validated against. +Setting this to ``["https"]`` only in production is strongly recommended. + APPLICATION_MODEL ~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your applications. Overwrite From 5aec05ee07c83a057ff9020572e91e969158b90f Mon Sep 17 00:00:00 2001 From: Alessandro De Angelis Date: Thu, 15 Dec 2016 23:36:02 +0100 Subject: [PATCH 068/722] fixed typo in admin.py, added form for ApplicationUpdate --- oauth2_provider/admin.py | 2 +- oauth2_provider/tests/test_scopes.py | 2 +- oauth2_provider/views/application.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 60929c2e5..cbb0c2c19 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -24,7 +24,7 @@ class AccessTokenAdmin(admin.ModelAdmin): class RefreshTokenAdmin(admin.ModelAdmin): - list_display = ("token", "user", "application", "expires") + list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") diff --git a/oauth2_provider/tests/test_scopes.py b/oauth2_provider/tests/test_scopes.py index 6d07f8355..26d2371ac 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/oauth2_provider/tests/test_scopes.py @@ -8,7 +8,7 @@ from django.test import TestCase, RequestFactory from .test_utils import TestCaseUtils -from ..compat import urlparse, parse_qs, urlencode +from ..compat import urlparse, parse_qs from ..models import get_application_model, Grant, AccessToken from ..settings import oauth2_settings from ..views import ScopedProtectedResourceView, ReadWriteScopedResourceView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 777ccf801..7d4cdbac8 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -69,3 +69,13 @@ class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView): """ context_object_name = 'application' template_name = "oauth2_provider/application_form.html" + + def get_form_class(self): + """ + Returns the form class for the application model + """ + return modelform_factory( + get_application_model(), + fields=('name', 'client_id', 'client_secret', 'client_type', + 'authorization_grant_type', 'redirect_uris') + ) From 63e2049e9097a59e3db5fd892c2343a537f3b542 Mon Sep 17 00:00:00 2001 From: Mitchel Humpherys Date: Mon, 9 Jan 2017 19:09:43 -0800 Subject: [PATCH 069/722] docs: permissions: Typo wwith => with --- docs/rest-framework/permissions.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index 629bf50d0..d10c4a9b5 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -70,7 +70,7 @@ IsAuthenticatedOrTokenHasScope The `TokenHasResourceScope` permission class allows the access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. And also allows access to Authenticated users who are authenticated in django, but were not authenticated trought the OAuth2Authentication class. This allows for protection of the api using scopes, but still let's users browse the full browseable API. -To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this wwith the DjangoModelPermission or the DjangoObjectPermission. +To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. For example: From cb2d1a5ae34979450b29eab917a2ca3ec187805b Mon Sep 17 00:00:00 2001 From: Chris Morbitzer Date: Mon, 16 Jan 2017 16:07:08 -0600 Subject: [PATCH 070/722] Add app_name to urls.py --- oauth2_provider/urls.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 41ca174c3..7a07bf28d 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -4,6 +4,9 @@ from . import views +app_name = 'oauth2_provider' + + base_urlpatterns = [ url(r'^authorize/$', views.AuthorizationView.as_view(), name="authorize"), url(r'^token/$', views.TokenView.as_view(), name="token"), From 59ba832d5a64bdda2d8d40731dff69e1c0e91046 Mon Sep 17 00:00:00 2001 From: Petri Lehtinen Date: Tue, 17 Jan 2017 12:51:10 +0200 Subject: [PATCH 071/722] Add on_delete to all ForeignKey and OneToOne fields --- oauth2_provider/migrations/0001_initial.py | 16 +++++++------- oauth2_provider/migrations/0002_08_updates.py | 4 ++-- .../migrations/0003_auto_20160316_1503.py | 2 +- oauth2_provider/models.py | 21 ++++++++++++------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py index a1c59c709..d896a833f 100644 --- a/oauth2_provider/migrations/0001_initial.py +++ b/oauth2_provider/migrations/0001_initial.py @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])), ('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)), ('name', models.CharField(max_length=255, blank=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], options={ 'abstract': False, @@ -40,8 +40,8 @@ class Migration(migrations.Migration): ('token', models.CharField(max_length=255, db_index=True)), ('expires', models.DateTimeField()), ('scope', models.TextField(blank=True)), - ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.CreateModel( @@ -52,8 +52,8 @@ class Migration(migrations.Migration): ('expires', models.DateTimeField()), ('redirect_uri', models.CharField(max_length=255)), ('scope', models.TextField(blank=True)), - ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), migrations.CreateModel( @@ -61,9 +61,9 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('token', models.CharField(max_length=255, db_index=True)), - ('access_token', models.OneToOneField(related_name='refresh_token', to='oauth2_provider.AccessToken')), - ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL)), + ('access_token', models.OneToOneField(related_name='refresh_token', to='oauth2_provider.AccessToken', on_delete=models.CASCADE)), + ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], ), ] diff --git a/oauth2_provider/migrations/0002_08_updates.py b/oauth2_provider/migrations/0002_08_updates.py index a95aedbf4..01e1a4a54 100644 --- a/oauth2_provider/migrations/0002_08_updates.py +++ b/oauth2_provider/migrations/0002_08_updates.py @@ -24,13 +24,13 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='Application', name='user', - field=models.ForeignKey(related_name='oauth2_provider_application', to=settings.AUTH_USER_MODEL), + field=models.ForeignKey(related_name='oauth2_provider_application', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), preserve_default=True, ), migrations.AlterField( model_name='AccessToken', name='user', - field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True), + field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), preserve_default=True, ), ] diff --git a/oauth2_provider/migrations/0003_auto_20160316_1503.py b/oauth2_provider/migrations/0003_auto_20160316_1503.py index 5dd05ddff..49cfb4b29 100644 --- a/oauth2_provider/migrations/0003_auto_20160316_1503.py +++ b/oauth2_provider/migrations/0003_auto_20160316_1503.py @@ -15,6 +15,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='application', name='user', - field=models.ForeignKey(related_name='oauth2_provider_application', blank=True, to=settings.AUTH_USER_MODEL, null=True), + field=models.ForeignKey(related_name='oauth2_provider_application', blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 0025fcc85..d04f4dbd4 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -60,7 +60,7 @@ class AbstractApplication(models.Model): client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", - null=True, blank=True) + null=True, blank=True, on_delete=models.CASCADE) help_text = _("Allowed URIs list, space separated") redirect_uris = models.TextField(help_text=help_text, @@ -148,9 +148,10 @@ class Grant(models.Model): * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional """ - user = models.ForeignKey(settings.AUTH_USER_MODEL) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) code = models.CharField(max_length=255, unique=True) # code comes from oauthlib - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE) expires = models.DateTimeField() redirect_uri = models.CharField(max_length=255) scope = models.TextField(blank=True) @@ -185,9 +186,11 @@ class AccessToken(models.Model): * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True) + user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, + on_delete=models.CASCADE) token = models.CharField(max_length=255, unique=True) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE) expires = models.DateTimeField() scope = models.TextField(blank=True) @@ -254,11 +257,13 @@ class RefreshToken(models.Model): * :attr:`access_token` AccessToken instance this refresh token is bounded to """ - user = models.ForeignKey(settings.AUTH_USER_MODEL) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) token = models.CharField(max_length=255, unique=True) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE) access_token = models.OneToOneField(AccessToken, - related_name='refresh_token') + related_name='refresh_token', + on_delete=models.CASCADE) def revoke(self): """ From 86e76174af78e820ad75b01b69cb400af461e819 Mon Sep 17 00:00:00 2001 From: Nuno Bajanca Date: Thu, 26 Jan 2017 19:40:05 +0000 Subject: [PATCH 072/722] Add intersphinx extension and map python3 --- docs/conf.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/conf.py b/docs/conf.py index d9529ec62..f6c441b1a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -108,6 +108,12 @@ def get_version(package): #keep_warnings = False +# http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html +extensions.append('sphinx.ext.intersphinx') +intersphinx_mapping = {'python3': ('https://docs.python.org/3.5', None)} + + + # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for From abec07cffe8505ea5d0698223ba52b14b0427424 Mon Sep 17 00:00:00 2001 From: Nuno Bajanca Date: Thu, 26 Jan 2017 19:42:55 +0000 Subject: [PATCH 073/722] Remove warning "title length must match underline" --- docs/changelog.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 2c7cd828c..533372bb5 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -2,7 +2,7 @@ Changelog ========= 0.11.0 [2016-12-1] -~~~~~~~~~~~ +------------------ * #424: Added a ROTATE_REFRESH_TOKEN setting to control whether refresh tokens are reused or not * #315: AuthorizationView does not overwrite requests on get @@ -13,7 +13,7 @@ Changelog 0.10.0 [2015-12-14] ------------------- +------------------- * **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** * #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant @@ -175,7 +175,7 @@ Changelog * Bugfix #27: OAuthlib refresh token refactoring 0.3.0 [2013-06-14] ----------------------- +------------------ * `Django REST Framework `_ integration layer * Bugfix #13: Populate request with client and user in validate_bearer_token From f9f5670738b699b272c3fe381b47735f7c4d8862 Mon Sep 17 00:00:00 2001 From: Nuno Bajanca Date: Thu, 26 Jan 2017 19:43:52 +0000 Subject: [PATCH 074/722] Remove warning "title length must match underline" --- docs/tutorial/tutorial_04.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index e115f827f..3908579da 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -1,12 +1,12 @@ Part 4 - Revoking an OAuth2 Token -==================================== +================================= Scenario -------- You've granted a user an :term:`Access Token`, following :doc:`part 1 ` and now you would like to revoke that token, probably in response to a client request (to logout). Revoking a Token --------------- +---------------- Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` into your `urls.py` as specified in :doc:`part 1 `, you'll have a URL at `/o/revoke_token`. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. `Oauthlib `_ is compliant with https://tools.ietf.org/html/rfc7009, so as specified, the revocation request requires: @@ -17,7 +17,7 @@ Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` i Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. Setup a Request ----------------- +--------------- Depending on the client type you're using, the token revocation request you may submit to the authentication server may vary. A `Public` client, for example, will not have access to your `Client Secret`. A revoke request from a public client would omit that secret, and take the form: :: From c739a093cf11e9040dfcf4b207e10fad4e33dd1e Mon Sep 17 00:00:00 2001 From: Nuno Bajanca Date: Thu, 26 Jan 2017 20:56:43 +0000 Subject: [PATCH 075/722] map django docs --- docs/conf.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index f6c441b1a..e3bd40819 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,8 @@ def get_version(package): # http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html extensions.append('sphinx.ext.intersphinx') -intersphinx_mapping = {'python3': ('https://docs.python.org/3.5', None)} +intersphinx_mapping = {'python3': ('https://docs.python.org/3.5', None), + 'django': ('http://django.readthedocs.org/en/latest/', None)} From 813feaa25c1d2dcc92538facd6acf24f65daf6ce Mon Sep 17 00:00:00 2001 From: Nuno Bajanca Date: Thu, 26 Jan 2017 21:00:01 +0000 Subject: [PATCH 076/722] docs about Templates --- docs/advanced_topics.rst | 1 + docs/index.rst | 1 + docs/settings.rst | 4 +- docs/templates.rst | 245 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 docs/templates.rst diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 6e1d5ace1..b0823ae57 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -1,6 +1,7 @@ Advanced topics +++++++++++++++ +.. _extend_app_model: Extending the Application model =============================== diff --git a/docs/index.rst b/docs/index.rst index 7464e954b..601bf36d2 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -34,6 +34,7 @@ Index tutorial/tutorial rest-framework/rest-framework views/views + templates views/details models advanced_topics diff --git a/docs/settings.rst b/docs/settings.rst index dd6f297aa..9360e701c 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -68,7 +68,7 @@ The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. OAUTH2_SERVER_CLASS -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) used in the ``OAuthLibMixin`` that implements OAuth2 grant types. @@ -86,6 +86,8 @@ SCOPES ~~~~~~ A dictionary mapping each scope name to its human description. +.. _settings_default_scopes: + DEFAULT_SCOPES ~~~~~~~~~~~~~~ A list of scopes that should be returned by default. diff --git a/docs/templates.rst b/docs/templates.rst new file mode 100644 index 000000000..4b7e1033a --- /dev/null +++ b/docs/templates.rst @@ -0,0 +1,245 @@ +Templates +========= + +A set of templates is provided. These templates range from Django Admin Site alternatives to manage the Apps that use your App as a provider, to Error and Authorization Templates. + +You can override default templates located in ``templates/oauth2_provider`` folder and provide a custom layout. +To override these templates you just need to create a folder named ``oauth2_provider`` inside your templates folder and, inside this folder, add a file that matches the name of the template you're trying to override. + +.. important: + + In ``INSTALLED_APPS`` on ``settings.py``, ``'django.contrib.staticfiles'``, must be before ``'oauth2_provider'``. + +.. note: + + Every view provides access only to data belonging to the logged in user who performs the request. + +The templates available are: + +- `base.html`_ +- `authorize.html`_ +- `Management`_: + - `Application`_: + - `application_list.html`_ + - `application_form.html`_ + - `application_registration_form.html`_ + - `application_detail.html`_ + - `application_confirm_delete.html`_ + - `Token`_: + - `authorized-tokens.html`_ + - `authorized-token-delete.html`_ + + + +base.html +--------- + +If you just want a different look and feel you may only override this template. +To inherit this template just add ``{% extends "oauth2_provider/base.html" %}`` in the first line of the other templates. This is what is done with the default templates. + +The blocks defined in it are: + +- ``title`` inside the HTML title tag; +- ``css`` inside the head; +- ``content`` in the body. + +.. note: + + See ` Django docs on template inheritance `_ for more information on the use of blocks. + +authorize.html +-------------- + +Authorize is rendered in :class:`~oauth2_provider.views.base.AuthorizationView` (``authorize/``). + +This template gets passed the following context variables: + + +- ``scopes`` - :obj:`list` with the scopes requested by the application; + +.. caution:: + See :ref:`settings_default_scopes` to understand what is returned if no scopes are requested. + +- ``scopes_descriptions`` - :obj:`list` with the descriptions for the scopes requested; + +- ``application`` - An :class:`~oauth2_provider.models.Application` object + +.. note:: + If you haven't created your own Application Model (see how in :ref:`extend_app_model`), you will get an + :class:`~oauth2_provider.models.AbstractApplication` object. + +- ``client_id`` - Passed in the URI, already validated. +- ``redirect_uri`` - Passed in the URI (optional), already validated. + +.. note:: + If it wasn't provided on the request, the default one has been set (see :meth:`~oauth2_provider.models.AbstractApplication.default_redirect_uri`). + +- ``response_type`` - Passed in the URI, already validated. +- ``state`` - Passed in the URI (optional). +- ``form`` - An :class:`~oauth2_provider.forms.AllowForm` with all the hidden fields already filled with the values above. + +.. important:: + One extra variable, named ``error`` will also be available if an Oauth2 exception occurs. + This variable is a :obj:`dict` with ``error`` and ``description`` + +Example (this is the default page you may find on ``templates/oauth2_provider/authorize.html``): :: + + {% extends "oauth2_provider/base.html" %} + + {% load i18n %} + {% block content %} +
+ {% if not error %} +
+

{% trans "Authorize" %} {{ application.name }}?

+ {% csrf_token %} + + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% endif %} + {% endfor %} + +

{% trans "Application requires following permissions" %}

+
    + {% for scope in scopes_descriptions %} +
  • {{ scope }}
  • + {% endfor %} +
+ + {{ form.errors }} + {{ form.non_field_errors }} + +
+
+ + +
+
+
+ + {% else %} +

Error: {{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+ {% endblock %} + + +Management +---------- +The management templates are Django Admin Site alternatives to manage the Apps. + + +Application +``````````` +All templates receive :class:`~oauth2_provider.models.Application` objects. + +.. note:: + If you haven't created your own Application Model (see how in :ref:`extend_app_model`), you will get an + :class:`~oauth2_provider.models.AbstractApplication` object. + + +application_list.html +~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.ApplicationList` (``applications/``). +This class inherits :class:`django.views.generic.edit.ListView`. + +This template gets passed the following template context variable: + +- ``applications`` - a :obj:`list` with all the applications, may be ``None``. + + +application_form.html +~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.ApplicationUpdate` (``applications//update/``). +This class inherits :class:`django.views.generic.edit.UpdateView`. + +This template gets passed the following template context variables: + +- ``application`` - the :class:`~oauth2_provider.models.Application` object. +- ``form`` - a :obj:`~django.forms.Form` with the following fields: + - ``name`` + - ``client_id`` + - ``client_secret`` + - ``client_type`` + - ``authorization_grant_type`` + - ``redirect_uris`` + +.. caution:: + In the default implementation this template in extended by `application_registration_form.html`_. + Be sure to provide the same blocks if you are only overiding this template. + +application_registration_form.html +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.ApplicationRegistration` (``applications/register/``). +This class inherits :class:`django.views.generic.edit.CreateView`. + +This template gets passed the following template context variable: + +- ``form`` - a :obj:`~django.forms.Form` with the following fields: + - ``name`` + - ``client_id`` + - ``client_secret`` + - ``client_type`` + - ``authorization_grant_type`` + - ``redirect_uris`` + +.. note:: + In the default implementation this template extends `application_form.html`_. + + + +application_detail.html +~~~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.ApplicationDetail` (``applications//``). +This class inherits :class:`django.views.generic.edit.DetailView`. + +This template gets passed the following template context variable: + +- ``application`` - the :class:`~oauth2_provider.models.Application` object. + +application_confirm_delete.html +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.ApplicationDelete` (``applications//delete/``). +This class inherits :class:`django.views.generic.edit.DeleteView`. + +This template gets passed the following template context variable: + +- ``application`` - the :class:`~oauth2_provider.models.Application` object. + +.. important:: + To override successfully this template you should provide a form that posts to the same URL, example: + ``
`` + + +Token +````` +All templates receive :class:`~oauth2_provider.models.AccessToken` objects. + +authorized-tokens.html +~~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.AuthorizedTokensListView` (``authorized_tokens/``). +This class inherits :class:`django.views.generic.edit.ListView`. + +This template gets passed the following template context variable: + +- ``authorized_tokens`` - a :obj:`list` with all the tokens that belong to applications that the user owns, may be ``None``. + +.. important:: + To override successfully this template you should provide links to revoke the token, example: + ``revoke`` + + +authorized-token-delete.html +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Rendered in :class:`~oauth2_provider.views.base.AuthorizedTokenDeleteView` (``authorized_tokens//delete/``). +This class inherits :class:`django.views.generic.edit.DeleteView`. + +This template gets passed the following template context variable: + +- ``authorized_token`` - the :class:`~oauth2_provider.models.AccessToken` object. + +.. important:: + To override successfully this template you should provide a form that posts to the same URL, example: + ```` \ No newline at end of file From c8bba725759f631d87de6a9a28b33953373267bc Mon Sep 17 00:00:00 2001 From: Noah Gilmore Date: Mon, 23 Jan 2017 01:36:48 -0800 Subject: [PATCH 077/722] Fix three typos --- oauth2_provider/views/base.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index a98ec0a82..9afb21abf 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -48,7 +48,7 @@ def error_response(self, error, **kwargs): class AuthorizationView(BaseAuthorizationView, FormView): """ - Implements and endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the + Implements an endpoint to handle *Authorization Requests* as in :rfc:`4.1.1` and prompting the user with a form to determine if she authorizes the client application to access her data. This endpoint is reached two times during the authorization process: * first receive a ``GET`` request from user asking authorization for a certain client @@ -58,11 +58,11 @@ class AuthorizationView(BaseAuthorizationView, FormView): * then receive a ``POST`` request possibly after user authorized the access Some informations contained in the ``GET`` request and needed to create a Grant token during - the ``POST`` request would be lost between the two steps above, so they are temporary stored in + the ``POST`` request would be lost between the two steps above, so they are temporarily stored in hidden fields on the form. A possible alternative could be keeping such informations in the session. - The endpoint is used in the followin flows: + The endpoint is used in the following flows: * Authorization code * Implicit grant """ From f86c1ea3f62eb5a5190158c31a7126dc6430d2b0 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 27 Jan 2017 00:20:38 +0200 Subject: [PATCH 078/722] Stop building Django master on unsupported python versions --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 5bd85e6e1..d5c6d7d59 100644 --- a/tox.ini +++ b/tox.ini @@ -4,10 +4,10 @@ testpaths=oauth2_provider [tox] envlist = - py27-django{18,19,110,master}, + py27-django{18,19,110}, py32-django18, py33-django18, - py34-django{18,19,110,master}, + py34-django{18,19,110}, py35-django{18,19,110,master}, docs, flake8 From 323f8f389bac7992562b8a01ad38e93239de9aa7 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 27 Jan 2017 00:27:51 +0200 Subject: [PATCH 079/722] Fix broken urlresolvers imports on Django 2.0 --- oauth2_provider/compat.py | 6 ++++++ oauth2_provider/models.py | 3 +-- oauth2_provider/tests/test_application_views.py | 2 +- oauth2_provider/tests/test_authorization_code.py | 3 +-- oauth2_provider/tests/test_client_credential.py | 2 +- oauth2_provider/tests/test_implicit.py | 3 +-- oauth2_provider/tests/test_password.py | 2 +- oauth2_provider/tests/test_scopes.py | 3 +-- oauth2_provider/tests/test_token_revocation.py | 3 +-- oauth2_provider/tests/test_token_view.py | 2 +- oauth2_provider/views/application.py | 2 +- oauth2_provider/views/token.py | 1 + 12 files changed, 17 insertions(+), 15 deletions(-) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index f8888505f..2fadd32c6 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -15,3 +15,9 @@ from urllib import urlencode, unquote_plus except ImportError: from urllib.parse import urlencode, unquote_plus + +# changed in Django 1.10 (broken in Django 2.0) +try: + from django.urls import reverse, reverse_lazy +except ImportError: + from django.core.urlresolvers import reverse, reverse_lazy diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index d04f4dbd4..6d3dc8305 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -4,7 +4,6 @@ from django.apps import apps from django.conf import settings -from django.core.urlresolvers import reverse from django.db import models, transaction from django.utils import timezone @@ -13,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured from .settings import oauth2_settings -from .compat import parse_qsl, urlparse +from .compat import parse_qsl, reverse, urlparse from .generators import generate_client_secret, generate_client_id from .validators import validate_uris diff --git a/oauth2_provider/tests/test_application_views.py b/oauth2_provider/tests/test_application_views.py index 8cf22b9a8..cb43ee403 100644 --- a/oauth2_provider/tests/test_application_views.py +++ b/oauth2_provider/tests/test_application_views.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase +from ..compat import reverse from ..models import get_application_model diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index 05c16739f..144df56bc 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -5,11 +5,10 @@ import datetime from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import urlparse, parse_qs, urlencode +from ..compat import parse_qs, reverse, urlparse, urlencode from ..models import get_application_model, Grant, AccessToken, RefreshToken from ..settings import oauth2_settings from ..views import ProtectedResourceView diff --git a/oauth2_provider/tests/test_client_credential.py b/oauth2_provider/tests/test_client_credential.py index 515cac59e..b75b2552a 100644 --- a/oauth2_provider/tests/test_client_credential.py +++ b/oauth2_provider/tests/test_client_credential.py @@ -7,13 +7,13 @@ except ImportError: import urllib -from django.core.urlresolvers import reverse from django.contrib.auth import get_user_model from django.test import TestCase, RequestFactory from django.views.generic import View from oauthlib.oauth2 import BackendApplicationServer +from ..compat import reverse from ..models import get_application_model, AccessToken from ..oauth2_backends import OAuthLibCore from ..oauth2_validators import OAuth2Validator diff --git a/oauth2_provider/tests/test_implicit.py b/oauth2_provider/tests/test_implicit.py index 25493ec61..9ff3fe8d1 100644 --- a/oauth2_provider/tests/test_implicit.py +++ b/oauth2_provider/tests/test_implicit.py @@ -1,10 +1,9 @@ from __future__ import unicode_literals from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase, RequestFactory -from ..compat import urlparse, parse_qs, urlencode +from ..compat import parse_qs, reverse, urlparse, urlencode from ..models import get_application_model from ..settings import oauth2_settings from ..views import ProtectedResourceView diff --git a/oauth2_provider/tests/test_password.py b/oauth2_provider/tests/test_password.py index 72db69f37..7ff944d8e 100644 --- a/oauth2_provider/tests/test_password.py +++ b/oauth2_provider/tests/test_password.py @@ -3,9 +3,9 @@ import json from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase, RequestFactory +from ..compat import reverse from ..models import get_application_model from ..settings import oauth2_settings from ..views import ProtectedResourceView diff --git a/oauth2_provider/tests/test_scopes.py b/oauth2_provider/tests/test_scopes.py index 26d2371ac..70cb1c4d8 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/oauth2_provider/tests/test_scopes.py @@ -4,11 +4,10 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured -from django.core.urlresolvers import reverse from django.test import TestCase, RequestFactory from .test_utils import TestCaseUtils -from ..compat import urlparse, parse_qs +from ..compat import parse_qs, reverse, urlparse from ..models import get_application_model, Grant, AccessToken from ..settings import oauth2_settings from ..views import ScopedProtectedResourceView, ReadWriteScopedResourceView diff --git a/oauth2_provider/tests/test_token_revocation.py b/oauth2_provider/tests/test_token_revocation.py index 868a3aa3b..36feeddd8 100644 --- a/oauth2_provider/tests/test_token_revocation.py +++ b/oauth2_provider/tests/test_token_revocation.py @@ -3,11 +3,10 @@ import datetime from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import urlencode +from ..compat import reverse, urlencode from ..models import get_application_model, AccessToken, RefreshToken from ..settings import oauth2_settings diff --git a/oauth2_provider/tests/test_token_view.py b/oauth2_provider/tests/test_token_view.py index 30c3fa020..fa768222a 100644 --- a/oauth2_provider/tests/test_token_view.py +++ b/oauth2_provider/tests/test_token_view.py @@ -3,10 +3,10 @@ import datetime from django.contrib.auth import get_user_model -from django.core.urlresolvers import reverse from django.test import TestCase from django.utils import timezone +from ..compat import reverse from ..models import get_application_model, AccessToken diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 7d4cdbac8..587524462 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,9 +1,9 @@ -from django.core.urlresolvers import reverse_lazy from django.forms.models import modelform_factory from django.views.generic import CreateView, DetailView, DeleteView, ListView, UpdateView from braces.views import LoginRequiredMixin +from ..compat import reverse_lazy from ..models import get_application_model diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index f7e4562e9..23d305a4e 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -5,6 +5,7 @@ from braces.views import LoginRequiredMixin +from ..compat import reverse_lazy from ..models import AccessToken From 50e02b42ae948a30513f461f16c67b365aa8b57a Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 27 Jan 2017 00:35:09 +0200 Subject: [PATCH 080/722] Test against Django 1.10.5 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index d5c6d7d59..f9b85f230 100644 --- a/tox.ini +++ b/tox.ini @@ -17,7 +17,7 @@ commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append deps = django18: Django==1.8.17 django19: Django==1.9.12 - django110: Django==1.10.4 + django110: Django==1.10.5 djangomaster: https://github.com/django/django/archive/master.tar.gz coverage==4.1 pytest-cov==2.3.0 From a2c631e7ffafd71be34415178e9d58706ec2f194 Mon Sep 17 00:00:00 2001 From: Damien de Lemeny Date: Tue, 20 Dec 2016 16:35:09 +0100 Subject: [PATCH 081/722] Fix typo in tests --- oauth2_provider/tests/test_oauth2_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/tests/test_oauth2_backends.py b/oauth2_provider/tests/test_oauth2_backends.py index 5203e09cc..d0b8a766b 100644 --- a/oauth2_provider/tests/test_oauth2_backends.py +++ b/oauth2_provider/tests/test_oauth2_backends.py @@ -13,7 +13,7 @@ def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() - def test_swappable_serer_class(self): + def test_swappable_server_class(self): with mock.patch('oauth2_provider.oauth2_backends.oauth2_settings.OAUTH2_SERVER_CLASS'): oauthlib_core = OAuthLibCore() self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) From 497de2b54412295163a5c5e6f06d51bedbe6ca43 Mon Sep 17 00:00:00 2001 From: Damien de Lemeny Date: Mon, 30 Jan 2017 18:28:01 +0100 Subject: [PATCH 082/722] Make Application.authorization_grant_type loose This modification allows swapped Application models to customize the way grant types are allowed per application (ex: multiple allowed grant types) --- oauth2_provider/models.py | 3 +++ oauth2_provider/oauth2_validators.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6d3dc8305..e47d9f43e 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -125,6 +125,9 @@ def get_absolute_url(self): def __str__(self): return self.name or self.client_id + def allows_grant_type(self, *grant_types): + return self.authorization_grant_type in grant_types + class Application(AbstractApplication): class Meta(AbstractApplication.Meta): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 1fd80bbfe..be60ad843 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -263,7 +263,7 @@ def validate_grant_type(self, client_id, grant_type, client, request, *args, **k Validate both grant_type is a valid string and grant_type is allowed for current workflow """ assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration - return request.client.authorization_grant_type in GRANT_TYPE_MAPPING[grant_type] + return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): """ @@ -271,9 +271,9 @@ def validate_response_type(self, client_id, response_type, client, request, *arg rfc:`8.4`, so validate the response_type only if it matches 'code' or 'token' """ if response_type == 'code': - return client.authorization_grant_type == AbstractApplication.GRANT_AUTHORIZATION_CODE + return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) elif response_type == 'token': - return client.authorization_grant_type == AbstractApplication.GRANT_IMPLICIT + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) else: return False From 7717f6df8ce1f37f31d4304db8b5f992dd72c737 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 7 Feb 2017 16:32:42 +0200 Subject: [PATCH 083/722] Bump django-braces requirement --- requirements/base.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/base.txt b/requirements/base.txt index 38cc44a03..52fb92a6f 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ Sphinx==1.3.3 oauthlib==1.1.2 -django-braces==1.8.1 +django-braces==1.11.0 six From cbeae24a606ac47304bb0118f9cc9cb3d7ba8f6e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 7 Feb 2017 16:38:52 +0200 Subject: [PATCH 084/722] Fix a duplicate import --- oauth2_provider/views/token.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 23d305a4e..ef8b9799f 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -1,8 +1,6 @@ from __future__ import absolute_import, unicode_literals -from django.core.urlresolvers import reverse_lazy from django.views.generic import ListView, DeleteView - from braces.views import LoginRequiredMixin from ..compat import reverse_lazy From 4a35426fe42b95ef841c33f03c8704f96aaf0e26 Mon Sep 17 00:00:00 2001 From: Clinton Blackburn Date: Wed, 8 Feb 2017 08:45:27 -0500 Subject: [PATCH 085/722] Added documentation for AbstractApplication.allows_grant_type This documents the feature added in #448. --- README.rst | 1 + docs/advanced_topics.rst | 13 +++++++++++++ 2 files changed, 14 insertions(+) diff --git a/README.rst b/README.rst index 812585364..082c4e3e2 100644 --- a/README.rst +++ b/README.rst @@ -101,6 +101,7 @@ Changelog ~~~~~~~~~~ * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped +* #448: Added support for customizing applications' allowed grant types 0.11.0 [2016-12-1] ~~~~~~~~~~~ diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index b0823ae57..0595e315c 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -54,6 +54,19 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application is because of the way Django currently implements swappable models. See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) for details +Multiple Grants +~~~~~~~~~~~~~~~ + +The default application model supports a single OAuth grant (e.g. authorization code, client credentials). If you need +applications to support multiple grants, override the `allows_grant_type` method. For example, if you want applications +to support the authorization code *and* client credentials grants, you might do the following:: + + from oauth2_provider.models import AbstractApplication + + class MyApplication(AbstractApplication): + def allows_grant_type(self, *grant_types): + # Assume, for this example, that self.authorization_grant_type is set to self.GRANT_AUTHORIZATION_CODE + return bool( set(self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS) & grant_types ) .. _skip-auth-form: From 8d8bb7b3443c92345c9061978a64922c99038fb7 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:20:58 +0200 Subject: [PATCH 086/722] Upgrade requirements --- requirements/base.txt | 4 ++-- requirements/testing.txt | 8 ++++---- setup.py | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/requirements/base.txt b/requirements/base.txt index 52fb92a6f..b42962b88 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -1,4 +1,4 @@ -Sphinx==1.3.3 -oauthlib==1.1.2 +Sphinx==1.5.2 +oauthlib==2.0.1 django-braces==1.11.0 six diff --git a/requirements/testing.txt b/requirements/testing.txt index 39b535c31..b30352e7d 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,5 +1,5 @@ -r optional.txt -mock==1.0.1 -pytest==2.8.3 -pytest-django==2.9.1 -pytest-xdist==1.13.1 +mock==2.0.0 +pytest==3.0.6 +pytest-django==3.1.2 +pytest-xdist==1.15.0 diff --git a/setup.py b/setup.py index 925725cef..98969597f 100644 --- a/setup.py +++ b/setup.py @@ -50,9 +50,9 @@ def get_version(package): include_package_data=True, test_suite='runtests', install_requires=[ - 'django>=1.7', - 'django-braces>=1.8.1', - 'oauthlib==1.1.2', + 'django>=1.8', + 'django-braces>=1.11.0', + 'oauthlib==2.0.1', 'six', ], zip_safe=False, From 72a6e3a24283b73ce424bf954b31b09891d5b79d Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:25:27 +0200 Subject: [PATCH 087/722] Drop support for Python 3.2, Python 3.3. Add support for Python 3.6 --- .travis.yml | 4 ++-- README.rst | 3 ++- docs/conf.py | 2 +- docs/index.rst | 2 +- setup.py | 3 +-- tox.ini | 7 ------- 6 files changed, 7 insertions(+), 14 deletions(-) diff --git a/.travis.yml b/.travis.yml index fa77f4acb..4a2486db7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ language: python python: - "3.5" + - "3.6" sudo: false @@ -9,8 +10,6 @@ env: - TOXENV=py27-django19 - TOXENV=py27-django110 - TOXENV=py27-djangomaster - - TOXENV=py32-django18 - - TOXENV=py33-django18 - TOXENV=py34-django18 - TOXENV=py34-django19 - TOXENV=py34-django110 @@ -19,6 +18,7 @@ env: - TOXENV=py35-django19 - TOXENV=py35-django110 - TOXENV=py35-djangomaster + - TOXENV=py36-djangomaster - TOXENV=docs matrix: diff --git a/README.rst b/README.rst index 082c4e3e2..853624b56 100644 --- a/README.rst +++ b/README.rst @@ -47,7 +47,7 @@ taken, you may receive further followup emails. Requirements ------------ -* Python 2.7, 3.2, 3.3, 3.4, 3.5 +* Python 2.7, 3.4, 3.5, 3.6 * Django 1.8, 1.9, 1.10 Installation @@ -100,6 +100,7 @@ Changelog 0.12.0 [Unreleased] ~~~~~~~~~~ +* **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped * #448: Added support for customizing applications' allowed grant types diff --git a/docs/conf.py b/docs/conf.py index e3bd40819..2fdfe9740 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -110,7 +110,7 @@ def get_version(package): # http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html extensions.append('sphinx.ext.intersphinx') -intersphinx_mapping = {'python3': ('https://docs.python.org/3.5', None), +intersphinx_mapping = {'python3': ('https://docs.python.org/3.6', None), 'django': ('http://django.readthedocs.org/en/latest/', None)} diff --git a/docs/index.rst b/docs/index.rst index 601bf36d2..1c37f7197 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,7 @@ If you need support please send a message to the `Django OAuth Toolkit Google Gr Requirements ------------ -* Python 2.7, 3.2, 3.3, 3.4, 3.5 +* Python 2.7, 3.4, 3.5, 3.6 * Django 1.8, 1.9, 1.10 Index diff --git a/setup.py b/setup.py index 98969597f..a0b6689c3 100644 --- a/setup.py +++ b/setup.py @@ -31,10 +31,9 @@ def get_version(package): "License :: OSI Approved :: BSD License", "Operating System :: OS Independent", "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.2", - "Programming Language :: Python :: 3.3", "Programming Language :: Python :: 3.4", "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", "Topic :: Software Development :: Libraries :: Python Modules", "Framework :: Django", "Framework :: Django :: 1.8", diff --git a/tox.ini b/tox.ini index f9b85f230..6884c7863 100644 --- a/tox.ini +++ b/tox.ini @@ -23,13 +23,6 @@ deps = pytest-cov==2.3.0 -rrequirements/testing.txt -[testenv:py32-django18] -# coverage-4.1 doesn't support python-3.2. -commands=python runtests.py -q -deps = - django18: Django==1.8.17 - -rrequirements/testing.txt - [testenv:docs] basepython=python changedir=docs From 6deacddd35581bcce12d59b17babb4558e3dc4ec Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 00:06:13 +0200 Subject: [PATCH 088/722] Travis: Remove dead Travis builds --- .travis.yml | 7 ------- tox.ini | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index 4a2486db7..359739bb8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,5 @@ language: python python: - - "3.5" - "3.6" sudo: false @@ -9,15 +8,9 @@ env: - TOXENV=py27-django18 - TOXENV=py27-django19 - TOXENV=py27-django110 - - TOXENV=py27-djangomaster - TOXENV=py34-django18 - TOXENV=py34-django19 - TOXENV=py34-django110 - - TOXENV=py34-djangomaster - - TOXENV=py35-django18 - - TOXENV=py35-django19 - - TOXENV=py35-django110 - - TOXENV=py35-djangomaster - TOXENV=py36-djangomaster - TOXENV=docs diff --git a/tox.ini b/tox.ini index 6884c7863..da11448de 100644 --- a/tox.ini +++ b/tox.ini @@ -7,8 +7,8 @@ envlist = py27-django{18,19,110}, py32-django18, py33-django18, - py34-django{18,19,110}, py35-django{18,19,110,master}, + py36-djangomaster, docs, flake8 From 670ab1f2a9bb3577567ffeb9851d0b8a25bfcae3 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 00:24:19 +0200 Subject: [PATCH 089/722] Travis: Add djangomaster to expected failures --- .travis.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.travis.yml b/.travis.yml index 359739bb8..f6e86166d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -16,6 +16,9 @@ env: matrix: fast_finish: true + allow_failures: + - env: TOXENV=py35-djangomaster + - env: TOXENV=py36-djangomaster install: - pip install tox "virtualenv<14" From 8aaccc826c2e44c9cff5ecf12c9d823601b82fb9 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:32:57 +0200 Subject: [PATCH 090/722] Remove a Python 3.2 hack Originally added in 3d3a75c6292f823b35fb728e05e40a84eb12a998 --- oauth2_provider/oauth2_validators.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index be60ad843..29066b89e 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,6 +1,5 @@ from __future__ import unicode_literals -import six import base64 import binascii import logging @@ -64,11 +63,6 @@ def _authenticate_basic_auth(self, request): except AttributeError: encoding = 'utf-8' - # Encode auth_string to bytes. This is needed for python3.2 compatibility - # because b64decode function only supports bytes type in input. - if isinstance(auth_string, six.string_types): - auth_string = auth_string.encode(encoding) - try: b64_decoded = base64.b64decode(auth_string) except (TypeError, binascii.Error): From a663e710cdd5a4a851c24c090a103254c37b2612 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:26:42 +0200 Subject: [PATCH 091/722] Move coverage requirements to requirements/testing.txt --- requirements/testing.txt | 2 ++ tox.ini | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/testing.txt b/requirements/testing.txt index b30352e7d..71e49af6b 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -1,5 +1,7 @@ -r optional.txt +coverage==4.3.4 mock==2.0.0 pytest==3.0.6 +pytest-cov==2.4.0 pytest-django==3.1.2 pytest-xdist==1.15.0 diff --git a/tox.ini b/tox.ini index da11448de..3dae266c4 100644 --- a/tox.ini +++ b/tox.ini @@ -19,8 +19,6 @@ deps = django19: Django==1.9.12 django110: Django==1.10.5 djangomaster: https://github.com/django/django/archive/master.tar.gz - coverage==4.1 - pytest-cov==2.3.0 -rrequirements/testing.txt [testenv:docs] From 9f72a35bf683d3952760afc33297de0c163b2ed3 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:28:25 +0200 Subject: [PATCH 092/722] Remove south dependency from tox --- tox.ini | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 3dae266c4..1d976cca4 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,6 @@ changedir=docs whitelist_externals=make deps = sphinx - south commands=make html [testenv:flake8] @@ -38,4 +37,4 @@ commands = [flake8] max-line-length = 120 -exclude = docs,migrations,south_migrations,.tox +exclude = docs,migrations,.tox From 3c652f73f01eab73306ccc26ef1e5cb94b78effc Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:42:12 +0200 Subject: [PATCH 093/722] Misc. style fixes --- .travis.yml | 3 +- README.rst | 4 +-- oauth2_provider/decorators.py | 8 ++--- .../ext/rest_framework/permissions.py | 4 +-- oauth2_provider/generators.py | 9 +++--- oauth2_provider/oauth2_backends.py | 11 ++++--- oauth2_provider/oauth2_validators.py | 32 +++++++++++-------- .../templates/oauth2_provider/base.html | 3 -- 8 files changed, 38 insertions(+), 36 deletions(-) diff --git a/.travis.yml b/.travis.yml index f6e86166d..f1417a7ba 100644 --- a/.travis.yml +++ b/.travis.yml @@ -21,8 +21,7 @@ matrix: - env: TOXENV=py36-djangomaster install: - - pip install tox "virtualenv<14" - - pip install coveralls + - pip install coveralls tox "virtualenv<14" script: - tox diff --git a/README.rst b/README.rst index 853624b56..39c3f7b37 100644 --- a/README.rst +++ b/README.rst @@ -98,14 +98,14 @@ Changelog --------- 0.12.0 [Unreleased] -~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ * **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped * #448: Added support for customizing applications' allowed grant types 0.11.0 [2016-12-1] -~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~ * #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index d1448f741..013626fcb 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -11,8 +11,8 @@ def protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): """ - Decorator to protect views by providing OAuth2 authentication out of the box, optionally with - scope handling. + Decorator to protect views by providing OAuth2 authentication out of the box, + optionally with scope handling. @protected_resource() def my_view(request): @@ -38,8 +38,8 @@ def _validate(request, *args, **kwargs): def rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server): """ - Decorator to protect views by providing OAuth2 authentication and read/write scopes out of the - box. + Decorator to protect views by providing OAuth2 authentication and read/write scopes + out of the box. GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. @rw_protected_resource() diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/ext/rest_framework/permissions.py index 71b2ac91d..997106506 100644 --- a/oauth2_provider/ext/rest_framework/permissions.py +++ b/oauth2_provider/ext/rest_framework/permissions.py @@ -93,8 +93,8 @@ class IsAuthenticatedOrTokenHasScope(BasePermission): This only returns True if the user is authenticated, but not using a token or using a token, and the token has the correct scope. - This is usefull when combined with the DjangoModelPermissions to allow people browse the browsable api's - if they log in using the a non token bassed middleware, + This is usefull when combined with the DjangoModelPermissions to allow people browse + the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ def has_permission(self, request, view): diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index 4b64146a9..5b861e74d 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -18,16 +18,17 @@ def hash(self): class ClientIdGenerator(BaseHashGenerator): def hash(self): """ - Generate a client_id without colon char as in http://tools.ietf.org/html/rfc2617#section-2 - for Basic Authentication scheme + Generate a client_id for Basic Authentication scheme without colon char + as in http://tools.ietf.org/html/rfc2617#section-2 """ return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) class ClientSecretGenerator(BaseHashGenerator): def hash(self): - return oauthlib_generate_client_id(length=oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH, - chars=UNICODE_ASCII_CHARACTER_SET) + length = oauth2_settings.CLIENT_SECRET_GENERATOR_LENGTH + chars = UNICODE_ASCII_CHARACTER_SET + return oauthlib_generate_client_id(length=length, chars=chars) def generate_client_id(): diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 6fbdc82cd..3a829b550 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -22,8 +22,8 @@ def __init__(self, server=None): def _get_escaped_full_path(self, request): """ - Django considers "safe" some characters that aren't so for oauthlib. We have to search for - them and properly escape. + Django considers "safe" some characters that aren't so for oauthlib. + We have to search for them and properly escape. """ parsed = list(urlparse(request.get_full_path())) unsafe = set(c for c in parsed[4]).difference(urlencoded) @@ -45,8 +45,9 @@ def _get_extra_credentials(self, request): def _extract_params(self, request): """ - Extract parameters from the Django request object. Such parameters will then be passed to - OAuthLib to build its own Request object. The body should be encoded using OAuthLib urlencoded + Extract parameters from the Django request object. + Such parameters will then be passed to OAuthLib to build its own + Request object. The body should be encoded using OAuthLib urlencoded. """ uri = self._get_escaped_full_path(request) http_method = request.method @@ -170,7 +171,7 @@ def verify_request(self, request, scopes): class JSONOAuthLibCore(OAuthLibCore): """ - Extends the default OAuthLibCore to parse correctly requests with application/json Content-Type + Extends the default OAuthLibCore to parse correctly application/json requests """ def extract_body(self, request): """ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 29066b89e..4f3c8ea63 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -17,6 +17,7 @@ from .models import Grant, AccessToken, RefreshToken, get_application_model, AbstractApplication from .settings import oauth2_settings + log = logging.getLogger('oauth2_provider') GRANT_TYPE_MAPPING = { @@ -31,7 +32,8 @@ class OAuth2Validator(RequestValidator): def _extract_basic_auth(self, request): """ - Return authentication string if request contains basic auth credentials, else return None + Return authentication string if request contains basic auth credentials, + otherwise return None """ auth = request.headers.get('HTTP_AUTHORIZATION', None) if not auth: @@ -93,11 +95,12 @@ def _authenticate_basic_auth(self, request): def _authenticate_request_body(self, request): """ - Try to authenticate the client using client_id and client_secret parameters - included in body. + Try to authenticate the client using client_id and client_secret + parameters included in body. - Remember that this method is NOT RECOMMENDED and SHOULD be limited to clients unable to - directly utilize the HTTP Basic authentication scheme. See rfc:`2.3.1` for more details. + Remember that this method is NOT RECOMMENDED and SHOULD be limited to + clients unable to directly utilize the HTTP Basic authentication scheme. + See rfc:`2.3.1` for more details. """ # TODO: check if oauthlib has already unquoted client_id and client_secret try: @@ -117,8 +120,8 @@ def _authenticate_request_body(self, request): def _load_application(self, client_id, request): """ - If request.client was not set, load application instance for given client_id and store it - in request.client + If request.client was not set, load application instance for given + client_id and store it in request.client """ # we want to be sure that request has the client attribute! @@ -141,11 +144,11 @@ def client_authentication_required(self, request, *args, **kwargs): * Resource owner password grant * Refresh token grant - If the request contains authorization headers, always authenticate the client no matter - the grant type. + If the request contains authorization headers, always authenticate the client + no matter the grant type. - If the request does not contain authorization headers, proceed with authentication only if - the client is of type `Confidential`. + If the request does not contain authorization headers, proceed with authentication + only if the client is of type `Confidential`. If something goes wrong, call oauthlib implementation of the method. """ @@ -172,9 +175,10 @@ def authenticate_client(self, request, *args, **kwargs): First we try to authenticate with HTTP Basic Auth, and that is the PREFERRED authentication method. - Whether this fails we support including the client credentials in the request-body, but - this method is NOT RECOMMENDED and SHOULD be limited to clients unable to directly utilize - the HTTP Basic authentication scheme. See rfc:`2.3.1` for more details + Whether this fails we support including the client credentials in the request-body, + but this method is NOT RECOMMENDED and SHOULD be limited to clients unable to + directly utilize the HTTP Basic authentication scheme. + See rfc:`2.3.1` for more details """ authenticated = self._authenticate_basic_auth(request) diff --git a/oauth2_provider/templates/oauth2_provider/base.html b/oauth2_provider/templates/oauth2_provider/base.html index 078195124..048c41f46 100644 --- a/oauth2_provider/templates/oauth2_provider/base.html +++ b/oauth2_provider/templates/oauth2_provider/base.html @@ -46,6 +46,3 @@ - - - From c90cd051688d12e946b0c69126dba6766c118c1e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 16 Feb 2017 23:48:02 +0200 Subject: [PATCH 094/722] Move MiddlewareMixin logic to oauth2_provider.compat --- oauth2_provider/compat.py | 9 +++++++++ oauth2_provider/middleware.py | 13 ++----------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 2fadd32c6..3ef0b3746 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -21,3 +21,12 @@ from django.urls import reverse, reverse_lazy except ImportError: from django.core.urlresolvers import reverse, reverse_lazy + +# bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style +# middleware working again +# More? +# https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware +try: + from django.utils.deprecation import MiddlewareMixin +except ImportError: + MiddlewareMixin = object diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 3ea729a4b..02f722a7b 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,18 +1,9 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers +from .compat import MiddlewareMixin -# bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style -# middleware working again -# More? -# https://docs.djangoproject.com/en/1.10/topics/http/middleware/#upgrading-pre-django-1-10-style-middleware -try: - from django.utils.deprecation import MiddlewareMixin - middleware_parent_class = MiddlewareMixin -except ImportError: - middleware_parent_class = object - -class OAuth2TokenMiddleware(middleware_parent_class): +class OAuth2TokenMiddleware(MiddlewareMixin): """ Middleware for OAuth2 user authentication From 14b830940a663d43bb0514d7adc41daea3a94820 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 00:02:58 +0200 Subject: [PATCH 095/722] Update documentation for Django 1.10 style middleware Touches #449 --- docs/tutorial/tutorial_01.rst | 7 +++++++ docs/tutorial/tutorial_03.rst | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index fb11db779..c257200cf 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -43,6 +43,13 @@ Include the CORS middleware in your `settings.py`: .. code-block:: python + MIDDLEWARE = ( + # ... + 'corsheaders.middleware.CorsMiddleware', + # ... + ) + + # Or on Django < 1.10: MIDDLEWARE_CLASSES = ( # ... 'corsheaders.middleware.CorsMiddleware', diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index d49e286c8..6877f5070 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -22,7 +22,7 @@ which takes care of token verification. In your settings.py: '...', ) - MIDDLEWARE_CLASSES = ( + MIDDLEWARE = ( '...', # If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. # SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. @@ -31,6 +31,14 @@ which takes care of token verification. In your settings.py: '...', ) + # Or on Django<1.10: + MIDDLEWARE_CLASSES = ( + '...', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'oauth2_provider.middleware.OAuth2TokenMiddleware', + '...', + ) + You will likely use the `django.contrib.auth.backends.ModelBackend` along with the OAuth2 backend (or you might not be able to log in into the admin), only pay attention to the order in which Django processes authentication backends. From 6b7c0b273091b91bee8bb1c04da931c272ff3d71 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 00:01:37 +0200 Subject: [PATCH 096/722] Fix OAuth2 backend middleware test on Django 2.0 Fixes #449 --- oauth2_provider/tests/test_auth_backends.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/tests/test_auth_backends.py b/oauth2_provider/tests/test_auth_backends.py index d5abb1935..640d64ac7 100644 --- a/oauth2_provider/tests/test_auth_backends.py +++ b/oauth2_provider/tests/test_auth_backends.py @@ -1,4 +1,3 @@ -from django.conf.global_settings import MIDDLEWARE_CLASSES from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.test import TestCase, RequestFactory @@ -10,6 +9,12 @@ from ..models import AccessToken from ..backends import OAuth2Backend from ..middleware import OAuth2TokenMiddleware +try: + # Django<1.10 compatibility + from django.conf.global_settings import MIDDLEWARE_CLASSES as MIDDLEWARE +except ImportError: + from django.conf.global_settings import MIDDLEWARE + UserModel = get_user_model() ApplicationModel = get_application_model() @@ -76,7 +81,9 @@ def test_get_user(self): 'oauth2_provider.backends.OAuth2Backend', 'django.contrib.auth.backends.ModelBackend', ), - MIDDLEWARE_CLASSES=tuple(MIDDLEWARE_CLASSES) + ('oauth2_provider.middleware.OAuth2TokenMiddleware',) + MIDDLEWARE=tuple(MIDDLEWARE) + ('oauth2_provider.middleware.OAuth2TokenMiddleware',), + # Django<1.10 compat: + MIDDLEWARE_CLASSES=tuple(MIDDLEWARE) + ('oauth2_provider.middleware.OAuth2TokenMiddleware',) ) class TestOAuth2Middleware(BaseTest): From b1bededca325069377635fddd77b0df9edaf3a14 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 03:02:07 +0200 Subject: [PATCH 097/722] Fix admin.site.urls inclusion on Django>=1.9 --- docs/rest-framework/getting_started.rst | 2 +- docs/tutorial/tutorial_01.rst | 2 +- docs/tutorial/tutorial_02.rst | 2 -- oauth2_provider/tests/urls.py | 8 +++++++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 9fa8f873e..3d5388f79 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -94,7 +94,7 @@ Here's our project's root `urls.py` module: urlpatterns = [ url(r'^', include(router.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), - url(r'^admin/', include(admin.site.urls)), + # ... ] Also add the following to your `settings.py` module: diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index c257200cf..e41de0021 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -34,7 +34,7 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python urlpatterns = [ - url(r'^admin/', include(admin.site.urls)), + url(r"^admin/", admin.site.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), # ... ] diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 326fa83e9..603e0e3e1 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -66,8 +66,6 @@ URL this view will respond to: urlpatterns = [ # OAuth 2 endpoints: url(r'^o/', include(oauth2_endpoint_views, namespace="oauth2_provider")), - - url(r'^admin/', include(admin.site.urls)), url(r'^api/hello', ApiEndpoint.as_view()), # an example resource endpoint ] diff --git a/oauth2_provider/tests/urls.py b/oauth2_provider/tests/urls.py index 7695ca39a..d72baba72 100644 --- a/oauth2_provider/tests/urls.py +++ b/oauth2_provider/tests/urls.py @@ -1,3 +1,4 @@ +import django from django.conf.urls import include, url from django.contrib import admin @@ -5,6 +6,11 @@ urlpatterns = [ - url(r'^admin/', include(admin.site.urls)), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] + + +if django.VERSION < (1, 9, 0): + urlpatterns += [url(r"^admin/", include(admin.site.urls))] +else: + urlpatterns += [url(r"^admin/", admin.site.urls)] From 846b6b131c67a21bf4d141264843bd67b61559e1 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 03:09:48 +0200 Subject: [PATCH 098/722] Fix middleware test settings on Django 2.0 --- oauth2_provider/tests/settings.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index e62bdf799..b538a4af0 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -57,13 +57,15 @@ }, ] -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', ) +# Django < 1.10 compatibility +MIDDLEWARE_CLASSES = MIDDLEWARE ROOT_URLCONF = 'oauth2_provider.tests.urls' From 7071c0e6861c41d26ab63bd4a4a5a575414732aa Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Mon, 14 Mar 2016 15:56:00 +0100 Subject: [PATCH 099/722] Add support for scopes backends to allow easier customization of scopes --- oauth2_provider/decorators.py | 3 +- oauth2_provider/models.py | 5 +- oauth2_provider/oauth2_validators.py | 7 ++- oauth2_provider/scopes.py | 53 +++++++++++++++++++ oauth2_provider/settings.py | 2 + oauth2_provider/tests/settings.py | 4 -- .../tests/test_authorization_code.py | 1 + oauth2_provider/tests/test_rest_framework.py | 3 ++ oauth2_provider/tests/test_scopes.py | 9 ++-- oauth2_provider/tests/test_scopes_backend.py | 14 +++++ oauth2_provider/views/base.py | 4 +- oauth2_provider/views/mixins.py | 3 +- 12 files changed, 94 insertions(+), 14 deletions(-) create mode 100644 oauth2_provider/scopes.py create mode 100644 oauth2_provider/tests/test_scopes_backend.py diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index 013626fcb..888717573 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -6,6 +6,7 @@ from .oauth2_validators import OAuth2Validator from .oauth2_backends import OAuthLibCore +from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -55,7 +56,7 @@ def decorator(view_func): @wraps(view_func) def _validate(request, *args, **kwargs): # Check if provided scopes are acceptable - provided_scopes = oauth2_settings._SCOPES + provided_scopes = get_scopes_backend().get_all_scopes() read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e47d9f43e..1252c8bec 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -11,6 +11,7 @@ from django.utils.encoding import python_2_unicode_compatible from django.core.exceptions import ImproperlyConfigured +from .scopes import get_scopes_backend from .settings import oauth2_settings from .compat import parse_qsl, reverse, urlparse from .generators import generate_client_secret, generate_client_id @@ -239,7 +240,9 @@ def scopes(self): """ Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) """ - return {name: desc for name, desc in oauth2_settings.SCOPES.items() if name in self.scope.split()} + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} def __str__(self): return self.token diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4f3c8ea63..37e0f9372 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -15,6 +15,7 @@ from .compat import unquote_plus from .exceptions import FatalClientError from .models import Grant, AccessToken, RefreshToken, get_application_model, AbstractApplication +from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -279,10 +280,12 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ Ensure required scopes are permitted (as specified in the settings file) """ - return set(scopes).issubset(set(oauth2_settings._SCOPES)) + available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + return set(scopes).issubset(set(available_scopes)) def get_default_scopes(self, client_id, request, *args, **kwargs): - return oauth2_settings._DEFAULT_SCOPES + default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) + return default_scopes def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): return request.client.redirect_uri_allowed(redirect_uri) diff --git a/oauth2_provider/scopes.py b/oauth2_provider/scopes.py new file mode 100644 index 000000000..5320e0f65 --- /dev/null +++ b/oauth2_provider/scopes.py @@ -0,0 +1,53 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from .settings import oauth2_settings + + +class BaseScopes(object): + def get_all_scopes(self): + """ + Return a dict-like object with all the scopes available in the + system. The key should be the scope name and the value should be + the description. + + ex: {"read": "A read scope", "write": "A write scope"} + """ + raise NotImplementedError("") + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + """ + Return a list of scopes available for the current application/request. + + TODO: add info on where and why this method is called. + + ex: ["read", "write"] + """ + raise NotImplementedError("") + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + """ + Return a list of the default scopes for the current application/request. + This MUST be a subset of the scopes returned by `get_available_scopes`. + + TODO: add info on where and why this method is called. + + ex: ["read"] + """ + raise NotImplementedError("") + + +class SettingsScopes(BaseScopes): + def get_all_scopes(self): + return oauth2_settings.SCOPES + + def get_available_scopes(self, application=None, request=None, *args, **kwargs): + return oauth2_settings._SCOPES + + def get_default_scopes(self, application=None, request=None, *args, **kwargs): + return oauth2_settings._DEFAULT_SCOPES + + +def get_scopes_backend(): + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + return scopes_class() diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 66ca78705..bab3626c8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -35,6 +35,7 @@ 'OAUTH2_BACKEND_CLASS': 'oauth2_provider.oauth2_backends.OAuthLibCore', 'SCOPES': {"read": "Reading scope", "write": "Writing scope"}, 'DEFAULT_SCOPES': ['__all__'], + 'SCOPES_BACKEND_CLASS': 'oauth2_provider.scopes.SettingsScopes', 'READ_SCOPE': 'read', 'WRITE_SCOPE': 'write', 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 60, @@ -68,6 +69,7 @@ 'OAUTH2_SERVER_CLASS', 'OAUTH2_VALIDATOR_CLASS', 'OAUTH2_BACKEND_CLASS', + 'SCOPES_BACKEND_CLASS', ) diff --git a/oauth2_provider/tests/settings.py b/oauth2_provider/tests/settings.py index b538a4af0..a9aa0b4e1 100644 --- a/oauth2_provider/tests/settings.py +++ b/oauth2_provider/tests/settings.py @@ -126,7 +126,3 @@ }, } } - -OAUTH2_PROVIDER = { - '_SCOPES': ['example'] -} diff --git a/oauth2_provider/tests/test_authorization_code.py b/oauth2_provider/tests/test_authorization_code.py index 144df56bc..9bb88e5a6 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/oauth2_provider/tests/test_authorization_code.py @@ -1056,3 +1056,4 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form['state'].value(), "random_state_string") self.assertEqual(form['scope'].value(), 'read') self.assertEqual(form['client_id'].value(), self.application.client_id) + oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] diff --git a/oauth2_provider/tests/test_rest_framework.py b/oauth2_provider/tests/test_rest_framework.py index 4bf62e14e..b7ee3bfec 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/oauth2_provider/tests/test_rest_framework.py @@ -96,6 +96,9 @@ def setUp(self): application=self.application ) + def tearDown(self): + oauth2_settings._SCOPES = ['read', 'write'] + def _create_authorization_header(self, token): return "Bearer {0}".format(token) diff --git a/oauth2_provider/tests/test_scopes.py b/oauth2_provider/tests/test_scopes.py index 70cb1c4d8..9f8b61677 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/oauth2_provider/tests/test_scopes.py @@ -60,6 +60,7 @@ def setUp(self): oauth2_settings.WRITE_SCOPE = 'write' def tearDown(self): + oauth2_settings._SCOPES = ["read", "write"] self.application.delete() self.test_user.delete() self.dev_user.delete() @@ -323,26 +324,26 @@ def get_access_token(self, scopes): return content['access_token'] def test_improperly_configured(self): - oauth2_settings._SCOPES = ['scope1'] + oauth2_settings.SCOPES = {'scope1': 'Scope 1'} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings._SCOPES = ['read', 'write'] + oauth2_settings.SCOPES = {'read': 'Read Scope', 'write': 'Write Scope'} oauth2_settings.READ_SCOPE = 'ciccia' view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_properly_configured(self): - oauth2_settings._SCOPES = ['scope1'] + oauth2_settings.SCOPES = {'scope1': 'Scope 1'} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings._SCOPES = ['read', 'write'] + oauth2_settings.SCOPES = {'read': 'Read Scope', 'write': 'Write Scope'} oauth2_settings.READ_SCOPE = 'ciccia' view = ReadWriteResourceView.as_view() diff --git a/oauth2_provider/tests/test_scopes_backend.py b/oauth2_provider/tests/test_scopes_backend.py new file mode 100644 index 000000000..26ca7ee85 --- /dev/null +++ b/oauth2_provider/tests/test_scopes_backend.py @@ -0,0 +1,14 @@ +from __future__ import absolute_import +from __future__ import unicode_literals + +from oauth2_provider.scopes import SettingsScopes + + +def test_settings_scopes_get_available_scopes(): + scopes = SettingsScopes() + assert scopes.get_available_scopes() == ["read", "write"] + + +def test_settings_scopes_get_default_scopes(): + scopes = SettingsScopes() + assert scopes.get_default_scopes() == ["read", "write"] diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 9afb21abf..f4d6d2f1b 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -8,6 +8,7 @@ from braces.views import LoginRequiredMixin, CsrfExemptMixin +from ..scopes import get_scopes_backend from ..settings import oauth2_settings from ..exceptions import OAuthToolkitError from ..forms import AllowForm @@ -110,7 +111,8 @@ def form_valid(self, form): def get(self, request, *args, **kwargs): try: scopes, credentials = self.validate_authorization_request(request) - kwargs['scopes_descriptions'] = [oauth2_settings.SCOPES[scope] for scope in scopes] + all_scopes = get_scopes_backend().get_all_scopes() + kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] kwargs['scopes'] = scopes # at this point we know an Application instance with such client_id exists in the database application = get_application_model().objects.get(client_id=credentials['client_id']) # TODO: cache it! diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 98d6f5b7a..9f51bfbd8 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -6,6 +6,7 @@ from django.http import HttpResponseForbidden from ..exceptions import FatalClientError +from ..scopes import get_scopes_backend from ..settings import oauth2_settings @@ -221,7 +222,7 @@ class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin): read_write_scope = None def __new__(cls, *args, **kwargs): - provided_scopes = oauth2_settings._SCOPES + provided_scopes = get_scopes_backend().get_all_scopes() read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): From 54fd4763235d98fb4dba6f4a5bc2241226bb7f4d Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 05:22:55 +0200 Subject: [PATCH 100/722] Document SCOPES_BACKEND_CLASS setting --- README.rst | 4 ++++ docs/settings.rst | 41 +++++++++++++++++++++++++++-------------- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index 39c3f7b37..ef69603e5 100644 --- a/README.rst +++ b/README.rst @@ -100,6 +100,10 @@ Changelog 0.12.0 [Unreleased] ~~~~~~~~~~~~~~~~~~~ +* **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes + is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. + By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the + legacy settings-based scope behaviour. No changes are necessary. * **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped * #448: Added support for customizing applications' allowed grant types diff --git a/docs/settings.rst b/docs/settings.rst index 9360e701c..c93f1dded 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -82,14 +82,37 @@ OAUTH2_BACKEND_CLASS The import string for the ``oauthlib_backend_class`` used in the ``OAuthLibMixin``, to get a ``Server`` instance. +REFRESH_TOKEN_EXPIRE_SECONDS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The number of seconds before a refresh token gets removed from the database by +the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. + +ROTATE_REFRESH_TOKEN +~~~~~~~~~~~~~~~~~~~~ +When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. + +REQUEST_APPROVAL_PROMPT +~~~~~~~~~~~~~~~~~~~~~~~ +Can be ``'force'`` or ``'auto'``. +The strategy used to display the authorization form. Refer to :ref:`skip-auth-form`. + +SCOPES_BACKEND_CLASS +~~~~~~~~~~~~~~~~~~~~ +**New in 0.12.0**. The import string for the scopes backend class. +Defaults to , which reads scopes through the settings defined below. + SCOPES ~~~~~~ +.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. + A dictionary mapping each scope name to its human description. .. _settings_default_scopes: DEFAULT_SCOPES ~~~~~~~~~~~~~~ +.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. + A list of scopes that should be returned by default. This is a subset of the keys of the SCOPES setting. By default this is set to '__all__' meaning that the whole set of SCOPES will be returned. @@ -100,22 +123,12 @@ By default this is set to '__all__' meaning that the whole set of SCOPES will be READ_SCOPE ~~~~~~~~~~ +.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. + The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ -The name of the *write* scope. +.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. -REFRESH_TOKEN_EXPIRE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The number of seconds before a refresh token gets removed from the database by -the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. - -ROTATE_REFRESH_TOKEN -~~~~~~~~~~~~~~~~~~~~ -When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. - -REQUEST_APPROVAL_PROMPT -~~~~~~~~~~~~~~~~~~~~~~~ -Can be ``'force'`` or ``'auto'``. -The strategy used to display the authorization form. Refer to :ref:`skip-auth-form`. +The name of the *write* scope. From 52d2402d37ded038ad8a8083c641ef053cbfa07c Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 17 Feb 2017 12:37:59 +0200 Subject: [PATCH 101/722] Implement Application.is_usable to allow disabling applications Closes #141 --- README.rst | 2 ++ oauth2_provider/models.py | 8 ++++++++ oauth2_provider/oauth2_validators.py | 7 +++++-- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index ef69603e5..eb9f33336 100644 --- a/README.rst +++ b/README.rst @@ -107,6 +107,8 @@ Changelog * **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 * Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped * #448: Added support for customizing applications' allowed grant types +* #141: The `is_usable(request)` method on the Application model can be overridden to dynamically + enable or disable applications. 0.11.0 [2016-12-1] ~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1252c8bec..6a0103cdd 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -129,6 +129,14 @@ def __str__(self): def allows_grant_type(self, *grant_types): return self.authorization_grant_type in grant_types + def is_usable(self, request): + """ + Determines whether the application can be used. + + :param request: The HTTP request being processed. + """ + return True + class Application(AbstractApplication): class Meta(AbstractApplication.Meta): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 37e0f9372..5ed033a20 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -131,9 +131,13 @@ def _load_application(self, client_id, request): Application = get_application_model() try: request.client = request.client or Application.objects.get(client_id=client_id) + # Check that the application can be used (defaults to always True) + if not request.client.is_usable(request): + log.debug("Failed body authentication: Application %r is disabled" % (client_id)) + return None return request.client except Application.DoesNotExist: - log.debug("Failed body authentication: Application %s does not exist" % client_id) + log.debug("Failed body authentication: Application %r does not exist" % (client_id)) return None def client_authentication_required(self, request, *args, **kwargs): @@ -192,7 +196,6 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): """ If we are here, the client did not authenticate itself as in rfc:`3.2.1` and we can proceed only if the client exists and it's not of type 'Confidential'. - Also assign Application instance to request.client. """ if self._load_application(client_id, request) is not None: log.debug("Application %s has type %s" % (client_id, request.client.client_type)) From 5425168f09f5a5cc5027d70e90c7a81ec48c1932 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 18 Feb 2017 04:05:37 +0200 Subject: [PATCH 102/722] Relax URL patterns to allow for UUID primary keys Closes #434 --- README.rst | 1 + oauth2_provider/urls.py | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index eb9f33336..19b29e3d5 100644 --- a/README.rst +++ b/README.rst @@ -109,6 +109,7 @@ Changelog * #448: Added support for customizing applications' allowed grant types * #141: The `is_usable(request)` method on the Application model can be overridden to dynamically enable or disable applications. +* #434: Relax URL patterns to allow for UUID primary keys 0.11.0 [2016-12-1] ~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 7a07bf28d..687a9771d 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -18,12 +18,12 @@ # Application management views url(r'^applications/$', views.ApplicationList.as_view(), name="list"), url(r'^applications/register/$', views.ApplicationRegistration.as_view(), name="register"), - url(r'^applications/(?P\d+)/$', views.ApplicationDetail.as_view(), name="detail"), - url(r'^applications/(?P\d+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), - url(r'^applications/(?P\d+)/update/$', views.ApplicationUpdate.as_view(), name="update"), + url(r'^applications/(?P[\w-]+)/$', views.ApplicationDetail.as_view(), name="detail"), + url(r'^applications/(?P[\w-]+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), + url(r'^applications/(?P[\w-]+)/update/$', views.ApplicationUpdate.as_view(), name="update"), # Token management views url(r'^authorized_tokens/$', views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - url(r'^authorized_tokens/(?P\d+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), + url(r'^authorized_tokens/(?P[\w-]+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] From 5c787d43737bc9cd14b4492dd8b2033cd04fc16e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 18 Feb 2017 04:07:31 +0200 Subject: [PATCH 103/722] Remove outdated roadmap Let's track this stuff in the issue tracker instead --- README.rst | 7 ------- 1 file changed, 7 deletions(-) diff --git a/README.rst b/README.rst index 19b29e3d5..2a46cea5e 100644 --- a/README.rst +++ b/README.rst @@ -87,13 +87,6 @@ License django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. -Roadmap / Todo list (help wanted) ---------------------------------- - -* OAuth1 support -* OpenID connector -* Nonrel storages support - Changelog --------- From 9794d27a3f192c2b3358187826ad3e931275ad69 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 18 Feb 2017 04:23:50 +0200 Subject: [PATCH 104/722] Move changelog to its own file --- CHANGELOG.md | 208 ++++++++++++++++++++++++++++++++++++++++++++++ README.rst | 229 ++------------------------------------------------- 2 files changed, 214 insertions(+), 223 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..fbfaab2be --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,208 @@ +### 0.12.0 [Unreleased] + +* **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes + is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. + By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the + legacy settings-based scope behaviour. No changes are necessary. +* **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 +* Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped +* #448: Added support for customizing applications' allowed grant types +* #141: The `is_usable(request)` method on the Application model can be overridden to dynamically + enable or disable applications. +* #434: Relax URL patterns to allow for UUID primary keys + + +### 0.11.0 [2016-12-1] + +* #315: AuthorizationView does not overwrite requests on get +* #425: Added support for Django 1.10 +* #396: added an IsAuthenticatedOrTokenHasScope Permission +* #357: Support multiple-user clients by allowing User to be NULL for Applications +* #389: Reuse refresh tokens if enabled. + + +### 0.10.0 [2015-12-14] + +* **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** +* #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant +* #333: Added possibility to specify the default list of scopes returned when scope parameter is missing +* #325: Added management views of issued tokens +* #249: Added a command to clean expired tokens +* #323: Application registration view uses custom application model in form class +* #299: `server_class` is now pluggable through Django settings +* #309: Add the py35-django19 env to travis +* #308: Use compact syntax for tox envs +* #306: Django 1.9 compatibility +* #288: Put additional information when generating token responses +* #297: Fixed doc about SessionAuthenticationMiddleware +* #273: Generic read write scope by resource + + +### 0.9.0 [2015-07-28] + +* ``oauthlib_backend_class`` is now pluggable through Django settings +* #127: ``application/json`` Content-Type is now supported using ``JSONOAuthLibCore`` +* #238: Fixed redirect uri handling in case of error +* #229: Invalidate access tokens when getting a new refresh token +* added support for oauthlib 1.0 + + +### 0.8.2 [2015-06-25] + +* Fix the migrations to be two-step and allow upgrade from 0.7.2 + +### 0.8.1 [2015-04-27] + +* South migrations fixed. Added new django migrations. + +### 0.8.0 [2015-03-27] + +* Several docs improvements and minor fixes +* #185: fixed vulnerabilities on Basic authentication +* #173: ProtectResourceMixin now allows OPTIONS requests +* Fixed `client_id` and `client_secret` characters set +* #169: hide sensitive informations in error emails +* #161: extend search to all token types when revoking a token +* #160: return empty response on successful token revocation +* #157: skip authorization form with ``skip_authorization_completely`` class field +* #155: allow custom uri schemes +* fixed ``get_application_model`` on Django 1.7 +* fixed non rotating refresh tokens +* #137: fixed base template +* customized ``client_secret`` length +* #38: create access tokens not bound to a user instance for *client credentials* flow + + +### 0.7.2 [2014-07-02] + +* Don't pin oauthlib + +### 0.7.1 [2014-04-27] + +* Added database indexes to the OAuth2 related models to improve performances. + +**Warning: schema migration does not work for sqlite3 database, migration should be performed manually** + +### 0.7.0 [2014-03-01] + +* Created a setting for the default value for approval prompt. +* Improved docs +* Don't pin django-braces and six versions + +**Backwards incompatible changes in 0.7.0** + +* Make Application model truly "swappable" (introduces a new non-namespaced setting `OAUTH2_PROVIDER_APPLICATION_MODEL`) + + +### 0.6.1 [2014-02-05] + +* added support for `scope` query parameter keeping backwards compatibility for the original `scopes` parameter. +* __str__ method in Application model returns content of `name` field when available + +### 0.6.0 [2014-01-26] + +* oauthlib 0.6.1 support +* Django dev branch support +* Python 2.6 support +* Skip authorization form via `approval_prompt` parameter + +**Bugfixes** + +* Several fixes to the docs +* Issue #71: Fix migrations +* Issue #65: Use OAuth2 password grant with multiple devices +* Issue #84: Add information about login template to tutorial. +* Issue #64: Fix urlencode clientid secret + + +### 0.5.0 [2013-09-17] + +* oauthlib 0.6.0 support + +**Backwards incompatible changes in 0.5.0** + +* `backends.py` module has been renamed to `oauth2_backends.py` so you should change your imports whether + you're extending this module + +**Bugfixes** + +* Issue #54: Auth backend proposal to address #50 +* Issue #61: Fix contributing page +* Issue #55: Add support for authenticating confidential client with request body params +* Issue #53: Quote characters in the url query that are safe for Django but not for oauthlib + + +### 0.4.1 [2013-09-06] + +* Optimize queries on access token validation + +### 0.4.0 [2013-08-09] + +**New Features** + +* Add Application management views, you no more need the admin to register, update and delete your application. +* Add support to configurable application model +* Add support for function based views + +**Backwards incompatible changes in 0.4.0** + +* `SCOPE` attribute in settings is now a dictionary to store `{'scope_name': 'scope_description'}` +* Namespace `oauth2_provider` is mandatory in urls. See issue #36 + +**Bugfixes** + +* Issue #25: Bug in the Basic Auth parsing in Oauth2RequestValidator +* Issue #24: Avoid generation of `client_id` with ":" colon char when using HTTP Basic Auth +* Issue #21: IndexError when trying to authorize an application +* Issue #9: `default_redirect_uri` is mandatory when `grant_type` is implicit, `authorization_code` or all-in-one +* Issue #22: Scopes need a verbose description +* Issue #33: Add django-oauth-toolkit version on example main page +* Issue #36: Add mandatory namespace to urls +* Issue #31: Add docstring to OAuthToolkitError and FatalClientError +* Issue #32: Add docstring to `validate_uris` +* Issue #34: Documentation tutorial part1 needs corsheaders explanation +* Issue #36: Add mandatory namespace to urls +* Issue #45: Add docs for AbstractApplication +* Issue #47: Add docs for views decorators + + +### 0.3.2 [2013-07-10] + +* Bugfix #37: Error in migrations with custom user on Django 1.5 + +### 0.3.1 [2013-07-10] + +* Bugfix #27: OAuthlib refresh token refactoring + +### 0.3.0 [2013-06-14] + +* [Django REST Framework](http://django-rest-framework.org/) integration layer +* Bugfix #13: Populate request with client and user in `validate_bearer_token` +* Bugfix #12: Fix paths in documentation + +**Backwards incompatible changes in 0.3.0** + +* `requested_scopes` parameter in ScopedResourceMixin changed to `required_scopes` + + +### 0.2.1 [2013-06-06] + +* Core optimizations + +### 0.2.0 [2013-06-05] + +* Add support for Django1.4 and Django1.6 +* Add support for Python 3.3 +* Add a default ReadWriteScoped view +* Add tutorial to docs + + +### 0.1.0 [2013-05-31] + +* Support OAuth2 Authorization Flows + + +### 0.0.0 [2013-05-17] + +* Discussion with Daniel Greenfeld at Django Circus +* Ignition diff --git a/README.rst b/README.rst index 2a46cea5e..a37fc9ecb 100644 --- a/README.rst +++ b/README.rst @@ -77,6 +77,12 @@ Notice that `oauth2_provider` namespace is mandatory. url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] +Changelog +--------- + +See `CHANGELOG.md `_. + + Documentation -------------- @@ -86,226 +92,3 @@ License ------- django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. - -Changelog ---------- - -0.12.0 [Unreleased] -~~~~~~~~~~~~~~~~~~~ - -* **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes - is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. - By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the - legacy settings-based scope behaviour. No changes are necessary. -* **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 -* Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped -* #448: Added support for customizing applications' allowed grant types -* #141: The `is_usable(request)` method on the Application model can be overridden to dynamically - enable or disable applications. -* #434: Relax URL patterns to allow for UUID primary keys - -0.11.0 [2016-12-1] -~~~~~~~~~~~~~~~~~~ - -* #315: AuthorizationView does not overwrite requests on get -* #425: Added support for Django 1.10 -* #396: added an IsAuthenticatedOrTokenHasScope Permission -* #357: Support multiple-user clients by allowing User to be NULL for Applications -* #389: Reuse refresh tokens if enabled. - -0.10.0 [2015-12-14] -~~~~~~~~~~~~~~~~~~~ - -* **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** -* #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant -* #333: Added possibility to specify the default list of scopes returned when scope parameter is missing -* #325: Added management views of issued tokens -* #249: Added a command to clean expired tokens -* #323: Application registration view uses custom application model in form class -* #299: 'server_class' is now pluggable through Django settings -* #309: Add the py35-django19 env to travis -* #308: Use compact syntax for tox envs -* #306: Django 1.9 compatibility -* #288: Put additional information when generating token responses -* #297: Fixed doc about SessionAuthenticationMiddleware -* #273: Generic read write scope by resource - -0.9.0 [2015-07-28] -~~~~~~~~~~~~~~~~~~ - -* ``oauthlib_backend_class`` is now pluggable through Django settings -* #127: ``application/json`` Content-Type is now supported using ``JSONOAuthLibCore`` -* #238: Fixed redirect uri handling in case of error -* #229: Invalidate access tokens when getting a new refresh token -* added support for oauthlib 1.0 - -0.8.2 [2015-06-25] -~~~~~~~~~~~~~~~~~~ - -* Fix the migrations to be two-step and allow upgrade from 0.7.2 - -0.8.1 [2015-04-27] -~~~~~~~~~~~~~~~~~~ - -* South migrations fixed. Added new django migrations. - -0.8.0 [2015-03-27] -~~~~~~~~~~~~~~~~~~ - -* Several docs improvements and minor fixes -* #185: fixed vulnerabilities on Basic authentication -* #173: ProtectResourceMixin now allows OPTIONS requests -* Fixed client_id and client_secret characters set -* #169: hide sensitive informations in error emails -* #161: extend search to all token types when revoking a token -* #160: return empty response on successful token revocation -* #157: skip authorization form with ``skip_authorization_completely`` class field -* #155: allow custom uri schemes -* fixed ``get_application_model`` on Django 1.7 -* fixed non rotating refresh tokens -* #137: fixed base template -* customized ``client_secret`` length -* #38: create access tokens not bound to a user instance for *client credentials* flow - -0.7.2 [2014-07-02] -~~~~~~~~~~~~~~~~~~ - -* Don't pin oauthlib - -0.7.1 [2014-04-27] -~~~~~~~~~~~~~~~~~~ - -* Added database indexes to the OAuth2 related models to improve performances. - -**Warning: schema migration does not work for sqlite3 database, migration should be performed manually** - -0.7.0 [2014-03-01] -~~~~~~~~~~~~~~~~~~ - -* Created a setting for the default value for approval prompt. -* Improved docs -* Don't pin django-braces and six versions - -**Backwards incompatible changes in 0.7.0** - -* Make Application model truly "swappable" (introduces a new non-namespaced setting OAUTH2_PROVIDER_APPLICATION_MODEL) - -0.6.1 [2014-02-05] -~~~~~~~~~~~~~~~~~~ - -* added support for `scope` query parameter keeping backwards compatibility for the original `scopes` parameter. -* __str__ method in Application model returns content of `name` field when available - -0.6.0 [2014-01-26] -~~~~~~~~~~~~~~~~~~ - -* oauthlib 0.6.1 support -* Django dev branch support -* Python 2.6 support -* Skip authorization form via `approval_prompt` parameter - -**Bugfixes** - -* Several fixes to the docs -* Issue #71: Fix migrations -* Issue #65: Use OAuth2 password grant with multiple devices -* Issue #84: Add information about login template to tutorial. -* Issue #64: Fix urlencode clientid secret - -0.5.0 [2013-09-17] -~~~~~~~~~~~~~~~~~~ - -* oauthlib 0.6.0 support - -**Backwards incompatible changes in 0.5.0** - -* `backends.py` module has been renamed to `oauth2_backends.py` so you should change your imports whether - you're extending this module - -**Bugfixes** - -* Issue #54: Auth backend proposal to address #50 -* Issue #61: Fix contributing page -* Issue #55: Add support for authenticating confidential client with request body params -* Issue #53: Quote characters in the url query that are safe for Django but not for oauthlib - -0.4.1 [2013-09-06] -~~~~~~~~~~~~~~~~~~ - -* Optimize queries on access token validation - -0.4.0 [2013-08-09] -~~~~~~~~~~~~~~~~~~ - -**New Features** - -* Add Application management views, you no more need the admin to register, update and delete your application. -* Add support to configurable application model -* Add support for function based views - -**Backwards incompatible changes in 0.4.0** - -* `SCOPE` attribute in settings is now a dictionary to store `{'scope_name': 'scope_description'}` -* Namespace 'oauth2_provider' is mandatory in urls. See issue #36 - -**Bugfixes** - -* Issue #25: Bug in the Basic Auth parsing in Oauth2RequestValidator -* Issue #24: Avoid generation of client_id with ":" colon char when using HTTP Basic Auth -* Issue #21: IndexError when trying to authorize an application -* Issue #9: Default_redirect_uri is mandatory when grant_type is implicit, authorization_code or all-in-one -* Issue #22: Scopes need a verbose description -* Issue #33: Add django-oauth-toolkit version on example main page -* Issue #36: Add mandatory namespace to urls -* Issue #31: Add docstring to OAuthToolkitError and FatalClientError -* Issue #32: Add docstring to validate_uris -* Issue #34: Documentation tutorial part1 needs corsheaders explanation -* Issue #36: Add mandatory namespace to urls -* Issue #45: Add docs for AbstractApplication -* Issue #47: Add docs for views decorators - - -0.3.2 [2013-07-10] -~~~~~~~~~~~~~~~~~~ - -* Bugfix #37: Error in migrations with custom user on Django 1.5 - -0.3.1 [2013-07-10] -~~~~~~~~~~~~~~~~~~ - -* Bugfix #27: OAuthlib refresh token refactoring - -0.3.0 [2013-06-14] -~~~~~~~~~~~~~~~~~~ - -* `Django REST Framework `_ integration layer -* Bugfix #13: Populate request with client and user in validate_bearer_token -* Bugfix #12: Fix paths in documentation - -**Backwards incompatible changes in 0.3.0** - -* `requested_scopes` parameter in ScopedResourceMixin changed to `required_scopes` - -0.2.1 [2013-06-06] -~~~~~~~~~~~~~~~~~~ - -* Core optimizations - -0.2.0 [2013-06-05] -~~~~~~~~~~~~~~~~~~ - -* Add support for Django1.4 and Django1.6 -* Add support for Python 3.3 -* Add a default ReadWriteScoped view -* Add tutorial to docs - -0.1.0 [2013-05-31] -~~~~~~~~~~~~~~~~~~ - -* Support OAuth2 Authorization Flows - -0.0.0 [2013-05-17] -~~~~~~~~~~~~~~~~~~ - -* Discussion with Daniel Greenfeld at Django Circus -* Ignition From 4e8a64cfbe37edbc2b71ae8591574da9cc1d7c59 Mon Sep 17 00:00:00 2001 From: Pan Keshang Date: Wed, 22 Feb 2017 16:17:15 +0800 Subject: [PATCH 105/722] Add missing imports in doc: Tutorial Part 2 --- docs/tutorial/tutorial_02.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 603e0e3e1..1992a08c6 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -34,7 +34,7 @@ URL this view will respond to: .. code-block:: python - from django.conf.urls import url + from django.conf.urls import url, include import oauth2_provider.views as oauth2_views from django.conf import settings from .views import ApiEndpoint From 2876a5a3bd388773da2093cf7d4928444372b893 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 23 Feb 2017 10:41:28 +0200 Subject: [PATCH 106/722] Drop tests on Python 3.2 and 3.3 --- tox.ini | 2 -- 1 file changed, 2 deletions(-) diff --git a/tox.ini b/tox.ini index 1d976cca4..250c3fe30 100644 --- a/tox.ini +++ b/tox.ini @@ -5,8 +5,6 @@ testpaths=oauth2_provider [tox] envlist = py27-django{18,19,110}, - py32-django18, - py33-django18, py35-django{18,19,110,master}, py36-djangomaster, docs, From a6f5dd45afb7e1841daef04413f25af3172ac8bc Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 23 Feb 2017 10:42:58 +0200 Subject: [PATCH 107/722] Run tests on Django 1.11 --- .travis.yml | 3 +++ tox.ini | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index f1417a7ba..a80fcfac4 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,9 +8,12 @@ env: - TOXENV=py27-django18 - TOXENV=py27-django19 - TOXENV=py27-django110 + - TOXENV=py27-django111 - TOXENV=py34-django18 - TOXENV=py34-django19 - TOXENV=py34-django110 + - TOXENV=py34-django111 + - TOXENV=py36-django111 - TOXENV=py36-djangomaster - TOXENV=docs diff --git a/tox.ini b/tox.ini index 250c3fe30..a07047318 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,8 @@ testpaths=oauth2_provider [tox] envlist = - py27-django{18,19,110}, - py35-django{18,19,110,master}, + py27-django{18,19,110,111}, + py35-django{18,19,110,111,master}, py36-djangomaster, docs, flake8 @@ -16,6 +16,7 @@ deps = django18: Django==1.8.17 django19: Django==1.9.12 django110: Django==1.10.5 + django111: Django==1.11b1 djangomaster: https://github.com/django/django/archive/master.tar.gz -rrequirements/testing.txt From dee102f084641b9b39f454b0bd12d9c15b38ca8b Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 24 Feb 2017 15:07:35 +0100 Subject: [PATCH 108/722] Bumped version to 0.12.0 --- CHANGELOG.md | 2 +- README.rst | 2 +- docs/changelog.rst | 15 +++++++++++++++ docs/index.rst | 2 +- oauth2_provider/__init__.py | 2 +- 5 files changed, 19 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbfaab2be..46d3746b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 0.12.0 [Unreleased] +### 0.12.0 [2017-02-24] * **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. diff --git a/README.rst b/README.rst index a37fc9ecb..a2eb10865 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,7 @@ Requirements ------------ * Python 2.7, 3.4, 3.5, 3.6 -* Django 1.8, 1.9, 1.10 +* Django 1.8, 1.9, 1.10, 1.11 Installation ------------ diff --git a/docs/changelog.rst b/docs/changelog.rst index 533372bb5..96e37ab30 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,21 @@ Changelog ========= +0.12.0 [2017-02-24] +------------------- + +* **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes + is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. + By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the + legacy settings-based scope behaviour. No changes are necessary. +* **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 +* Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped +* #448: Added support for customizing applications' allowed grant types +* #141: The `is_usable(request)` method on the Application model can be overridden to dynamically + enable or disable applications. +* #434: Relax URL patterns to allow for UUID primary keys + + 0.11.0 [2016-12-1] ------------------ diff --git a/docs/index.rst b/docs/index.rst index 1c37f7197..9a79b6d7c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,7 +22,7 @@ Requirements ------------ * Python 2.7, 3.4, 3.5, 3.6 -* Django 1.8, 1.9, 1.10 +* Django 1.8, 1.9, 1.10, 1.11 Index ===== diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 326f4a2f9..1bf835839 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,4 +1,4 @@ -__version__ = '0.11.0' +__version__ = '0.12.0' __author__ = "Massimiliano Pippi & Federico Frenguelli" From 2273d6a63f55366b1a434fb98b98517c57cfdd4d Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 24 Feb 2017 15:18:23 +0100 Subject: [PATCH 109/722] Added py35 tests to travis --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index a80fcfac4..00ce570ab 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,11 @@ env: - TOXENV=py34-django19 - TOXENV=py34-django110 - TOXENV=py34-django111 + - TOXENV=py35-django18 + - TOXENV=py35-django19 + - TOXENV=py35-django110 + - TOXENV=py35-django111 + - TOXENV=py35-djangomaster - TOXENV=py36-django111 - TOXENV=py36-djangomaster - TOXENV=docs From 34f3b7b3511c15686039079026165feaadb1b87d Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Fri, 24 Feb 2017 15:41:30 +0100 Subject: [PATCH 110/722] Fixed test matrix on travis --- .travis.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 00ce570ab..c9f1a6abe 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,6 @@ language: python python: - - "3.6" + - "3.5" sudo: false @@ -24,6 +24,17 @@ env: matrix: fast_finish: true + include: + - python: "3.6" + env: TOXENV=py36-django111 + - python: "3.6" + env: TOXENV=py36-djangomaster + exclude: + - python: "3.5" + env: TOXENV=py36-django111 + - python: "3.5" + env: TOXENV=py36-djangomaster + allow_failures: - env: TOXENV=py35-djangomaster - env: TOXENV=py36-djangomaster From 3fdc7e66e47886137acd3c0322b0d8c02e56c797 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 07:01:48 +0200 Subject: [PATCH 111/722] Move most setup metadata to setup.cfg --- setup.cfg | 38 +++++++++++++++++++++++++++++++++++++ setup.py | 56 ++----------------------------------------------------- 2 files changed, 40 insertions(+), 54 deletions(-) mode change 100644 => 100755 setup.py diff --git a/setup.cfg b/setup.cfg index 2a9acf13d..63a7753ba 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,2 +1,40 @@ +[metadata] +name = django-oauth-toolkit +version = 0.12.0 +description = OAuth2 Provider for Django +author = Federico Frenguelli, Massimiliano Pippi +author_email = synasius@gmail.com +url = https://github.com/evonove/django-oauth-toolkit +download_url = https://github.com/evonove/django-oauth-toolkit/tarball/master +keywords = django, oauth, oauth2, oauthlib +classifiers = + Development Status :: 4 - Beta + Environment :: Web Environment + Framework :: Django + Framework :: Django :: 1.8 + Framework :: Django :: 1.10 + Intended Audience :: Developers + License :: OSI Approved :: BSD License + Operating System :: OS Independent + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.4 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Topic :: Internet :: WWW/HTTP + +[options] +packages = find: +include_package_data = True +zip_safe = False +install_requires = + django >= 1.8 + django-braces >= 1.11.0 + oauthlib >= 2.0.1 + six + +[options.packages.find] +exclude = tests + [bdist_wheel] universal = 1 diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index a0b6689c3..5fd2d6d7d --- a/setup.py +++ b/setup.py @@ -1,58 +1,6 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- -from setuptools import setup, find_packages -import os -import re +from setuptools import setup -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - init_py = open(os.path.join(package, '__init__.py')).read() - return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) - - -version = get_version('oauth2_provider') - - -LONG_DESCRIPTION = open('README.rst').read() - -setup( - name="django-oauth-toolkit", - version=version, - description="OAuth2 goodies for Django", - long_description=LONG_DESCRIPTION, - classifiers=[ - "Development Status :: 4 - Beta", - "Environment :: Web Environment", - "Framework :: Django", - "License :: OSI Approved :: BSD License", - "Operating System :: OS Independent", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Topic :: Software Development :: Libraries :: Python Modules", - "Framework :: Django", - "Framework :: Django :: 1.8", - "Framework :: Django :: 1.9", - "Framework :: Django :: 1.10", - ], - keywords='django oauth oauth2 oauthlib', - author="Federico Frenguelli, Massimiliano Pippi", - author_email='synasius@gmail.com, mpippi@gmail.com', - url='https://github.com/evonove/django-oauth-toolkit', - license='BSD', - packages=find_packages(), - include_package_data=True, - test_suite='runtests', - install_requires=[ - 'django>=1.8', - 'django-braces>=1.11.0', - 'oauthlib==2.0.1', - 'six', - ], - zip_safe=False, -) +setup(test_suite="runtests") From e59049301b574a181aaa6a6989f2c3eb4e8d5bc0 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 07:08:42 +0200 Subject: [PATCH 112/722] Move tests outside of the oauth2_provider package --- docs/conf.py | 2 +- runtests.py | 8 ---- setup.py | 2 +- {oauth2_provider/tests => tests}/__init__.py | 0 {oauth2_provider/tests => tests}/models.py | 0 {oauth2_provider/tests => tests}/settings.py | 4 +- .../tests => tests}/test_application_views.py | 11 +++-- .../tests => tests}/test_auth_backends.py | 8 ++-- .../test_authorization_code.py | 8 ++-- .../tests => tests}/test_client_credential.py | 14 +++--- .../tests => tests}/test_decorators.py | 6 +-- .../tests => tests}/test_generator.py | 8 ++-- .../tests => tests}/test_implicit.py | 8 ++-- .../tests => tests}/test_mixins.py | 6 +-- .../tests => tests}/test_models.py | 2 +- .../tests => tests}/test_oauth2_backends.py | 4 +- .../tests => tests}/test_oauth2_validators.py | 6 +-- .../tests => tests}/test_password.py | 8 ++-- .../tests => tests}/test_rest_framework.py | 10 ++-- .../tests => tests}/test_scopes.py | 8 ++-- .../tests => tests}/test_scopes_backend.py | 0 .../tests => tests}/test_token_revocation.py | 6 +-- .../tests => tests}/test_token_view.py | 4 +- .../tests => tests}/test_utils.py | 0 .../tests => tests}/test_validators.py | 4 +- {oauth2_provider/tests => tests}/urls.py | 0 tox.ini | 48 +++++++++---------- 27 files changed, 89 insertions(+), 96 deletions(-) delete mode 100755 runtests.py rename {oauth2_provider/tests => tests}/__init__.py (100%) rename {oauth2_provider/tests => tests}/models.py (100%) rename {oauth2_provider/tests => tests}/settings.py (97%) rename {oauth2_provider/tests => tests}/test_application_views.py (93%) rename {oauth2_provider/tests => tests}/test_auth_backends.py (95%) rename {oauth2_provider/tests => tests}/test_authorization_code.py (99%) rename {oauth2_provider/tests => tests}/test_client_credential.py (94%) rename {oauth2_provider/tests => tests}/test_decorators.py (94%) rename {oauth2_provider/tests => tests}/test_generator.py (83%) rename {oauth2_provider/tests => tests}/test_implicit.py (97%) rename {oauth2_provider/tests => tests}/test_mixins.py (93%) rename {oauth2_provider/tests => tests}/test_models.py (98%) rename {oauth2_provider/tests => tests}/test_oauth2_backends.py (96%) rename {oauth2_provider/tests => tests}/test_oauth2_validators.py (97%) rename {oauth2_provider/tests => tests}/test_password.py (94%) rename {oauth2_provider/tests => tests}/test_rest_framework.py (97%) rename {oauth2_provider/tests => tests}/test_scopes.py (98%) rename {oauth2_provider/tests => tests}/test_scopes_backend.py (100%) rename {oauth2_provider/tests => tests}/test_token_revocation.py (97%) rename {oauth2_provider/tests => tests}/test_token_view.py (98%) rename {oauth2_provider/tests => tests}/test_utils.py (100%) rename {oauth2_provider/tests => tests}/test_validators.py (92%) rename {oauth2_provider/tests => tests}/urls.py (100%) diff --git a/docs/conf.py b/docs/conf.py index 2fdfe9740..3c4db7d5c 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -20,7 +20,7 @@ sys.path.insert(0, here) sys.path.insert(0, os.path.dirname(here)) -os.environ['DJANGO_SETTINGS_MODULE'] = 'oauth2_provider.tests.settings' +os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" import django django.setup() diff --git a/runtests.py b/runtests.py deleted file mode 100755 index 852de9c22..000000000 --- a/runtests.py +++ /dev/null @@ -1,8 +0,0 @@ -#!/usr/bin/env python -import sys -import pytest - - -# sys.exit() is required otherwise the wrapper exits -# with exit code 0, regardless the pytest.main() execution -sys.exit(pytest.main()) diff --git a/setup.py b/setup.py index 5fd2d6d7d..dd4e63e40 100755 --- a/setup.py +++ b/setup.py @@ -3,4 +3,4 @@ from setuptools import setup -setup(test_suite="runtests") +setup() diff --git a/oauth2_provider/tests/__init__.py b/tests/__init__.py similarity index 100% rename from oauth2_provider/tests/__init__.py rename to tests/__init__.py diff --git a/oauth2_provider/tests/models.py b/tests/models.py similarity index 100% rename from oauth2_provider/tests/models.py rename to tests/models.py diff --git a/oauth2_provider/tests/settings.py b/tests/settings.py similarity index 97% rename from oauth2_provider/tests/settings.py rename to tests/settings.py index a9aa0b4e1..eafcf0daf 100644 --- a/oauth2_provider/tests/settings.py +++ b/tests/settings.py @@ -67,7 +67,7 @@ # Django < 1.10 compatibility MIDDLEWARE_CLASSES = MIDDLEWARE -ROOT_URLCONF = 'oauth2_provider.tests.urls' +ROOT_URLCONF = 'tests.urls' INSTALLED_APPS = ( 'django.contrib.auth', @@ -78,7 +78,7 @@ 'django.contrib.admin', 'oauth2_provider', - 'oauth2_provider.tests', + 'tests', ) LOGGING = { diff --git a/oauth2_provider/tests/test_application_views.py b/tests/test_application_views.py similarity index 93% rename from oauth2_provider/tests/test_application_views.py rename to tests/test_application_views.py index cb43ee403..5f1b3bee5 100644 --- a/oauth2_provider/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -3,8 +3,12 @@ from django.contrib.auth import get_user_model from django.test import TestCase -from ..compat import reverse -from ..models import get_application_model +from oauth2_provider.compat import reverse +from oauth2_provider.models import get_application_model +from oauth2_provider.views.application import ApplicationRegistration +from oauth2_provider.settings import oauth2_settings + +from .models import TestApplication Application = get_application_model() @@ -28,9 +32,6 @@ def test_get_form_class(self): bound to custom application model defined in the 'OAUTH2_PROVIDER_APPLICATION_MODEL' setting. """ - from ..views.application import ApplicationRegistration - from .models import TestApplication - from ..settings import oauth2_settings # Patch oauth2 settings to use a custom Application model oauth2_settings.APPLICATION_MODEL = 'tests.TestApplication' # Create a registration view and tests that the model form is bound diff --git a/oauth2_provider/tests/test_auth_backends.py b/tests/test_auth_backends.py similarity index 95% rename from oauth2_provider/tests/test_auth_backends.py rename to tests/test_auth_backends.py index 640d64ac7..86cbf2536 100644 --- a/oauth2_provider/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -5,10 +5,10 @@ from django.utils.timezone import now, timedelta from django.http import HttpResponse -from ..models import get_application_model -from ..models import AccessToken -from ..backends import OAuth2Backend -from ..middleware import OAuth2TokenMiddleware +from oauth2_provider.models import get_application_model +from oauth2_provider.models import AccessToken +from oauth2_provider.backends import OAuth2Backend +from oauth2_provider.middleware import OAuth2TokenMiddleware try: # Django<1.10 compatibility from django.conf.global_settings import MIDDLEWARE_CLASSES as MIDDLEWARE diff --git a/oauth2_provider/tests/test_authorization_code.py b/tests/test_authorization_code.py similarity index 99% rename from oauth2_provider/tests/test_authorization_code.py rename to tests/test_authorization_code.py index 9bb88e5a6..bd3cd2d7c 100644 --- a/oauth2_provider/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -8,10 +8,10 @@ from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import parse_qs, reverse, urlparse, urlencode -from ..models import get_application_model, Grant, AccessToken, RefreshToken -from ..settings import oauth2_settings -from ..views import ProtectedResourceView +from oauth2_provider.compat import parse_qs, reverse, urlparse, urlencode +from oauth2_provider.models import get_application_model, Grant, AccessToken, RefreshToken +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_client_credential.py b/tests/test_client_credential.py similarity index 94% rename from oauth2_provider/tests/test_client_credential.py rename to tests/test_client_credential.py index b75b2552a..72db61e9c 100644 --- a/oauth2_provider/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -13,13 +13,13 @@ from oauthlib.oauth2 import BackendApplicationServer -from ..compat import reverse -from ..models import get_application_model, AccessToken -from ..oauth2_backends import OAuthLibCore -from ..oauth2_validators import OAuth2Validator -from ..settings import oauth2_settings -from ..views import ProtectedResourceView -from ..views.mixins import OAuthLibMixin +from oauth2_provider.compat import reverse +from oauth2_provider.models import get_application_model, AccessToken +from oauth2_provider.oauth2_backends import OAuthLibCore +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView +from oauth2_provider.views.mixins import OAuthLibMixin from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_decorators.py b/tests/test_decorators.py similarity index 94% rename from oauth2_provider/tests/test_decorators.py rename to tests/test_decorators.py index 294497e1f..bfb9a8ffb 100644 --- a/oauth2_provider/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -4,9 +4,9 @@ from django.test import TestCase, RequestFactory from django.utils import timezone -from ..decorators import protected_resource, rw_protected_resource -from ..settings import oauth2_settings -from ..models import get_application_model, AccessToken +from oauth2_provider.decorators import protected_resource, rw_protected_resource +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.models import get_application_model, AccessToken from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_generator.py b/tests/test_generator.py similarity index 83% rename from oauth2_provider/tests/test_generator.py rename to tests/test_generator.py index 0a36ddcda..8009231db 100644 --- a/oauth2_provider/tests/test_generator.py +++ b/tests/test_generator.py @@ -2,9 +2,11 @@ from django.test import TestCase -from ..settings import oauth2_settings -from ..generators import (BaseHashGenerator, ClientIdGenerator, ClientSecretGenerator, - generate_client_id, generate_client_secret) +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.generators import ( + BaseHashGenerator, ClientIdGenerator, ClientSecretGenerator, + generate_client_id, generate_client_secret +) class MockHashGenerator(BaseHashGenerator): diff --git a/oauth2_provider/tests/test_implicit.py b/tests/test_implicit.py similarity index 97% rename from oauth2_provider/tests/test_implicit.py rename to tests/test_implicit.py index 9ff3fe8d1..f1784fbc6 100644 --- a/oauth2_provider/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -3,10 +3,10 @@ from django.contrib.auth import get_user_model from django.test import TestCase, RequestFactory -from ..compat import parse_qs, reverse, urlparse, urlencode -from ..models import get_application_model -from ..settings import oauth2_settings -from ..views import ProtectedResourceView +from oauth2_provider.compat import parse_qs, reverse, urlparse, urlencode +from oauth2_provider.models import get_application_model +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView Application = get_application_model() diff --git a/oauth2_provider/tests/test_mixins.py b/tests/test_mixins.py similarity index 93% rename from oauth2_provider/tests/test_mixins.py rename to tests/test_mixins.py index 97695a526..19c9f9fa5 100644 --- a/oauth2_provider/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -6,9 +6,9 @@ from oauthlib.oauth2 import Server -from ..views.mixins import OAuthLibMixin, ScopedResourceMixin, ProtectedResourceMixin -from ..oauth2_backends import OAuthLibCore -from ..oauth2_validators import OAuth2Validator +from oauth2_provider.views.mixins import OAuthLibMixin, ScopedResourceMixin, ProtectedResourceMixin +from oauth2_provider.oauth2_backends import OAuthLibCore +from oauth2_provider.oauth2_validators import OAuth2Validator class BaseTest(TestCase): diff --git a/oauth2_provider/tests/test_models.py b/tests/test_models.py similarity index 98% rename from oauth2_provider/tests/test_models.py rename to tests/test_models.py index 022beefa7..c28e59199 100644 --- a/oauth2_provider/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,7 @@ from django.test.utils import override_settings from django.utils import timezone -from ..models import get_application_model, Grant, AccessToken, RefreshToken +from oauth2_provider.models import get_application_model, Grant, AccessToken, RefreshToken Application = get_application_model() diff --git a/oauth2_provider/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py similarity index 96% rename from oauth2_provider/tests/test_oauth2_backends.py rename to tests/test_oauth2_backends.py index d0b8a766b..f1f2f0d02 100644 --- a/oauth2_provider/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -3,8 +3,8 @@ from django.test import TestCase, RequestFactory -from ..backends import get_oauthlib_core -from ..oauth2_backends import OAuthLibCore, JSONOAuthLibCore +from oauth2_provider.backends import get_oauthlib_core +from oauth2_provider.oauth2_backends import OAuthLibCore, JSONOAuthLibCore class TestOAuthLibCoreBackend(TestCase): diff --git a/oauth2_provider/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py similarity index 97% rename from oauth2_provider/tests/test_oauth2_validators.py rename to tests/test_oauth2_validators.py index 36a52f588..658bafd8d 100644 --- a/oauth2_provider/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -7,9 +7,9 @@ import mock from oauthlib.common import Request -from ..exceptions import FatalClientError -from ..oauth2_validators import OAuth2Validator -from ..models import get_application_model, AccessToken, RefreshToken +from oauth2_provider.exceptions import FatalClientError +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.models import get_application_model, AccessToken, RefreshToken UserModel = get_user_model() AppModel = get_application_model() diff --git a/oauth2_provider/tests/test_password.py b/tests/test_password.py similarity index 94% rename from oauth2_provider/tests/test_password.py rename to tests/test_password.py index 7ff944d8e..a66e9e1d8 100644 --- a/oauth2_provider/tests/test_password.py +++ b/tests/test_password.py @@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model from django.test import TestCase, RequestFactory -from ..compat import reverse -from ..models import get_application_model -from ..settings import oauth2_settings -from ..views import ProtectedResourceView +from oauth2_provider.compat import reverse +from oauth2_provider.models import get_application_model +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_rest_framework.py b/tests/test_rest_framework.py similarity index 97% rename from oauth2_provider/tests/test_rest_framework.py rename to tests/test_rest_framework.py index b7ee3bfec..bf5a43460 100644 --- a/oauth2_provider/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -9,8 +9,8 @@ from django.utils import timezone from .test_utils import TestCaseUtils -from ..models import AccessToken, get_application_model -from ..settings import oauth2_settings +from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.settings import oauth2_settings Application = get_application_model() @@ -21,8 +21,10 @@ from rest_framework import permissions from rest_framework.views import APIView from rest_framework.test import force_authenticate, APIRequestFactory - from ..ext.rest_framework import OAuth2Authentication, TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope - from ..ext.rest_framework import IsAuthenticatedOrTokenHasScope + from oauth2_provider.ext.rest_framework import ( + IsAuthenticatedOrTokenHasScope, OAuth2Authentication, TokenHasScope, + TokenHasReadWriteScope, TokenHasResourceScope + ) class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) diff --git a/oauth2_provider/tests/test_scopes.py b/tests/test_scopes.py similarity index 98% rename from oauth2_provider/tests/test_scopes.py rename to tests/test_scopes.py index 9f8b61677..dedcae592 100644 --- a/oauth2_provider/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -7,10 +7,10 @@ from django.test import TestCase, RequestFactory from .test_utils import TestCaseUtils -from ..compat import parse_qs, reverse, urlparse -from ..models import get_application_model, Grant, AccessToken -from ..settings import oauth2_settings -from ..views import ScopedProtectedResourceView, ReadWriteScopedResourceView +from oauth2_provider.compat import parse_qs, reverse, urlparse +from oauth2_provider.models import get_application_model, Grant, AccessToken +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ScopedProtectedResourceView, ReadWriteScopedResourceView Application = get_application_model() diff --git a/oauth2_provider/tests/test_scopes_backend.py b/tests/test_scopes_backend.py similarity index 100% rename from oauth2_provider/tests/test_scopes_backend.py rename to tests/test_scopes_backend.py diff --git a/oauth2_provider/tests/test_token_revocation.py b/tests/test_token_revocation.py similarity index 97% rename from oauth2_provider/tests/test_token_revocation.py rename to tests/test_token_revocation.py index 36feeddd8..c0962ec5d 100644 --- a/oauth2_provider/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -6,9 +6,9 @@ from django.test import TestCase, RequestFactory from django.utils import timezone -from ..compat import reverse, urlencode -from ..models import get_application_model, AccessToken, RefreshToken -from ..settings import oauth2_settings +from oauth2_provider.compat import reverse, urlencode +from oauth2_provider.models import get_application_model, AccessToken, RefreshToken +from oauth2_provider.settings import oauth2_settings from .test_utils import TestCaseUtils diff --git a/oauth2_provider/tests/test_token_view.py b/tests/test_token_view.py similarity index 98% rename from oauth2_provider/tests/test_token_view.py rename to tests/test_token_view.py index fa768222a..21437f745 100644 --- a/oauth2_provider/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -6,8 +6,8 @@ from django.test import TestCase from django.utils import timezone -from ..compat import reverse -from ..models import get_application_model, AccessToken +from oauth2_provider.compat import reverse +from oauth2_provider.models import get_application_model, AccessToken Application = get_application_model() diff --git a/oauth2_provider/tests/test_utils.py b/tests/test_utils.py similarity index 100% rename from oauth2_provider/tests/test_utils.py rename to tests/test_utils.py diff --git a/oauth2_provider/tests/test_validators.py b/tests/test_validators.py similarity index 92% rename from oauth2_provider/tests/test_validators.py rename to tests/test_validators.py index 9d72997ef..d3a45dd9d 100644 --- a/oauth2_provider/tests/test_validators.py +++ b/tests/test_validators.py @@ -3,8 +3,8 @@ from django.test import TestCase from django.core.validators import ValidationError -from ..settings import oauth2_settings -from ..validators import validate_uris +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.validators import validate_uris class TestValidators(TestCase): diff --git a/oauth2_provider/tests/urls.py b/tests/urls.py similarity index 100% rename from oauth2_provider/tests/urls.py rename to tests/urls.py diff --git a/tox.ini b/tox.ini index a07047318..805e5088b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,38 +1,34 @@ -[pytest] -DJANGO_SETTINGS_MODULE=oauth2_provider.tests.settings -testpaths=oauth2_provider - [tox] envlist = - py27-django{18,19,110,111}, - py35-django{18,19,110,111,master}, - py36-djangomaster, - docs, - flake8 + py27-django{18,19,110,111}, + py35-django{18,19,110,111,master}, + py36-djangomaster, + docs, + flake8 [testenv] -commands=python runtests.py -q --cov=oauth2_provider --cov-report= --cov-append +commands = pytest --cov=oauth2_provider --cov-report= --cov-append +setenv = + DJANGO_SETTINGS_MODULE=tests.settings + PYTHONPATH={toxinidir} deps = - django18: Django==1.8.17 - django19: Django==1.9.12 - django110: Django==1.10.5 - django111: Django==1.11b1 - djangomaster: https://github.com/django/django/archive/master.tar.gz - -rrequirements/testing.txt + django18: Django==1.8.17 + django19: Django==1.9.12 + django110: Django==1.10.5 + django111: Django==1.11b1 + djangomaster: https://github.com/django/django/archive/master.tar.gz + -rrequirements/testing.txt [testenv:docs] -basepython=python -changedir=docs -whitelist_externals=make -deps = - sphinx -commands=make html +basepython = python +changedir = docs +whitelist_externals = make +deps = sphinx +commands = make html [testenv:flake8] -deps = - flake8 -commands = - flake8 oauth2_provider +deps = flake8 +commands = flake8 oauth2_provider [flake8] max-line-length = 120 From aa91b2e176b65f16714438cbfd1a4dae11fb2682 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 07:31:57 +0200 Subject: [PATCH 113/722] tests: Rename TestApplication so that pytest doesn't get confused --- tests/models.py | 2 +- tests/test_application_views.py | 6 +++--- tests/test_models.py | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/models.py b/tests/models.py index 27b01d66f..bd1079b33 100644 --- a/tests/models.py +++ b/tests/models.py @@ -2,5 +2,5 @@ from oauth2_provider.models import AbstractApplication -class TestApplication(AbstractApplication): +class SampleApplication(AbstractApplication): custom_field = models.CharField(max_length=255) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 5f1b3bee5..72d3c7d26 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -8,7 +8,7 @@ from oauth2_provider.views.application import ApplicationRegistration from oauth2_provider.settings import oauth2_settings -from .models import TestApplication +from .models import SampleApplication Application = get_application_model() @@ -33,11 +33,11 @@ def test_get_form_class(self): 'OAUTH2_PROVIDER_APPLICATION_MODEL' setting. """ # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = 'tests.TestApplication' + oauth2_settings.APPLICATION_MODEL = "tests.SampleApplication" # Create a registration view and tests that the model form is bound # to the custom Application model application_form_class = ApplicationRegistration().get_form_class() - self.assertEqual(TestApplication, application_form_class._meta.model) + self.assertEqual(SampleApplication, application_form_class._meta.model) # Revert oauth2 settings oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' diff --git a/tests/test_models.py b/tests/test_models.py index c28e59199..881137d7b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -107,7 +107,7 @@ def test_scopes_property(self): self.assertEqual(access_token2.scopes, {'write': 'Writing scope'}) -@override_settings(OAUTH2_PROVIDER_APPLICATION_MODEL='tests.TestApplication') +@override_settings(OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication") class TestCustomApplicationModel(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") @@ -131,8 +131,7 @@ def test_related_objects(self): and f.auto_created and not f.concrete ] self.assertNotIn('oauth2_provider:application', related_object_names) - self.assertIn('tests%stestapplication' % (':' if django.VERSION < (1, 8) else '_'), - related_object_names) + self.assertIn("tests_sampleapplication", related_object_names) class TestGrantModel(TestCase): From fcd8a88b48e46a2c0043c0eb0b1b32a1b50f8f27 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 07:43:10 +0200 Subject: [PATCH 114/722] Move test requirements to tox.ini --- .travis.yml | 2 +- requirements/base.txt | 4 ---- requirements/optional.txt | 2 -- requirements/project.txt | 2 -- requirements/testing.txt | 7 ------- tox.ini | 13 ++++++++++--- 6 files changed, 11 insertions(+), 19 deletions(-) delete mode 100644 requirements/base.txt delete mode 100644 requirements/optional.txt delete mode 100644 requirements/project.txt delete mode 100644 requirements/testing.txt diff --git a/.travis.yml b/.travis.yml index c9f1a6abe..a79774724 100644 --- a/.travis.yml +++ b/.travis.yml @@ -40,7 +40,7 @@ matrix: - env: TOXENV=py36-djangomaster install: - - pip install coveralls tox "virtualenv<14" + - pip install coveralls tox script: - tox diff --git a/requirements/base.txt b/requirements/base.txt deleted file mode 100644 index b42962b88..000000000 --- a/requirements/base.txt +++ /dev/null @@ -1,4 +0,0 @@ -Sphinx==1.5.2 -oauthlib==2.0.1 -django-braces==1.11.0 -six diff --git a/requirements/optional.txt b/requirements/optional.txt deleted file mode 100644 index f37bc8ce0..000000000 --- a/requirements/optional.txt +++ /dev/null @@ -1,2 +0,0 @@ --r base.txt -djangorestframework>=3.3 diff --git a/requirements/project.txt b/requirements/project.txt deleted file mode 100644 index 85af3c6f3..000000000 --- a/requirements/project.txt +++ /dev/null @@ -1,2 +0,0 @@ --r optional.txt -Django>=1.8 diff --git a/requirements/testing.txt b/requirements/testing.txt deleted file mode 100644 index 71e49af6b..000000000 --- a/requirements/testing.txt +++ /dev/null @@ -1,7 +0,0 @@ --r optional.txt -coverage==4.3.4 -mock==2.0.0 -pytest==3.0.6 -pytest-cov==2.4.0 -pytest-django==3.1.2 -pytest-xdist==1.15.0 diff --git a/tox.ini b/tox.ini index 805e5088b..2170b7b0d 100644 --- a/tox.ini +++ b/tox.ini @@ -17,18 +17,25 @@ deps = django110: Django==1.10.5 django111: Django==1.11b1 djangomaster: https://github.com/django/django/archive/master.tar.gz - -rrequirements/testing.txt + djangorestframework >= 3.5 + coverage + mock + pytest + pytest-cov + pytest-django + pytest-xdist + [testenv:docs] basepython = python changedir = docs whitelist_externals = make -deps = sphinx commands = make html +deps = sphinx [testenv:flake8] -deps = flake8 commands = flake8 oauth2_provider +deps = flake8 [flake8] max-line-length = 120 From 9577d5b79301a1466bed1640fc512b06251e8018 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 08:07:24 +0200 Subject: [PATCH 115/722] Sort AUTHORS list, add myself to it --- AUTHORS | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/AUTHORS b/AUTHORS index 36008359c..39cbb0482 100644 --- a/AUTHORS +++ b/AUTHORS @@ -7,19 +7,20 @@ Federico Frenguelli Contributors ============ -Stéphane Raimbault -Emanuele Palazzetti -David Fischer +Alessandro De Angelis Ash Christopher -Rodney Richardson -Hiroki Kiyohara -Diego Garcia -Bas van Oostveen Bart Merenda -Paul Oswald +Bas van Oostveen +David Fischer +Diego Garcia +Emanuele Palazzetti +Federico Dolce +Hiroki Kiyohara Jens Timmerman +Jerome Leclanche Jim Graham +Paul Oswald pySilver +Rodney Richardson Silvano Cerza -Federico Dolce -Alessandro De Angelis +Stéphane Raimbault From 92311a57cc96ecdf17eb32775ec86ad190d47ad7 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Thu, 9 Mar 2017 08:27:55 +0200 Subject: [PATCH 116/722] Pull oauth2_provider.__version__ from pkg_resources --- docs/conf.py | 10 +--------- oauth2_provider/__init__.py | 7 +++---- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 3c4db7d5c..321887d76 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -53,20 +53,12 @@ copyright = u'2013, Evonove' -def get_version(package): - """ - Return package version as listed in `__version__` in `init.py`. - """ - init_py = open(os.path.join(package, '__init__.py')).read() - return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) - - # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = get_version(os.path.join("..", "oauth2_provider")) +version = oauth2_provider.__version__ # The full version, including alpha/beta/rc tags. release = version diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 1bf835839..c4e8c4eb4 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,6 @@ -__version__ = '0.12.0' +import pkg_resources -__author__ = "Massimiliano Pippi & Federico Frenguelli" -default_app_config = 'oauth2_provider.apps.DOTConfig' +__version__ = pkg_resources.require("django-oauth-toolkit")[0].version -VERSION = __version__ # synonym +default_app_config = "oauth2_provider.apps.DOTConfig" From 4552933ebf211e50bddb6edaa1d316def8e2faf2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 10 Mar 2017 14:33:06 +0200 Subject: [PATCH 117/722] Travis: Cache pip and tox directories --- .travis.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.travis.yml b/.travis.yml index a79774724..58cfb582d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -39,6 +39,11 @@ matrix: - env: TOXENV=py35-djangomaster - env: TOXENV=py36-djangomaster +cache: + directories: + - $HOME/.cache/pip + - $TRAVIS_BUILD_DIR/.tox + install: - pip install coveralls tox From bcabc5f39d7711cd0c8f5873929b1ae3dcb73355 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 14 Mar 2017 23:18:52 +0200 Subject: [PATCH 118/722] Clean up imports --- oauth2_provider/admin.py | 2 +- oauth2_provider/compat.py | 4 ++-- oauth2_provider/decorators.py | 6 +++--- .../ext/rest_framework/permissions.py | 3 +-- .../management/commands/cleartokens.py | 1 + oauth2_provider/middleware.py | 1 + oauth2_provider/models.py | 9 ++++----- oauth2_provider/oauth2_backends.py | 6 +++--- oauth2_provider/oauth2_validators.py | 4 ++-- oauth2_provider/settings.py | 1 + oauth2_provider/urls.py | 1 + oauth2_provider/validators.py | 4 ++-- oauth2_provider/views/application.py | 5 ++--- oauth2_provider/views/base.py | 13 ++++++------- oauth2_provider/views/generic.py | 2 +- oauth2_provider/views/token.py | 2 +- tests/models.py | 1 + tests/test_application_views.py | 2 +- tests/test_auth_backends.py | 7 +++---- tests/test_authorization_code.py | 8 ++++---- tests/test_client_credential.py | 16 +++++----------- tests/test_decorators.py | 5 +++-- tests/test_generator.py | 2 +- tests/test_implicit.py | 4 ++-- tests/test_mixins.py | 4 ++-- tests/test_models.py | 5 ++--- tests/test_oauth2_backends.py | 5 +++-- tests/test_oauth2_validators.py | 15 ++++++++------- tests/test_password.py | 2 +- tests/test_rest_framework.py | 4 ++-- tests/test_scopes.py | 9 +++++---- tests/test_token_revocation.py | 4 ++-- tests/test_token_view.py | 2 +- tests/test_validators.py | 2 +- 34 files changed, 79 insertions(+), 82 deletions(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index cbb0c2c19..844ac5df1 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Grant, AccessToken, RefreshToken, get_application_model +from .models import AccessToken, get_application_model, Grant, RefreshToken class ApplicationAdmin(admin.ModelAdmin): diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 3ef0b3746..342271faa 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -12,9 +12,9 @@ from urllib.parse import urlparse, parse_qs, parse_qsl, urlunparse try: - from urllib import urlencode, unquote_plus + from urllib import urlencode, quote_plus, unquote_plus except ImportError: - from urllib.parse import urlencode, unquote_plus + from urllib.parse import urlencode, quote_plus, unquote_plus # changed in Django 1.10 (broken in Django 2.0) try: diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index 888717573..10b2885af 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -1,11 +1,11 @@ from functools import wraps -from oauthlib.oauth2 import Server -from django.http import HttpResponseForbidden from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponseForbidden +from oauthlib.oauth2 import Server -from .oauth2_validators import OAuth2Validator from .oauth2_backends import OAuthLibCore +from .oauth2_validators import OAuth2Validator from .scopes import get_scopes_backend from .settings import oauth2_settings diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/ext/rest_framework/permissions.py index 997106506..c449f9c21 100644 --- a/oauth2_provider/ext/rest_framework/permissions.py +++ b/oauth2_provider/ext/rest_framework/permissions.py @@ -1,10 +1,9 @@ import logging from django.core.exceptions import ImproperlyConfigured - from rest_framework.permissions import BasePermission, IsAuthenticated -from .authentication import OAuth2Authentication +from .authentication import OAuth2Authentication from ...settings import oauth2_settings diff --git a/oauth2_provider/management/commands/cleartokens.py b/oauth2_provider/management/commands/cleartokens.py index 48f70b822..3fb1827f6 100644 --- a/oauth2_provider/management/commands/cleartokens.py +++ b/oauth2_provider/management/commands/cleartokens.py @@ -1,4 +1,5 @@ from django.core.management.base import BaseCommand + from ...models import clear_expired diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 02f722a7b..dacf6733b 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,5 +1,6 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers + from .compat import MiddlewareMixin diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6a0103cdd..a5d6fb8da 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -4,17 +4,16 @@ from django.apps import apps from django.conf import settings +from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.utils import timezone - -from django.utils.translation import ugettext_lazy as _ from django.utils.encoding import python_2_unicode_compatible -from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import ugettext_lazy as _ +from .compat import parse_qsl, reverse, urlparse +from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend from .settings import oauth2_settings -from .compat import parse_qsl, reverse, urlparse -from .generators import generate_client_secret, generate_client_id from .validators import validate_uris diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 3a829b550..2028f0de1 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -3,11 +3,11 @@ import json from oauthlib import oauth2 -from oauthlib.common import urlencode, urlencoded, quote +from oauthlib.common import quote, urlencode, urlencoded -from .exceptions import OAuthToolkitError, FatalClientError -from .settings import oauth2_settings from .compat import urlparse, urlunparse +from .exceptions import FatalClientError, OAuthToolkitError +from .settings import oauth2_settings class OAuthLibCore(object): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 5ed033a20..505f51d5f 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -5,16 +5,16 @@ import logging from datetime import timedelta -from django.utils import timezone from django.conf import settings from django.contrib.auth import authenticate from django.core.exceptions import ObjectDoesNotExist from django.db import transaction +from django.utils import timezone from oauthlib.oauth2 import RequestValidator from .compat import unquote_plus from .exceptions import FatalClientError -from .models import Grant, AccessToken, RefreshToken, get_application_model, AbstractApplication +from .models import AbstractApplication, AccessToken, get_application_model, Grant, RefreshToken from .scopes import get_scopes_backend from .settings import oauth2_settings diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index bab3626c8..d659ccaa8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -18,6 +18,7 @@ from __future__ import unicode_literals import importlib + import six from django.conf import settings diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 687a9771d..53535382a 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -1,4 +1,5 @@ from __future__ import absolute_import + from django.conf.urls import url from . import views diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 5495f51f0..d3fefc4dc 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -3,10 +3,10 @@ import re from django.core.exceptions import ValidationError -from django.utils.translation import ugettext_lazy as _ +from django.core.validators import RegexValidator from django.utils.encoding import force_text from django.utils.six.moves.urllib.parse import urlsplit, urlunsplit -from django.core.validators import RegexValidator +from django.utils.translation import ugettext_lazy as _ from .settings import oauth2_settings diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 587524462..523d3f63d 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,7 +1,6 @@ -from django.forms.models import modelform_factory -from django.views.generic import CreateView, DetailView, DeleteView, ListView, UpdateView - from braces.views import LoginRequiredMixin +from django.forms.models import modelform_factory +from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from ..compat import reverse_lazy from ..models import get_application_model diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index f4d6d2f1b..77d107067 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,20 +1,19 @@ import logging +from braces.views import CsrfExemptMixin, LoginRequiredMixin from django.http import HttpResponse -from django.views.decorators.debug import sensitive_post_parameters -from django.views.generic import View, FormView from django.utils import timezone from django.utils.decorators import method_decorator +from django.views.decorators.debug import sensitive_post_parameters +from django.views.generic import FormView, View -from braces.views import LoginRequiredMixin, CsrfExemptMixin - -from ..scopes import get_scopes_backend -from ..settings import oauth2_settings +from .mixins import OAuthLibMixin from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import HttpResponseUriRedirect from ..models import get_application_model -from .mixins import OAuthLibMixin +from ..scopes import get_scopes_backend +from ..settings import oauth2_settings log = logging.getLogger('oauth2_provider') diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index 77f1795df..2a1219036 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -1,7 +1,7 @@ from django.views.generic import View +from .mixins import ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin from ..settings import oauth2_settings -from .mixins import ProtectedResourceMixin, ScopedResourceMixin, ReadWriteScopedResourceMixin class ProtectedResourceView(ProtectedResourceMixin, View): diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index ef8b9799f..c13fe8028 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -1,7 +1,7 @@ from __future__ import absolute_import, unicode_literals -from django.views.generic import ListView, DeleteView from braces.views import LoginRequiredMixin +from django.views.generic import DeleteView, ListView from ..compat import reverse_lazy from ..models import AccessToken diff --git a/tests/models.py b/tests/models.py index bd1079b33..b4f6468ef 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,4 +1,5 @@ from django.db import models + from oauth2_provider.models import AbstractApplication diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 72d3c7d26..7c6072765 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -5,8 +5,8 @@ from oauth2_provider.compat import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.views.application import ApplicationRegistration from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views.application import ApplicationRegistration from .models import SampleApplication diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 86cbf2536..356c4982f 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -1,14 +1,13 @@ from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser -from django.test import TestCase, RequestFactory +from django.http import HttpResponse +from django.test import RequestFactory, TestCase from django.test.utils import override_settings from django.utils.timezone import now, timedelta -from django.http import HttpResponse -from oauth2_provider.models import get_application_model -from oauth2_provider.models import AccessToken from oauth2_provider.backends import OAuth2Backend from oauth2_provider.middleware import OAuth2TokenMiddleware +from oauth2_provider.models import AccessToken, get_application_model try: # Django<1.10 compatibility from django.conf.global_settings import MIDDLEWARE_CLASSES as MIDDLEWARE diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index bd3cd2d7c..1faf3586e 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1,15 +1,15 @@ from __future__ import unicode_literals import base64 -import json import datetime +import json from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from django.utils import timezone -from oauth2_provider.compat import parse_qs, reverse, urlparse, urlencode -from oauth2_provider.models import get_application_model, Grant, AccessToken, RefreshToken +from oauth2_provider.compat import parse_qs, reverse, urlencode, urlparse +from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 72db61e9c..3ce1a08d2 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -2,19 +2,13 @@ import json -try: - import urllib.parse as urllib -except ImportError: - import urllib - from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from django.views.generic import View - from oauthlib.oauth2 import BackendApplicationServer -from oauth2_provider.compat import reverse -from oauth2_provider.models import get_application_model, AccessToken +from oauth2_provider.compat import quote_plus, reverse +from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings @@ -171,8 +165,8 @@ def test_client_resource_password_based(self): 'password': '123456' } auth_headers = self.get_basic_auth_header( - urllib.quote_plus(self.application.client_id), - urllib.quote_plus(self.application.client_secret)) + quote_plus(self.application.client_id), + quote_plus(self.application.client_secret)) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index bfb9a8ffb..755eb762c 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,12 +1,13 @@ from datetime import timedelta from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from django.utils import timezone from oauth2_provider.decorators import protected_resource, rw_protected_resource +from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.settings import oauth2_settings -from oauth2_provider.models import get_application_model, AccessToken + from .test_utils import TestCaseUtils diff --git a/tests/test_generator.py b/tests/test_generator.py index 8009231db..3e810c65b 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -2,11 +2,11 @@ from django.test import TestCase -from oauth2_provider.settings import oauth2_settings from oauth2_provider.generators import ( BaseHashGenerator, ClientIdGenerator, ClientSecretGenerator, generate_client_id, generate_client_secret ) +from oauth2_provider.settings import oauth2_settings class MockHashGenerator(BaseHashGenerator): diff --git a/tests/test_implicit.py b/tests/test_implicit.py index f1784fbc6..4f2b7ac31 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,9 +1,9 @@ from __future__ import unicode_literals from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase -from oauth2_provider.compat import parse_qs, reverse, urlparse, urlencode +from oauth2_provider.compat import parse_qs, reverse, urlencode, urlparse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 19c9f9fa5..24ceb0208 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,14 +1,14 @@ from __future__ import unicode_literals from django.core.exceptions import ImproperlyConfigured +from django.test import RequestFactory, TestCase from django.views.generic import View -from django.test import TestCase, RequestFactory from oauthlib.oauth2 import Server -from oauth2_provider.views.mixins import OAuthLibMixin, ScopedResourceMixin, ProtectedResourceMixin from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.views.mixins import OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin class BaseTest(TestCase): diff --git a/tests/test_models.py b/tests/test_models.py index 881137d7b..e6c5ffbde 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,7 +7,7 @@ from django.test.utils import override_settings from django.utils import timezone -from oauth2_provider.models import get_application_model, Grant, AccessToken, RefreshToken +from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken Application = get_application_model() @@ -127,8 +127,7 @@ def test_related_objects(self): else: related_object_names = [ f.name for f in UserModel._meta.get_fields() - if (f.one_to_many or f.one_to_one) - and f.auto_created and not f.concrete + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn('oauth2_provider:application', related_object_names) self.assertIn("tests_sampleapplication", related_object_names) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index f1f2f0d02..b79073bba 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,10 +1,11 @@ import json + import mock -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core -from oauth2_provider.oauth2_backends import OAuthLibCore, JSONOAuthLibCore +from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore class TestOAuthLibCoreBackend(TestCase): diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 658bafd8d..1f312dd86 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,15 +1,16 @@ -from datetime import timedelta +import datetime + +import mock from django.contrib.auth import get_user_model from django.test import TransactionTestCase from django.utils import timezone - -import mock from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError +from oauth2_provider.models import AccessToken, get_application_model, RefreshToken from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.models import get_application_model, AccessToken, RefreshToken + UserModel = get_user_model() AppModel = get_application_model() @@ -133,7 +134,7 @@ def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(sel access_token = AccessToken.objects.create( token="123", user=self.user, - expires=timezone.now() + timedelta(seconds=60), + expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application ) refresh_token = RefreshToken.objects.create( @@ -166,7 +167,7 @@ def test_save_bearer_token__checks_to_rotate_tokens(self): access_token = AccessToken.objects.create( token="123", user=self.user, - expires=timezone.now() + timedelta(seconds=60), + expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application ) refresh_token = RefreshToken.objects.create( @@ -204,7 +205,7 @@ def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_ access_token = AccessToken.objects.create( token="123", user=self.user, - expires=timezone.now() + timedelta(seconds=60), + expires=timezone.now() + datetime.timedelta(seconds=60), application=self.application ) refresh_token = RefreshToken.objects.create( diff --git a/tests/test_password.py b/tests/test_password.py index a66e9e1d8..fc9ac2b7f 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -3,7 +3,7 @@ import json from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from oauth2_provider.compat import reverse from oauth2_provider.models import get_application_model diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index bf5a43460..41272c321 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,16 +1,16 @@ import unittest from datetime import timedelta -from django.conf.urls import url, include +from django.conf.urls import include, url from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone -from .test_utils import TestCaseUtils from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.settings import oauth2_settings +from .test_utils import TestCaseUtils Application = get_application_model() diff --git a/tests/test_scopes.py b/tests/test_scopes.py index dedcae592..61afaf5f7 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -4,13 +4,14 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase -from .test_utils import TestCaseUtils from oauth2_provider.compat import parse_qs, reverse, urlparse -from oauth2_provider.models import get_application_model, Grant, AccessToken +from oauth2_provider.models import AccessToken, get_application_model, Grant from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ScopedProtectedResourceView, ReadWriteScopedResourceView +from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView + +from .test_utils import TestCaseUtils Application = get_application_model() diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index c0962ec5d..654fa4b6c 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -3,11 +3,11 @@ import datetime from django.contrib.auth import get_user_model -from django.test import TestCase, RequestFactory +from django.test import RequestFactory, TestCase from django.utils import timezone from oauth2_provider.compat import reverse, urlencode -from oauth2_provider.models import get_application_model, AccessToken, RefreshToken +from oauth2_provider.models import AccessToken, get_application_model, RefreshToken from oauth2_provider.settings import oauth2_settings from .test_utils import TestCaseUtils diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 21437f745..fe187b670 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -7,7 +7,7 @@ from django.utils import timezone from oauth2_provider.compat import reverse -from oauth2_provider.models import get_application_model, AccessToken +from oauth2_provider.models import AccessToken, get_application_model Application = get_application_model() diff --git a/tests/test_validators.py b/tests/test_validators.py index d3a45dd9d..754fc34fb 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,7 +1,7 @@ from __future__ import unicode_literals -from django.test import TestCase from django.core.validators import ValidationError +from django.test import TestCase from oauth2_provider.settings import oauth2_settings from oauth2_provider.validators import validate_uris From 5ba2751af813d5a59e6cf4f6b11a852a67572267 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 14 Mar 2017 23:24:07 +0200 Subject: [PATCH 119/722] Clean up some long lines --- oauth2_provider/oauth2_validators.py | 2 +- oauth2_provider/settings.py | 4 +++- oauth2_provider/views/base.py | 5 ++++- oauth2_provider/views/mixins.py | 7 +++++-- tests/test_application_views.py | 6 ++++-- tests/test_token_view.py | 15 ++++++++++----- 6 files changed, 27 insertions(+), 12 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 505f51d5f..ac7a390bc 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -164,7 +164,7 @@ def client_authentication_required(self, request, *args, **kwargs): if request.client_id and request.client_secret: return True except AttributeError: - log.debug("Client id or client secret not provided, proceed evaluating if authentication is required...") + log.debug("Client ID or client secret not provided...") pass self._load_application(request.client_id, request) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index d659ccaa8..7e35be3f5 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -27,6 +27,8 @@ USER_SETTINGS = getattr(settings, 'OAUTH2_PROVIDER', None) +APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") + DEFAULTS = { 'CLIENT_ID_GENERATOR_CLASS': 'oauth2_provider.generators.ClientIdGenerator', 'CLIENT_SECRET_GENERATOR_CLASS': 'oauth2_provider.generators.ClientSecretGenerator', @@ -43,7 +45,7 @@ 'ACCESS_TOKEN_EXPIRE_SECONDS': 36000, 'REFRESH_TOKEN_EXPIRE_SECONDS': None, 'ROTATE_REFRESH_TOKEN': True, - 'APPLICATION_MODEL': getattr(settings, 'OAUTH2_PROVIDER_APPLICATION_MODEL', 'oauth2_provider.Application'), + 'APPLICATION_MODEL': APPLICATION_MODEL, 'REQUEST_APPROVAL_PROMPT': 'force', 'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'], diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 77d107067..57bbec471 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -114,7 +114,10 @@ def get(self, request, *args, **kwargs): kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] kwargs['scopes'] = scopes # at this point we know an Application instance with such client_id exists in the database - application = get_application_model().objects.get(client_id=credentials['client_id']) # TODO: cache it! + + # TODO: Cache this! + application = get_application_model().objects.get(client_id=credentials["client_id"]) + kwargs['application'] = application kwargs['client_id'] = credentials['client_id'] kwargs['redirect_uri'] = credentials['redirect_uri'] diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 9f51bfbd8..c02908dbc 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -145,7 +145,8 @@ def verify_request(self, request): def get_scopes(self): """ - This should return the list of scopes required to access the resources. By default it returns an empty list + This should return the list of scopes required to access the resources. + By default it returns an empty list. """ return [] @@ -243,4 +244,6 @@ def dispatch(self, request, *args, **kwargs): def get_scopes(self, *args, **kwargs): scopes = super(ReadWriteScopedResourceMixin, self).get_scopes(*args, **kwargs) - return scopes + [self.read_write_scope] # this returns a copy so that self.required_scopes is not modified + + # this returns a copy so that self.required_scopes is not modified + return scopes + [self.read_write_scope] diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 7c6072765..abcdea390 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -64,8 +64,10 @@ class TestApplicationViews(BaseTest): def _create_application(self, name, user): app = Application.objects.create( name=name, redirect_uris="http://example.com", - client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - user=user) + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + user=user + ) return app def setUp(self): diff --git a/tests/test_token_view.py b/tests/test_token_view.py index fe187b670..9b8ff731e 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -120,7 +120,8 @@ def test_delete_view_authorization_required(self): expires=timezone.now() + datetime.timedelta(days=1), scope='read write') - response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) + response = self.client.get(url) self.assertEqual(response.status_code, 302) self.assertTrue('/accounts/login/?next=' in response['Location']) @@ -134,7 +135,8 @@ def test_delete_view_works(self): scope='read write') self.client.login(username="foo_user", password="123456") - response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) + response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_delete_view_token_belongs_to_user(self): @@ -147,7 +149,8 @@ def test_delete_view_token_belongs_to_user(self): scope='read write') self.client.login(username="bar_user", password="123456") - response = self.client.get(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) + response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_delete_view_post_actually_deletes(self): @@ -160,7 +163,8 @@ def test_delete_view_post_actually_deletes(self): scope='read write') self.client.login(username="foo_user", password="123456") - response = self.client.post(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) + response = self.client.post(url) self.assertFalse(AccessToken.objects.exists()) self.assertRedirects(response, reverse('oauth2_provider:authorized-token-list')) @@ -174,6 +178,7 @@ def test_delete_view_only_deletes_user_own_token(self): scope='read write') self.client.login(username="bar_user", password="123456") - response = self.client.post(reverse('oauth2_provider:authorized-token-delete', kwargs={'pk': self.token.pk})) + url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) + response = self.client.post(url) self.assertTrue(AccessToken.objects.exists()) self.assertEqual(response.status_code, 404) From 624b728183e1ee07f8fe326f4feb5eb1d2aae8a6 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 14 Mar 2017 23:24:21 +0200 Subject: [PATCH 120/722] Run flake8-import-order as part of the QA job --- tox.ini | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index 2170b7b0d..095aca5d7 100644 --- a/tox.ini +++ b/tox.ini @@ -35,8 +35,12 @@ deps = sphinx [testenv:flake8] commands = flake8 oauth2_provider -deps = flake8 +deps = + flake8 + flake8-import-order [flake8] -max-line-length = 120 -exclude = docs,migrations,.tox +max-line-length = 110 +exclude = docs/, migrations/, .tox/ +import-order-style = smarkets +application-import-names = oauth2_provider From 3dcad7e7e7878a264c8ef93b97260fa31720c02d Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 14 Mar 2017 23:25:46 +0200 Subject: [PATCH 121/722] Drop support for Django 1.9 --- .travis.yml | 3 --- README.rst | 2 +- tox.ini | 5 ++--- 3 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.travis.yml b/.travis.yml index 58cfb582d..0496f178a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,15 +6,12 @@ sudo: false env: - TOXENV=py27-django18 - - TOXENV=py27-django19 - TOXENV=py27-django110 - TOXENV=py27-django111 - TOXENV=py34-django18 - - TOXENV=py34-django19 - TOXENV=py34-django110 - TOXENV=py34-django111 - TOXENV=py35-django18 - - TOXENV=py35-django19 - TOXENV=py35-django110 - TOXENV=py35-django111 - TOXENV=py35-djangomaster diff --git a/README.rst b/README.rst index a2eb10865..e086db738 100644 --- a/README.rst +++ b/README.rst @@ -48,7 +48,7 @@ Requirements ------------ * Python 2.7, 3.4, 3.5, 3.6 -* Django 1.8, 1.9, 1.10, 1.11 +* Django 1.8, 1.10, 1.11 Installation ------------ diff --git a/tox.ini b/tox.ini index 095aca5d7..8031f257e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py27-django{18,19,110,111}, - py35-django{18,19,110,111,master}, + py27-django{18,110,111}, + py35-django{18,110,111,master}, py36-djangomaster, docs, flake8 @@ -13,7 +13,6 @@ setenv = PYTHONPATH={toxinidir} deps = django18: Django==1.8.17 - django19: Django==1.9.12 django110: Django==1.10.5 django111: Django==1.11b1 djangomaster: https://github.com/django/django/archive/master.tar.gz From ad043f01d508ce2cf88b4512a5c5c33a9d92db61 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 14 Mar 2017 23:44:08 +0200 Subject: [PATCH 122/722] Move TestCaseUtils.get_basic_auth_header to its own function --- tests/test_authorization_code.py | 35 ++++++++++++++++---------------- tests/test_client_credential.py | 18 ++++++++-------- tests/test_decorators.py | 4 +--- tests/test_password.py | 10 ++++----- tests/test_rest_framework.py | 10 +-------- tests/test_scopes.py | 16 +++++++-------- tests/test_token_revocation.py | 4 +--- tests/test_utils.py | 17 ---------------- tests/utils.py | 16 +++++++++++++++ 9 files changed, 58 insertions(+), 72 deletions(-) delete mode 100644 tests/test_utils.py create mode 100644 tests/utils.py diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 1faf3586e..e74e66db2 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -12,8 +12,7 @@ from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView - -from .test_utils import TestCaseUtils +from .utils import get_basic_auth_header Application = get_application_model() @@ -26,7 +25,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" -class BaseTest(TestCaseUtils, TestCase): +class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") @@ -515,7 +514,7 @@ def test_basic_auth(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -537,7 +536,7 @@ def test_refresh(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -581,7 +580,7 @@ def test_refresh_invalidates_old_tokens(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -612,7 +611,7 @@ def test_refresh_no_scopes(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -640,7 +639,7 @@ def test_refresh_bad_scopes(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -666,7 +665,7 @@ def test_refresh_fail_repeating_requests(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -694,7 +693,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -725,7 +724,7 @@ def test_basic_auth_bad_authcode(self): 'code': 'BLAH', 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) @@ -741,7 +740,7 @@ def test_basic_auth_bad_granttype(self): 'code': 'BLAH', 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -760,7 +759,7 @@ def test_basic_auth_grant_expired(self): 'code': 'BLAH', 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) @@ -777,7 +776,7 @@ def test_basic_auth_bad_secret(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, 'BOOM!') + auth_headers = get_basic_auth_header(self.application.client_id, 'BOOM!') response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) @@ -898,7 +897,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): 'code': authorization_code, 'redirect_uri': 'http://example.it?foo=bar' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -933,7 +932,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): 'code': authorization_code, 'redirect_uri': 'http://example.it?foo=baraa' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) @@ -965,7 +964,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param 'code': authorization_code, 'redirect_uri': 'http://example.com?bar=baz&foo=bar' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -999,7 +998,7 @@ def test_resource_access_allowed(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 3ce1a08d2..283450278 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -14,7 +14,7 @@ from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from oauth2_provider.views.mixins import OAuthLibMixin -from .test_utils import TestCaseUtils +from .utils import get_basic_auth_header Application = get_application_model() @@ -27,7 +27,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" -class BaseTest(TestCaseUtils, TestCase): +class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") @@ -58,7 +58,7 @@ def test_client_credential_access_allowed(self): token_request_data = { 'grant_type': 'client_credentials', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -81,7 +81,7 @@ def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { 'grant_type': 'client_credentials', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -91,7 +91,7 @@ def test_client_credential_does_not_issue_refresh_token(self): def test_client_credential_user_is_none_on_access_token(self): token_request_data = {'grant_type': 'client_credentials'} - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -119,7 +119,7 @@ def get_scopes(self): token_request_data = { 'grant_type': 'client_credentials', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -164,9 +164,9 @@ def test_client_resource_password_based(self): 'username': 'test_user', 'password': '123456' } - auth_headers = self.get_basic_auth_header( - quote_plus(self.application.client_id), - quote_plus(self.application.client_secret)) + auth_headers = get_basic_auth_header( + quote_plus(self.application.client_id), quote_plus(self.application.client_secret) + ) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 755eb762c..35e41c94a 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -8,14 +8,12 @@ from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.settings import oauth2_settings -from .test_utils import TestCaseUtils - Application = get_application_model() UserModel = get_user_model() -class TestProtectedResourceDecorator(TestCase, TestCaseUtils): +class TestProtectedResourceDecorator(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() diff --git a/tests/test_password.py b/tests/test_password.py index fc9ac2b7f..cd0572ab2 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -9,7 +9,7 @@ from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView -from .test_utils import TestCaseUtils +from .utils import get_basic_auth_header Application = get_application_model() @@ -22,7 +22,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" -class BaseTest(TestCaseUtils, TestCase): +class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") @@ -55,7 +55,7 @@ def test_get_token(self): 'username': 'test_user', 'password': '123456', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -74,7 +74,7 @@ def test_bad_credentials(self): 'username': 'test_user', 'password': 'NOT_MY_PASS', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) @@ -87,7 +87,7 @@ def test_password_resource_access_allowed(self): 'username': 'test_user', 'password': '123456', } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 41272c321..057d6a54c 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -10,7 +10,6 @@ from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.settings import oauth2_settings -from .test_utils import TestCaseUtils Application = get_application_model() @@ -67,15 +66,8 @@ class ResourceScopedView(OAuth2View): rest_framework_installed = False -class BaseTest(TestCaseUtils, TestCase): - """ - TODO: add docs - """ - pass - - @override_settings(ROOT_URLCONF=__name__) -class TestOAuth2Authentication(BaseTest): +class TestOAuth2Authentication(TestCase): def setUp(self): oauth2_settings._SCOPES = ['read', 'write', 'scope1', 'scope2', 'resource1'] diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 61afaf5f7..5e6469ddd 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -11,7 +11,7 @@ from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView -from .test_utils import TestCaseUtils +from .utils import get_basic_auth_header Application = get_application_model() @@ -41,7 +41,7 @@ def post(self, request, *args, **kwargs): return "This is a write protected resource" -class BaseTest(TestCaseUtils, TestCase): +class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") @@ -115,7 +115,7 @@ def test_scopes_save_in_access_token(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -151,7 +151,7 @@ def test_scopes_protection_valid(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -193,7 +193,7 @@ def test_scopes_protection_fail(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -235,7 +235,7 @@ def test_multi_scope_fail(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -277,7 +277,7 @@ def test_multi_scope_valid(self): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -318,7 +318,7 @@ def get_access_token(self, scopes): 'code': authorization_code, 'redirect_uri': 'http://example.it' } - auth_headers = self.get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 654fa4b6c..aab9a77b5 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -10,14 +10,12 @@ from oauth2_provider.models import AccessToken, get_application_model, RefreshToken from oauth2_provider.settings import oauth2_settings -from .test_utils import TestCaseUtils - Application = get_application_model() UserModel = get_user_model() -class BaseTest(TestCaseUtils, TestCase): +class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") diff --git a/tests/test_utils.py b/tests/test_utils.py deleted file mode 100644 index 21db71251..000000000 --- a/tests/test_utils.py +++ /dev/null @@ -1,17 +0,0 @@ -from __future__ import unicode_literals - -import base64 - - -class TestCaseUtils(object): - def get_basic_auth_header(self, user, password): - """ - Return a dict containg the correct headers to set to make HTTP Basic Auth request - """ - user_pass = '{0}:{1}'.format(user, password) - auth_string = base64.b64encode(user_pass.encode('utf-8')) - auth_headers = { - 'HTTP_AUTHORIZATION': 'Basic ' + auth_string.decode("utf-8"), - } - - return auth_headers diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 000000000..29bdb588a --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,16 @@ +from __future__ import unicode_literals + +import base64 + + +def get_basic_auth_header(user, password): + """ + Return a dict containg the correct headers to set to make HTTP Basic Auth request + """ + user_pass = "{0}:{1}".format(user, password) + auth_string = base64.b64encode(user_pass.encode("utf-8")) + auth_headers = { + "HTTP_AUTHORIZATION": "Basic " + auth_string.decode("utf-8"), + } + + return auth_headers From 39ae1f4424ca57999b279824134dff0731d3fbe8 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 00:00:39 +0200 Subject: [PATCH 123/722] Make Django dependency a per-version range --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 8031f257e..d654beb4b 100644 --- a/tox.ini +++ b/tox.ini @@ -12,11 +12,11 @@ setenv = DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH={toxinidir} deps = - django18: Django==1.8.17 - django110: Django==1.10.5 - django111: Django==1.11b1 + django18: Django >=1.8, <1.9 + django110: Django >=1.10, <1.11 + django111: Django >=1.11b1, <2.0 djangomaster: https://github.com/django/django/archive/master.tar.gz - djangorestframework >= 3.5 + djangorestframework >=3.5 coverage mock pytest From f59509f99dd690e09af32a372eee86634485d6a1 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 00:03:31 +0200 Subject: [PATCH 124/722] Update CONTRIBUTING message --- CONTRIBUTING.md | 6 ++++++ CONTRIBUTING.rst | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 CONTRIBUTING.md delete mode 100644 CONTRIBUTING.rst diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..f1703ac0d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,6 @@ +# Contribute to Django OAuth Toolkit + +Thanks for your interest, we love contributions! + +Please [follow these guidelines](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) +when submitting pull requests. diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst deleted file mode 100644 index 61d13273e..000000000 --- a/CONTRIBUTING.rst +++ /dev/null @@ -1,5 +0,0 @@ -Contributing -============ - -Thanks for your interest! We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines `_ and submit a PR. From a900420c7ae6d10e1450cf505713cf287f1172c1 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 02:59:09 +0200 Subject: [PATCH 125/722] Use native LoginRequiredMixin on Django 1.9+ --- oauth2_provider/compat.py | 6 ++++++ oauth2_provider/views/application.py | 3 +-- oauth2_provider/views/base.py | 3 ++- oauth2_provider/views/token.py | 3 +-- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 342271faa..dd4914ab8 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -22,6 +22,12 @@ except ImportError: from django.core.urlresolvers import reverse, reverse_lazy +# Added in Django 1.9, required as long as 1.8 is supported +try: + from django.contrib.auth.mixins import LoginRequiredMixin +except ImportError: + from braces.views import LoginRequiredMixin + # bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style # middleware working again # More? diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 523d3f63d..84421349c 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,8 +1,7 @@ -from braces.views import LoginRequiredMixin from django.forms.models import modelform_factory from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView -from ..compat import reverse_lazy +from ..compat import LoginRequiredMixin, reverse_lazy from ..models import get_application_model diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 57bbec471..60f7b99a2 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,6 +1,6 @@ import logging -from braces.views import CsrfExemptMixin, LoginRequiredMixin +from braces.views import CsrfExemptMixin from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -8,6 +8,7 @@ from django.views.generic import FormView, View from .mixins import OAuthLibMixin +from ..compat import LoginRequiredMixin from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import HttpResponseUriRedirect diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index c13fe8028..69aef16a6 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -1,9 +1,8 @@ from __future__ import absolute_import, unicode_literals -from braces.views import LoginRequiredMixin from django.views.generic import DeleteView, ListView -from ..compat import reverse_lazy +from ..compat import LoginRequiredMixin, reverse_lazy from ..models import AccessToken From 5ff751062eb9b94daa13454704d85838f6ee7cc6 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:07:35 +0200 Subject: [PATCH 126/722] Use native csrf_exempt instead of braces CsrfExemptMixin --- oauth2_provider/views/base.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 60f7b99a2..3ca469f80 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,9 +1,9 @@ import logging -from braces.views import CsrfExemptMixin from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View @@ -16,6 +16,7 @@ from ..scopes import get_scopes_backend from ..settings import oauth2_settings + log = logging.getLogger('oauth2_provider') @@ -161,7 +162,7 @@ def get(self, request, *args, **kwargs): return self.error_response(error) -class TokenView(CsrfExemptMixin, OAuthLibMixin, View): +class TokenView(OAuthLibMixin, View): """ Implements an endpoint to provide access tokens @@ -174,6 +175,11 @@ class TokenView(CsrfExemptMixin, OAuthLibMixin, View): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS + # XXX: Django 1.8 compat + @method_decorator(csrf_exempt) + def dispatch(self, *args, **kwargs): + return super(TokenView, self).dispatch(*args, **kwargs) + @method_decorator(sensitive_post_parameters('password')) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) @@ -184,7 +190,7 @@ def post(self, request, *args, **kwargs): return response -class RevokeTokenView(CsrfExemptMixin, OAuthLibMixin, View): +class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ @@ -192,6 +198,10 @@ class RevokeTokenView(CsrfExemptMixin, OAuthLibMixin, View): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS + # XXX: Django 1.8 compat + def dispatch(self, *args, **kwargs): + return super(RevokeTokenView, self).dispatch(*args, **kwargs) + def post(self, request, *args, **kwargs): url, headers, body, status = self.create_revocation_response(request) response = HttpResponse(content=body or '', status=status) From 49d258631c05cfb4175cc85d7541d4eff118c7c9 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:07:54 +0200 Subject: [PATCH 127/722] Make django-braces a dependency only on Django 1.8 --- README.rst | 1 + setup.cfg | 1 - tox.ini | 1 + 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index e086db738..f9013acb6 100644 --- a/README.rst +++ b/README.rst @@ -49,6 +49,7 @@ Requirements * Python 2.7, 3.4, 3.5, 3.6 * Django 1.8, 1.10, 1.11 +* On Django 1.8: django-braces >= 1.11.0 Installation ------------ diff --git a/setup.cfg b/setup.cfg index 63a7753ba..c440030b9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,6 @@ include_package_data = True zip_safe = False install_requires = django >= 1.8 - django-braces >= 1.11.0 oauthlib >= 2.0.1 six diff --git a/tox.ini b/tox.ini index d654beb4b..7702f86bc 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,7 @@ deps = django110: Django >=1.10, <1.11 django111: Django >=1.11b1, <2.0 djangomaster: https://github.com/django/django/archive/master.tar.gz + django18: django-braces djangorestframework >=3.5 coverage mock From 0be0f259a3a6ce3da1f2f62341b0ead23a1ca8cf Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:13:08 +0200 Subject: [PATCH 128/722] Move urlsplit / urlunsplit import to compat --- oauth2_provider/compat.py | 4 ++-- oauth2_provider/validators.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index dd4914ab8..94d7e749a 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -7,9 +7,9 @@ # urlparse in python3 has been renamed to urllib.parse try: - from urlparse import urlparse, parse_qs, parse_qsl, urlunparse + from urlparse import parse_qs, parse_qsl, urlparse, urlsplit, urlunparse, urlunsplit except ImportError: - from urllib.parse import urlparse, parse_qs, parse_qsl, urlunparse + from urllib.parse import parse_qs, parse_qsl, urlparse, urlsplit, urlunsplit, urlunparse try: from urllib import urlencode, quote_plus, unquote_plus diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index d3fefc4dc..e3ece929c 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError from django.core.validators import RegexValidator from django.utils.encoding import force_text -from django.utils.six.moves.urllib.parse import urlsplit, urlunsplit from django.utils.translation import ugettext_lazy as _ +from .compat import urlsplit, urlunsplit from .settings import oauth2_settings From cf8401de1503333060669d49634d1bdee14108fc Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:15:42 +0200 Subject: [PATCH 129/722] Remove dependency on six --- oauth2_provider/settings.py | 13 ++++++------- setup.cfg | 1 - 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 7e35be3f5..96b6a38fd 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -19,8 +19,6 @@ import importlib -import six - from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -81,11 +79,12 @@ def perform_import(val, setting_name): If the given setting is a string import notation, then perform the necessary import or imports. """ - if isinstance(val, six.string_types): - return import_from_string(val, setting_name) - elif isinstance(val, (list, tuple)): + if isinstance(val, (list, tuple)): return [import_from_string(item, setting_name) for item in val] - return val + elif "." in val: + return import_from_string(val, setting_name) + else: + raise ImproperlyConfigured("Bad value for %r: %r" % (setting_name, val)) def import_from_string(val, setting_name): @@ -133,7 +132,7 @@ def __getattr__(self, attr): # Overriding special settings if attr == '_SCOPES': - val = list(six.iterkeys(self.SCOPES)) + val = list(self.SCOPES.keys()) if attr == '_DEFAULT_SCOPES': if '__all__' in self.DEFAULT_SCOPES: # If DEFAULT_SCOPES is set to ['__all__'] the whole set of scopes is returned diff --git a/setup.cfg b/setup.cfg index c440030b9..ff5390f0a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,7 +30,6 @@ zip_safe = False install_requires = django >= 1.8 oauthlib >= 2.0.1 - six [options.packages.find] exclude = tests From c7dfae10071e831d13ec07283761b54a6fdebf6a Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:32:33 +0200 Subject: [PATCH 130/722] Set django_find_project = false for pytest --- tox.ini | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tox.ini b/tox.ini index 7702f86bc..c602b1911 100644 --- a/tox.ini +++ b/tox.ini @@ -39,6 +39,9 @@ deps = flake8 flake8-import-order +[pytest] +django_find_project = false + [flake8] max-line-length = 110 exclude = docs/, migrations/, .tox/ From 4e892344b0a6e494b9c61ef134d0a68b0871760e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 15 Mar 2017 03:48:27 +0200 Subject: [PATCH 131/722] Use user.is_anonymous instead of user.is_anonymous() --- oauth2_provider/middleware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index dacf6733b..dbfd5e2db 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -26,7 +26,7 @@ class OAuth2TokenMiddleware(MiddlewareMixin): def process_request(self, request): # do something only if request contains a Bearer token if request.META.get('HTTP_AUTHORIZATION', '').startswith('Bearer'): - if not hasattr(request, 'user') or request.user.is_anonymous(): + if not hasattr(request, 'user') or request.user.is_anonymous: user = authenticate(request=request) if user: request.user = request._cached_user = user From 1e12fe4940d9fd5980e5135293ffe7490b419ff2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Wed, 5 Apr 2017 10:32:20 +0300 Subject: [PATCH 132/722] Drop support for Django 1.8 --- .travis.yml | 3 --- README.rst | 3 +-- oauth2_provider/compat.py | 12 ------------ oauth2_provider/models.py | 3 ++- oauth2_provider/views/application.py | 3 ++- oauth2_provider/views/base.py | 13 +++---------- oauth2_provider/views/token.py | 3 ++- tests/test_application_views.py | 2 +- tests/test_authorization_code.py | 3 ++- tests/test_client_credential.py | 3 ++- tests/test_implicit.py | 3 ++- tests/test_models.py | 14 ++++---------- tests/test_password.py | 2 +- tests/test_scopes.py | 3 ++- tests/test_token_revocation.py | 3 ++- tests/test_token_view.py | 2 +- tests/urls.py | 6 +----- tox.ini | 8 +++----- 18 files changed, 31 insertions(+), 58 deletions(-) diff --git a/.travis.yml b/.travis.yml index 0496f178a..8e09ce681 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,13 +5,10 @@ python: sudo: false env: - - TOXENV=py27-django18 - TOXENV=py27-django110 - TOXENV=py27-django111 - - TOXENV=py34-django18 - TOXENV=py34-django110 - TOXENV=py34-django111 - - TOXENV=py35-django18 - TOXENV=py35-django110 - TOXENV=py35-django111 - TOXENV=py35-djangomaster diff --git a/README.rst b/README.rst index f9013acb6..4592aa3f5 100644 --- a/README.rst +++ b/README.rst @@ -48,8 +48,7 @@ Requirements ------------ * Python 2.7, 3.4, 3.5, 3.6 -* Django 1.8, 1.10, 1.11 -* On Django 1.8: django-braces >= 1.11.0 +* Django 1.10, 1.11 Installation ------------ diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 94d7e749a..4e8be2296 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -16,18 +16,6 @@ except ImportError: from urllib.parse import urlencode, quote_plus, unquote_plus -# changed in Django 1.10 (broken in Django 2.0) -try: - from django.urls import reverse, reverse_lazy -except ImportError: - from django.core.urlresolvers import reverse, reverse_lazy - -# Added in Django 1.9, required as long as 1.8 is supported -try: - from django.contrib.auth.mixins import LoginRequiredMixin -except ImportError: - from braces.views import LoginRequiredMixin - # bastb Django 1.10 has updated Middleware. This code imports the Mixin required to get old-style # middleware working again # More? diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a5d6fb8da..e8af09894 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,11 +6,12 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction +from django.urls import reverse from django.utils import timezone from django.utils.encoding import python_2_unicode_compatible from django.utils.translation import ugettext_lazy as _ -from .compat import parse_qsl, reverse, urlparse +from .compat import parse_qsl, urlparse from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend from .settings import oauth2_settings diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 84421349c..9dea96931 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,7 +1,8 @@ +from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.models import modelform_factory +from django.urls import reverse_lazy from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView -from ..compat import LoginRequiredMixin, reverse_lazy from ..models import get_application_model diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 3ca469f80..3cc381099 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,5 +1,6 @@ import logging +from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator @@ -8,7 +9,6 @@ from django.views.generic import FormView, View from .mixins import OAuthLibMixin -from ..compat import LoginRequiredMixin from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import HttpResponseUriRedirect @@ -162,6 +162,7 @@ def get(self, request, *args, **kwargs): return self.error_response(error) +@method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): """ Implements an endpoint to provide access tokens @@ -175,11 +176,6 @@ class TokenView(OAuthLibMixin, View): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - # XXX: Django 1.8 compat - @method_decorator(csrf_exempt) - def dispatch(self, *args, **kwargs): - return super(TokenView, self).dispatch(*args, **kwargs) - @method_decorator(sensitive_post_parameters('password')) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) @@ -190,6 +186,7 @@ def post(self, request, *args, **kwargs): return response +@method_decorator(csrf_exempt, name="dispatch") class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens @@ -198,10 +195,6 @@ class RevokeTokenView(OAuthLibMixin, View): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - # XXX: Django 1.8 compat - def dispatch(self, *args, **kwargs): - return super(RevokeTokenView, self).dispatch(*args, **kwargs) - def post(self, request, *args, **kwargs): url, headers, body, status = self.create_revocation_response(request) response = HttpResponse(content=body or '', status=status) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 69aef16a6..84cd11131 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -1,8 +1,9 @@ from __future__ import absolute_import, unicode_literals +from django.contrib.auth.mixins import LoginRequiredMixin +from django.urls import reverse_lazy from django.views.generic import DeleteView, ListView -from ..compat import LoginRequiredMixin, reverse_lazy from ..models import AccessToken diff --git a/tests/test_application_views.py b/tests/test_application_views.py index abcdea390..a5a528bf7 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -2,8 +2,8 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from django.urls import reverse -from oauth2_provider.compat import reverse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views.application import ApplicationRegistration diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index e74e66db2..75aaadc5b 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -6,9 +6,10 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.urls import reverse from django.utils import timezone -from oauth2_provider.compat import parse_qs, reverse, urlencode, urlparse +from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 283450278..29438ff2b 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -4,10 +4,11 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.urls import reverse from django.views.generic import View from oauthlib.oauth2 import BackendApplicationServer -from oauth2_provider.compat import quote_plus, reverse +from oauth2_provider.compat import quote_plus from oauth2_provider.models import AccessToken, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 4f2b7ac31..b7df729ea 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -2,8 +2,9 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.urls import reverse -from oauth2_provider.compat import parse_qs, reverse, urlencode, urlparse +from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_models.py b/tests/test_models.py index e6c5ffbde..7de223bd9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -119,16 +119,10 @@ def test_related_objects(self): See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) """ - # Django internals caches the related objects. - if django.VERSION < (1, 8): - del UserModel._meta._related_objects_cache - if django.VERSION < (1, 10): - related_object_names = [ro.name for ro in UserModel._meta.get_all_related_objects()] - else: - related_object_names = [ - f.name for f in UserModel._meta.get_fields() - if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete - ] + related_object_names = [ + f.name for f in UserModel._meta.get_fields() + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] self.assertNotIn('oauth2_provider:application', related_object_names) self.assertIn("tests_sampleapplication", related_object_names) diff --git a/tests/test_password.py b/tests/test_password.py index cd0572ab2..e4a66845b 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -4,8 +4,8 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.urls import reverse -from oauth2_provider.compat import reverse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 5e6469ddd..1e0f508cd 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -5,8 +5,9 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase +from django.urls import reverse -from oauth2_provider.compat import parse_qs, reverse, urlparse +from oauth2_provider.compat import parse_qs, urlparse from oauth2_provider.models import AccessToken, get_application_model, Grant from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index aab9a77b5..d5e36a429 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -4,9 +4,10 @@ from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.urls import reverse from django.utils import timezone -from oauth2_provider.compat import reverse, urlencode +from oauth2_provider.compat import urlencode from oauth2_provider.models import AccessToken, get_application_model, RefreshToken from oauth2_provider.settings import oauth2_settings diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 9b8ff731e..6afe2b0fd 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -4,9 +4,9 @@ from django.contrib.auth import get_user_model from django.test import TestCase +from django.urls import reverse from django.utils import timezone -from oauth2_provider.compat import reverse from oauth2_provider.models import AccessToken, get_application_model diff --git a/tests/urls.py b/tests/urls.py index d72baba72..c9d24a9d6 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,4 +1,3 @@ -import django from django.conf.urls import include, url from django.contrib import admin @@ -10,7 +9,4 @@ ] -if django.VERSION < (1, 9, 0): - urlpatterns += [url(r"^admin/", include(admin.site.urls))] -else: - urlpatterns += [url(r"^admin/", admin.site.urls)] +urlpatterns += [url(r"^admin/", admin.site.urls)] diff --git a/tox.ini b/tox.ini index c602b1911..7bb0a61d2 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py27-django{18,110,111}, - py35-django{18,110,111,master}, + py27-django{110,111}, + py35-django{110,111,master}, py36-djangomaster, docs, flake8 @@ -12,11 +12,9 @@ setenv = DJANGO_SETTINGS_MODULE=tests.settings PYTHONPATH={toxinidir} deps = - django18: Django >=1.8, <1.9 django110: Django >=1.10, <1.11 - django111: Django >=1.11b1, <2.0 + django111: Django >=1.11, <2.0 djangomaster: https://github.com/django/django/archive/master.tar.gz - django18: django-braces djangorestframework >=3.5 coverage mock From cb5b231c0ab85d62eab6eb348399a8ccdc9160da Mon Sep 17 00:00:00 2001 From: Andrew Lombardi Date: Thu, 30 Mar 2017 23:09:23 -0700 Subject: [PATCH 133/722] docs: Specify DRF fields in the serializer example New syntax as of DRF 3.3.x --- docs/rest-framework/getting_started.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 3d5388f79..987ef82a6 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -62,11 +62,13 @@ Here's our project's root `urls.py` module: class UserSerializer(serializers.ModelSerializer): class Meta: model = User + fields = ("username", "email", "first_name", "last_name", ) class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group + fields = ("name", ) # ViewSets define the view behavior. From bc48e9468e1005eb3bfbf31c90e2ac590ef3e0ca Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:31:37 +0200 Subject: [PATCH 134/722] Change Grant model and make it swappable --- oauth2_provider/models.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e8af09894..b54139eac 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -144,7 +144,7 @@ class Meta(AbstractApplication.Meta): @python_2_unicode_compatible -class Grant(models.Model): +class AbstractGrant(models.Model): """ A Grant instance represents a token with a short lifetime that can be swapped for an access token, as described in :rfc:`4.1.2` @@ -182,6 +182,14 @@ def redirect_uri_allowed(self, uri): def __str__(self): return self.code + class Meta: + abstract = True + + +class Grant(AbstractGrant): + class Meta(AbstractGrant.Meta): + swappable = "OAUTH2_PROVIDER_GRANT_MODEL" + @python_2_unicode_compatible class AccessToken(models.Model): From d247074194fa17fa4f783320df1ffce93b87ff65 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:35:06 +0200 Subject: [PATCH 135/722] Make AccessToken swappable --- oauth2_provider/models.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index b54139eac..47e443377 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -192,7 +192,7 @@ class Meta(AbstractGrant.Meta): @python_2_unicode_compatible -class AccessToken(models.Model): +class AbstractAccessToken(models.Model): """ An AccessToken instance represents the actual access token to access user's resources, as in :rfc:`5`. @@ -263,6 +263,14 @@ def scopes(self): def __str__(self): return self.token + class Meta: + abstract = True + + +class AccessToken(AbstractAccessToken): + class Meta(AbstractAccessToken.Meta): + swappable = "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL" + @python_2_unicode_compatible class RefreshToken(models.Model): From 3b23edf8a01354401ef2278b80b8d653b3561457 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:37:56 +0200 Subject: [PATCH 136/722] Make RefreshToken swappable --- oauth2_provider/models.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 47e443377..3da66c77f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -273,7 +273,7 @@ class Meta(AbstractAccessToken.Meta): @python_2_unicode_compatible -class RefreshToken(models.Model): +class AbstractRefreshToken(models.Model): """ A RefreshToken instance represents a token that can be swapped for a new access token when it expires. @@ -304,6 +304,14 @@ def revoke(self): def __str__(self): return self.token + class Meta: + abstract = True + + +class RefreshToken(AbstractRefreshToken): + class Meta(AbstractRefreshToken.Meta): + swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" + def get_application_model(): """ Return the Application model that is active in this project. """ From bf0d0ab62aa19e5d97e2caa59dddeb071e0ac5d2 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:41:39 +0200 Subject: [PATCH 137/722] Add get_*_model methods for Grant / AccessToken / RefreshToken --- oauth2_provider/models.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 3da66c77f..615a52616 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -315,16 +315,22 @@ class Meta(AbstractRefreshToken.Meta): def get_application_model(): """ Return the Application model that is active in this project. """ - try: - app_label, model_name = oauth2_settings.APPLICATION_MODEL.split('.') - except ValueError: - e = "APPLICATION_MODEL must be of the form 'app_label.model_name'" - raise ImproperlyConfigured(e) - app_model = apps.get_model(app_label, model_name) - if app_model is None: - e = "APPLICATION_MODEL refers to model {0} that has not been installed" - raise ImproperlyConfigured(e.format(oauth2_settings.APPLICATION_MODEL)) - return app_model + return apps.get_model(oauth2_settings.APPLICATION_MODEL) + + +def get_grant_model(): + """ Return the Grant model that is active in this project. """ + return apps.get_model(oauth2_settings.GRANT_MODEL) + + +def get_access_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) + + +def get_refresh_token_model(): + """ Return the RefreshToken model that is active in this project. """ + return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) def clear_expired(): From be8bcccc0adce86862c9d35ae50afc42a3a28d95 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:55:03 +0200 Subject: [PATCH 138/722] Use swappable models on clear_expired method --- oauth2_provider/models.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 615a52616..715cf854d 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -336,7 +336,9 @@ def get_refresh_token_model(): def clear_expired(): now = timezone.now() refresh_expire_at = None - + access_token_model = get_access_token_model() + refresh_token_model = get_refresh_token_model() + grant_model = get_grant_model() REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS if REFRESH_TOKEN_EXPIRE_SECONDS: if not isinstance(REFRESH_TOKEN_EXPIRE_SECONDS, timedelta): @@ -349,6 +351,6 @@ def clear_expired(): with transaction.atomic(): if refresh_expire_at: - RefreshToken.objects.filter(access_token__expires__lt=refresh_expire_at).delete() - AccessToken.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() - Grant.objects.filter(expires__lt=now).delete() + refresh_token_model.objects.filter(access_token__expires__lt=refresh_expire_at).delete() + access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() + grant_model.objects.filter(expires__lt=now).delete() From eca5ddca2aff71481ba0ed8050948b0dafb6f41f Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 16:58:54 +0200 Subject: [PATCH 139/722] Fix AbstractRefreshToken, make it use ACCESS_TOKEN_MODEL --- oauth2_provider/models.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 715cf854d..13e99c401 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -290,7 +290,7 @@ class AbstractRefreshToken(models.Model): token = models.CharField(max_length=255, unique=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) - access_token = models.OneToOneField(AccessToken, + access_token = models.OneToOneField(oauth2_settings.ACCESS_TOKEN_MODEL, related_name='refresh_token', on_delete=models.CASCADE) @@ -298,7 +298,8 @@ def revoke(self): """ Delete this refresh token along with related access token """ - AccessToken.objects.get(id=self.access_token.id).revoke() + access_token_model = get_access_token_model() + access_token_model.objects.get(id=self.access_token.id).revoke() self.delete() def __str__(self): From 92b0c6bf5a23319c2f218d2df533881cfe6ee1b5 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 17:04:29 +0200 Subject: [PATCH 140/722] Use swappable models to register on admin.site --- oauth2_provider/admin.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 844ac5df1..1f8312f4a 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,6 +1,11 @@ from django.contrib import admin -from .models import AccessToken, get_application_model, Grant, RefreshToken +from .models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) class ApplicationAdmin(admin.ModelAdmin): @@ -29,6 +34,9 @@ class RefreshTokenAdmin(admin.ModelAdmin): Application = get_application_model() +Grant = get_grant_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() admin.site.register(Application, ApplicationAdmin) admin.site.register(Grant, GrantAdmin) From 18c90fc79b634864fa507195a4daebebb0caa71d Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 17:14:00 +0200 Subject: [PATCH 141/722] Use get_*_model methods to call swappable models --- oauth2_provider/oauth2_validators.py | 19 +++++++++++++++---- tests/test_authorization_code.py | 10 +++++++++- tests/test_client_credential.py | 6 +++++- tests/test_decorators.py | 6 +++++- tests/test_models.py | 10 +++++++++- tests/test_oauth2_validators.py | 18 ++++++++++++------ tests/test_rest_framework.py | 6 +++++- tests/test_scopes.py | 8 +++++++- tests/test_token_revocation.py | 8 +++++++- tests/test_token_view.py | 6 +++++- 10 files changed, 79 insertions(+), 18 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ac7a390bc..6f84a5ad8 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -14,7 +14,13 @@ from .compat import unquote_plus from .exceptions import FatalClientError -from .models import AbstractApplication, AccessToken, get_application_model, Grant, RefreshToken +from .models import ( + AbstractApplication, + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -25,10 +31,16 @@ 'authorization_code': (AbstractApplication.GRANT_AUTHORIZATION_CODE,), 'password': (AbstractApplication.GRANT_PASSWORD,), 'client_credentials': (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), - 'refresh_token': (AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, - AbstractApplication.GRANT_CLIENT_CREDENTIALS) + 'refresh_token': (AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_PASSWORD, + AbstractApplication.GRANT_CLIENT_CREDENTIALS,) } +Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() + class OAuth2Validator(RequestValidator): def _extract_basic_auth(self, request): @@ -128,7 +140,6 @@ def _load_application(self, client_id, request): # we want to be sure that request has the client attribute! assert hasattr(request, "client"), "'request' instance has no 'client' attribute" - Application = get_application_model() try: request.client = request.client or Application.objects.get(client_id=client_id) # Check that the application can be used (defaults to always True) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 75aaadc5b..4e5875f8f 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -10,13 +10,21 @@ from django.utils import timezone from oauth2_provider.compat import parse_qs, urlencode, urlparse -from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from .utils import get_basic_auth_header Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() UserModel = get_user_model() diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 29438ff2b..f9f47ffec 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -9,7 +9,10 @@ from oauthlib.oauth2 import BackendApplicationServer from oauth2_provider.compat import quote_plus -from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, +) from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings @@ -19,6 +22,7 @@ Application = get_application_model() +AccessToken = get_access_token_model() UserModel = get_user_model() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 35e41c94a..c720d2257 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -5,11 +5,15 @@ from django.utils import timezone from oauth2_provider.decorators import protected_resource, rw_protected_resource -from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, +) from oauth2_provider.settings import oauth2_settings Application = get_application_model() +AccessToken = get_access_token_model() UserModel = get_user_model() diff --git a/tests/test_models.py b/tests/test_models.py index 7de223bd9..d10d11490 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,10 +7,18 @@ from django.test.utils import override_settings from django.utils import timezone -from oauth2_provider.models import AccessToken, get_application_model, Grant, RefreshToken +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) Application = get_application_model() +Grant = get_grant_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() UserModel = get_user_model() diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 1f312dd86..439161e80 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -8,12 +8,18 @@ from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError -from oauth2_provider.models import AccessToken, get_application_model, RefreshToken +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_refresh_token_model, +) from oauth2_provider.oauth2_validators import OAuth2Validator UserModel = get_user_model() -AppModel = get_application_model() +Application = get_application_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() class TestOAuth2Validator(TransactionTestCase): @@ -23,9 +29,9 @@ def setUp(self): self.request.user = self.user self.request.grant_type = "not client" self.validator = OAuth2Validator() - self.application = AppModel.objects.create( + self.application = Application.objects.create( client_id='client_id', client_secret='client_secret', user=self.user, - client_type=AppModel.CLIENT_PUBLIC, authorization_grant_type=AppModel.GRANT_PASSWORD) + client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD) self.request.client = self.application def tearDown(self): @@ -94,7 +100,7 @@ def test_authenticate_client_id(self): self.assertTrue(self.validator.authenticate_client_id('client_id', self.request)) def test_authenticate_client_id_fail(self): - self.application.client_type = AppModel.CLIENT_CONFIDENTIAL + self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() self.assertFalse(self.validator.authenticate_client_id('client_id', self.request)) self.assertFalse(self.validator.authenticate_client_id('fake_client_id', self.request)) @@ -108,7 +114,7 @@ def test_client_authentication_required(self): self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.client_secret = '' self.assertFalse(self.validator.client_authentication_required(self.request)) - self.application.client_type = AppModel.CLIENT_CONFIDENTIAL + self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() self.request.client = '' self.assertTrue(self.validator.client_authentication_required(self.request)) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 057d6a54c..73f1eefd1 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -8,11 +8,15 @@ from django.test.utils import override_settings from django.utils import timezone -from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, +) from oauth2_provider.settings import oauth2_settings Application = get_application_model() +AccessToken = get_access_token_model() UserModel = get_user_model() diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 1e0f508cd..e52f0661d 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -8,7 +8,11 @@ from django.urls import reverse from oauth2_provider.compat import parse_qs, urlparse -from oauth2_provider.models import AccessToken, get_application_model, Grant +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, +) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView @@ -16,6 +20,8 @@ Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() UserModel = get_user_model() diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index d5e36a429..47d359a27 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -8,11 +8,17 @@ from django.utils import timezone from oauth2_provider.compat import urlencode -from oauth2_provider.models import AccessToken, get_application_model, RefreshToken +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_refresh_token_model, +) from oauth2_provider.settings import oauth2_settings Application = get_application_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() UserModel = get_user_model() diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 6afe2b0fd..1a1866399 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -7,10 +7,14 @@ from django.urls import reverse from django.utils import timezone -from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, +) Application = get_application_model() +AccessToken = get_access_token_model() UserModel = get_user_model() From 099878f8d09c9d21ebdbe3d239866a652ca86a68 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 17:17:26 +0200 Subject: [PATCH 142/722] Set default models --- oauth2_provider/settings.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 96b6a38fd..e443e7424 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -26,6 +26,9 @@ USER_SETTINGS = getattr(settings, 'OAUTH2_PROVIDER', None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") +ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") +GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") +REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_MODEL", "oauth2_provider.RefreshToken") DEFAULTS = { 'CLIENT_ID_GENERATOR_CLASS': 'oauth2_provider.generators.ClientIdGenerator', @@ -44,6 +47,9 @@ 'REFRESH_TOKEN_EXPIRE_SECONDS': None, 'ROTATE_REFRESH_TOKEN': True, 'APPLICATION_MODEL': APPLICATION_MODEL, + 'ACCESS_TOKEN_MODEL': ACCESS_TOKEN_MODEL, + 'GRANT_MODEL': GRANT_MODEL, + 'REFRESH_TOKEN_MODEL': REFRESH_TOKEN_MODEL, 'REQUEST_APPROVAL_PROMPT': 'force', 'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'], From d647e16c4c2aeb46fd01b17a7e4b3eff215826fa Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 17:21:00 +0200 Subject: [PATCH 143/722] Use get_access_token_model instead of AccessToken --- oauth2_provider/views/token.py | 6 +++--- tests/test_auth_backends.py | 8 ++++++-- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 84cd11131..4239bd1d7 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -4,7 +4,7 @@ from django.urls import reverse_lazy from django.views.generic import DeleteView, ListView -from ..models import AccessToken +from ..models import get_access_token_model class AuthorizedTokensListView(LoginRequiredMixin, ListView): @@ -13,7 +13,7 @@ class AuthorizedTokensListView(LoginRequiredMixin, ListView): """ context_object_name = 'authorized_tokens' template_name = 'oauth2_provider/authorized-tokens.html' - model = AccessToken + model = get_access_token_model() def get_queryset(self): """ @@ -29,7 +29,7 @@ class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): """ template_name = 'oauth2_provider/authorized-token-delete.html' success_url = reverse_lazy('oauth2_provider:authorized-token-list') - model = AccessToken + model = get_access_token_model() def get_queryset(self): return super(AuthorizedTokenDeleteView, self).get_queryset().filter(user=self.request.user) diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 356c4982f..233992dbf 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -7,7 +7,10 @@ from oauth2_provider.backends import OAuth2Backend from oauth2_provider.middleware import OAuth2TokenMiddleware -from oauth2_provider.models import AccessToken, get_application_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, +) try: # Django<1.10 compatibility from django.conf.global_settings import MIDDLEWARE_CLASSES as MIDDLEWARE @@ -17,6 +20,7 @@ UserModel = get_user_model() ApplicationModel = get_application_model() +AccessTokenModel = get_access_token_model() class BaseTest(TestCase): @@ -31,7 +35,7 @@ def setUp(self): authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, user=self.user ) - self.token = AccessToken.objects.create(user=self.user, + self.token = AccessTokenModel.objects.create(user=self.user, token='tokstr', application=self.app, expires=now() + timedelta(days=365)) From 7fc62d4081ee6b5129d4d15b0bab292662d2b800 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Mon, 27 Mar 2017 17:47:50 +0200 Subject: [PATCH 144/722] Use swappable methods to declare migrations --- oauth2_provider/migrations/0001_initial.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py index d896a833f..4b5601003 100644 --- a/oauth2_provider/migrations/0001_initial.py +++ b/oauth2_provider/migrations/0001_initial.py @@ -13,6 +13,9 @@ class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), + migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL), + migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL), + migrations.swappable_dependency(oauth2_settings.GRANT_MODEL), ] operations = [ @@ -43,6 +46,10 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL', + }, ), migrations.CreateModel( name='Grant', @@ -55,15 +62,23 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_GRANT_MODEL', + }, ), migrations.CreateModel( name='RefreshToken', fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('token', models.CharField(max_length=255, db_index=True)), - ('access_token', models.OneToOneField(related_name='refresh_token', to='oauth2_provider.AccessToken', on_delete=models.CASCADE)), + ('access_token', models.OneToOneField(related_name='refresh_token', to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.CASCADE)), ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', + }, ), ] From bb6c890e5d0602fd77ea5aaee354b820c9d6c5b9 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:28:30 +0200 Subject: [PATCH 145/722] Set user related_name on the rest of abstract models. --- oauth2_provider/models.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 13e99c401..599478263 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -59,7 +59,8 @@ class AbstractApplication(models.Model): client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", + user = models.ForeignKey(settings.AUTH_USER_MODEL, + related_name="%(app_label)s_%(class)s", null=True, blank=True, on_delete=models.CASCADE) help_text = _("Allowed URIs list, space separated") @@ -159,7 +160,8 @@ class AbstractGrant(models.Model): * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s") code = models.CharField(max_length=255, unique=True) # code comes from oauthlib application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) @@ -206,8 +208,9 @@ class AbstractAccessToken(models.Model): * :attr:`scope` Allowed scopes """ user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, - on_delete=models.CASCADE) - token = models.CharField(max_length=255, unique=True) + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s") + token = models.CharField(max_length=255, unique=True, ) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) expires = models.DateTimeField() @@ -286,7 +289,8 @@ class AbstractRefreshToken(models.Model): * :attr:`access_token` AccessToken instance this refresh token is bounded to """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s") token = models.CharField(max_length=255, unique=True) application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) From 2a13b98494ec3de8329dc07559200b69c38719aa Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:29:30 +0200 Subject: [PATCH 146/722] Fix AuthorizationView.get method As user.accesstoken is not accesible anymore since it refers to %(app_label)s_%(class)s from the swapped model, tokens must be queried by filtering on get_access_token_model() and use request.user --- oauth2_provider/views/base.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 3cc381099..e8527a835 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -12,7 +12,7 @@ from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import HttpResponseUriRedirect -from ..models import get_application_model +from ..models import get_access_token_model, get_application_model from ..scopes import get_scopes_backend from ..settings import oauth2_settings @@ -146,8 +146,12 @@ def get(self, request, *args, **kwargs): return HttpResponseUriRedirect(uri) elif require_approval == 'auto': - tokens = request.user.accesstoken_set.filter(application=kwargs['application'], - expires__gt=timezone.now()).all() + tokens = get_access_token_model().objects.filter( + user=request.user, + application=kwargs["application"], + expires__gt=timezone.now() + ).all() + # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): From 20ec7baf6fcc63c04013524d4ff6ab3721ab8a25 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:32:19 +0200 Subject: [PATCH 147/722] Declare sample models for access_token, refresh_token and grant. --- tests/models.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/models.py b/tests/models.py index b4f6468ef..c7ab5c092 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,7 +1,24 @@ from django.db import models -from oauth2_provider.models import AbstractApplication +from oauth2_provider.models import ( + AbstractApplication, + AbstractAccessToken, + AbstractGrant, + AbstractRefreshToken, +) class SampleApplication(AbstractApplication): custom_field = models.CharField(max_length=255) + + +class SampleAccessToken(AbstractAccessToken): + custom_field = models.CharField(max_length=255) + + +class SampleRefreshToken(AbstractRefreshToken): + custom_field = models.CharField(max_length=255) + + +class SampleGrant(AbstractGrant): + custom_field = models.CharField(max_length=255) From e4035dea764e7e9f0f9009af8ee1a170ac902ca8 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:33:09 +0200 Subject: [PATCH 148/722] Application model is swapped, use get_application_model instead. --- tests/test_application_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index a5a528bf7..2c3c4772d 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -26,6 +26,7 @@ def tearDown(self): class TestApplicationRegistrationView(BaseTest): + def test_get_form_class(self): """ Tests that the form class returned by the 'get_form_class' method is @@ -56,7 +57,7 @@ def test_application_registration_user(self): response = self.client.post(reverse('oauth2_provider:register'), form_data) self.assertEqual(response.status_code, 302) - app = Application.objects.get(name="Foo app") + app = get_application_model().objects.get(name="Foo app") self.assertEqual(app.user.username, "foo_user") @@ -81,7 +82,7 @@ def setUp(self): def tearDown(self): super(TestApplicationViews, self).tearDown() - Application.objects.all().delete() + get_application_model().objects.all().delete() def test_application_list(self): self.client.login(username="foo_user", password="123456") From a9e1ee8ca4e76644b892a47002bfc68d09f5dddb Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:34:12 +0200 Subject: [PATCH 149/722] Test all custom models Application, Grant, AccessToken and RefreshToken are now swappable. --- tests/test_models.py | 50 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index d10d11490..24b9fe903 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -115,12 +115,17 @@ def test_scopes_property(self): self.assertEqual(access_token2.scopes, {'write': 'Writing scope'}) -@override_settings(OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication") -class TestCustomApplicationModel(TestCase): +@override_settings( + OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", + OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", + OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", + OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" +) +class TestCustomModels(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - def test_related_objects(self): + def test_custom_application_model(self): """ If a custom application model is installed, it should be present in the related objects and not the swapped out one. @@ -134,6 +139,45 @@ def test_related_objects(self): self.assertNotIn('oauth2_provider:application', related_object_names) self.assertIn("tests_sampleapplication", related_object_names) + def test_custom_access_token_model(self): + """ + If a custom access token model is installed, it should be present in + the related objects and not the swapped out one. + """ + # Django internals caches the related objects. + related_object_names = [ + f.name for f in UserModel._meta.get_fields() + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] + self.assertNotIn('oauth2_provider:access_token', related_object_names) + self.assertIn("tests_sampleaccesstoken", related_object_names) + + def test_custom_refresh_token_model(self): + """ + If a custom refresh token model is installed, it should be present in + the related objects and not the swapped out one. + """ + # Django internals caches the related objects. + related_object_names = [ + f.name for f in UserModel._meta.get_fields() + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] + self.assertNotIn('oauth2_provider:refresh_token', related_object_names) + self.assertIn("tests_samplerefreshtoken", related_object_names) + + def test_custom_grant_model(self): + """ + If a custom grant model is installed, it should be present in + the related objects and not the swapped out one. + """ + # Django internals caches the related objects. + related_object_names = [ + f.name for f in UserModel._meta.get_fields() + if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete + ] + self.assertNotIn('oauth2_provider:grant', related_object_names) + self.assertIn("tests_samplegrant", related_object_names) + class TestGrantModel(TestCase): From 35d8870f3e41e9d738e5837677eccb69ec322bee Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 17:34:15 +0200 Subject: [PATCH 150/722] Add test cases for improper swappable model names --- tests/test_models.py | 40 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 24b9fe903..f1c23b887 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,7 +2,7 @@ import django from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ImproperlyConfigured from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -13,7 +13,7 @@ get_grant_model, get_refresh_token_model, ) - +from oauth2_provider.settings import oauth2_settings Application = get_application_model() Grant = get_grant_model() @@ -139,6 +139,15 @@ def test_custom_application_model(self): self.assertNotIn('oauth2_provider:application', related_object_names) self.assertIn("tests_sampleapplication", related_object_names) + def test_custom_application_model_incorrect_format(self): + # Patch oauth2 settings to use a custom Application model + oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" + + self.assertRaises(ImproperlyConfigured, get_application_model) + + # Revert oauth2 settings + oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' + def test_custom_access_token_model(self): """ If a custom access token model is installed, it should be present in @@ -152,6 +161,15 @@ def test_custom_access_token_model(self): self.assertNotIn('oauth2_provider:access_token', related_object_names) self.assertIn("tests_sampleaccesstoken", related_object_names) + def test_custom_access_token_model_incorrect_format(self): + # Patch oauth2 settings to use a custom AccessToken model + oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" + + self.assertRaises(ImproperlyConfigured, get_access_token_model) + + # Revert oauth2 settings + oauth2_settings.ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' + def test_custom_refresh_token_model(self): """ If a custom refresh token model is installed, it should be present in @@ -165,6 +183,15 @@ def test_custom_refresh_token_model(self): self.assertNotIn('oauth2_provider:refresh_token', related_object_names) self.assertIn("tests_samplerefreshtoken", related_object_names) + def test_custom_refresh_token_model_incorrect_format(self): + # Patch oauth2 settings to use a custom RefreshToken model + oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" + + self.assertRaises(ImproperlyConfigured, get_refresh_token_model) + + # Revert oauth2 settings + oauth2_settings.REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + def test_custom_grant_model(self): """ If a custom grant model is installed, it should be present in @@ -178,6 +205,15 @@ def test_custom_grant_model(self): self.assertNotIn('oauth2_provider:grant', related_object_names) self.assertIn("tests_samplegrant", related_object_names) + def test_custom_grant_model_incorrect_format(self): + # Patch oauth2 settings to use a custom Grant model + oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" + + self.assertRaises(ImproperlyConfigured, get_grant_model) + + # Revert oauth2 settings + oauth2_settings.GRANT_MODEL = 'oauth2_provider.Grant' + class TestGrantModel(TestCase): From 5c30b93a31a8ea8b5a75993a296b83f5fc87f465 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 17:55:06 +0200 Subject: [PATCH 151/722] Add test cases to validate proper model names and setup --- tests/test_models.py | 44 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index f1c23b887..f1deb508f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -143,7 +143,16 @@ def test_custom_application_model_incorrect_format(self): # Patch oauth2 settings to use a custom Application model oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" - self.assertRaises(ImproperlyConfigured, get_application_model) + self.assertRaises(ValueError, get_application_model) + + # Revert oauth2 settings + oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' + + def test_custom_application_model_not_installed(self): + # Patch oauth2 settings to use a custom Application model + oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" + + self.assertRaises(LookupError, get_application_model) # Revert oauth2 settings oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' @@ -165,7 +174,16 @@ def test_custom_access_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom AccessToken model oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" - self.assertRaises(ImproperlyConfigured, get_access_token_model) + self.assertRaises(ValueError, get_access_token_model) + + # Revert oauth2 settings + oauth2_settings.ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' + + def test_custom_access_token_model_not_installed(self): + # Patch oauth2 settings to use a custom AccessToken model + oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" + + self.assertRaises(LookupError, get_access_token_model) # Revert oauth2 settings oauth2_settings.ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' @@ -187,7 +205,16 @@ def test_custom_refresh_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom RefreshToken model oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" - self.assertRaises(ImproperlyConfigured, get_refresh_token_model) + self.assertRaises(ValueError, get_refresh_token_model) + + # Revert oauth2 settings + oauth2_settings.REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + + def test_custom_refresh_token_model_not_installed(self): + # Patch oauth2 settings to use a custom AccessToken model + oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" + + self.assertRaises(LookupError, get_refresh_token_model) # Revert oauth2 settings oauth2_settings.REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' @@ -209,7 +236,16 @@ def test_custom_grant_model_incorrect_format(self): # Patch oauth2 settings to use a custom Grant model oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" - self.assertRaises(ImproperlyConfigured, get_grant_model) + self.assertRaises(ValueError, get_grant_model) + + # Revert oauth2 settings + oauth2_settings.GRANT_MODEL = 'oauth2_provider.Grant' + + def test_custom_grant_model_not_installed(self): + # Patch oauth2 settings to use a custom AccessToken model + oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" + + self.assertRaises(LookupError, get_grant_model) # Revert oauth2 settings oauth2_settings.GRANT_MODEL = 'oauth2_provider.Grant' From 566fe67b3e590173f75357c47adbd9de19624f56 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:47:16 +0200 Subject: [PATCH 152/722] Document new swappable models --- docs/settings.rst | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index c93f1dded..2ae5323b7 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -1,8 +1,9 @@ Settings ======== -Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the solely exception of -`OAUTH2_PROVIDER_APPLICATION_MODEL`: this is because of the way Django currently implements +Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the exception of +`OAUTH2_PROVIDER_APPLICATION_MODEL, OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, OAUTH2_PROVIDER_GRANT_MODEL, +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL`: this is because of the way Django currently implements swappable models. See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) for details. For example: @@ -32,6 +33,12 @@ The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients can cache the token for a reasonable amount of time. +ACCESS_TOKEN_MODEL +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your access tokens. Overwrite +this value if you wrote your own implementation (subclass of +``oauth2_provider.models.AccessToken``). + ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -67,6 +74,12 @@ CLIENT_SECRET_GENERATOR_LENGTH The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. +GRANT_MODEL +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your grants. Overwrite +this value if you wrote your own implementation (subclass of +``oauth2_provider.models.Grant``). + OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) @@ -87,6 +100,12 @@ REFRESH_TOKEN_EXPIRE_SECONDS The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +REFRESH_TOKEN_MODEL +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your refresh tokens. Overwrite +this value if you wrote your own implementation (subclass of +``oauth2_provider.models.RefreshToken``). + ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. From 7b13ed5946e44f069e5efd489c1373d01ca51b50 Mon Sep 17 00:00:00 2001 From: Aristobulo Meneses Date: Tue, 28 Mar 2017 16:47:39 +0200 Subject: [PATCH 153/722] Update AUTHORS file --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index 39cbb0482..88dabd036 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Contributors Alessandro De Angelis Ash Christopher +Aristóbulo Meneses Bart Merenda Bas van Oostveen David Fischer From c1b96d097630a6653ff749c4d3ab8146565e44b4 Mon Sep 17 00:00:00 2001 From: Rigas Papathanasopoulos Date: Mon, 15 May 2017 12:55:24 +0300 Subject: [PATCH 154/722] Disallow empty redirect URIs The empty string does no longer pass the URI validator. This does not make existing code that relies on `blank=True' fail, it just fixes the case where the user provides an empty string. Signed-off-by: Rigas Papathanasopoulos --- oauth2_provider/validators.py | 5 ++++- tests/test_validators.py | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index e3ece929c..77e085927 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -61,5 +61,8 @@ def validate_uris(value): This validator ensures that `value` contains valid blank-separated URIs" """ v = RedirectURIValidator(oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES) - for uri in value.split(): + uris = value.split() + if not uris: + raise ValidationError('Redirect URI cannot be empty') + for uri in uris: v(uri) diff --git a/tests/test_validators.py b/tests/test_validators.py index 754fc34fb..66f292a38 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -34,3 +34,7 @@ def test_validate_bad_uris(self): self.assertRaises(ValidationError, validate_uris, bad_uri) bad_uri = 'sdklfsjlfjljdflksjlkfjsdkl' self.assertRaises(ValidationError, validate_uris, bad_uri) + bad_uri = ' ' + self.assertRaises(ValidationError, validate_uris, bad_uri) + bad_uri = '' + self.assertRaises(ValidationError, validate_uris, bad_uri) From 8ef945e8078b2673adb0052c0a1394f4fd19a065 Mon Sep 17 00:00:00 2001 From: Anton Date: Fri, 2 Jun 2017 18:58:23 +0300 Subject: [PATCH 155/722] Fix install.rst code markdown --- docs/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index 60e9d8fe6..462e2d536 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -2,7 +2,7 @@ Installation ============ Install with pip - +:: pip install django-oauth-toolkit Add `oauth2_provider` to your `INSTALLED_APPS` From c1cd5d7b575f12313f664036e52383ba5cabf0ec Mon Sep 17 00:00:00 2001 From: Tobias Gall Date: Sat, 13 May 2017 14:50:18 +0200 Subject: [PATCH 156/722] Implement IntrospectTokenView (RFC 7662) --- .../migrations/0005_auto_20170514_1141.py | 22 ++ oauth2_provider/models.py | 2 +- oauth2_provider/urls.py | 1 + oauth2_provider/views/__init__.py | 1 + oauth2_provider/views/introspect.py | 75 +++++ setup.cfg | 1 + tests/test_introspection_view.py | 260 ++++++++++++++++++ 7 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 oauth2_provider/migrations/0005_auto_20170514_1141.py create mode 100644 oauth2_provider/views/introspect.py create mode 100644 tests/test_introspection_view.py diff --git a/oauth2_provider/migrations/0005_auto_20170514_1141.py b/oauth2_provider/migrations/0005_auto_20170514_1141.py new file mode 100644 index 000000000..bb3b60469 --- /dev/null +++ b/oauth2_provider/migrations/0005_auto_20170514_1141.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-14 11:41 +from __future__ import unicode_literals + +from oauth2_provider.settings import oauth2_settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0004_auto_20160525_1623'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 599478263..7eb20c708 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -211,7 +211,7 @@ class AbstractAccessToken(models.Model): on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s") token = models.CharField(max_length=255, unique=True, ) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, blank=True, null=True, on_delete=models.CASCADE) expires = models.DateTimeField() scope = models.TextField(blank=True) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 53535382a..036569aa9 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -12,6 +12,7 @@ url(r'^authorize/$', views.AuthorizationView.as_view(), name="authorize"), url(r'^token/$', views.TokenView.as_view(), name="token"), url(r'^revoke_token/$', views.RevokeTokenView.as_view(), name="revoke-token"), + url(r'^introspect/$', views.IntrospectTokenView.as_view(), name="introspect"), ] diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 4f444f55d..7bf60cece 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -4,3 +4,4 @@ ApplicationDelete, ApplicationUpdate from .generic import ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView +from .introspect import IntrospectTokenView diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py new file mode 100644 index 000000000..2fbaf2ce7 --- /dev/null +++ b/oauth2_provider/views/introspect.py @@ -0,0 +1,75 @@ +from __future__ import unicode_literals + +import calendar +import json + +from django.core.exceptions import ObjectDoesNotExist +from django.http import HttpResponse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt + +from oauth2_provider.models import get_access_token_model +from oauth2_provider.views import ReadWriteScopedResourceView + + +@method_decorator(csrf_exempt, name="dispatch") +class IntrospectTokenView(ReadWriteScopedResourceView): + """ + Implements an endpoint for token introspection based + on RFC 7662 https://tools.ietf.org/html/rfc7662 + + To access this view the request must pass a OAuth2 Bearer Token + which is allowed to access the scope `introspection`. + """ + required_scopes = ["introspection"] + + @staticmethod + def get_token_response(token_value=None): + try: + token = get_access_token_model().objects.get(token=token_value) + except ObjectDoesNotExist: + return HttpResponse( + content=json.dumps({"active": False}), + status=401, + content_type="application/json" + ) + else: + if token.is_valid(): + data = { + "active": True, + "scope": token.scope, + "exp": int(calendar.timegm(token.expires.timetuple())), + } + if token.application: + data["client_id"] = token.application.client_id + if token.user: + data["username"] = token.user.get_username() + return HttpResponse(content=json.dumps(data), status=200, content_type="application/json") + else: + return HttpResponse(content=json.dumps({ + "active": False, + }), status=200, content_type="application/json") + + def get(self, request, *args, **kwargs): + """ + Get the token from the URL parameters. + URL: https://example.com/introspect?token=mF_9.B5f-4.1JqM + + :param request: + :param args: + :param kwargs: + :return: + """ + return self.get_token_response(request.GET.get("token", None)) + + def post(self, request, *args, **kwargs): + """ + Get the token from the body form parameters. + Body: token=mF_9.B5f-4.1JqM + + :param request: + :param args: + :param kwargs: + :return: + """ + return self.get_token_response(request.POST.get("token", None)) diff --git a/setup.cfg b/setup.cfg index ff5390f0a..8e9f936e3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,7 @@ zip_safe = False install_requires = django >= 1.8 oauthlib >= 2.0.1 + requests >= 2.13.0 [options.packages.find] exclude = tests diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py new file mode 100644 index 000000000..18e5107a6 --- /dev/null +++ b/tests/test_introspection_view.py @@ -0,0 +1,260 @@ +from __future__ import unicode_literals + +import calendar +import datetime + +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse +from django.utils import timezone + +from oauth2_provider.models import get_access_token_model, get_application_model +from oauth2_provider.settings import oauth2_settings + +Application = get_application_model() +AccessToken = get_access_token_model() +UserModel = get_user_model() + + +class TestTokenIntrospectionViews(TestCase): + """ + Tests for Authorized Token Introspection Views + """ + def setUp(self): + self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") + self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") + + self.application = Application( + name="Test Application", + redirect_uris="http://localhost http://example.com http://example.it", + user=self.test_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + self.application.save() + + self.resource_server_token = AccessToken.objects.create( + user=self.resource_server_user, token="12345678900", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write introspection" + ) + + self.valid_token = AccessToken.objects.create( + user=self.test_user, token="12345678901", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write dolphin" + ) + + self.invalid_token = AccessToken.objects.create( + user=self.test_user, token="12345678902", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=-1), + scope="read write dolphin" + ) + + self.token_without_user = AccessToken.objects.create( + user=None, token="12345678903", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write dolphin" + ) + + self.token_without_app = AccessToken.objects.create( + user=self.test_user, token="12345678904", + application=None, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write dolphin" + ) + + oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] + oauth2_settings.READ_SCOPE = "read" + oauth2_settings.WRITE_SCOPE = "write" + + def tearDown(self): + oauth2_settings._SCOPES = ["read", "write"] + AccessToken.objects.all().delete() + Application.objects.all().delete() + UserModel.objects.all().delete() + + def test_view_forbidden(self): + """ + Test that the view is restricted for logged-in users. + """ + response = self.client.get(reverse("oauth2_provider:introspect")) + self.assertEqual(response.status_code, 403) + + def test_view_get_valid_token(self): + """ + Test that when you pass a valid token as URL parameter, + a json with an active token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.get( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }) + + def test_view_get_valid_token_without_user(self): + """ + Test that when you pass a valid token as URL parameter, + a json with an active token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.get( + reverse("oauth2_provider:introspect"), + {"token": self.token_without_user.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.token_without_user.scope, + "client_id": self.token_without_user.application.client_id, + "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), + }) + + def test_view_get_valid_token_without_app(self): + """ + Test that when you pass a valid token as URL parameter, + a json with an active token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.get( + reverse("oauth2_provider:introspect"), + {"token": self.token_without_app.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.token_without_app.scope, + "username": self.token_without_app.user.get_username(), + "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), + }) + + def test_view_get_invalid_token(self): + """ + Test that when you pass an invalid token as URL parameter, + a json with an inactive token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.get( + reverse("oauth2_provider:introspect"), + {"token": self.invalid_token.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": False, + }) + + def test_view_get_notexisting_token(self): + """ + Test that when you pass an non existing token as URL parameter, + a json with an inactive token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.get( + reverse("oauth2_provider:introspect"), + {"token": "kaudawelsch"}, + **auth_headers) + + self.assertEqual(response.status_code, 401) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": False, + }) + + def test_view_post_valid_token(self): + """ + Test that when you pass a valid token as form parameter, + a json with an active token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }) + + def test_view_post_invalid_token(self): + """ + Test that when you pass an invalid token as form parameter, + a json with an inactive token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.invalid_token.token}, + **auth_headers) + + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": False, + }) + + def test_view_post_notexisting_token(self): + """ + Test that when you pass an non existing token as form parameter, + a json with an inactive token state is provided + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": "kaudawelsch"}, + **auth_headers) + + self.assertEqual(response.status_code, 401) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": False, + }) From abb304af02c5aebe2cdf5bab01a78bd8abdb53a5 Mon Sep 17 00:00:00 2001 From: Tobias Gall Date: Sun, 14 May 2017 16:23:43 +0200 Subject: [PATCH 157/722] Extend bearer validation for token introspection --- oauth2_provider/oauth2_validators.py | 96 +++++++++++++- oauth2_provider/settings.py | 5 + tests/test_introspection_auth.py | 189 +++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 tests/test_introspection_auth.py diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 6f84a5ad8..1fd10f55b 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -3,13 +3,15 @@ import base64 import binascii import logging -from datetime import timedelta +from datetime import datetime, timedelta +import requests from django.conf import settings -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_user_model from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.utils import timezone +from django.utils.timezone import make_aware from oauthlib.oauth2 import RequestValidator from .compat import unquote_plus @@ -24,7 +26,6 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings - log = logging.getLogger('oauth2_provider') GRANT_TYPE_MAPPING = { @@ -40,6 +41,7 @@ AccessToken = get_access_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() +UserModel = get_user_model() class OAuth2Validator(RequestValidator): @@ -237,6 +239,63 @@ def validate_client_id(self, client_id, request, *args, **kwargs): def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri + def _get_token_from_authentication_server(self, token, introspection_url, introspection_token): + bearer = "Bearer {}".format(introspection_token) + + try: + response = requests.post( + introspection_url, + data={"token": token}, headers={"Authorization": bearer} + ) + except requests.exceptions.RequestException: + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + return None + + try: + content = response.json() + except ValueError: + log.exception("Introspection: Failed to parse response as json") + return None + + if "active" in content and content['active'] is True: + if "username" in content: + user, _created = UserModel.objects.get_or_create( + **{UserModel.USERNAME_FIELD: content["username"]} + ) + else: + user = None + + max_caching_time = datetime.now() + timedelta( + seconds=oauth2_settings.RESOURCE_SERVER_TOKEN_CACHING_SECONDS + ) + + if "exp" in content: + expires = datetime.utcfromtimestamp(content["exp"]) + if expires > max_caching_time: + expires = max_caching_time + else: + expires = max_caching_time + + scope = content.get("scope", "") + expires = make_aware(expires) + + try: + access_token = AccessToken.objects.select_related("application", "user").get(token=token) + except AccessToken.DoesNotExist: + access_token = AccessToken.objects.create( + token=token, + user=user, + application=None, + scope=scope, + expires=expires + ) + else: + access_token.expires = expires + access_token.scope = scope + access_token.save() + + return access_token + def validate_bearer_token(self, token, scopes, request): """ When users try to access resources, check that provided token is valid @@ -244,10 +303,20 @@ def validate_bearer_token(self, token, scopes, request): if not token: return False + introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL + introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + try: - access_token = AccessToken.objects.select_related("application", "user").get( - token=token) - if access_token.is_valid(scopes): + access_token = AccessToken.objects.select_related("application", "user").get(token=token) + # if there is a token but invalid then look up the token + if introspection_url and introspection_token: + if not access_token.is_valid(scopes): + access_token = self._get_token_from_authentication_server( + token, + introspection_url, + introspection_token + ) + if access_token and access_token.is_valid(scopes): request.client = access_token.application request.user = access_token.user request.scopes = scopes @@ -257,6 +326,21 @@ def validate_bearer_token(self, token, scopes, request): return True return False except AccessToken.DoesNotExist: + # there is no initial token, look up the token + if introspection_url and introspection_token: + access_token = self._get_token_from_authentication_server( + token, + introspection_url, + introspection_token + ) + if access_token and access_token.is_valid(scopes): + request.client = access_token.application + request.user = access_token.user + request.scopes = scopes + + # this is needed by django rest framework + request.access_token = access_token + return True return False def validate_code(self, client_id, code, client, request, *args, **kwargs): diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index e443e7424..3ab1545cf 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -56,6 +56,11 @@ # Special settings that will be evaluated at runtime '_SCOPES': [], '_DEFAULT_SCOPES': [], + + # Resource Server with Token Introspection + 'RESOURCE_SERVER_INTROSPECTION_URL': None, + 'RESOURCE_SERVER_AUTH_TOKEN': None, + 'RESOURCE_SERVER_TOKEN_CACHING_SECONDS': 36000, } # List of settings that cannot be empty diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py new file mode 100644 index 000000000..ad6667a1c --- /dev/null +++ b/tests/test_introspection_auth.py @@ -0,0 +1,189 @@ +from __future__ import unicode_literals + +import calendar +import datetime + +from django.conf.urls import include, url +from django.contrib.auth import get_user_model +from django.http import HttpResponse +from django.test import override_settings, TestCase +from django.utils import timezone +from oauthlib.common import Request + +from oauth2_provider.models import get_access_token_model, get_application_model +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ScopedProtectedResourceView + +try: + from unittest import mock +except ImportError: + import mock + + +Application = get_application_model() +AccessToken = get_access_token_model() +UserModel = get_user_model() + +exp = datetime.datetime.now() + datetime.timedelta(days=1) + + +class ScopeResourceView(ScopedProtectedResourceView): + required_scopes = ["dolphin"] + + def get(self, request, *args, **kwargs): + return HttpResponse("This is a protected resource", 200) + + def post(self, request, *args, **kwargs): + return HttpResponse("This is a protected resource", 200) + + +def mocked_requests_post(url, data, *args, **kwargs): + """ + Mock the response from the authentication server + """ + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + if "token" in data and data["token"] and data["token"] != "12345678900": + return MockResponse({ + "active": True, + "scope": "read write dolphin", + "client_id": "client_id_{}".format(data["token"]), + "username": "{}_user".format(data["token"]), + "exp": int(calendar.timegm(exp.timetuple())), + }, 200) + + return MockResponse({ + "active": False, + }, 200) + + +urlpatterns = [ + url(r"^oauth2/", include("oauth2_provider.urls")), + url(r"^oauth2-test-resource/$", ScopeResourceView.as_view()), +] + + +@override_settings(ROOT_URLCONF=__name__) +class TestTokenIntrospectionAuth(TestCase): + """ + Tests for Authorization through token introspection + """ + def setUp(self): + self.validator = OAuth2Validator() + self.request = mock.MagicMock(wraps=Request) + self.resource_server_user = UserModel.objects.create_user( + "resource_server", "test@example.com", "123456" + ) + + self.application = Application( + name="Test Application", + redirect_uris="http://localhost http://example.com http://example.org", + user=self.resource_server_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + self.application.save() + + self.resource_server_token = AccessToken.objects.create( + user=self.resource_server_user, token="12345678900", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write introspection" + ) + + self.invalid_token = AccessToken.objects.create( + user=self.resource_server_user, token="12345678901", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=-1), + scope="read write dolphin" + ) + + oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = "http://example.org/introspection" + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token + oauth2_settings.READ_SCOPE = "read" + oauth2_settings.WRITE_SCOPE = "write" + + def tearDown(self): + oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = None + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = None + self.resource_server_token.delete() + self.application.delete() + AccessToken.objects.all().delete() + UserModel.objects.all().delete() + + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_token_from_authentication_server_not_existing_token(self, mock_get): + """ + Test method _get_token_from_authentication_server with non existing token + """ + token = self.validator._get_token_from_authentication_server( + self.resource_server_token.token, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + ) + self.assertIsNone(token) + + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_token_from_authentication_server_existing_token(self, mock_get): + """ + Test method _get_token_from_authentication_server with existing token + """ + token = self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + ) + self.assertIsInstance(token, AccessToken) + self.assertEqual(token.user.username, "foo_user") + self.assertEqual(token.scope, "read write dolphin") + + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_validate_bearer_token(self, mock_get): + """ + Test method validate_bearer_token + """ + # with token = None + self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request)) + # with valid token and scope + self.assertTrue(self.validator.validate_bearer_token(self.resource_server_token.token, ["introspection"], self.request)) + # with initially invalid token, but validated through request + self.assertTrue(self.validator.validate_bearer_token(self.invalid_token.token, ["dolphin"], self.request)) + # with locally unavailable token, but validated through request + self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request)) + # with valid token but invalid scope + self.assertFalse(self.validator.validate_bearer_token("foo", ["kaudawelsch"], self.request)) + # with token validated through request, but invalid scope + self.assertFalse(self.validator.validate_bearer_token("butz", ["kaudawelsch"], self.request)) + # with token validated through request and valid scope + self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request)) + + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_resource(self, mock_get): + """ + Test that we can access the resource with a get request and a remotely validated token + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer bar", + } + response = self.client.get("/oauth2-test-resource/", **auth_headers) + self.assertEqual(response.content.decode("utf-8"), "This is a protected resource") + + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_post_resource(self, mock_get): + """ + Test that we can access the resource with a post request and a remotely validated token + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer batz", + } + response = self.client.post("/oauth2-test-resource/", **auth_headers) + self.assertEqual(response.content.decode("utf-8"), "This is a protected resource") From 29409d4effc7c877b7d47b1066dbab9b348001bc Mon Sep 17 00:00:00 2001 From: Tobias Gall Date: Mon, 15 May 2017 17:59:56 +0200 Subject: [PATCH 158/722] Document RFC 7662 support --- docs/index.rst | 1 + docs/resource_server.rst | 68 ++++++++++++++++++++++++++++++++++++++++ docs/settings.rst | 15 +++++++++ 3 files changed, 84 insertions(+) create mode 100644 docs/resource_server.rst diff --git a/docs/index.rst b/docs/index.rst index 9a79b6d7c..4b1c13a6b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -39,6 +39,7 @@ Index models advanced_topics settings + resource_server management_commands glossary diff --git a/docs/resource_server.rst b/docs/resource_server.rst new file mode 100644 index 000000000..d0d5d2335 --- /dev/null +++ b/docs/resource_server.rst @@ -0,0 +1,68 @@ +Separate Resource Server +======================== +Django OAuth Toolkit allows to separate the :term:`Authentication Server` and the :term:`Resource Server.` +Based one the `RFC 7662 `_ Django OAuth Toolkit provides +a rfc-compliant introspection endpoint. +As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. + + +Setup the Authentication Server +------------------------------- +Setup the :term:`Authentication Server` as described in the :ref:`tutorial`. +Create a OAuth2 access token for the :term:`Resource Server` and add the +``introspection``-Scope to the settings. + +.. code-block:: python + + 'SCOPES': { + 'read': 'Read scope', + 'write': 'Write scope', + 'introspection': 'Introspect token scope', + ... + }, + +The :term:`Authentication Server` will listen for introspection requests. +The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. + +Example Request:: + + POST /o/introspect/ HTTP/1.1 + Host: server.example.com + Accept: application/json + Content-Type: application/x-www-form-urlencoded + Authorization: Bearer 3yUqsWtwKYKHnfivFcJu + + token=uH3Po4KXWP4dsY4zgyxH + +Example Response:: + + HTTP/1.1 200 OK + Content-Type: application/json + + { + "active": true, + "client_id": "oUdofn7rfhRtKWbmhyVk", + "username": "jdoe", + "scope": "read write dolphin", + "exp": 1419356238 + } + +Setup the Resource Server +------------------------- +Setup the :term:`Resource Server` like the :term:`Authentication Server` as described in the :ref:`tutorial`. +Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and ``RESOURCE_SERVER_AUTH_TOKEN`` to your settings. +The :term:`Resource Server` will try to verify its requests on the :term:`Authentication Server`. + +.. code-block:: python + + OAUTH2_PROVIDER = { + ... + 'RESOURCE_SERVER_INTROSPECTION_URL': 'https://example.org/o/introspect/', + 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', + ... + } + +``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and +``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the +:term:`Authentication Server`. + diff --git a/docs/settings.rst b/docs/settings.rst index 2ae5323b7..63ca23764 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -151,3 +151,18 @@ WRITE_SCOPE .. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. The name of the *write* scope. + +RESOURCE_SERVER_INTROSPECTION_URL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The introspection endpoint for validating token remotely (RFC7662). + +RESOURCE_SERVER_AUTH_TOKEN +~~~~~~~~~~~~~~~~~~~~~~~~~~ +The bearer token to authenticate the introspection request towards the introspection endpoint (RFC7662). + + +RESOURCE_SERVER_TOKEN_CACHING_SECONDS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The number of seconds an authorization token received from the introspection endpoint remains valid. +If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time +will be used. \ No newline at end of file From 472761b9882b8508debc3b979a3215802f2baafa Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 15:18:44 +0300 Subject: [PATCH 159/722] tests: Use unittest.mock if available --- tests/test_oauth2_backends.py | 5 ++++- tests/test_oauth2_validators.py | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index b79073bba..bb0074bcc 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,6 +1,9 @@ import json -import mock +try: + from unittest import mock +except ImportError: + import mock from django.test import RequestFactory, TestCase diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 439161e80..a55510ad7 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,6 +1,9 @@ import datetime -import mock +try: + from unittest import mock +except ImportError: + import mock from django.contrib.auth import get_user_model from django.test import TransactionTestCase From d93f9c3f68900f6cceba9288d452a8fe2d5dc6b7 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 16:18:13 +0300 Subject: [PATCH 160/722] Fix bad url parameter in error response with invalid redirect_uri --- oauth2_provider/views/mixins.py | 6 +++--- tests/test_authorization_code.py | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index c02908dbc..8b4fc7798 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -159,11 +159,11 @@ def error_response(self, error, **kwargs): oauthlib_error = error.oauthlib_error redirect_uri = oauthlib_error.redirect_uri or "" - separator = '&' if '?' in redirect_uri else '?' + separator = "&" if "?" in redirect_uri else "?" error_response = { - 'error': oauthlib_error, - 'url': "{0}{1}{2}".format(oauthlib_error.redirect_uri, separator, oauthlib_error.urlencoded) + "error": oauthlib_error, + "url": redirect_uri + separator + oauthlib_error.urlencoded, } error_response.update(kwargs) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 4e5875f8f..90e345b66 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -117,6 +117,10 @@ def test_pre_auth_invalid_client(self): response = self.client.get(url) self.assertEqual(response.status_code, 400) + self.assertEqual( + response.context_data["url"], + "?error=invalid_request&error_description=Invalid+client_id+parameter+value." + ) def test_pre_auth_valid_client(self): """ From 8ef417832427786791f42d1a1ebe29552f01abb1 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 16:31:31 +0300 Subject: [PATCH 161/722] Standardize quotes and add flake8-quotes linter --- oauth2_provider/apps.py | 2 +- oauth2_provider/decorators.py | 2 +- .../ext/rest_framework/authentication.py | 2 +- .../ext/rest_framework/permissions.py | 19 ++-- oauth2_provider/middleware.py | 10 +- oauth2_provider/models.py | 85 ++++++++------ oauth2_provider/oauth2_backends.py | 24 ++-- oauth2_provider/oauth2_validators.py | 88 ++++++++------- oauth2_provider/settings.py | 106 +++++++++--------- oauth2_provider/urls.py | 24 ++-- oauth2_provider/validators.py | 26 ++--- oauth2_provider/views/application.py | 24 ++-- oauth2_provider/views/base.py | 56 ++++----- oauth2_provider/views/mixins.py | 10 +- oauth2_provider/views/token.py | 12 +- tests/test_validators.py | 20 ++-- tox.ini | 2 + 17 files changed, 267 insertions(+), 245 deletions(-) diff --git a/oauth2_provider/apps.py b/oauth2_provider/apps.py index 6f67f3871..887e4e3fb 100644 --- a/oauth2_provider/apps.py +++ b/oauth2_provider/apps.py @@ -2,5 +2,5 @@ class DOTConfig(AppConfig): - name = 'oauth2_provider' + name = "oauth2_provider" verbose_name = "Django OAuth Toolkit" diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index 10b2885af..d4b7085aa 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -67,7 +67,7 @@ def _validate(request, *args, **kwargs): ) # Check if method is safe - if request.method.upper() in ['GET', 'HEAD', 'OPTIONS']: + if request.method.upper() in ["GET", "HEAD", "OPTIONS"]: _scopes.append(oauth2_settings.READ_SCOPE) else: _scopes.append(oauth2_settings.WRITE_SCOPE) diff --git a/oauth2_provider/ext/rest_framework/authentication.py b/oauth2_provider/ext/rest_framework/authentication.py index 35c7439ad..2383078a3 100644 --- a/oauth2_provider/ext/rest_framework/authentication.py +++ b/oauth2_provider/ext/rest_framework/authentication.py @@ -7,7 +7,7 @@ class OAuth2Authentication(BaseAuthentication): """ OAuth 2 authentication backend using `django-oauth-toolkit` """ - www_authenticate_realm = 'api' + www_authenticate_realm = "api" def authenticate(self, request): """ diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/ext/rest_framework/permissions.py index c449f9c21..cce946a4b 100644 --- a/oauth2_provider/ext/rest_framework/permissions.py +++ b/oauth2_provider/ext/rest_framework/permissions.py @@ -7,9 +7,9 @@ from ...settings import oauth2_settings -log = logging.getLogger('oauth2_provider') +log = logging.getLogger("oauth2_provider") -SAFE_HTTP_METHODS = ['GET', 'HEAD', 'OPTIONS'] +SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] class TokenHasScope(BasePermission): @@ -23,22 +23,23 @@ def has_permission(self, request, view): if not token: return False - if hasattr(token, 'scope'): # OAuth 2 + if hasattr(token, "scope"): # OAuth 2 required_scopes = self.get_scopes(request, view) log.debug("Required scopes to access resource: {0}".format(required_scopes)) return token.is_valid(required_scopes) - assert False, ('TokenHasScope requires the' - '`oauth2_provider.rest_framework.OAuth2Authentication` authentication ' - 'class to be used.') + assert False, ("TokenHasScope requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used.") def get_scopes(self, request, view): try: - return getattr(view, 'required_scopes') + return getattr(view, "required_scopes") except AttributeError: raise ImproperlyConfigured( - 'TokenHasScope requires the view to define the required_scopes attribute') + "TokenHasScope requires the view to define the required_scopes attribute" + ) class TokenHasReadWriteScope(TokenHasScope): @@ -80,7 +81,7 @@ def get_scopes(self, request, view): scope_type = oauth2_settings.WRITE_SCOPE required_scopes = [ - '{0}:{1}'.format(scope, scope_type) for scope in view_scopes + "{}:{}".format(scope, scope_type) for scope in view_scopes ] return required_scopes diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index dbfd5e2db..f41f3f3ab 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -20,17 +20,17 @@ class OAuth2TokenMiddleware(MiddlewareMixin): also request._cached_user field makes AuthenticationMiddleware use that instead of the one from the session. - It also adds 'Authorization' to the 'Vary' header. So that django's cache middleware or a - reverse proxy can create proper cache keys + It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a + reverse proxy can create proper cache keys. """ def process_request(self, request): # do something only if request contains a Bearer token - if request.META.get('HTTP_AUTHORIZATION', '').startswith('Bearer'): - if not hasattr(request, 'user') or request.user.is_anonymous: + if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): + if not hasattr(request, "user") or request.user.is_anonymous: user = authenticate(request=request) if user: request.user = request._cached_user = user def process_response(self, request, response): - patch_vary_headers(response, ('Authorization',)) + patch_vary_headers(response, ("Authorization",)) return response diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 7eb20c708..0fab34755 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -39,38 +39,44 @@ class AbstractApplication(models.Model): the registration process as described in :rfc:`2.2` * :attr:`name` Friendly name for the Application """ - CLIENT_CONFIDENTIAL = 'confidential' - CLIENT_PUBLIC = 'public' + CLIENT_CONFIDENTIAL = "confidential" + CLIENT_PUBLIC = "public" CLIENT_TYPES = ( - (CLIENT_CONFIDENTIAL, _('Confidential')), - (CLIENT_PUBLIC, _('Public')), + (CLIENT_CONFIDENTIAL, _("Confidential")), + (CLIENT_PUBLIC, _("Public")), ) - GRANT_AUTHORIZATION_CODE = 'authorization-code' - GRANT_IMPLICIT = 'implicit' - GRANT_PASSWORD = 'password' - GRANT_CLIENT_CREDENTIALS = 'client-credentials' + GRANT_AUTHORIZATION_CODE = "authorization-code" + GRANT_IMPLICIT = "implicit" + GRANT_PASSWORD = "password" + GRANT_CLIENT_CREDENTIALS = "client-credentials" GRANT_TYPES = ( - (GRANT_AUTHORIZATION_CODE, _('Authorization code')), - (GRANT_IMPLICIT, _('Implicit')), - (GRANT_PASSWORD, _('Resource owner password-based')), - (GRANT_CLIENT_CREDENTIALS, _('Client credentials')), + (GRANT_AUTHORIZATION_CODE, _("Authorization code")), + (GRANT_IMPLICIT, _("Implicit")), + (GRANT_PASSWORD, _("Resource owner password-based")), + (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), ) - client_id = models.CharField(max_length=100, unique=True, - default=generate_client_id, db_index=True) - user = models.ForeignKey(settings.AUTH_USER_MODEL, - related_name="%(app_label)s_%(class)s", - null=True, blank=True, on_delete=models.CASCADE) + client_id = models.CharField( + max_length=100, unique=True, default=generate_client_id, db_index=True + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + related_name="%(app_label)s_%(class)s", + null=True, blank=True, on_delete=models.CASCADE + ) help_text = _("Allowed URIs list, space separated") - redirect_uris = models.TextField(help_text=help_text, - validators=[validate_uris], blank=True) + redirect_uris = models.TextField( + blank=True, help_text=help_text, validators=[validate_uris] + ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) - authorization_grant_type = models.CharField(max_length=32, - choices=GRANT_TYPES) - client_secret = models.CharField(max_length=255, blank=True, - default=generate_client_secret, db_index=True) + authorization_grant_type = models.CharField( + max_length=32, choices=GRANT_TYPES + ) + client_secret = models.CharField( + max_length=255, blank=True, default=generate_client_secret, db_index=True + ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) @@ -86,9 +92,11 @@ def default_redirect_uri(self): if self.redirect_uris: return self.redirect_uris.split().pop(0) - assert False, "If you are using implicit, authorization_code" \ - "or all-in-one grant_type, you must define " \ - "redirect_uris field in your Application model" + assert False, ( + "If you are using implicit, authorization_code" + "or all-in-one grant_type, you must define " + "redirect_uris field in your Application model" + ) def redirect_uri_allowed(self, uri): """ @@ -118,11 +126,11 @@ def clean(self): and self.authorization_grant_type \ in (AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_IMPLICIT): - error = _('Redirect_uris could not be empty with {0} grant_type') - raise ValidationError(error.format(self.authorization_grant_type)) + error = _("Redirect_uris could not be empty with {grant_type} grant_type") + raise ValidationError(error.format(grant_type=self.authorization_grant_type)) def get_absolute_url(self): - return reverse('oauth2_provider:detail', args=[str(self.id)]) + return reverse("oauth2_provider:detail", args=[str(self.id)]) def __str__(self): return self.name or self.client_id @@ -141,7 +149,7 @@ def is_usable(self, request): class Application(AbstractApplication): class Meta(AbstractApplication.Meta): - swappable = 'OAUTH2_PROVIDER_APPLICATION_MODEL' + swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL" @python_2_unicode_compatible @@ -289,14 +297,17 @@ class AbstractRefreshToken(models.Model): * :attr:`access_token` AccessToken instance this refresh token is bounded to """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s") + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s" + ) token = models.CharField(max_length=255, unique=True) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, - on_delete=models.CASCADE) - access_token = models.OneToOneField(oauth2_settings.ACCESS_TOKEN_MODEL, - related_name='refresh_token', - on_delete=models.CASCADE) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) + access_token = models.OneToOneField( + oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.CASCADE, + related_name="refresh_token" + ) def revoke(self): """ diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 2028f0de1..8aeab7a8e 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -28,7 +28,7 @@ def _get_escaped_full_path(self, request): parsed = list(urlparse(request.get_full_path())) unsafe = set(c for c in parsed[4]).difference(urlencoded) for c in unsafe: - parsed[4] = parsed[4].replace(c, quote(c, safe=b'')) + parsed[4] = parsed[4].replace(c, quote(c, safe=b"")) return urlunparse(parsed) @@ -62,12 +62,12 @@ def extract_headers(self, request): :return: a dictionary with OAuthLib needed headers """ headers = request.META.copy() - if 'wsgi.input' in headers: - del headers['wsgi.input'] - if 'wsgi.errors' in headers: - del headers['wsgi.errors'] - if 'HTTP_AUTHORIZATION' in headers: - headers['Authorization'] = headers['HTTP_AUTHORIZATION'] + if "wsgi.input" in headers: + del headers["wsgi.input"] + if "wsgi.errors" in headers: + del headers["wsgi.errors"] + if "HTTP_AUTHORIZATION" in headers: + headers["Authorization"] = headers["HTTP_AUTHORIZATION"] return headers @@ -113,18 +113,18 @@ def create_authorization_response(self, request, scopes, credentials, allow): raise oauth2.AccessDeniedError() # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS - credentials['user'] = request.user + credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=credentials['redirect_uri'], scopes=scopes, credentials=credentials) + uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) uri = headers.get("Location", None) return uri, headers, body, status except oauth2.FatalClientError as error: - raise FatalClientError(error=error, redirect_uri=credentials['redirect_uri']) + raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) except oauth2.OAuth2Error as error: - raise OAuthToolkitError(error=error, redirect_uri=credentials['redirect_uri']) + raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) def create_token_response(self, request): """ @@ -180,7 +180,7 @@ def extract_body(self, request): :return: provided POST parameters "urlencodable" """ try: - body = json.loads(request.body.decode('utf-8')).items() + body = json.loads(request.body.decode("utf-8")).items() except ValueError: body = "" diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 1fd10f55b..ec34e58d5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -26,15 +26,18 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings -log = logging.getLogger('oauth2_provider') + +log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - 'authorization_code': (AbstractApplication.GRANT_AUTHORIZATION_CODE,), - 'password': (AbstractApplication.GRANT_PASSWORD,), - 'client_credentials': (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), - 'refresh_token': (AbstractApplication.GRANT_AUTHORIZATION_CODE, - AbstractApplication.GRANT_PASSWORD, - AbstractApplication.GRANT_CLIENT_CREDENTIALS,) + "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), + "password": (AbstractApplication.GRANT_PASSWORD, ), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), + "refresh_token": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_PASSWORD, + AbstractApplication.GRANT_CLIENT_CREDENTIALS, + ) } Application = get_application_model() @@ -50,11 +53,11 @@ def _extract_basic_auth(self, request): Return authentication string if request contains basic auth credentials, otherwise return None """ - auth = request.headers.get('HTTP_AUTHORIZATION', None) + auth = request.headers.get("HTTP_AUTHORIZATION", None) if not auth: return None - splitted = auth.split(' ', 1) + splitted = auth.split(" ", 1) if len(splitted) != 2: return None auth_type, auth_string = splitted @@ -76,25 +79,26 @@ def _authenticate_basic_auth(self, request): return False try: - encoding = request.encoding or settings.DEFAULT_CHARSET or 'utf-8' + encoding = request.encoding or settings.DEFAULT_CHARSET or "utf-8" except AttributeError: - encoding = 'utf-8' + encoding = "utf-8" try: b64_decoded = base64.b64decode(auth_string) except (TypeError, binascii.Error): - log.debug("Failed basic auth: %s can't be decoded as base64", auth_string) + log.debug("Failed basic auth: %r can't be decoded as base64", auth_string) return False try: auth_string_decoded = b64_decoded.decode(encoding) except UnicodeDecodeError: - log.debug("Failed basic auth: %s can't be decoded as unicode by %s", - auth_string, - encoding) + log.debug( + "Failed basic auth: %r can't be decoded as unicode by %r", + auth_string, encoding + ) return False - client_id, client_secret = map(unquote_plus, auth_string_decoded.split(':', 1)) + client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) if self._load_application(client_id, request) is None: log.debug("Failed basic auth: Application %s does not exist" % client_id) @@ -140,7 +144,7 @@ def _load_application(self, client_id, request): """ # we want to be sure that request has the client attribute! - assert hasattr(request, "client"), "'request' instance has no 'client' attribute" + assert hasattr(request, "client"), '"request" instance has no "client" attribute' try: request.client = request.client or Application.objects.get(client_id=client_id) @@ -189,7 +193,7 @@ def client_authentication_required(self, request, *args, **kwargs): def authenticate_client(self, request, *args, **kwargs): """ - Check if client exists and it's authenticating itself as in rfc:`3.2.1` + Check if client exists and is authenticating itself as in rfc:`3.2.1` First we try to authenticate with HTTP Basic Auth, and that is the PREFERRED authentication method. @@ -208,10 +212,10 @@ def authenticate_client(self, request, *args, **kwargs): def authenticate_client_id(self, client_id, request, *args, **kwargs): """ If we are here, the client did not authenticate itself as in rfc:`3.2.1` and we can - proceed only if the client exists and it's not of type 'Confidential'. + proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug("Application %s has type %s" % (client_id, request.client.client_type)) + log.debug("Application %r has type %r" % (client_id, request.client.client_type)) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False @@ -231,8 +235,8 @@ def invalidate_authorization_code(self, client_id, code, request, *args, **kwarg def validate_client_id(self, client_id, request, *args, **kwargs): """ - Ensure an Application exists with given client_id. If it exists, it's assigned to - request.client. + Ensure an Application exists with given client_id. + If it exists, it's assigned to request.client. """ return self._load_application(client_id, request) is not None @@ -257,7 +261,7 @@ def _get_token_from_authentication_server(self, token, introspection_url, intros log.exception("Introspection: Failed to parse response as json") return None - if "active" in content and content['active'] is True: + if "active" in content and content["active"] is True: if "username" in content: user, _created = UserModel.objects.get_or_create( **{UserModel.USERNAME_FIELD: content["username"]} @@ -347,7 +351,7 @@ def validate_code(self, client_id, code, client, request, *args, **kwargs): try: grant = Grant.objects.get(code=code, application=client) if not grant.is_expired(): - request.scopes = grant.scope.split(' ') + request.scopes = grant.scope.split(" ") request.user = grant.user return True return False @@ -365,11 +369,11 @@ def validate_grant_type(self, client_id, grant_type, client, request, *args, **k def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): """ We currently do not support the Authorization Endpoint Response Types registry as in - rfc:`8.4`, so validate the response_type only if it matches 'code' or 'token' + rfc:`8.4`, so validate the response_type only if it matches "code" or "token" """ - if response_type == 'code': + if response_type == "code": return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) - elif response_type == 'token': + elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) else: return False @@ -391,9 +395,9 @@ def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwarg def save_authorization_code(self, client_id, code, request, *args, **kwargs): expires = timezone.now() + timedelta( seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - g = Grant(application=request.client, user=request.user, code=code['code'], + g = Grant(application=request.client, user=request.user, code=code["code"], expires=expires, redirect_uri=request.redirect_uri, - scope=' '.join(request.scopes)) + scope=" ".join(request.scopes)) g.save() def rotate_refresh_token(self, request): @@ -411,12 +415,12 @@ def save_bearer_token(self, token, request, *args, **kwargs): @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 """ - if 'scope' not in token: + if "scope" not in token: raise FatalClientError("Failed to renew access token: missing scope") expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - if request.grant_type == 'client_credentials': + if request.grant_type == "client_credentials": request.user = None # This comes from OAuthLib: @@ -424,12 +428,12 @@ def save_bearer_token(self, token, request, *args, **kwargs): # Its value is either a new random code; or if we are reusing # refresh tokens, then it is the same value that the request passed in # (stored in `request.refresh_token`) - refresh_token_code = token.get('refresh_token', None) + refresh_token_code = token.get("refresh_token", None) if refresh_token_code: # an instance of `RefreshToken` that matches the old refresh code. # Set on the request in `validate_refresh_token` - refresh_token_instance = getattr(request, 'refresh_token_instance', None) + refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so if not self.rotate_refresh_token(request) and \ @@ -440,9 +444,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): pk=refresh_token_instance.access_token.pk ) access_token.user = request.user - access_token.scope = token['scope'] + access_token.scope = token["scope"] access_token.expires = expires - access_token.token = token['access_token'] + access_token.token = token["access_token"] access_token.application = request.client access_token.save() @@ -455,7 +459,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): pass else: - setattr(request, 'refresh_token_instance', None) + setattr(request, "refresh_token_instance", None) access_token = self._create_access_token(expires, request, token) @@ -472,14 +476,14 @@ def save_bearer_token(self, token, request, *args, **kwargs): self._create_access_token(expires, request, token) # TODO: check out a more reliable way to communicate expire time to oauthlib - token['expires_in'] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS def _create_access_token(self, expires, request, token): access_token = AccessToken( user=request.user, - scope=token['scope'], + scope=token["scope"], expires=expires, - token=token['access_token'], + token=token["access_token"], application=request.client ) access_token.save() @@ -493,12 +497,12 @@ def revoke_token(self, token, token_type_hint, request, *args, **kwargs): :param token_type_hint: access_token or refresh_token. :param request: The HTTP Request (oauthlib.common.Request) """ - if token_type_hint not in ['access_token', 'refresh_token']: + if token_type_hint not in ["access_token", "refresh_token"]: token_type_hint = None token_types = { - 'access_token': AccessToken, - 'refresh_token': RefreshToken, + "access_token": AccessToken, + "refresh_token": RefreshToken, } token_type = token_types.get(token_type_hint, AccessToken) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 3ab1545cf..f7242487a 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -5,10 +5,10 @@ For example your project's `settings.py` file might look like this: OAUTH2_PROVIDER = { - 'CLIENT_ID_GENERATOR_CLASS': - 'oauth2_provider.generators.ClientIdGenerator', - 'CLIENT_SECRET_GENERATOR_CLASS': - 'oauth2_provider.generators.ClientSecretGenerator', + "CLIENT_ID_GENERATOR_CLASS": + "oauth2_provider.generators.ClientIdGenerator", + "CLIENT_SECRET_GENERATOR_CLASS": + "oauth2_provider.generators.ClientSecretGenerator", } This module provides the `oauth2_settings` object, that is used to access @@ -23,7 +23,7 @@ from django.core.exceptions import ImproperlyConfigured -USER_SETTINGS = getattr(settings, 'OAUTH2_PROVIDER', None) +USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") @@ -31,57 +31,57 @@ REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_MODEL", "oauth2_provider.RefreshToken") DEFAULTS = { - 'CLIENT_ID_GENERATOR_CLASS': 'oauth2_provider.generators.ClientIdGenerator', - 'CLIENT_SECRET_GENERATOR_CLASS': 'oauth2_provider.generators.ClientSecretGenerator', - 'CLIENT_SECRET_GENERATOR_LENGTH': 128, - 'OAUTH2_SERVER_CLASS': 'oauthlib.oauth2.Server', - 'OAUTH2_VALIDATOR_CLASS': 'oauth2_provider.oauth2_validators.OAuth2Validator', - 'OAUTH2_BACKEND_CLASS': 'oauth2_provider.oauth2_backends.OAuthLibCore', - 'SCOPES': {"read": "Reading scope", "write": "Writing scope"}, - 'DEFAULT_SCOPES': ['__all__'], - 'SCOPES_BACKEND_CLASS': 'oauth2_provider.scopes.SettingsScopes', - 'READ_SCOPE': 'read', - 'WRITE_SCOPE': 'write', - 'AUTHORIZATION_CODE_EXPIRE_SECONDS': 60, - 'ACCESS_TOKEN_EXPIRE_SECONDS': 36000, - 'REFRESH_TOKEN_EXPIRE_SECONDS': None, - 'ROTATE_REFRESH_TOKEN': True, - 'APPLICATION_MODEL': APPLICATION_MODEL, - 'ACCESS_TOKEN_MODEL': ACCESS_TOKEN_MODEL, - 'GRANT_MODEL': GRANT_MODEL, - 'REFRESH_TOKEN_MODEL': REFRESH_TOKEN_MODEL, - 'REQUEST_APPROVAL_PROMPT': 'force', - 'ALLOWED_REDIRECT_URI_SCHEMES': ['http', 'https'], + "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", + "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", + "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", + "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", + "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", + "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, + "DEFAULT_SCOPES": ["__all__"], + "SCOPES_BACKEND_CLASS": "oauth2_provider.scopes.SettingsScopes", + "READ_SCOPE": "read", + "WRITE_SCOPE": "write", + "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, + "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, + "REFRESH_TOKEN_EXPIRE_SECONDS": None, + "ROTATE_REFRESH_TOKEN": True, + "APPLICATION_MODEL": APPLICATION_MODEL, + "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, + "GRANT_MODEL": GRANT_MODEL, + "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, + "REQUEST_APPROVAL_PROMPT": "force", + "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], # Special settings that will be evaluated at runtime - '_SCOPES': [], - '_DEFAULT_SCOPES': [], + "_SCOPES": [], + "_DEFAULT_SCOPES": [], # Resource Server with Token Introspection - 'RESOURCE_SERVER_INTROSPECTION_URL': None, - 'RESOURCE_SERVER_AUTH_TOKEN': None, - 'RESOURCE_SERVER_TOKEN_CACHING_SECONDS': 36000, + "RESOURCE_SERVER_INTROSPECTION_URL": None, + "RESOURCE_SERVER_AUTH_TOKEN": None, + "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, } # List of settings that cannot be empty MANDATORY = ( - 'CLIENT_ID_GENERATOR_CLASS', - 'CLIENT_SECRET_GENERATOR_CLASS', - 'OAUTH2_SERVER_CLASS', - 'OAUTH2_VALIDATOR_CLASS', - 'OAUTH2_BACKEND_CLASS', - 'SCOPES', - 'ALLOWED_REDIRECT_URI_SCHEMES', + "CLIENT_ID_GENERATOR_CLASS", + "CLIENT_SECRET_GENERATOR_CLASS", + "OAUTH2_SERVER_CLASS", + "OAUTH2_VALIDATOR_CLASS", + "OAUTH2_BACKEND_CLASS", + "SCOPES", + "ALLOWED_REDIRECT_URI_SCHEMES", ) # List of settings that may be in string import notation. IMPORT_STRINGS = ( - 'CLIENT_ID_GENERATOR_CLASS', - 'CLIENT_SECRET_GENERATOR_CLASS', - 'OAUTH2_SERVER_CLASS', - 'OAUTH2_VALIDATOR_CLASS', - 'OAUTH2_BACKEND_CLASS', - 'SCOPES_BACKEND_CLASS', + "CLIENT_ID_GENERATOR_CLASS", + "CLIENT_SECRET_GENERATOR_CLASS", + "OAUTH2_SERVER_CLASS", + "OAUTH2_VALIDATOR_CLASS", + "OAUTH2_BACKEND_CLASS", + "SCOPES_BACKEND_CLASS", ) @@ -103,12 +103,12 @@ def import_from_string(val, setting_name): Attempt to import a class from a string representation. """ try: - parts = val.split('.') - module_path, class_name = '.'.join(parts[:-1]), parts[-1] + parts = val.split(".") + module_path, class_name = ".".join(parts[:-1]), parts[-1] module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = "Could not import '%s' for setting '%s'. %s: %s." % (val, setting_name, e.__class__.__name__, e) + msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) raise ImportError(msg) @@ -128,7 +128,7 @@ def __init__(self, user_settings=None, defaults=None, import_strings=None, manda def __getattr__(self, attr): if attr not in self.defaults.keys(): - raise AttributeError("Invalid OAuth2Provider setting: '%s'" % attr) + raise AttributeError("Invalid OAuth2Provider setting: %r" % (attr)) try: # Check if present in user settings @@ -142,11 +142,11 @@ def __getattr__(self, attr): val = perform_import(val, attr) # Overriding special settings - if attr == '_SCOPES': + if attr == "_SCOPES": val = list(self.SCOPES.keys()) - if attr == '_DEFAULT_SCOPES': - if '__all__' in self.DEFAULT_SCOPES: - # If DEFAULT_SCOPES is set to ['__all__'] the whole set of scopes is returned + if attr == "_DEFAULT_SCOPES": + if "__all__" in self.DEFAULT_SCOPES: + # If DEFAULT_SCOPES is set to ["__all__"] the whole set of scopes is returned val = list(self._SCOPES) else: # Otherwise we return a subset (that can be void) of SCOPES @@ -165,7 +165,7 @@ def __getattr__(self, attr): def validate_setting(self, attr, val): if not val and attr in self.mandatory: - raise AttributeError("OAuth2Provider setting: '%s' is mandatory" % attr) + raise AttributeError("OAuth2Provider setting: %r is mandatory" % (attr)) oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 036569aa9..3523824ff 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -5,27 +5,27 @@ from . import views -app_name = 'oauth2_provider' +app_name = "oauth2_provider" base_urlpatterns = [ - url(r'^authorize/$', views.AuthorizationView.as_view(), name="authorize"), - url(r'^token/$', views.TokenView.as_view(), name="token"), - url(r'^revoke_token/$', views.RevokeTokenView.as_view(), name="revoke-token"), - url(r'^introspect/$', views.IntrospectTokenView.as_view(), name="introspect"), + url(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), + url(r"^token/$", views.TokenView.as_view(), name="token"), + url(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), + url(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views - url(r'^applications/$', views.ApplicationList.as_view(), name="list"), - url(r'^applications/register/$', views.ApplicationRegistration.as_view(), name="register"), - url(r'^applications/(?P[\w-]+)/$', views.ApplicationDetail.as_view(), name="detail"), - url(r'^applications/(?P[\w-]+)/delete/$', views.ApplicationDelete.as_view(), name="delete"), - url(r'^applications/(?P[\w-]+)/update/$', views.ApplicationUpdate.as_view(), name="update"), + url(r"^applications/$", views.ApplicationList.as_view(), name="list"), + url(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), + url(r"^applications/(?P[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), + url(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), + url(r"^applications/(?P[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views - url(r'^authorized_tokens/$', views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - url(r'^authorized_tokens/(?P[\w-]+)/delete/$', views.AuthorizedTokenDeleteView.as_view(), + url(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + url(r"^authorized_tokens/(?P[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 77e085927..5ae5aa0ed 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -13,14 +13,14 @@ class URIValidator(RegexValidator): regex = re.compile( - r'^(?:[a-z][a-z0-9\.\-\+]*)://' # scheme... - r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # domain... - r'(?!-)[A-Z\d-]{1,63}(? ACE + netloc = netloc.encode("idna").decode("ascii") # IDN -> ACE except UnicodeError: # invalid domain part raise e url = urlunsplit((scheme, netloc, path, query, fragment)) @@ -49,11 +49,11 @@ def __init__(self, allowed_schemes): def __call__(self, value): super(RedirectURIValidator, self).__call__(value) value = force_text(value) - if len(value.split('#')) > 1: - raise ValidationError('Redirect URIs must not contain fragments') + if len(value.split("#")) > 1: + raise ValidationError("Redirect URIs must not contain fragments") scheme, netloc, path, query, fragment = urlsplit(value) if scheme.lower() not in self.allowed_schemes: - raise ValidationError('Redirect URI scheme is not allowed.') + raise ValidationError("Redirect URI scheme is not allowed.") def validate_uris(value): @@ -63,6 +63,6 @@ def validate_uris(value): v = RedirectURIValidator(oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES) uris = value.split() if not uris: - raise ValidationError('Redirect URI cannot be empty') + raise ValidationError("Redirect URI cannot be empty") for uri in uris: v(uri) diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 9dea96931..10cfff87d 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -10,7 +10,7 @@ class ApplicationOwnerIsUserMixin(LoginRequiredMixin): """ This mixin is used to provide an Application queryset filtered by the current request.user. """ - fields = '__all__' + fields = "__all__" def get_queryset(self): return get_application_model().objects.filter(user=self.request.user) @@ -28,8 +28,10 @@ def get_form_class(self): """ return modelform_factory( get_application_model(), - fields=('name', 'client_id', 'client_secret', 'client_type', - 'authorization_grant_type', 'redirect_uris') + fields=( + "name", "client_id", "client_secret", "client_type", + "authorization_grant_type", "redirect_uris" + ) ) def form_valid(self, form): @@ -41,7 +43,7 @@ class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): """ Detail view for an application instance owned by the request.user """ - context_object_name = 'application' + context_object_name = "application" template_name = "oauth2_provider/application_detail.html" @@ -49,7 +51,7 @@ class ApplicationList(ApplicationOwnerIsUserMixin, ListView): """ List view for all the applications owned by the request.user """ - context_object_name = 'applications' + context_object_name = "applications" template_name = "oauth2_provider/application_list.html" @@ -57,8 +59,8 @@ class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView): """ View used to delete an application owned by the request.user """ - context_object_name = 'application' - success_url = reverse_lazy('oauth2_provider:list') + context_object_name = "application" + success_url = reverse_lazy("oauth2_provider:list") template_name = "oauth2_provider/application_confirm_delete.html" @@ -66,7 +68,7 @@ class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView): """ View used to update an application owned by the request.user """ - context_object_name = 'application' + context_object_name = "application" template_name = "oauth2_provider/application_form.html" def get_form_class(self): @@ -75,6 +77,8 @@ def get_form_class(self): """ return modelform_factory( get_application_model(), - fields=('name', 'client_id', 'client_secret', 'client_type', - 'authorization_grant_type', 'redirect_uris') + fields=( + "name", "client_id", "client_secret", "client_type", + "authorization_grant_type", "redirect_uris" + ) ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e8527a835..5a8ec2277 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -17,7 +17,7 @@ from ..settings import oauth2_settings -log = logging.getLogger('oauth2_provider') +log = logging.getLogger("oauth2_provider") class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View): @@ -42,9 +42,9 @@ def error_response(self, error, **kwargs): redirect, error_response = super(BaseAuthorizationView, self).error_response(error, **kwargs) if redirect: - return HttpResponseUriRedirect(error_response['url']) + return HttpResponseUriRedirect(error_response["url"]) - status = error_response['error'].status_code + status = error_response["error"].status_code return self.render_to_response(error_response, status=status) @@ -68,7 +68,7 @@ class AuthorizationView(BaseAuthorizationView, FormView): * Authorization code * Implicit grant """ - template_name = 'oauth2_provider/authorize.html' + template_name = "oauth2_provider/authorize.html" form_class = AllowForm server_class = oauth2_settings.OAUTH2_SERVER_CLASS @@ -79,27 +79,27 @@ class AuthorizationView(BaseAuthorizationView, FormView): def get_initial(self): # TODO: move this scopes conversion from and to string into a utils function - scopes = self.oauth2_data.get('scope', self.oauth2_data.get('scopes', [])) + scopes = self.oauth2_data.get("scope", self.oauth2_data.get("scopes", [])) initial_data = { - 'redirect_uri': self.oauth2_data.get('redirect_uri', None), - 'scope': ' '.join(scopes), - 'client_id': self.oauth2_data.get('client_id', None), - 'state': self.oauth2_data.get('state', None), - 'response_type': self.oauth2_data.get('response_type', None), + "redirect_uri": self.oauth2_data.get("redirect_uri", None), + "scope": " ".join(scopes), + "client_id": self.oauth2_data.get("client_id", None), + "state": self.oauth2_data.get("state", None), + "response_type": self.oauth2_data.get("response_type", None), } return initial_data def form_valid(self, form): try: credentials = { - 'client_id': form.cleaned_data.get('client_id'), - 'redirect_uri': form.cleaned_data.get('redirect_uri'), - 'response_type': form.cleaned_data.get('response_type', None), - 'state': form.cleaned_data.get('state', None), + "client_id": form.cleaned_data.get("client_id"), + "redirect_uri": form.cleaned_data.get("redirect_uri"), + "response_type": form.cleaned_data.get("response_type", None), + "state": form.cleaned_data.get("state", None), } - scopes = form.cleaned_data.get('scope') - allow = form.cleaned_data.get('allow') + scopes = form.cleaned_data.get("scope") + allow = form.cleaned_data.get("allow") uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=scopes, credentials=credentials, allow=allow) self.success_url = uri @@ -114,26 +114,26 @@ def get(self, request, *args, **kwargs): scopes, credentials = self.validate_authorization_request(request) all_scopes = get_scopes_backend().get_all_scopes() kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] - kwargs['scopes'] = scopes + kwargs["scopes"] = scopes # at this point we know an Application instance with such client_id exists in the database # TODO: Cache this! application = get_application_model().objects.get(client_id=credentials["client_id"]) - kwargs['application'] = application - kwargs['client_id'] = credentials['client_id'] - kwargs['redirect_uri'] = credentials['redirect_uri'] - kwargs['response_type'] = credentials['response_type'] - kwargs['state'] = credentials['state'] + kwargs["application"] = application + kwargs["client_id"] = credentials["client_id"] + kwargs["redirect_uri"] = credentials["redirect_uri"] + kwargs["response_type"] = credentials["response_type"] + kwargs["state"] = credentials["state"] self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 form = self.get_form(self.get_form_class()) - kwargs['form'] = form + kwargs["form"] = form # Check to see if the user has already granted access and return - # a successful response depending on 'approval_prompt' url parameter - require_approval = request.GET.get('approval_prompt', oauth2_settings.REQUEST_APPROVAL_PROMPT) + # a successful response depending on "approval_prompt" url parameter + require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) # If skip_authorization field is True, skip the authorization screen even # if this is the first use of the application and there was no previous authorization. @@ -145,7 +145,7 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True) return HttpResponseUriRedirect(uri) - elif require_approval == 'auto': + elif require_approval == "auto": tokens = get_access_token_model().objects.filter( user=request.user, application=kwargs["application"], @@ -180,7 +180,7 @@ class TokenView(OAuthLibMixin, View): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - @method_decorator(sensitive_post_parameters('password')) + @method_decorator(sensitive_post_parameters("password")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) response = HttpResponse(content=body, status=status) @@ -201,7 +201,7 @@ class RevokeTokenView(OAuthLibMixin, View): def post(self, request, *args, **kwargs): url, headers, body, status = self.create_revocation_response(request) - response = HttpResponse(content=body or '', status=status) + response = HttpResponse(content=body or "", status=status) for k, v in headers.items(): response[k] = v diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 8b4fc7798..f89c3ea63 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -12,7 +12,7 @@ log = logging.getLogger("oauth2_provider") -SAFE_HTTP_METHODS = ['GET', 'HEAD', 'OPTIONS'] +SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] class OAuthLibMixin(object): @@ -83,7 +83,7 @@ def get_oauthlib_core(cls): """ Cache and return `OAuthlibCore` instance so it will be created only on first request """ - if not hasattr(cls, '_oauthlib_core'): + if not hasattr(cls, "_oauthlib_core"): server = cls.get_server() core_class = cls.get_oauthlib_backend_class() cls._oauthlib_core = core_class(server) @@ -203,7 +203,7 @@ class ProtectedResourceMixin(OAuthLibMixin): """ def dispatch(self, request, *args, **kwargs): # let preflight OPTIONS requests pass - if request.method.upper() == 'OPTIONS': + if request.method.upper() == "OPTIONS": return super(ProtectedResourceMixin, self).dispatch(request, *args, **kwargs) # check if the request is valid and the protected resource may be accessed @@ -228,8 +228,8 @@ def __new__(cls, *args, **kwargs): if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( - "ReadWriteScopedResourceMixin requires following scopes {0}" - " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) + "ReadWriteScopedResourceMixin requires following scopes {}" + ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) ) return super(ReadWriteScopedResourceMixin, cls).__new__(cls, *args, **kwargs) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 4239bd1d7..ebb42856e 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -11,24 +11,24 @@ class AuthorizedTokensListView(LoginRequiredMixin, ListView): """ Show a page where the current logged-in user can see his tokens so they can revoke them """ - context_object_name = 'authorized_tokens' - template_name = 'oauth2_provider/authorized-tokens.html' + context_object_name = "authorized_tokens" + template_name = "oauth2_provider/authorized-tokens.html" model = get_access_token_model() def get_queryset(self): """ - Show only user's tokens + Show only user"s tokens """ return super(AuthorizedTokensListView, self).get_queryset()\ - .select_related('application').filter(user=self.request.user) + .select_related("application").filter(user=self.request.user) class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): """ View for revoking a specific token """ - template_name = 'oauth2_provider/authorized-token-delete.html' - success_url = reverse_lazy('oauth2_provider:authorized-token-list') + template_name = "oauth2_provider/authorized-token-delete.html" + success_url = reverse_lazy("oauth2_provider:authorized-token-list") model = get_access_token_model() def get_queryset(self): diff --git a/tests/test_validators.py b/tests/test_validators.py index 66f292a38..052e3bc71 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -9,32 +9,32 @@ class TestValidators(TestCase): def test_validate_good_uris(self): - good_uris = 'http://example.com/ http://example.it/?key=val http://example' + good_uris = "http://example.com/ http://example.org/?key=val http://example" # Check ValidationError not thrown validate_uris(good_uris) def test_validate_custom_uri_scheme(self): - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ['my-scheme', 'http'] - good_uris = 'my-scheme://example.com http://example.com' + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["my-scheme", "http"] + good_uris = "my-scheme://example.com http://example.com" # Check ValidationError not thrown validate_uris(good_uris) def test_validate_whitespace_separators(self): # Check that whitespace can be used as a separator - good_uris = 'http://example\r\nhttp://example\thttp://example' + good_uris = "http://example\r\nhttp://example\thttp://example" # Check ValidationError not thrown validate_uris(good_uris) def test_validate_bad_uris(self): - bad_uri = 'http://example.com/#fragment' + bad_uri = "http://example.com/#fragment" self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = 'http:/example.com' + bad_uri = "http:/example.com" self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = 'my-scheme://example.com' + bad_uri = "my-scheme://example.com" self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = 'sdklfsjlfjljdflksjlkfjsdkl' + bad_uri = "sdklfsjlfjljdflksjlkfjsdkl" self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = ' ' + bad_uri = " " self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = '' + bad_uri = "" self.assertRaises(ValidationError, validate_uris, bad_uri) diff --git a/tox.ini b/tox.ini index 7bb0a61d2..44e06edca 100644 --- a/tox.ini +++ b/tox.ini @@ -36,6 +36,7 @@ commands = flake8 oauth2_provider deps = flake8 flake8-import-order + flake8-quotes [pytest] django_find_project = false @@ -45,3 +46,4 @@ max-line-length = 110 exclude = docs/, migrations/, .tox/ import-order-style = smarkets application-import-names = oauth2_provider +inline-quotes = " From 3bd9c28ceb863fc89d69aa239ac8dba47a3a3f6e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 16:43:00 +0300 Subject: [PATCH 162/722] tests: Run with UTC timezone --- tests/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/settings.py b/tests/settings.py index eafcf0daf..e4df9704f 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -11,7 +11,7 @@ ALLOWED_HOSTS = [] -TIME_ZONE = 'America/Chicago' +TIME_ZONE = "UTC" LANGUAGE_CODE = 'en-us' From 1b06b39cccc6955981d27285202aa0012908dc43 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 16:44:23 +0300 Subject: [PATCH 163/722] tests: Use example.com email addresses instead of real ones --- tests/test_application_views.py | 4 ++-- tests/test_auth_backends.py | 2 +- tests/test_authorization_code.py | 4 ++-- tests/test_client_credential.py | 4 ++-- tests/test_decorators.py | 2 +- tests/test_implicit.py | 4 ++-- tests/test_models.py | 6 +++--- tests/test_oauth2_validators.py | 2 +- tests/test_password.py | 4 ++-- tests/test_rest_framework.py | 4 ++-- tests/test_scopes.py | 4 ++-- tests/test_token_revocation.py | 4 ++-- tests/test_token_view.py | 4 ++-- 13 files changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 2c3c4772d..69f8b56b6 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -17,8 +17,8 @@ class BaseTest(TestCase): def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@user.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@user.com", "123456") + self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") + self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") def tearDown(self): self.foo_user.delete() diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 233992dbf..c762afdfa 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -28,7 +28,7 @@ class BaseTest(TestCase): Base class for cases in this module """ def setUp(self): - self.user = UserModel.objects.create_user("user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.app = ApplicationModel.objects.create( name='app', client_type=ApplicationModel.CLIENT_CONFIDENTIAL, diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 90e345b66..2d99b7a2a 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -37,8 +37,8 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ['http', 'custom-scheme'] diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index f9f47ffec..b1cccf34f 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -35,8 +35,8 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application( name="test_client_credentials_app", diff --git a/tests/test_decorators.py b/tests/test_decorators.py index c720d2257..99026bb0a 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -24,7 +24,7 @@ def setUpClass(cls): super(TestProtectedResourceDecorator, cls).setUpClass() def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.application = Application.objects.create( name="test_client_credentials_app", user=self.user, diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b7df729ea..bcae3ddfe 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -23,8 +23,8 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application( name="Test Implicit Application", diff --git a/tests/test_models.py b/tests/test_models.py index f1deb508f..3338dbf4e 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -24,7 +24,7 @@ class TestModels(TestCase): def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_allow_scopes(self): self.client.login(username="test_user", password="123456") @@ -123,7 +123,7 @@ def test_scopes_property(self): ) class TestCustomModels(TestCase): def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_custom_application_model(self): """ @@ -265,7 +265,7 @@ def test_expires_can_be_none(self): class TestAccessTokenModel(TestCase): def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") def test_str(self): access_token = AccessToken(token="test_token") diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index a55510ad7..1172034ef 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -27,7 +27,7 @@ class TestOAuth2Validator(TransactionTestCase): def setUp(self): - self.user = UserModel.objects.create_user("user", "test@user.com", "123456") + self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.request = mock.MagicMock(wraps=Request) self.request.user = self.user self.request.grant_type = "not client" diff --git a/tests/test_password.py b/tests/test_password.py index e4a66845b..e6ce30a13 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -25,8 +25,8 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application( name="Test Password Application", diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 73f1eefd1..36d8a07c4 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -75,8 +75,8 @@ class TestOAuth2Authentication(TestCase): def setUp(self): oauth2_settings._SCOPES = ['read', 'write', 'scope1', 'scope2', 'resource1'] - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application.objects.create( name="Test Application", diff --git a/tests/test_scopes.py b/tests/test_scopes.py index e52f0661d..b7eaf4eef 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -51,8 +51,8 @@ def post(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application( name="Test Application", diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 47d359a27..955883284 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -25,8 +25,8 @@ class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@user.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@user.com", "123456") + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.application = Application( name="Test Application", diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 1a1866399..76e461702 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -23,8 +23,8 @@ class TestAuthorizedTokenViews(TestCase): TestCase superclass for Authorized Token Views' Test Cases """ def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@user.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@user.com", "123456") + self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") + self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") self.application = Application( name="Test Application", From 9ee08d46213534a4188a756005e43bcceb955a41 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 16:47:47 +0300 Subject: [PATCH 164/722] tests: Use example.org rather than potentially-valid domain name --- tests/test_authorization_code.py | 74 ++++++++++++++++---------------- tests/test_implicit.py | 20 ++++----- tests/test_introspection_view.py | 2 +- tests/test_models.py | 6 +-- tests/test_rest_framework.py | 2 +- tests/test_scopes.py | 28 ++++++------ tests/test_token_revocation.py | 4 +- tests/test_token_view.py | 2 +- 8 files changed, 69 insertions(+), 69 deletions(-) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 2d99b7a2a..dc7e97047 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -44,7 +44,7 @@ def setUp(self): self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it custom-scheme://example.com", + redirect_uris="http://localhost http://example.com http://example.org custom-scheme://example.com", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -73,7 +73,7 @@ def test_request_is_not_overwritten(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -96,7 +96,7 @@ def test_skip_authorization_completely(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -133,7 +133,7 @@ def test_pre_auth_valid_client(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -144,7 +144,7 @@ def test_pre_auth_valid_client(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.it") + self.assertEqual(form['redirect_uri'].value(), "http://example.org") self.assertEqual(form['state'].value(), "random_state_string") self.assertEqual(form['scope'].value(), "read write") self.assertEqual(form['client_id'].value(), self.application.client_id) @@ -191,7 +191,7 @@ def test_pre_auth_approval_prompt(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'approval_prompt': 'auto', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -219,7 +219,7 @@ def test_pre_auth_approval_prompt_default(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) response = self.client.get(url) @@ -241,7 +241,7 @@ def test_pre_auth_approval_prompt_default_override(self): 'response_type': 'code', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) response = self.client.get(url) @@ -307,14 +307,14 @@ def test_code_post_auth_allow(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.it?', response['Location']) + self.assertIn('http://example.org?', response['Location']) self.assertIn('state=random_state_string', response['Location']) self.assertIn('code=', response['Location']) @@ -328,7 +328,7 @@ def test_code_post_auth_deny(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': False, } @@ -347,14 +347,14 @@ def test_code_post_auth_bad_responsetype(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'UNKNOWN', 'allow': True, } response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.it?error', response['Location']) + self.assertIn('http://example.org?error', response['Location']) def test_code_post_auth_forbidden_redirect_uri(self): """ @@ -506,7 +506,7 @@ def get_auth(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -525,7 +525,7 @@ def test_basic_auth(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -547,7 +547,7 @@ def test_refresh(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -560,7 +560,7 @@ def test_refresh(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) @@ -591,7 +591,7 @@ def test_refresh_invalidates_old_tokens(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -622,7 +622,7 @@ def test_refresh_no_scopes(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -650,7 +650,7 @@ def test_refresh_bad_scopes(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -676,7 +676,7 @@ def test_refresh_fail_repeating_requests(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -704,7 +704,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -735,7 +735,7 @@ def test_basic_auth_bad_authcode(self): token_request_data = { 'grant_type': 'authorization_code', 'code': 'BLAH', - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -751,7 +751,7 @@ def test_basic_auth_bad_granttype(self): token_request_data = { 'grant_type': 'UNKNOWN', 'code': 'BLAH', - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -770,7 +770,7 @@ def test_basic_auth_grant_expired(self): token_request_data = { 'grant_type': 'authorization_code', 'code': 'BLAH', - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -787,7 +787,7 @@ def test_basic_auth_bad_secret(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, 'BOOM!') @@ -804,7 +804,7 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } user_pass = '{0}:{1}'.format(self.application.client_id, self.application.client_secret) @@ -826,7 +826,7 @@ def test_request_body_params(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'client_id': self.application.client_id, 'client_secret': self.application.client_secret, } @@ -852,7 +852,7 @@ def test_public(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'client_id': self.application.client_id } @@ -896,7 +896,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it?foo=bar', + 'redirect_uri': 'http://example.org?foo=bar', 'response_type': 'code', 'allow': True, } @@ -908,7 +908,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it?foo=bar' + 'redirect_uri': 'http://example.org?foo=bar' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -931,7 +931,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it?foo=bar', + 'redirect_uri': 'http://example.org?foo=bar', 'response_type': 'code', 'allow': True, } @@ -943,7 +943,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it?foo=baraa' + 'redirect_uri': 'http://example.org?foo=baraa' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -997,7 +997,7 @@ def test_resource_access_allowed(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -1009,7 +1009,7 @@ def test_resource_access_allowed(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1053,7 +1053,7 @@ def test_pre_auth_default_scopes(self): 'client_id': self.application.client_id, 'response_type': 'code', 'state': 'random_state_string', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -1064,7 +1064,7 @@ def test_pre_auth_default_scopes(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.it") + self.assertEqual(form['redirect_uri'].value(), "http://example.org") self.assertEqual(form['state'].value(), "random_state_string") self.assertEqual(form['scope'].value(), 'read') self.assertEqual(form['client_id'].value(), self.application.client_id) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index bcae3ddfe..b36544016 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -28,7 +28,7 @@ def setUp(self): self.application = Application( name="Test Implicit Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, @@ -54,7 +54,7 @@ def test_pre_auth_valid_client_default_scopes(self): 'client_id': self.application.client_id, 'response_type': 'token', 'state': 'random_state_string', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -76,7 +76,7 @@ def test_pre_auth_valid_client(self): 'response_type': 'token', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) @@ -87,7 +87,7 @@ def test_pre_auth_valid_client(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.it") + self.assertEqual(form['redirect_uri'].value(), "http://example.org") self.assertEqual(form['state'].value(), "random_state_string") self.assertEqual(form['scope'].value(), "read write") self.assertEqual(form['client_id'].value(), self.application.client_id) @@ -151,14 +151,14 @@ def test_post_auth_allow(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'token', 'allow': True, } response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.it#', response['Location']) + self.assertIn('http://example.org#', response['Location']) self.assertIn('access_token=', response['Location']) self.assertIn('state=random_state_string', response['Location']) @@ -175,14 +175,14 @@ def test_skip_authorization_completely(self): 'response_type': 'token', 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', }) url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.it#', response['Location']) + self.assertIn('http://example.org#', response['Location']) self.assertIn('access_token=', response['Location']) self.assertIn('state=random_state_string', response['Location']) @@ -196,7 +196,7 @@ def test_token_post_auth_deny(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'token', 'allow': False, } @@ -255,7 +255,7 @@ def test_resource_access_allowed(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'read write', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'token', 'allow': True, } diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 18e5107a6..0bbcfa88c 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -26,7 +26,7 @@ def setUp(self): self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, diff --git a/tests/test_models.py b/tests/test_models.py index 3338dbf4e..fa8c29719 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -30,7 +30,7 @@ def test_allow_scopes(self): self.client.login(username="test_user", password="123456") app = Application.objects.create( name="test_app", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -89,7 +89,7 @@ def test_scopes_property(self): app = Application.objects.create( name="test_app", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -274,7 +274,7 @@ def test_str(self): def test_user_can_be_none(self): app = Application.objects.create( name="test_app", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 36d8a07c4..1ceb98d5f 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -80,7 +80,7 @@ def setUp(self): self.application = Application.objects.create( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, diff --git a/tests/test_scopes.py b/tests/test_scopes.py index b7eaf4eef..2507d60a5 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -56,7 +56,7 @@ def setUp(self): self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -86,7 +86,7 @@ def test_scopes_saved_in_grant(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -108,7 +108,7 @@ def test_scopes_save_in_access_token(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -120,7 +120,7 @@ def test_scopes_save_in_access_token(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -144,7 +144,7 @@ def test_scopes_protection_valid(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -156,7 +156,7 @@ def test_scopes_protection_valid(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -186,7 +186,7 @@ def test_scopes_protection_fail(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope2', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -198,7 +198,7 @@ def test_scopes_protection_fail(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -228,7 +228,7 @@ def test_multi_scope_fail(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope1 scope3', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -240,7 +240,7 @@ def test_multi_scope_fail(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -270,7 +270,7 @@ def test_multi_scope_valid(self): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -282,7 +282,7 @@ def test_multi_scope_valid(self): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -311,7 +311,7 @@ def get_access_token(self, scopes): 'client_id': self.application.client_id, 'state': 'random_state_string', 'scope': scopes, - 'redirect_uri': 'http://example.it', + 'redirect_uri': 'http://example.org', 'response_type': 'code', 'allow': True, } @@ -323,7 +323,7 @@ def get_access_token(self, scopes): token_request_data = { 'grant_type': 'authorization_code', 'code': authorization_code, - 'redirect_uri': 'http://example.it' + 'redirect_uri': 'http://example.org' } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 955883284..b6ecc0b4e 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -30,7 +30,7 @@ def setUp(self): self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -68,7 +68,7 @@ def test_revoke_access_token(self): def test_revoke_access_token_public(self): public_app = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 76e461702..a5e437f10 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -28,7 +28,7 @@ def setUp(self): self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.it", + redirect_uris="http://localhost http://example.com http://example.org", user=self.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, From b64fecd2526bf845b5b80a0af9f65706cf6364f3 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:04:16 +0300 Subject: [PATCH 165/722] tests: Clean up code style and run flake8 over tests/ directory too --- docs/rfc.py | 6 +- tests/models.py | 6 +- tests/settings.py | 140 +++--- tests/test_application_views.py | 38 +- tests/test_auth_backends.py | 42 +- tests/test_authorization_code.py | 756 +++++++++++++++---------------- tests/test_client_credential.py | 44 +- tests/test_decorators.py | 28 +- tests/test_implicit.py | 164 +++---- tests/test_introspection_auth.py | 10 +- tests/test_mixins.py | 9 +- tests/test_models.py | 51 +-- tests/test_oauth2_backends.py | 6 +- tests/test_oauth2_validators.py | 66 +-- tests/test_password.py | 38 +- tests/test_rest_framework.py | 84 ++-- tests/test_scopes.py | 232 +++++----- tests/test_token_revocation.py | 127 +++--- tests/test_token_view.py | 128 +++--- tests/urls.py | 2 +- tox.ini | 2 +- 21 files changed, 1004 insertions(+), 975 deletions(-) diff --git a/docs/rfc.py b/docs/rfc.py index e6a8fb95b..e5af5f476 100644 --- a/docs/rfc.py +++ b/docs/rfc.py @@ -1,7 +1,8 @@ """ Custom Sphinx documentation module to link to parts of the OAuth2 RFC. """ -from docutils import nodes, utils +from docutils import nodes + base_url = "http://tools.ietf.org/html/rfc6749" @@ -33,5 +34,4 @@ def setup(app): :param app: Sphinx application context. """ - app.add_role('rfc', rfclink) - return + app.add_role("rfc", rfclink) diff --git a/tests/models.py b/tests/models.py index c7ab5c092..1c57d2e63 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,10 +1,8 @@ from django.db import models from oauth2_provider.models import ( - AbstractApplication, - AbstractAccessToken, - AbstractGrant, - AbstractRefreshToken, + AbstractAccessToken, AbstractApplication, + AbstractGrant, AbstractRefreshToken ) diff --git a/tests/settings.py b/tests/settings.py index e4df9704f..982e87580 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -3,9 +3,9 @@ MANAGERS = ADMINS DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'example.sqlite', + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": "example.sqlite", } } @@ -13,7 +13,7 @@ TIME_ZONE = "UTC" -LANGUAGE_CODE = 'en-us' +LANGUAGE_CODE = "en-us" SITE_ID = 1 @@ -21,108 +21,108 @@ USE_L10N = True USE_TZ = True -MEDIA_ROOT = '' -MEDIA_URL = '' +MEDIA_ROOT = "" +MEDIA_URL = "" -STATIC_ROOT = '' -STATIC_URL = '/static/' +STATIC_ROOT = "" +STATIC_URL = "/static/" STATICFILES_DIRS = () STATICFILES_FINDERS = ( - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', + "django.contrib.staticfiles.finders.FileSystemFinder", + "django.contrib.staticfiles.finders.AppDirectoriesFinder", ) -# Make this unique, and don't share it with anybody. +# Make this unique, and don"t share it with anybody. SECRET_KEY = "1234567890evonove" TEMPLATES = [ { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'debug': True, - 'context_processors': [ - 'django.contrib.auth.context_processors.auth', - 'django.template.context_processors.debug', - 'django.template.context_processors.i18n', - 'django.template.context_processors.media', - 'django.template.context_processors.static', - 'django.template.context_processors.tz', - 'django.contrib.messages.context_processors.messages', + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "debug": True, + "context_processors": [ + "django.contrib.auth.context_processors.auth", + "django.template.context_processors.debug", + "django.template.context_processors.i18n", + "django.template.context_processors.media", + "django.template.context_processors.static", + "django.template.context_processors.tz", + "django.contrib.messages.context_processors.messages", ], }, }, ] MIDDLEWARE = ( - 'django.middleware.common.CommonMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', + "django.middleware.common.CommonMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", ) # Django < 1.10 compatibility MIDDLEWARE_CLASSES = MIDDLEWARE -ROOT_URLCONF = 'tests.urls' +ROOT_URLCONF = "tests.urls" INSTALLED_APPS = ( - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.sites', - 'django.contrib.staticfiles', - 'django.contrib.admin', - - 'oauth2_provider', - 'tests', + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.staticfiles", + "django.contrib.admin", + + "oauth2_provider", + "tests", ) LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'formatters': { - 'verbose': { - 'format': '%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s' + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "verbose": { + "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" }, - 'simple': { - 'format': '%(levelname)s %(message)s' + "simple": { + "format": "%(levelname)s %(message)s" }, }, - 'filters': { - 'require_debug_false': { - '()': 'django.utils.log.RequireDebugFalse' + "filters": { + "require_debug_false": { + "()": "django.utils.log.RequireDebugFalse" } }, - 'handlers': { - 'mail_admins': { - 'level': 'ERROR', - 'filters': ['require_debug_false'], - 'class': 'django.utils.log.AdminEmailHandler' + "handlers": { + "mail_admins": { + "level": "ERROR", + "filters": ["require_debug_false"], + "class": "django.utils.log.AdminEmailHandler" }, - 'console': { - 'level': 'DEBUG', - 'class': 'logging.StreamHandler', - 'formatter': 'simple' + "console": { + "level": "DEBUG", + "class": "logging.StreamHandler", + "formatter": "simple" }, - 'null': { - 'level': 'DEBUG', - 'class': 'logging.NullHandler', + "null": { + "level": "DEBUG", + "class": "logging.NullHandler", }, }, - 'loggers': { - 'django.request': { - 'handlers': ['mail_admins'], - 'level': 'ERROR', - 'propagate': True, + "loggers": { + "django.request": { + "handlers": ["mail_admins"], + "level": "ERROR", + "propagate": True, }, - 'oauth2_provider': { - 'handlers': ['null'], - 'level': 'DEBUG', - 'propagate': True, + "oauth2_provider": { + "handlers": ["null"], + "level": "DEBUG", + "propagate": True, }, } } diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 69f8b56b6..4e09cb789 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -29,9 +29,9 @@ class TestApplicationRegistrationView(BaseTest): def test_get_form_class(self): """ - Tests that the form class returned by the 'get_form_class' method is + Tests that the form class returned by the "get_form_class" method is bound to custom application model defined in the - 'OAUTH2_PROVIDER_APPLICATION_MODEL' setting. + "OAUTH2_PROVIDER_APPLICATION_MODEL" setting. """ # Patch oauth2 settings to use a custom Application model oauth2_settings.APPLICATION_MODEL = "tests.SampleApplication" @@ -40,21 +40,21 @@ def test_get_form_class(self): application_form_class = ApplicationRegistration().get_form_class() self.assertEqual(SampleApplication, application_form_class._meta.model) # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' + oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" def test_application_registration_user(self): self.client.login(username="foo_user", password="123456") form_data = { - 'name': 'Foo app', - 'client_id': 'client_id', - 'client_secret': 'client_secret', - 'client_type': Application.CLIENT_CONFIDENTIAL, - 'redirect_uris': 'http://example.com', - 'authorization_grant_type': Application.GRANT_AUTHORIZATION_CODE + "name": "Foo app", + "client_id": "client_id", + "client_secret": "client_secret", + "client_type": Application.CLIENT_CONFIDENTIAL, + "redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, } - response = self.client.post(reverse('oauth2_provider:register'), form_data) + response = self.client.post(reverse("oauth2_provider:register"), form_data) self.assertEqual(response.status_code, 302) app = get_application_model().objects.get(name="Foo app") @@ -73,12 +73,12 @@ def _create_application(self, name, user): def setUp(self): super(TestApplicationViews, self).setUp() - self.app_foo_1 = self._create_application('app foo_user 1', self.foo_user) - self.app_foo_2 = self._create_application('app foo_user 2', self.foo_user) - self.app_foo_3 = self._create_application('app foo_user 3', self.foo_user) + self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) + self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) + self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) - self.app_bar_1 = self._create_application('app bar_user 1', self.bar_user) - self.app_bar_2 = self._create_application('app bar_user 2', self.bar_user) + self.app_bar_1 = self._create_application("app bar_user 1", self.bar_user) + self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) def tearDown(self): super(TestApplicationViews, self).tearDown() @@ -87,18 +87,18 @@ def tearDown(self): def test_application_list(self): self.client.login(username="foo_user", password="123456") - response = self.client.get(reverse('oauth2_provider:list')) + response = self.client.get(reverse("oauth2_provider:list")) self.assertEqual(response.status_code, 200) - self.assertEqual(len(response.context['object_list']), 3) + self.assertEqual(len(response.context["object_list"]), 3) def test_application_detail_owner(self): self.client.login(username="foo_user", password="123456") - response = self.client.get(reverse('oauth2_provider:detail', args=(self.app_foo_1.pk,))) + response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.assertEqual(response.status_code, 200) def test_application_detail_not_owner(self): self.client.login(username="foo_user", password="123456") - response = self.client.get(reverse('oauth2_provider:detail', args=(self.app_bar_1.pk,))) + response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_bar_1.pk,))) self.assertEqual(response.status_code, 404) diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index c762afdfa..5cbe43488 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -30,15 +30,15 @@ class BaseTest(TestCase): def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.app = ApplicationModel.objects.create( - name='app', + name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, user=self.user ) - self.token = AccessTokenModel.objects.create(user=self.user, - token='tokstr', - application=self.app, - expires=now() + timedelta(days=365)) + self.token = AccessTokenModel.objects.create( + user=self.user, token="tokstr", application=self.app, + expires=now() + timedelta(days=365) + ) self.factory = RequestFactory() def tearDown(self): @@ -51,26 +51,26 @@ class TestOAuth2Backend(BaseTest): def test_authenticate(self): auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'tokstr', + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) backend = OAuth2Backend() - credentials = {'request': request} + credentials = {"request": request} u = backend.authenticate(**credentials) self.assertEqual(u, self.user) def test_authenticate_fail(self): auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'badstring', + "HTTP_AUTHORIZATION": "Bearer " + "badstring", } request = self.factory.get("/a-resource", **auth_headers) backend = OAuth2Backend() - credentials = {'request': request} + credentials = {"request": request} self.assertIsNone(backend.authenticate(**credentials)) - credentials = {'username': 'u', 'password': 'p'} + credentials = {"username": "u", "password": "p"} self.assertIsNone(backend.authenticate(**credentials)) def test_get_user(self): @@ -81,12 +81,12 @@ def test_get_user(self): @override_settings( AUTHENTICATION_BACKENDS=( - 'oauth2_provider.backends.OAuth2Backend', - 'django.contrib.auth.backends.ModelBackend', + "oauth2_provider.backends.OAuth2Backend", + "django.contrib.auth.backends.ModelBackend", ), - MIDDLEWARE=tuple(MIDDLEWARE) + ('oauth2_provider.middleware.OAuth2TokenMiddleware',), + MIDDLEWARE=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware",), # Django<1.10 compat: - MIDDLEWARE_CLASSES=tuple(MIDDLEWARE) + ('oauth2_provider.middleware.OAuth2TokenMiddleware',) + MIDDLEWARE_CLASSES=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware",) ) class TestOAuth2Middleware(BaseTest): @@ -99,7 +99,7 @@ def test_middleware_wrong_headers(self): request = self.factory.get("/a-resource") self.assertIsNone(m.process_request(request)) auth_headers = { - 'HTTP_AUTHORIZATION': 'Beerer ' + 'badstring', # a Beer token for you! + "HTTP_AUTHORIZATION": "Beerer " + "badstring", # a Beer token for you! } request = self.factory.get("/a-resource", **auth_headers) self.assertIsNone(m.process_request(request)) @@ -107,7 +107,7 @@ def test_middleware_wrong_headers(self): def test_middleware_user_is_set(self): m = OAuth2TokenMiddleware() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'tokstr', + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) request.user = self.user @@ -118,7 +118,7 @@ def test_middleware_user_is_set(self): def test_middleware_success(self): m = OAuth2TokenMiddleware() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'tokstr', + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) m.process_request(request) @@ -127,7 +127,7 @@ def test_middleware_success(self): def test_middleware_response(self): m = OAuth2TokenMiddleware() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'tokstr', + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) response = HttpResponse() @@ -137,10 +137,10 @@ def test_middleware_response(self): def test_middleware_response_header(self): m = OAuth2TokenMiddleware() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + 'tokstr', + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) response = HttpResponse() m.process_response(request, response) - self.assertIn('Vary', response) - self.assertIn('Authorization', response['Vary']) + self.assertIn("Vary", response) + self.assertIn("Authorization", response["Vary"]) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index dc7e97047..6ca53e7f6 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -40,19 +40,21 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ['http', 'custom-scheme'] + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( name="Test Application", - redirect_uris="http://localhost http://example.com http://example.org custom-scheme://example.com", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write'] - oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] def tearDown(self): self.application.delete() @@ -69,17 +71,17 @@ class TestRegressionIssue315(BaseTest): def test_request_is_not_overwritten(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) - assert 'request' not in response.context_data + assert "request" not in response.context_data class TestAuthorizationCodeView(BaseTest): @@ -92,13 +94,13 @@ def test_skip_authorization_completely(self): self.application.save() query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) @@ -110,10 +112,10 @@ def test_pre_auth_invalid_client(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': 'fakeclientid', - 'response_type': 'code', + "client_id": "fakeclientid", + "response_type": "code", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) @@ -129,13 +131,13 @@ def test_pre_auth_valid_client(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -144,10 +146,10 @@ def test_pre_auth_valid_client(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.org") - self.assertEqual(form['state'].value(), "random_state_string") - self.assertEqual(form['scope'].value(), "read write") - self.assertEqual(form['client_id'].value(), self.application.client_id) + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ @@ -157,13 +159,13 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'custom-scheme://example.com', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -172,78 +174,75 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "custom-scheme://example.com") - self.assertEqual(form['state'].value(), "random_state_string") - self.assertEqual(form['scope'].value(), "read write") - self.assertEqual(form['client_id'].value(), self.application.client_id) + self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_approval_prompt(self): - """ - TODO - """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'approval_prompt': 'auto', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. - tok.scope = 'read' + tok.scope = "read" tok.save() response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): - """ - TODO - """ - self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, 'force') + self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") - AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): - """ - TODO - """ - oauth2_settings.REQUEST_APPROVAL_PROMPT = 'auto' + oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" - AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) @@ -254,16 +253,16 @@ def test_pre_auth_default_redirect(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', + "client_id": self.application.client_id, + "response_type": "code", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://localhost") + self.assertEqual(form["redirect_uri"].value(), "http://localhost") def test_pre_auth_forbibben_redirect(self): """ @@ -272,11 +271,11 @@ def test_pre_auth_forbibben_redirect(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'redirect_uri': 'http://forbidden.it', + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) @@ -288,14 +287,14 @@ def test_pre_auth_wrong_response_type(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'WRONG', + "client_id": self.application.client_id, + "response_type": "WRONG", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) - self.assertIn("error=unsupported_response_type", response['Location']) + self.assertIn("error=unsupported_response_type", response["Location"]) def test_code_post_auth_allow(self): """ @@ -304,19 +303,19 @@ def test_code_post_auth_allow(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.org?', response['Location']) - self.assertIn('state=random_state_string', response['Location']) - self.assertIn('code=', response['Location']) + self.assertIn("http://example.org?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) def test_code_post_auth_deny(self): """ @@ -325,17 +324,17 @@ def test_code_post_auth_deny(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': False, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": False, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn("error=access_denied", response['Location']) + self.assertIn("error=access_denied", response["Location"]) def test_code_post_auth_bad_responsetype(self): """ @@ -344,17 +343,17 @@ def test_code_post_auth_bad_responsetype(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'UNKNOWN', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "UNKNOWN", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.org?error', response['Location']) + self.assertIn("http://example.org?error", response["Location"]) def test_code_post_auth_forbidden_redirect_uri(self): """ @@ -363,15 +362,15 @@ def test_code_post_auth_forbidden_redirect_uri(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://forbidden.it', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://forbidden.it", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): @@ -381,15 +380,15 @@ def test_code_post_auth_malicious_redirect_uri(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': '/../', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "/../", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): @@ -400,19 +399,19 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'custom-scheme://example.com', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('custom-scheme://example.com?', response['Location']) - self.assertIn('state=random_state_string', response['Location']) - self.assertIn('code=', response['Location']) + self.assertIn("custom-scheme://example.com?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) def test_code_post_auth_deny_custom_redirect_uri_scheme(self): """ @@ -422,18 +421,18 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'custom-scheme://example.com', - 'response_type': 'code', - 'allow': False, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code", + "allow": False, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('custom-scheme://example.com?', response['Location']) - self.assertIn("error=access_denied", response['Location']) + self.assertIn("custom-scheme://example.com?", response["Location"]) + self.assertIn("error=access_denied", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring(self): """ @@ -444,18 +443,18 @@ def test_code_post_auth_redirection_uri_with_querystring(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com?foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn("http://example.com?foo=bar", response['Location']) - self.assertIn("code=", response['Location']) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ @@ -466,17 +465,17 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com?foo=bar', - 'response_type': 'code', - 'allow': False, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code", + "allow": False, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertEqual("http://example.com?foo=bar&error=access_denied", response['Location']) + self.assertEqual("http://example.com?foo=bar&error=access_denied", response["Location"]) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ @@ -485,15 +484,15 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com/a?foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com/a?foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) @@ -503,17 +502,17 @@ def get_auth(self): Helper method to retrieve a valid authorization code """ authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - return query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + return query_dict["code"].pop() def test_basic_auth(self): """ @@ -523,19 +522,19 @@ def test_basic_auth(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_refresh(self): """ @@ -545,41 +544,41 @@ def test_refresh(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('refresh_token' in content) + self.assertTrue("refresh_token" in content) # make a second token request to be sure the previous refresh token remains valid, see #65 authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': content['refresh_token'], - 'scope': content['scope'], + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('access_token' in content) + self.assertTrue("access_token" in content) # check refresh token cannot be used twice - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('invalid_grant' in content.values()) + self.assertTrue("invalid_grant" in content.values()) def test_refresh_invalidates_old_tokens(self): """ @@ -589,24 +588,24 @@ def test_refresh_invalidates_old_tokens(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - rt = content['refresh_token'] - at = content['access_token'] + rt = content["refresh_token"] + at = content["access_token"] token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': rt, - 'scope': content['scope'], + "grant_type": "refresh_token", + "refresh_token": rt, + "scope": content["scope"], } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertFalse(RefreshToken.objects.filter(token=rt).exists()) @@ -620,25 +619,25 @@ def test_refresh_no_scopes(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('refresh_token' in content) + self.assertTrue("refresh_token" in content) token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': content['refresh_token'], + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('access_token' in content) + self.assertTrue("access_token" in content) def test_refresh_bad_scopes(self): """ @@ -648,22 +647,22 @@ def test_refresh_bad_scopes(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('refresh_token' in content) + self.assertTrue("refresh_token" in content) token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': content['refresh_token'], - 'scope': 'read write nuke', + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": "read write nuke", } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_refresh_fail_repeating_requests(self): @@ -674,24 +673,24 @@ def test_refresh_fail_repeating_requests(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('refresh_token' in content) + self.assertTrue("refresh_token" in content) token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': content['refresh_token'], - 'scope': content['scope'], + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_refresh_repeating_requests_non_rotating_tokens(self): @@ -702,26 +701,26 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - self.assertTrue('refresh_token' in content) + self.assertTrue("refresh_token" in content) token_request_data = { - 'grant_type': 'refresh_token', - 'refresh_token': content['refresh_token'], - 'scope': content['scope'], + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], } oauth2_settings.ROTATE_REFRESH_TOKEN = False - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) oauth2_settings.ROTATE_REFRESH_TOKEN = True @@ -733,13 +732,13 @@ def test_basic_auth_bad_authcode(self): self.client.login(username="test_user", password="123456") token_request_data = { - 'grant_type': 'authorization_code', - 'code': 'BLAH', - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_bad_granttype(self): @@ -749,13 +748,13 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="test_user", password="123456") token_request_data = { - 'grant_type': 'UNKNOWN', - 'code': 'BLAH', - 'redirect_uri': 'http://example.org' + "grant_type": "UNKNOWN", + "code": "BLAH", + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): @@ -763,18 +762,19 @@ def test_basic_auth_grant_expired(self): Request an access token using an expired grant token """ self.client.login(username="test_user", password="123456") - g = Grant(application=self.application, user=self.test_user, code='BLAH', expires=timezone.now(), - redirect_uri='', scope='') + g = Grant( + application=self.application, user=self.test_user, code="BLAH", + expires=timezone.now(), redirect_uri="", scope="") g.save() token_request_data = { - 'grant_type': 'authorization_code', - 'code': 'BLAH', - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_bad_secret(self): @@ -785,13 +785,13 @@ def test_basic_auth_bad_secret(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header(self.application.client_id, 'BOOM!') + auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): @@ -802,18 +802,18 @@ def test_basic_auth_wrong_auth_type(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } - user_pass = '{0}:{1}'.format(self.application.client_id, self.application.client_secret) - auth_string = base64.b64encode(user_pass.encode('utf-8')) + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { - 'HTTP_AUTHORIZATION': 'Wrong ' + auth_string.decode("utf-8"), + "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_request_body_params(self): @@ -824,20 +824,20 @@ def test_request_body_params(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org', - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ @@ -850,19 +850,19 @@ def test_public(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org', - 'client_id': self.application.client_id + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_malicious_redirect_uri(self): """ @@ -876,13 +876,13 @@ def test_malicious_redirect_uri(self): authorization_code = self.get_auth() token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': '/../', - 'client_id': self.application.client_id + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "/../", + "client_id": self.application.client_id } - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 401) def test_code_exchange_succeed_when_redirect_uri_match(self): @@ -893,32 +893,32 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org?foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org?foo=bar' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=bar" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -928,26 +928,26 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org?foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org?foo=baraa' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=baraa" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): @@ -960,32 +960,32 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com?bar=baz&foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.com?bar=baz&foo=bar' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) class TestAuthorizationCodeProtectedResource(BaseTest): @@ -994,32 +994,32 @@ def test_resource_access_allowed(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -1030,7 +1030,7 @@ def test_resource_access_allowed(self): def test_resource_access_deny(self): auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + "faketoken", + "HTTP_AUTHORIZATION": "Bearer " + "faketoken", } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -1047,15 +1047,15 @@ def test_pre_auth_default_scopes(self): Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="test_user", password="123456") - oauth2_settings._DEFAULT_SCOPES = ['read'] + oauth2_settings._DEFAULT_SCOPES = ["read"] query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'code', - 'state': 'random_state_string', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -1064,8 +1064,8 @@ def test_pre_auth_default_scopes(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.org") - self.assertEqual(form['state'].value(), "random_state_string") - self.assertEqual(form['scope'].value(), 'read') - self.assertEqual(form['client_id'].value(), self.application.client_id) - oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read") + self.assertEqual(form["client_id"].value(), self.application.client_id) + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index b1cccf34f..80a94e5ad 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -46,8 +46,8 @@ def setUp(self): ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write'] - oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] def tearDown(self): self.application.delete() @@ -61,19 +61,19 @@ def test_client_credential_access_allowed(self): Request an access token using Client Credential Flow """ token_request_data = { - 'grant_type': 'client_credentials', + "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -84,21 +84,21 @@ def test_client_credential_access_allowed(self): def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { - 'grant_type': 'client_credentials', + "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertNotIn("refresh_token", content) def test_client_credential_user_is_none_on_access_token(self): - token_request_data = {'grant_type': 'client_credentials'} + token_request_data = {"grant_type": "client_credentials"} auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -119,21 +119,21 @@ class TestView(OAuthLibMixin, View): oauthlib_backend_class = OAuthLibCore def get_scopes(self): - return ['read', 'write'] + return ["read", "write"] token_request_data = { - 'grant_type': 'client_credentials', + "grant_type": "client_credentials", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.request_factory.get("/fake-req", **auth_headers) @@ -146,7 +146,7 @@ def get_scopes(self): self.assertTrue(valid) self.assertIsNone(r.user) self.assertEqual(r.client, self.application) - self.assertEqual(r.scopes, ['read', 'write']) + self.assertEqual(r.scopes, ["read", "write"]) class TestClientResourcePasswordBased(BaseTest): @@ -165,23 +165,23 @@ def test_client_resource_password_based(self): self.application.save() token_request_data = { - 'grant_type': 'password', - 'username': 'test_user', - 'password': '123456' + "grant_type": "password", + "username": "test_user", + "password": "123456" } auth_headers = get_basic_auth_header( quote_plus(self.application.client_id), quote_plus(self.application.client_secret) ) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 99026bb0a..6443b9611 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -34,18 +34,18 @@ def setUp(self): self.access_token = AccessToken.objects.create( user=self.user, - scope='read write', + scope="read write", expires=timezone.now() + timedelta(seconds=300), - token='secret-access-token-key', + token="secret-access-token-key", application=self.application ) - oauth2_settings._SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] def test_access_denied(self): @protected_resource() def view(request, *args, **kwargs): - return 'protected contents' + return "protected contents" request = self.request_factory.get("/fake-resource") response = view(request) @@ -54,39 +54,39 @@ def view(request, *args, **kwargs): def test_access_allowed(self): @protected_resource() def view(request, *args, **kwargs): - return 'protected contents' + return "protected contents" - @protected_resource(scopes=['can_touch_this']) + @protected_resource(scopes=["can_touch_this"]) def scoped_view(request, *args, **kwargs): - return 'moar protected contents' + return "moar protected contents" auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token.token, + "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } request = self.request_factory.get("/fake-resource", **auth_headers) response = view(request) self.assertEqual(response, "protected contents") # now with scopes - self.access_token.scope = 'can_touch_this' + self.access_token.scope = "can_touch_this" self.access_token.save() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token.token, + "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } request = self.request_factory.get("/fake-resource", **auth_headers) response = scoped_view(request) self.assertEqual(response, "moar protected contents") def test_rw_protected(self): - self.access_token.scope = 'exotic_scope write' + self.access_token.scope = "exotic_scope write" self.access_token.save() auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token.token, + "HTTP_AUTHORIZATION": "Bearer " + self.access_token.token, } - @rw_protected_resource(scopes=['exotic_scope']) + @rw_protected_resource(scopes=["exotic_scope"]) def scoped_view(request, *args, **kwargs): - return 'other protected contents' + return "other protected contents" request = self.request_factory.post("/fake-resource", **auth_headers) response = scoped_view(request) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b36544016..6a979fc74 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -35,8 +35,8 @@ def setUp(self): ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write'] - oauth2_settings._DEFAULT_SCOPES = ['read'] + oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._DEFAULT_SCOPES = ["read"] def tearDown(self): self.application.delete() @@ -51,19 +51,19 @@ def test_pre_auth_valid_client_default_scopes(self): """ self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'token', - 'state': 'random_state_string', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "token", + "state": "random_state_string", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['scope'].value(), 'read') + self.assertEqual(form["scope"].value(), "read") def test_pre_auth_valid_client(self): """ @@ -72,13 +72,13 @@ def test_pre_auth_valid_client(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'token', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) @@ -87,10 +87,10 @@ def test_pre_auth_valid_client(self): self.assertIn("form", response.context) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://example.org") - self.assertEqual(form['state'].value(), "random_state_string") - self.assertEqual(form['scope'].value(), "read write") - self.assertEqual(form['client_id'].value(), self.application.client_id) + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) def test_pre_auth_invalid_client(self): """ @@ -99,10 +99,10 @@ def test_pre_auth_invalid_client(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': 'fakeclientid', - 'response_type': 'token', + "client_id": "fakeclientid", + "response_type": "token", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) @@ -114,16 +114,16 @@ def test_pre_auth_default_redirect(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'token', + "client_id": self.application.client_id, + "response_type": "token", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 200) form = response.context["form"] - self.assertEqual(form['redirect_uri'].value(), "http://localhost") + self.assertEqual(form["redirect_uri"].value(), "http://localhost") def test_pre_auth_forbibben_redirect(self): """ @@ -132,11 +132,11 @@ def test_pre_auth_forbibben_redirect(self): self.client.login(username="test_user", password="123456") query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'token', - 'redirect_uri': 'http://forbidden.it', + "client_id": self.application.client_id, + "response_type": "token", + "redirect_uri": "http://forbidden.it", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 400) @@ -148,19 +148,19 @@ def test_post_auth_allow(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'token', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "token", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.org#', response['Location']) - self.assertIn('access_token=', response['Location']) - self.assertIn('state=random_state_string', response['Location']) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) def test_skip_authorization_completely(self): """ @@ -171,20 +171,20 @@ def test_skip_authorization_completely(self): self.application.save() query_string = urlencode({ - 'client_id': self.application.client_id, - 'response_type': 'token', - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', + "client_id": self.application.client_id, + "response_type": "token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:authorize'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) - self.assertIn('http://example.org#', response['Location']) - self.assertIn('access_token=', response['Location']) - self.assertIn('state=random_state_string', response['Location']) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) def test_token_post_auth_deny(self): """ @@ -193,17 +193,17 @@ def test_token_post_auth_deny(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'token', - 'allow': False, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "token", + "allow": False, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn("error=access_denied", response['Location']) + self.assertIn("error=access_denied", response["Location"]) def test_implicit_redirection_uri_with_querystring(self): """ @@ -214,18 +214,18 @@ def test_implicit_redirection_uri_with_querystring(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com?foo=bar', - 'response_type': 'token', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "token", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertIn("http://example.com?foo=bar", response['Location']) - self.assertIn("access_token=", response['Location']) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("access_token=", response["Location"]) def test_implicit_fails_when_redirect_uri_path_is_invalid(self): """ @@ -234,15 +234,15 @@ def test_implicit_fails_when_redirect_uri_path_is_invalid(self): self.client.login(username="test_user", password="123456") form_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.com/a?foo=bar', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com/a?foo=bar", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=form_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) @@ -252,21 +252,21 @@ def test_resource_access_allowed(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'read write', - 'redirect_uri': 'http://example.org', - 'response_type': 'token', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "token", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) # within implicit grant, access token is in the url fragment - frag_dict = parse_qs(urlparse(response['Location']).fragment) - access_token = frag_dict['access_token'].pop() + frag_dict = parse_qs(urlparse(response["Location"]).fragment) + access_token = frag_dict["access_token"].pop() # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index ad6667a1c..76aa6cec5 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -154,9 +154,15 @@ def test_validate_bearer_token(self, mock_get): # with token = None self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request)) # with valid token and scope - self.assertTrue(self.validator.validate_bearer_token(self.resource_server_token.token, ["introspection"], self.request)) + self.assertTrue( + self.validator.validate_bearer_token( + self.resource_server_token.token, ["introspection"], self.request + ) + ) # with initially invalid token, but validated through request - self.assertTrue(self.validator.validate_bearer_token(self.invalid_token.token, ["dolphin"], self.request)) + self.assertTrue( + self.validator.validate_bearer_token(self.invalid_token.token, ["dolphin"], self.request) + ) # with locally unavailable token, but validated through request self.assertTrue(self.validator.validate_bearer_token("butzi", ["dolphin"], self.request)) # with valid token but invalid scope diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 24ceb0208..4dd6bc329 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -71,8 +71,9 @@ class TestView(OAuthLibMixin, View): request.user = "fake" test_view = TestView() - self.assertEqual(test_view.get_oauthlib_backend_class(), - AnotherOauthLibBackend) + self.assertEqual( + test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend + ) class TestScopedResourceMixin(BaseTest): @@ -86,11 +87,11 @@ class TestView(ScopedResourceMixin, View): def test_correct_required_scopes(self): class TestView(ScopedResourceMixin, View): - required_scopes = ['scope1', 'scope2'] + required_scopes = ["scope1", "scope2"] test_view = TestView() - self.assertEqual(test_view.get_scopes(), ['scope1', 'scope2']) + self.assertEqual(test_view.get_scopes(), ["scope1", "scope2"]) class TestProtectedResourceMixin(BaseTest): diff --git a/tests/test_models.py b/tests/test_models.py index fa8c29719..474b830e7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,8 +1,7 @@ from __future__ import unicode_literals -import django from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError, ImproperlyConfigured +from django.core.exceptions import ValidationError from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -38,17 +37,17 @@ def test_allow_scopes(self): access_token = AccessToken( user=self.user, - scope='read write', + scope="read write", expires=0, - token='', + token="", application=app ) - self.assertTrue(access_token.allow_scopes(['read', 'write'])) - self.assertTrue(access_token.allow_scopes(['write', 'read'])) - self.assertTrue(access_token.allow_scopes(['write', 'read', 'read'])) + self.assertTrue(access_token.allow_scopes(["read", "write"])) + self.assertTrue(access_token.allow_scopes(["write", "read"])) + self.assertTrue(access_token.allow_scopes(["write", "read", "read"])) self.assertTrue(access_token.allow_scopes([])) - self.assertFalse(access_token.allow_scopes(['write', 'destroy'])) + self.assertFalse(access_token.allow_scopes(["write", "destroy"])) def test_grant_authorization_code_redirect_uris(self): app = Application( @@ -97,22 +96,22 @@ def test_scopes_property(self): access_token = AccessToken( user=self.user, - scope='read write', + scope="read write", expires=0, - token='', + token="", application=app ) access_token2 = AccessToken( user=self.user, - scope='write', + scope="write", expires=0, - token='', + token="", application=app ) - self.assertEqual(access_token.scopes, {'read': 'Reading scope', 'write': 'Writing scope'}) - self.assertEqual(access_token2.scopes, {'write': 'Writing scope'}) + self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) + self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @override_settings( @@ -136,7 +135,7 @@ def test_custom_application_model(self): f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] - self.assertNotIn('oauth2_provider:application', related_object_names) + self.assertNotIn("oauth2_provider:application", related_object_names) self.assertIn("tests_sampleapplication", related_object_names) def test_custom_application_model_incorrect_format(self): @@ -146,7 +145,7 @@ def test_custom_application_model_incorrect_format(self): self.assertRaises(ValueError, get_application_model) # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' + oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" def test_custom_application_model_not_installed(self): # Patch oauth2 settings to use a custom Application model @@ -155,7 +154,7 @@ def test_custom_application_model_not_installed(self): self.assertRaises(LookupError, get_application_model) # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = 'oauth2_provider.Application' + oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" def test_custom_access_token_model(self): """ @@ -167,7 +166,7 @@ def test_custom_access_token_model(self): f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] - self.assertNotIn('oauth2_provider:access_token', related_object_names) + self.assertNotIn("oauth2_provider:access_token", related_object_names) self.assertIn("tests_sampleaccesstoken", related_object_names) def test_custom_access_token_model_incorrect_format(self): @@ -177,7 +176,7 @@ def test_custom_access_token_model_incorrect_format(self): self.assertRaises(ValueError, get_access_token_model) # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' + oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" def test_custom_access_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model @@ -186,7 +185,7 @@ def test_custom_access_token_model_not_installed(self): self.assertRaises(LookupError, get_access_token_model) # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = 'oauth2_provider.AccessToken' + oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" def test_custom_refresh_token_model(self): """ @@ -198,7 +197,7 @@ def test_custom_refresh_token_model(self): f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] - self.assertNotIn('oauth2_provider:refresh_token', related_object_names) + self.assertNotIn("oauth2_provider:refresh_token", related_object_names) self.assertIn("tests_samplerefreshtoken", related_object_names) def test_custom_refresh_token_model_incorrect_format(self): @@ -208,7 +207,7 @@ def test_custom_refresh_token_model_incorrect_format(self): self.assertRaises(ValueError, get_refresh_token_model) # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" def test_custom_refresh_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model @@ -217,7 +216,7 @@ def test_custom_refresh_token_model_not_installed(self): self.assertRaises(LookupError, get_refresh_token_model) # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = 'oauth2_provider.RefreshToken' + oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" def test_custom_grant_model(self): """ @@ -229,7 +228,7 @@ def test_custom_grant_model(self): f.name for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] - self.assertNotIn('oauth2_provider:grant', related_object_names) + self.assertNotIn("oauth2_provider:grant", related_object_names) self.assertIn("tests_samplegrant", related_object_names) def test_custom_grant_model_incorrect_format(self): @@ -239,7 +238,7 @@ def test_custom_grant_model_incorrect_format(self): self.assertRaises(ValueError, get_grant_model) # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = 'oauth2_provider.Grant' + oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" def test_custom_grant_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model @@ -248,7 +247,7 @@ def test_custom_grant_model_not_installed(self): self.assertRaises(LookupError, get_grant_model) # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = 'oauth2_provider.Grant' + oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" class TestGrantModel(TestCase): diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index bb0074bcc..a18e62a3a 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -18,7 +18,7 @@ def setUp(self): self.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): - with mock.patch('oauth2_provider.oauth2_backends.oauth2_settings.OAUTH2_SERVER_CLASS'): + with mock.patch("oauth2_provider.oauth2_backends.oauth2_settings.OAUTH2_SERVER_CLASS"): oauthlib_core = OAuthLibCore() self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) @@ -64,7 +64,7 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch('oauthlib.oauth2.Server.create_token_response') as create_token_response: + with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() @@ -97,7 +97,7 @@ def setUp(self): def test_validate_authorization_request_unsafe_query(self): auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + "a_casual_token", + "HTTP_AUTHORIZATION": "Bearer " + "a_casual_token", } request = self.factory.get("/fake-resource?next=/fake", **auth_headers) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 1172034ef..929e70a9b 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -33,7 +33,7 @@ def setUp(self): self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = Application.objects.create( - client_id='client_id', client_secret='client_secret', user=self.user, + client_id="client_id", client_secret="client_secret", user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD) self.request.client = self.application @@ -41,89 +41,89 @@ def tearDown(self): self.application.delete() def test_authenticate_request_body(self): - self.request.client_id = 'client_id' - self.request.client_secret = '' + self.request.client_id = "client_id" + self.request.client_secret = "" self.assertFalse(self.validator._authenticate_request_body(self.request)) - self.request.client_secret = 'wrong_client_secret' + self.request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.request)) - self.request.client_secret = 'client_secret' + self.request.client_secret = "client_secret" self.assertTrue(self.validator._authenticate_request_body(self.request)) def test_extract_basic_auth(self): - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic 123456'} - self.assertEqual(self.validator._extract_basic_auth(self.request), '123456') + self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} + self.assertEqual(self.validator._extract_basic_auth(self.request), "123456") self.request.headers = {} self.assertIsNone(self.validator._extract_basic_auth(self.request)) - self.request.headers = {'HTTP_AUTHORIZATION': 'Dummy 123456'} + self.request.headers = {"HTTP_AUTHORIZATION": "Dummy 123456"} self.assertIsNone(self.validator._extract_basic_auth(self.request)) - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic"} self.assertIsNone(self.validator._extract_basic_auth(self.request)) - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic 123456 789'} - self.assertEqual(self.validator._extract_basic_auth(self.request), '123456 789') + self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456 789"} + self.assertEqual(self.validator._extract_basic_auth(self.request), "123456 789") def test_authenticate_basic_auth(self): - self.request.encoding = 'utf-8' + self.request.encoding = "utf-8" # client_id:client_secret - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_default_encoding(self): self.request.encoding = None # client_id:client_secret - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_id(self): - self.request.encoding = 'utf-8' + self.request.encoding = "utf-8" # wrong_id:client_secret - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic d3JvbmdfaWQ6Y2xpZW50X3NlY3JldA==\n'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic d3JvbmdfaWQ6Y2xpZW50X3NlY3JldA==\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_secret(self): - self.request.encoding = 'utf-8' + self.request.encoding = "utf-8" # client_id:wrong_secret - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic Y2xpZW50X2lkOndyb25nX3NlY3JldA==\n'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOndyb25nX3NlY3JldA==\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_b64_auth_string(self): - self.request.encoding = 'utf-8' - # Can't b64decode - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic not_base64'} + self.request.encoding = "utf-8" + # Can"t b64decode + self.request.headers = {"HTTP_AUTHORIZATION": "Basic not_base64"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_utf8(self): - self.request.encoding = 'utf-8' - # b64decode('test') will become b'\xb5\xeb-', it can't be decoded as utf-8 - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic test'} + self.request.encoding = "utf-8" + # b64decode("test") will become b"\xb5\xeb-", it can"t be decoded as utf-8 + self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_client_id(self): - self.assertTrue(self.validator.authenticate_client_id('client_id', self.request)) + self.assertTrue(self.validator.authenticate_client_id("client_id", self.request)) def test_authenticate_client_id_fail(self): self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() - self.assertFalse(self.validator.authenticate_client_id('client_id', self.request)) - self.assertFalse(self.validator.authenticate_client_id('fake_client_id', self.request)) + self.assertFalse(self.validator.authenticate_client_id("client_id", self.request)) + self.assertFalse(self.validator.authenticate_client_id("fake_client_id", self.request)) def test_client_authentication_required(self): - self.request.headers = {'HTTP_AUTHORIZATION': 'Basic 123456'} + self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.headers = {} - self.request.client_id = 'client_id' - self.request.client_secret = 'client_secret' + self.request.client_id = "client_id" + self.request.client_secret = "client_secret" self.assertTrue(self.validator.client_authentication_required(self.request)) - self.request.client_secret = '' + self.request.client_secret = "" self.assertFalse(self.validator.client_authentication_required(self.request)) self.application.client_type = Application.CLIENT_CONFIDENTIAL self.application.save() - self.request.client = '' + self.request.client = "" self.assertTrue(self.validator.client_authentication_required(self.request)) def test_load_application_fails_when_request_has_no_client(self): - self.assertRaises(AssertionError, self.validator.authenticate_client_id, 'client_id', {}) + self.assertRaises(AssertionError, self.validator.authenticate_client_id, "client_id", {}) def test_rotate_refresh_token__is_true(self): self.assertTrue(self.validator.rotate_refresh_token(mock.MagicMock())) diff --git a/tests/test_password.py b/tests/test_password.py index e6ce30a13..40dcd1f15 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -36,8 +36,8 @@ def setUp(self): ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write'] - oauth2_settings._DEFAULT_SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] def tearDown(self): self.application.delete() @@ -51,51 +51,51 @@ def test_get_token(self): Request an access token using Resource Owner Password Flow """ token_request_data = { - 'grant_type': 'password', - 'username': 'test_user', - 'password': '123456', + "grant_type": "password", + "username": "test_user", + "password": "123456", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content['token_type'], "Bearer") - self.assertEqual(content['scope'], "read write") - self.assertEqual(content['expires_in'], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_bad_credentials(self): """ Request an access token using Resource Owner Password Flow """ token_request_data = { - 'grant_type': 'password', - 'username': 'test_user', - 'password': 'NOT_MY_PASS', + "grant_type": "password", + "username": "test_user", + "password": "NOT_MY_PASS", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) class TestPasswordProtectedResource(BaseTest): def test_password_resource_access_allowed(self): token_request_data = { - 'grant_type': 'password', - 'username': 'test_user', - 'password': '123456', + "grant_type": "password", + "username": "test_user", + "password": "123456", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 1ceb98d5f..b3d370e46 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -33,36 +33,36 @@ class MockView(APIView): permission_classes = (permissions.IsAuthenticated,) def get(self, request): - return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + return HttpResponse({"a": 1, "b": 2, "c": 3}) def post(self, request): - return HttpResponse({'a': 1, 'b': 2, 'c': 3}) + return HttpResponse({"a": 1, "b": 2, "c": 3}) class OAuth2View(MockView): authentication_classes = [OAuth2Authentication] class ScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasScope] - required_scopes = ['scope1'] + required_scopes = ["scope1"] class AuthenticatedOrScopedView(OAuth2View): permission_classes = [IsAuthenticatedOrTokenHasScope] - required_scopes = ['scope1'] + required_scopes = ["scope1"] class ReadWriteScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] class ResourceScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope] - required_scopes = ['resource1'] + required_scopes = ["resource1"] urlpatterns = [ - url(r'^oauth2/', include('oauth2_provider.urls')), - url(r'^oauth2-test/$', OAuth2View.as_view()), - url(r'^oauth2-scoped-test/$', ScopedView.as_view()), - url(r'^oauth2-read-write-test/$', ReadWriteScopedView.as_view()), - url(r'^oauth2-resource-scoped-test/$', ResourceScopedView.as_view()), - url(r'^oauth2-authenticated-or-scoped-test/$', AuthenticatedOrScopedView.as_view()), + url(r"^oauth2/", include("oauth2_provider.urls")), + url(r"^oauth2-test/$", OAuth2View.as_view()), + url(r"^oauth2-scoped-test/$", ScopedView.as_view()), + url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), + url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), + url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), ] rest_framework_installed = True @@ -73,7 +73,7 @@ class ResourceScopedView(OAuth2View): @override_settings(ROOT_URLCONF=__name__) class TestOAuth2Authentication(TestCase): def setUp(self): - oauth2_settings._SCOPES = ['read', 'write', 'scope1', 'scope2', 'resource1'] + oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "resource1"] self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") @@ -88,38 +88,38 @@ def setUp(self): self.access_token = AccessToken.objects.create( user=self.test_user, - scope='read write', + scope="read write", expires=timezone.now() + timedelta(seconds=300), - token='secret-access-token-key', + token="secret-access-token-key", application=self.application ) def tearDown(self): - oauth2_settings._SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] def _create_authorization_header(self, token): return "Bearer {0}".format(token) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_allow(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_denied(self): auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_or_scope_denied(self): # user is not authenticated # not a correct token auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-authenticated-or-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) - # token doesn't have correct scope + # token doesn"t have correct scope auth = self._create_authorization_header(self.access_token.token) factory = APIRequestFactory() @@ -130,18 +130,18 @@ def test_authentication_or_scope_denied(self): # authenticated but wrong scope, this is 403, not 401 self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_scoped_permission_allow(self): - self.access_token.scope = 'scope1' + self.access_token.scope = "scope1" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authenticated_or_scoped_permission_allow(self): - self.access_token.scope = 'scope1' + self.access_token.scope = "scope1" self.access_token.save() # correct token and correct scope auth = self._create_authorization_header(self.access_token.token) @@ -160,87 +160,87 @@ def test_authenticated_or_scoped_permission_allow(self): # correct token but not authenticated request = factory.get("/oauth2-authenticated-or-scoped-test/") request.auth = auth - self.access_token.scope = 'scope1' + self.access_token.scope = "scope1" self.access_token.save() force_authenticate(request, token=self.access_token) response = AuthenticatedOrScopedView.as_view()(request) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_scoped_permission_deny(self): - self.access_token.scope = 'scope2' + self.access_token.scope = "scope2" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_get_allow(self): - self.access_token.scope = 'read' + self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_post_allow(self): - self.access_token.scope = 'write' + self.access_token.scope = "write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_get_deny(self): - self.access_token.scope = 'write' + self.access_token.scope = "write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_post_deny(self): - self.access_token.scope = 'read' + self.access_token.scope = "read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_get_allow(self): - self.access_token.scope = 'resource1:read' + self.access_token.scope = "resource1:read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_post_allow(self): - self.access_token.scope = 'resource1:write' + self.access_token.scope = "resource1:write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_get_denied(self): - self.access_token.scope = 'resource1:write' + self.access_token.scope = "resource1:write" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, 'djangorestframework not installed') + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_post_denied(self): - self.access_token.scope = 'resource1:read' + self.access_token.scope = "resource1:read" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 2507d60a5..75eb557df 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -27,14 +27,14 @@ # mocking a protected resource view class ScopeResourceView(ScopedProtectedResourceView): - required_scopes = ['scope1'] + required_scopes = ["scope1"] def get(self, request, *args, **kwargs): return "This is a protected resource" class MultiScopeResourceView(ScopedProtectedResourceView): - required_scopes = ['scope1', 'scope2'] + required_scopes = ["scope1", "scope2"] def get(self, request, *args, **kwargs): return "This is a protected resource" @@ -63,9 +63,9 @@ def setUp(self): ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write', 'scope1', 'scope2', 'scope3'] - oauth2_settings.READ_SCOPE = 'read' - oauth2_settings.WRITE_SCOPE = 'write' + oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "scope3"] + oauth2_settings.READ_SCOPE = "read" + oauth2_settings.WRITE_SCOPE = "write" def tearDown(self): oauth2_settings._SCOPES = ["read", "write"] @@ -83,16 +83,16 @@ def test_scopes_saved_in_grant(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope1 scope2", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() grant = Grant.objects.get(code=authorization_code) self.assertEqual(grant.scope, "scope1 scope2") @@ -105,28 +105,28 @@ def test_scopes_save_in_access_token(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope1 scope2", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] at = AccessToken.objects.get(token=access_token) self.assertEqual(at.scope, "scope1 scope2") @@ -141,32 +141,32 @@ def test_scopes_protection_valid(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope1 scope2", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -183,32 +183,32 @@ def test_scopes_protection_fail(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope2', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope2", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -225,32 +225,32 @@ def test_multi_scope_fail(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope1 scope3', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope1 scope3", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -267,32 +267,32 @@ def test_multi_scope_valid(self): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': 'scope1 scope2', - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "scope1 scope2", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - access_token = content['access_token'] + access_token = content["access_token"] # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -308,61 +308,61 @@ def get_access_token(self, scopes): # retrieve a valid authorization code authcode_data = { - 'client_id': self.application.client_id, - 'state': 'random_state_string', - 'scope': scopes, - 'redirect_uri': 'http://example.org', - 'response_type': 'code', - 'allow': True, + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": scopes, + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, } - response = self.client.post(reverse('oauth2_provider:authorize'), data=authcode_data) - query_dict = parse_qs(urlparse(response['Location']).query) - authorization_code = query_dict['code'].pop() + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() # exchange authorization code for a valid access token token_request_data = { - 'grant_type': 'authorization_code', - 'code': authorization_code, - 'redirect_uri': 'http://example.org' + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post(reverse('oauth2_provider:token'), data=token_request_data, **auth_headers) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) - return content['access_token'] + return content["access_token"] def test_improperly_configured(self): - oauth2_settings.SCOPES = {'scope1': 'Scope 1'} + oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {'read': 'Read Scope', 'write': 'Write Scope'} - oauth2_settings.READ_SCOPE = 'ciccia' + oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_properly_configured(self): - oauth2_settings.SCOPES = {'scope1': 'Scope 1'} + oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {'read': 'Read Scope', 'write': 'Write Scope'} - oauth2_settings.READ_SCOPE = 'ciccia' + oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_has_read_scope(self): - access_token = self.get_access_token('read') + access_token = self.get_access_token("read") # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -372,11 +372,11 @@ def test_has_read_scope(self): self.assertEqual(response, "This is a read protected resource") def test_no_read_scope(self): - access_token = self.get_access_token('scope1') + access_token = self.get_access_token("scope1") # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.get("/fake-resource", **auth_headers) request.user = self.test_user @@ -386,11 +386,11 @@ def test_no_read_scope(self): self.assertEqual(response.status_code, 403) def test_has_write_scope(self): - access_token = self.get_access_token('write') + access_token = self.get_access_token("write") # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.post("/fake-resource", **auth_headers) request.user = self.test_user @@ -400,11 +400,11 @@ def test_has_write_scope(self): self.assertEqual(response, "This is a write protected resource") def test_no_write_scope(self): - access_token = self.get_access_token('scope1') + access_token = self.get_access_token("scope1") # use token to access the resource auth_headers = { - 'HTTP_AUTHORIZATION': 'Bearer ' + access_token, + "HTTP_AUTHORIZATION": "Bearer " + access_token, } request = self.factory.post("/fake-resource", **auth_headers) request.user = self.test_user diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index b6ecc0b4e..60d4456b1 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -37,7 +37,7 @@ def setUp(self): ) self.application.save() - oauth2_settings._SCOPES = ['read', 'write'] + oauth2_settings._SCOPES = ["read", "write"] def tearDown(self): self.application.delete() @@ -50,19 +50,21 @@ def test_revoke_access_token(self): """ """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) query_string = urlencode({ - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, - 'token': tok.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": tok.token, }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) - self.assertEqual(response.content, b'') + self.assertEqual(response.content, b"") self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_access_token_public(self): @@ -75,17 +77,18 @@ def test_revoke_access_token_public(self): ) public_app.save() - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=public_app, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", application=public_app, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) query_string = urlencode({ - 'client_id': public_app.client_id, - 'token': tok.token, + "client_id": public_app.client_id, + "token": tok.token, }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) @@ -93,57 +96,59 @@ def test_revoke_access_token_with_hint(self): """ """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) query_string = urlencode({ - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, - 'token': tok.token, - 'token_type_hint': 'access_token' + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": tok.token, + "token_type_hint": "access_token" }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_access_token_with_invalid_hint(self): - """ - - """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) # invalid hint should have no effect query_string = urlencode({ - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, - 'token': tok.token, - 'token_type_hint': 'bad_hint' + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": tok.token, + "token_type_hint": "bad_hint" }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) def test_revoke_refresh_token(self): - """ - - """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') - rtok = RefreshToken.objects.create(user=self.test_user, token='999999999', - application=self.application, access_token=tok) + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + rtok = RefreshToken.objects.create( + user=self.test_user, token="999999999", + application=self.application, access_token=tok + ) query_string = urlencode({ - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, - 'token': rtok.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": rtok.token, }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertFalse(RefreshToken.objects.filter(id=rtok.id).exists()) @@ -157,18 +162,20 @@ def test_revoke_token_with_wrong_hint(self): it MUST extend its search across all of its supported token types .. _`Section 4.1.2`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 """ - tok = AccessToken.objects.create(user=self.test_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) query_string = urlencode({ - 'client_id': self.application.client_id, - 'client_secret': self.application.client_secret, - 'token': tok.token, - 'token_type_hint': 'refresh_token' + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": tok.token, + "token_type_hint": "refresh_token" }) - url = "{url}?{qs}".format(url=reverse('oauth2_provider:revoke-token'), qs=query_string) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) diff --git a/tests/test_token_view.py b/tests/test_token_view.py index a5e437f10..e64d3e0f2 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -20,7 +20,7 @@ class TestAuthorizedTokenViews(TestCase): """ - TestCase superclass for Authorized Token Views' Test Cases + TestCase superclass for Authorized Token Views" Test Cases """ def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") @@ -48,9 +48,9 @@ def test_list_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ - response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 302) - self.assertTrue('/accounts/login/?next=' in response['Location']) + self.assertTrue("/accounts/login/?next=" in response["Location"]) def test_empty_list_view(self): """ @@ -58,57 +58,65 @@ def test_empty_list_view(self): """ self.client.login(username="foo_user", password="123456") - response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) - self.assertIn(b'There are no authorized tokens yet.', response.content) + self.assertIn(b"There are no authorized tokens yet.", response.content) def test_list_view_one_token(self): """ Test that the view shows your token """ self.client.login(username="bar_user", password="123456") - AccessToken.objects.create(user=self.bar_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + AccessToken.objects.create( + user=self.bar_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) - response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) - self.assertIn(b'read', response.content) - self.assertIn(b'write', response.content) - self.assertNotIn(b'There are no authorized tokens yet.', response.content) + self.assertIn(b"read", response.content) + self.assertIn(b"write", response.content) + self.assertNotIn(b"There are no authorized tokens yet.", response.content) def test_list_view_two_tokens(self): """ Test that the view shows your tokens """ self.client.login(username="bar_user", password="123456") - AccessToken.objects.create(user=self.bar_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') - AccessToken.objects.create(user=self.bar_user, token='0123456789', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') - - response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + AccessToken.objects.create( + user=self.bar_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + AccessToken.objects.create( + user=self.bar_user, token="0123456789", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + + response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) - self.assertNotIn(b'There are no authorized tokens yet.', response.content) + self.assertNotIn(b"There are no authorized tokens yet.", response.content) def test_list_view_shows_correct_user_token(self): """ - Test that only currently logged-in user's tokens are shown + Test that only currently logged-in user"s tokens are shown """ self.client.login(username="bar_user", password="123456") - AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) - response = self.client.get(reverse('oauth2_provider:authorized-token-list')) + response = self.client.get(reverse("oauth2_provider:authorized-token-list")) self.assertEqual(response.status_code, 200) - self.assertIn(b'There are no authorized tokens yet.', response.content) + self.assertIn(b"There are no authorized tokens yet.", response.content) class TestAuthorizedTokenDeleteView(TestAuthorizedTokenViews): @@ -119,24 +127,28 @@ def test_delete_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ - self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + self.token = AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.get(url) self.assertEqual(response.status_code, 302) - self.assertTrue('/accounts/login/?next=' in response['Location']) + self.assertTrue("/accounts/login/?next=" in response["Location"]) def test_delete_view_works(self): """ Test that a GET on this view returns 200 if the token belongs to the logged-in user. """ - self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + self.token = AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="foo_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) @@ -145,12 +157,14 @@ def test_delete_view_works(self): def test_delete_view_token_belongs_to_user(self): """ - Test that a 404 is returned when trying to GET this view with someone else's tokens. + Test that a 404 is returned when trying to GET this view with someone else"s tokens. """ - self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + self.token = AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="bar_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) @@ -161,25 +175,29 @@ def test_delete_view_post_actually_deletes(self): """ Test that a POST on this view works if the token belongs to the logged-in user. """ - self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + self.token = AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="foo_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) response = self.client.post(url) self.assertFalse(AccessToken.objects.exists()) - self.assertRedirects(response, reverse('oauth2_provider:authorized-token-list')) + self.assertRedirects(response, reverse("oauth2_provider:authorized-token-list")) def test_delete_view_only_deletes_user_own_token(self): """ - Test that a 404 is returned when trying to POST on this view with someone else's tokens. + Test that a 404 is returned when trying to POST on this view with someone else"s tokens. """ - self.token = AccessToken.objects.create(user=self.foo_user, token='1234567890', - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope='read write') + self.token = AccessToken.objects.create( + user=self.foo_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) self.client.login(username="bar_user", password="123456") url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) diff --git a/tests/urls.py b/tests/urls.py index c9d24a9d6..96f95e7c7 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -5,7 +5,7 @@ urlpatterns = [ - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), ] diff --git a/tox.ini b/tox.ini index 44e06edca..672f5e29f 100644 --- a/tox.ini +++ b/tox.ini @@ -32,7 +32,7 @@ commands = make html deps = sphinx [testenv:flake8] -commands = flake8 oauth2_provider +commands = flake8 deps = flake8 flake8-import-order From ec70b179f8e884000c39eeafa90509ba4570fbdd Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:07:54 +0300 Subject: [PATCH 166/722] tests: Merge .coveragerc into tox.ini --- .coveragerc | 3 --- tox.ini | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) delete mode 100644 .coveragerc diff --git a/.coveragerc b/.coveragerc deleted file mode 100644 index d1e89920e..000000000 --- a/.coveragerc +++ /dev/null @@ -1,3 +0,0 @@ -[run] -source = oauth2_provider -omit = */migrations/* diff --git a/tox.ini b/tox.ini index 672f5e29f..01d15a430 100644 --- a/tox.ini +++ b/tox.ini @@ -38,6 +38,10 @@ deps = flake8-import-order flake8-quotes +[coverage:run] +source = oauth2_provider +omit = */migrations/* + [pytest] django_find_project = false From a7535420b172a787fab1695681d23ba5045cd9a2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:08:47 +0300 Subject: [PATCH 167/722] tests: Only install mock on py27 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 01d15a430..ce59a6ab3 100644 --- a/tox.ini +++ b/tox.ini @@ -17,11 +17,11 @@ deps = djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework >=3.5 coverage - mock pytest pytest-cov pytest-django pytest-xdist + py27: mock [testenv:docs] From 943d9ffe2cdc0642259407912be35f4befeab4da Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:13:02 +0300 Subject: [PATCH 168/722] tests: Remove Django<1.10 hacks --- tests/settings.py | 2 -- tests/test_auth_backends.py | 10 ++-------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/tests/settings.py b/tests/settings.py index 982e87580..b7a228262 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -64,8 +64,6 @@ "django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", ) -# Django < 1.10 compatibility -MIDDLEWARE_CLASSES = MIDDLEWARE ROOT_URLCONF = "tests.urls" diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 5cbe43488..922d04b60 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -1,3 +1,4 @@ +from django.conf.global_settings import MIDDLEWARE from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse @@ -11,11 +12,6 @@ get_access_token_model, get_application_model, ) -try: - # Django<1.10 compatibility - from django.conf.global_settings import MIDDLEWARE_CLASSES as MIDDLEWARE -except ImportError: - from django.conf.global_settings import MIDDLEWARE UserModel = get_user_model() @@ -84,9 +80,7 @@ def test_get_user(self): "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", ), - MIDDLEWARE=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware",), - # Django<1.10 compat: - MIDDLEWARE_CLASSES=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware",) + MIDDLEWARE=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware", ), ) class TestOAuth2Middleware(BaseTest): From cc65bae8f16fdc1a433589537cadd8bd25d85a40 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:19:20 +0300 Subject: [PATCH 169/722] tests: Use modify_settings() instead of override_settings() --- tests/test_auth_backends.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 922d04b60..4096a62fe 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -1,9 +1,8 @@ -from django.conf.global_settings import MIDDLEWARE from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse from django.test import RequestFactory, TestCase -from django.test.utils import override_settings +from django.test.utils import modify_settings, override_settings from django.utils.timezone import now, timedelta from oauth2_provider.backends import OAuth2Backend @@ -80,7 +79,11 @@ def test_get_user(self): "oauth2_provider.backends.OAuth2Backend", "django.contrib.auth.backends.ModelBackend", ), - MIDDLEWARE=tuple(MIDDLEWARE) + ("oauth2_provider.middleware.OAuth2TokenMiddleware", ), +) +@modify_settings( + MIDDLEWARE={ + "append": "oauth2_provider.middleware.OAuth2TokenMiddleware", + } ) class TestOAuth2Middleware(BaseTest): From 14eb51b946971a38de8ba9907a3613462d4817ba Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:20:57 +0300 Subject: [PATCH 170/722] tests: Run with PYTHONWARNINGS=all --- tox.ini | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index ce59a6ab3..329e2ceac 100644 --- a/tox.ini +++ b/tox.ini @@ -9,8 +9,9 @@ envlist = [testenv] commands = pytest --cov=oauth2_provider --cov-report= --cov-append setenv = - DJANGO_SETTINGS_MODULE=tests.settings - PYTHONPATH={toxinidir} + DJANGO_SETTINGS_MODULE = tests.settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all deps = django110: Django >=1.10, <1.11 django111: Django >=1.11, <2.0 From a0c2d7a0a30daa46bad60e0e20613aa5f26e04a4 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:30:41 +0300 Subject: [PATCH 171/722] docs: Fix header underlines --- docs/settings.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 63ca23764..9ac4b9ac4 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -34,7 +34,7 @@ resource after this duration will fail. Keep this value high enough so clients can cache the token for a reasonable amount of time. ACCESS_TOKEN_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access tokens. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.AccessToken``). @@ -101,7 +101,7 @@ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. REFRESH_TOKEN_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh tokens. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.RefreshToken``). @@ -165,4 +165,4 @@ RESOURCE_SERVER_TOKEN_CACHING_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds an authorization token received from the introspection endpoint remains valid. If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time -will be used. \ No newline at end of file +will be used. From f574b4fe5675752bfa66509d572a6ab657436e7c Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:32:02 +0300 Subject: [PATCH 172/722] setup: Add Django 1.11 classifier --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index 8e9f936e3..77042b1a7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,6 +13,7 @@ classifiers = Framework :: Django Framework :: Django :: 1.8 Framework :: Django :: 1.10 + Framework :: Django :: 1.11 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent From d1620b24e2ddbfa822cfb04a0f06e9be05b819ed Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:33:01 +0300 Subject: [PATCH 173/722] setup: Update minimum Django version --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 77042b1a7..782310dec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ packages = find: include_package_data = True zip_safe = False install_requires = - django >= 1.8 + django >= 1.10 oauthlib >= 2.0.1 requests >= 2.13.0 From 3622f8504eb8823b94b1e7ad83f54e381da64c3f Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:59:18 +0300 Subject: [PATCH 174/722] Change all default IDs to BigAutoField Closes #177 --- .../migrations/0005_auto_20170514_1141.py | 36 +++++++++++++++++++ oauth2_provider/models.py | 27 +++++++++----- 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/oauth2_provider/migrations/0005_auto_20170514_1141.py b/oauth2_provider/migrations/0005_auto_20170514_1141.py index bb3b60469..0a64daeed 100644 --- a/oauth2_provider/migrations/0005_auto_20170514_1141.py +++ b/oauth2_provider/migrations/0005_auto_20170514_1141.py @@ -3,6 +3,7 @@ from __future__ import unicode_literals from oauth2_provider.settings import oauth2_settings +from django.conf import settings from django.db import migrations, models import django.db.models.deletion @@ -19,4 +20,39 @@ class Migration(migrations.Migration): name='application', field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL), ), + migrations.AlterField( + model_name='accesstoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='accesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='application', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='grant', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='grant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='refreshtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL), + ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 0fab34755..ed0ee457c 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -57,6 +57,7 @@ class AbstractApplication(models.Model): (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), ) + id = models.BigAutoField(primary_key=True) client_id = models.CharField( max_length=100, unique=True, default=generate_client_id, db_index=True ) @@ -168,11 +169,15 @@ class AbstractGrant(models.Model): * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s") + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s" + ) code = models.CharField(max_length=255, unique=True) # code comes from oauthlib - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, - on_delete=models.CASCADE) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE + ) expires = models.DateTimeField() redirect_uri = models.CharField(max_length=255) scope = models.TextField(blank=True) @@ -215,12 +220,15 @@ class AbstractAccessToken(models.Model): * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ - user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, - on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s") + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, + related_name="%(app_label)s_%(class)s" + ) token = models.CharField(max_length=255, unique=True, ) - application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, blank=True, null=True, - on_delete=models.CASCADE) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, + ) expires = models.DateTimeField() scope = models.TextField(blank=True) @@ -297,6 +305,7 @@ class AbstractRefreshToken(models.Model): * :attr:`access_token` AccessToken instance this refresh token is bounded to """ + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" From e205632350ce9b8761ca9219d3425ed8b53319d6 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 17:42:50 +0300 Subject: [PATCH 175/722] Update CHANGELOG.md --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d3746b9..0d0e3c162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,16 @@ +### 0.13.0 [unreleased] + +* **New feature**: AccessToken, RefreshToken and Grant models are now swappable. +* #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) +* **Compatibility**: Django 1.10 is the new minimum required version +* **Compatibility**: Django 1.11 is now supported +* #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) +* #476: Disallow empty redirect URIs +* Fixed bad `url` parameter in some error responses. +* Django 2.0 compatibility fixes. +* The dependency on django-braces has been dropped. +* The oauthlib dependency is no longer pinned. + ### 0.12.0 [2017-02-24] * **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes From 643c3d0c8dd0f8b05f523902d1375b3b8d621e31 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 4 Jun 2017 18:10:29 +0300 Subject: [PATCH 176/722] setup: Drop Django 1.8 classifier --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 782310dec..2dc7a1431 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ classifiers = Development Status :: 4 - Beta Environment :: Web Environment Framework :: Django - Framework :: Django :: 1.8 Framework :: Django :: 1.10 Framework :: Django :: 1.11 Intended Audience :: Developers From 2f28da12278ca7017b033e80ddf8d64c6b6a34f8 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 6 Jun 2017 18:53:40 +0300 Subject: [PATCH 177/722] Add created and updated fields to all models Closes #321 --- .../migrations/0005_auto_20170514_1141.py | 44 +++++++++++++++++++ oauth2_provider/models.py | 12 +++++ 2 files changed, 56 insertions(+) diff --git a/oauth2_provider/migrations/0005_auto_20170514_1141.py b/oauth2_provider/migrations/0005_auto_20170514_1141.py index 0a64daeed..4eca6c863 100644 --- a/oauth2_provider/migrations/0005_auto_20170514_1141.py +++ b/oauth2_provider/migrations/0005_auto_20170514_1141.py @@ -55,4 +55,48 @@ class Migration(migrations.Migration): name='user', field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL), ), + migrations.AddField( + model_name='accesstoken', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='accesstoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='application', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='application', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='grant', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='grant', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='refreshtoken', + name='created', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AddField( + model_name='refreshtoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index ed0ee457c..b2967c7c1 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -81,6 +81,9 @@ class AbstractApplication(models.Model): name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + class Meta: abstract = True @@ -182,6 +185,9 @@ class AbstractGrant(models.Model): redirect_uri = models.CharField(max_length=255) scope = models.TextField(blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def is_expired(self): """ Check token expiration with timezone awareness @@ -232,6 +238,9 @@ class AbstractAccessToken(models.Model): expires = models.DateTimeField() scope = models.TextField(blank=True) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def is_valid(self, scopes=None): """ Checks if the access token is valid. @@ -318,6 +327,9 @@ class AbstractRefreshToken(models.Model): related_name="refresh_token" ) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + def revoke(self): """ Delete this refresh token along with related access token From cdc00603e738fdf78a217754196113cadc000f4f Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 6 Jun 2017 19:10:35 +0300 Subject: [PATCH 178/722] Move ext.rest_framework to contrib.rest_framework --- CHANGELOG.md | 2 ++ docs/rest-framework/getting_started.rst | 4 ++-- oauth2_provider/{ext => contrib}/__init__.py | 0 oauth2_provider/{ext => contrib}/rest_framework/__init__.py | 0 .../{ext => contrib}/rest_framework/authentication.py | 0 .../{ext => contrib}/rest_framework/permissions.py | 0 tests/test_rest_framework.py | 2 +- 7 files changed, 5 insertions(+), 3 deletions(-) rename oauth2_provider/{ext => contrib}/__init__.py (100%) rename oauth2_provider/{ext => contrib}/rest_framework/__init__.py (100%) rename oauth2_provider/{ext => contrib}/rest_framework/authentication.py (100%) rename oauth2_provider/{ext => contrib}/rest_framework/permissions.py (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0e3c162..cc6c36501 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) * **Compatibility**: Django 1.10 is the new minimum required version * **Compatibility**: Django 1.11 is now supported +* **Backwards-incompatible**: The `oauth2_provider.ext.rest_framework` module + has been moved to `oauth2_provider.contrib.rest_framework` * #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) * #476: Disallow empty redirect URIs * Fixed bad `url` parameter in some error responses. diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 987ef82a6..e929b2310 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -35,7 +35,7 @@ To do so add the following lines at the end of your `settings.py` module: REST_FRAMEWORK = { 'DEFAULT_AUTHENTICATION_CLASSES': ( - 'oauth2_provider.ext.rest_framework.OAuth2Authentication', + 'oauth2_provider.contrib.rest_framework.OAuth2Authentication', ) } @@ -55,7 +55,7 @@ Here's our project's root `urls.py` module: from rest_framework import permissions, routers, serializers, viewsets - from oauth2_provider.ext.rest_framework import TokenHasReadWriteScope, TokenHasScope + from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope # first we define the serializers diff --git a/oauth2_provider/ext/__init__.py b/oauth2_provider/contrib/__init__.py similarity index 100% rename from oauth2_provider/ext/__init__.py rename to oauth2_provider/contrib/__init__.py diff --git a/oauth2_provider/ext/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py similarity index 100% rename from oauth2_provider/ext/rest_framework/__init__.py rename to oauth2_provider/contrib/rest_framework/__init__.py diff --git a/oauth2_provider/ext/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py similarity index 100% rename from oauth2_provider/ext/rest_framework/authentication.py rename to oauth2_provider/contrib/rest_framework/authentication.py diff --git a/oauth2_provider/ext/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py similarity index 100% rename from oauth2_provider/ext/rest_framework/permissions.py rename to oauth2_provider/contrib/rest_framework/permissions.py diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index b3d370e46..6c910bbfe 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -24,7 +24,7 @@ from rest_framework import permissions from rest_framework.views import APIView from rest_framework.test import force_authenticate, APIRequestFactory - from oauth2_provider.ext.rest_framework import ( + from oauth2_provider.contrib.rest_framework import ( IsAuthenticatedOrTokenHasScope, OAuth2Authentication, TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope ) From bcb94b9827da0755f1784d6ab8f475283bb27a05 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 6 Jun 2017 18:42:08 +0300 Subject: [PATCH 179/722] Release 1.0.0 --- CHANGELOG.md | 3 ++- setup.cfg | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc6c36501..4611e0dad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 0.13.0 [unreleased] +### 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. * #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) @@ -7,6 +7,7 @@ * **Backwards-incompatible**: The `oauth2_provider.ext.rest_framework` module has been moved to `oauth2_provider.contrib.rest_framework` * #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) +* #321: Added `created` and `updated` auto fields to Application, AccessToken, RefreshToken and Grant * #476: Disallow empty redirect URIs * Fixed bad `url` parameter in some error responses. * Django 2.0 compatibility fixes. diff --git a/setup.cfg b/setup.cfg index 2dc7a1431..0ba316c85 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 0.12.0 +version = 1.0.0 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com @@ -8,7 +8,7 @@ url = https://github.com/evonove/django-oauth-toolkit download_url = https://github.com/evonove/django-oauth-toolkit/tarball/master keywords = django, oauth, oauth2, oauthlib classifiers = - Development Status :: 4 - Beta + Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django Framework :: Django :: 1.10 From 3140426e029cda8274942b3dabfe32f986bc4b8d Mon Sep 17 00:00:00 2001 From: Klemens Mantzos Date: Mon, 3 Jul 2017 18:05:44 +0200 Subject: [PATCH 180/722] added info about position of `CorsMiddleware` --- docs/tutorial/tutorial_01.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index e41de0021..9f32c87ab 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -41,6 +41,8 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y Include the CORS middleware in your `settings.py`: +CorsMiddleware should be placed as high as possible, especially before any middleware that can generate responses such as Django's CommonMiddleware or Whitenoise's WhiteNoiseMiddleware. If it is not before, it will not be able to add the CORS headers to these responses. + .. code-block:: python MIDDLEWARE = ( From ac6ccf92964ed3d18af98bb29f542af477e3d9f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Szafra=C5=84ski?= Date: Fri, 16 Jun 2017 16:49:21 +0200 Subject: [PATCH 181/722] Add signals when a token is generated --- oauth2_provider/signals.py | 3 +++ oauth2_provider/views/base.py | 10 ++++++++++ 2 files changed, 13 insertions(+) create mode 100644 oauth2_provider/signals.py diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py new file mode 100644 index 000000000..9c1519276 --- /dev/null +++ b/oauth2_provider/signals.py @@ -0,0 +1,3 @@ +from django.dispatch import Signal + +app_authorized = Signal(providing_args=['request', 'application']) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 5a8ec2277..0db0630db 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,3 +1,4 @@ +import json import logging from django.contrib.auth.mixins import LoginRequiredMixin @@ -8,6 +9,7 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from ..signals import app_authorized from .mixins import OAuthLibMixin from ..exceptions import OAuthToolkitError from ..forms import AllowForm @@ -183,6 +185,14 @@ class TokenView(OAuthLibMixin, View): @method_decorator(sensitive_post_parameters("password")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) + if status == 200: + access_token = json.loads(body).get('access_token') + if access_token is not None: + token = get_access_token_model().objects.get( + token=access_token) + app_authorized.send( + sender=self, request=request, + application=token.application) response = HttpResponse(content=body, status=status) for k, v in headers.items(): From 5cf989b73547af119490de24bbbc1942df12f98d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Szafra=C5=84ski?= Date: Sun, 18 Jun 2017 17:10:50 +0200 Subject: [PATCH 182/722] Documentation on signals --- docs/index.rst | 1 + docs/signals.rst | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 docs/signals.rst diff --git a/docs/index.rst b/docs/index.rst index 4b1c13a6b..af62f266c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -38,6 +38,7 @@ Index views/details models advanced_topics + signals settings resource_server management_commands diff --git a/docs/signals.rst b/docs/signals.rst new file mode 100644 index 000000000..1b751042f --- /dev/null +++ b/docs/signals.rst @@ -0,0 +1,24 @@ +Signals +======= + +Django-oauth-toolkit sends messages to various signals, depending on the action +that has been triggered. + +You can easily import signals from `oauth2_provider.signals` and attach your +own listeners. + +For example: + +.. code-block:: python + + from oauth2_provider.signals import app_authorized + + def handle_app_authorized(sender, request, application, **kwargs): + print('App {} was authorized'.format(application.name)) + + app_authorized.connect(handle_app_authorized) + +Currently supported signals are: + +* `oauth2_provider.signals.app_authorized` - fired once an oauth code has been + authorized and an access token has been granted From 18bbdc177f5f68f7db173bd3b883787418816a81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Szafra=C5=84ski?= Date: Tue, 20 Jun 2017 08:20:42 +0200 Subject: [PATCH 183/722] Replace application with token --- docs/signals.rst | 4 ++-- oauth2_provider/signals.py | 2 +- oauth2_provider/views/base.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/signals.rst b/docs/signals.rst index 1b751042f..fe696ae2c 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -13,8 +13,8 @@ For example: from oauth2_provider.signals import app_authorized - def handle_app_authorized(sender, request, application, **kwargs): - print('App {} was authorized'.format(application.name)) + def handle_app_authorized(sender, request, token, **kwargs): + print('App {} was authorized'.format(token.application.name)) app_authorized.connect(handle_app_authorized) diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py index 9c1519276..1ed40b4aa 100644 --- a/oauth2_provider/signals.py +++ b/oauth2_provider/signals.py @@ -1,3 +1,3 @@ from django.dispatch import Signal -app_authorized = Signal(providing_args=['request', 'application']) +app_authorized = Signal(providing_args=['request', 'token']) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 0db0630db..2d5cd7fc3 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -192,7 +192,7 @@ def post(self, request, *args, **kwargs): token=access_token) app_authorized.send( sender=self, request=request, - application=token.application) + token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): From 8cd61e30ae845abd82d6a28ef740f7a7c9e1a0d7 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 02:34:01 +0300 Subject: [PATCH 184/722] Move redirect logic to BaseAuthorizationView This will later allow overriding allowed_schemes from the view. --- oauth2_provider/http.py | 6 ++---- oauth2_provider/views/base.py | 12 ++++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py index a73ff3efd..2621a6cca 100644 --- a/oauth2_provider/http.py +++ b/oauth2_provider/http.py @@ -1,9 +1,7 @@ from django.http import HttpResponseRedirect -from .settings import oauth2_settings - class HttpResponseUriRedirect(HttpResponseRedirect): - def __init__(self, redirect_to, *args, **kwargs): - self.allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES + def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): + self.allowed_schemes = allowed_schemes super(HttpResponseUriRedirect, self).__init__(redirect_to, *args, **kwargs) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 2d5cd7fc3..d15572f51 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -44,11 +44,15 @@ def error_response(self, error, **kwargs): redirect, error_response = super(BaseAuthorizationView, self).error_response(error, **kwargs) if redirect: - return HttpResponseUriRedirect(error_response["url"]) + return self.redirect(error_response["url"]) status = error_response["error"].status_code return self.render_to_response(error_response, status=status) + def redirect(self, redirect_to): + allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES + return HttpResponseUriRedirect(redirect_to, allowed_schemes) + class AuthorizationView(BaseAuthorizationView, FormView): """ @@ -106,7 +110,7 @@ def form_valid(self, form): request=self.request, scopes=scopes, credentials=credentials, allow=allow) self.success_url = uri log.debug("Success url for the request: {0}".format(self.success_url)) - return HttpResponseUriRedirect(self.success_url) + return self.redirect(self.success_url) except OAuthToolkitError as error: return self.error_response(error) @@ -145,7 +149,7 @@ def get(self, request, *args, **kwargs): uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True) - return HttpResponseUriRedirect(uri) + return self.redirect(uri) elif require_approval == "auto": tokens = get_access_token_model().objects.filter( @@ -160,7 +164,7 @@ def get(self, request, *args, **kwargs): uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True) - return HttpResponseUriRedirect(uri) + return self.redirect(uri) return self.render_to_response(self.get_context_data(**kwargs)) From ed96137542ef721bf94fe97fbdeb0a79c9394565 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 03:10:25 +0300 Subject: [PATCH 185/722] tests: Handle {posargs} from tox --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 329e2ceac..6919bdef9 100644 --- a/tox.ini +++ b/tox.ini @@ -7,7 +7,7 @@ envlist = flake8 [testenv] -commands = pytest --cov=oauth2_provider --cov-report= --cov-append +commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} From 47dd70f7f5755a6f5bc6a2d9edccc97ab0082a39 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 03:14:53 +0300 Subject: [PATCH 186/722] Fix test regression in oauthlib 2.0.3 --- setup.cfg | 2 +- tests/test_authorization_code.py | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index 0ba316c85..fd556aea3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_package_data = True zip_safe = False install_requires = django >= 1.10 - oauthlib >= 2.0.1 + oauthlib >= 2.0.3 requests >= 2.13.0 [options.packages.find] diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 6ca53e7f6..fa7410216 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -8,6 +8,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import ( @@ -883,7 +884,10 @@ def test_malicious_redirect_uri(self): } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) def test_code_exchange_succeed_when_redirect_uri_match(self): """ @@ -948,7 +952,10 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): """ From d5a5cfc0c37b44b8d6872aeb7e2243722d021fb2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 03:20:50 +0300 Subject: [PATCH 187/722] Switch to isort and clean up imports --- docs/conf.py | 11 ++++++++--- oauth2_provider/admin.py | 6 ++---- oauth2_provider/compat.py | 1 + .../contrib/rest_framework/permissions.py | 2 +- oauth2_provider/generators.py | 3 +-- oauth2_provider/oauth2_validators.py | 7 ++----- oauth2_provider/scopes.py | 3 +-- oauth2_provider/signals.py | 1 + oauth2_provider/views/application.py | 4 +++- oauth2_provider/views/base.py | 4 ++-- oauth2_provider/views/generic.py | 4 +++- tests/test_auth_backends.py | 5 +---- tests/test_authorization_code.py | 7 +++---- tests/test_client_credential.py | 6 ++---- tests/test_decorators.py | 5 +---- tests/test_introspection_auth.py | 3 ++- tests/test_introspection_view.py | 1 + tests/test_mixins.py | 5 +++-- tests/test_models.py | 7 +++---- tests/test_oauth2_backends.py | 11 ++++++----- tests/test_oauth2_validators.py | 15 +++++++-------- tests/test_password.py | 1 + tests/test_rest_framework.py | 5 +---- tests/test_scopes.py | 8 ++++---- tests/test_scopes_backend.py | 3 +-- tests/test_token_revocation.py | 4 +--- tests/test_token_view.py | 5 +---- tests/urls.py | 1 + tox.ini | 17 +++++++++++++---- 29 files changed, 77 insertions(+), 78 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 321887d76..f4a5f2148 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,7 +11,14 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os, re +import os +import re +import sys + +import django + +import oauth2_provider + # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the @@ -22,10 +29,8 @@ os.environ["DJANGO_SETTINGS_MODULE"] = "tests.settings" -import django django.setup() -import oauth2_provider # -- General configuration ----------------------------------------------------- diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 1f8312f4a..c6bbe44b7 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,10 +1,8 @@ from django.contrib import admin from .models import ( - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model ) diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 4e8be2296..6e455b0b3 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -5,6 +5,7 @@ # flake8: noqa from __future__ import unicode_literals + # urlparse in python3 has been renamed to urllib.parse try: from urlparse import parse_qs, parse_qsl, urlparse, urlsplit, urlunparse, urlunsplit diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index cce946a4b..e5df053d0 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -3,8 +3,8 @@ from django.core.exceptions import ImproperlyConfigured from rest_framework.permissions import BasePermission, IsAuthenticated -from .authentication import OAuth2Authentication from ...settings import oauth2_settings +from .authentication import OAuth2Authentication log = logging.getLogger("oauth2_provider") diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index 5b861e74d..6e8124979 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -1,5 +1,4 @@ -from __future__ import absolute_import -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from oauthlib.common import generate_client_id as oauthlib_generate_client_id from oauthlib.common import UNICODE_ASCII_CHARACTER_SET diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ec34e58d5..2cc4b4b20 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -17,11 +17,8 @@ from .compat import unquote_plus from .exceptions import FatalClientError from .models import ( - AbstractApplication, - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + AbstractApplication, get_access_token_model, + get_application_model, get_grant_model, get_refresh_token_model ) from .scopes import get_scopes_backend from .settings import oauth2_settings diff --git a/oauth2_provider/scopes.py b/oauth2_provider/scopes.py index 5320e0f65..d0eae5789 100644 --- a/oauth2_provider/scopes.py +++ b/oauth2_provider/scopes.py @@ -1,5 +1,4 @@ -from __future__ import absolute_import -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from .settings import oauth2_settings diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py index 1ed40b4aa..af7f4ae2f 100644 --- a/oauth2_provider/signals.py +++ b/oauth2_provider/signals.py @@ -1,3 +1,4 @@ from django.dispatch import Signal + app_authorized = Signal(providing_args=['request', 'token']) diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 10cfff87d..0bd0dd691 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,7 +1,9 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.models import modelform_factory from django.urls import reverse_lazy -from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView +from django.views.generic import ( + CreateView, DeleteView, DetailView, ListView, UpdateView +) from ..models import get_application_model diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index d15572f51..f56f7eb3a 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -9,14 +9,14 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View -from ..signals import app_authorized -from .mixins import OAuthLibMixin from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import HttpResponseUriRedirect from ..models import get_access_token_model, get_application_model from ..scopes import get_scopes_backend from ..settings import oauth2_settings +from ..signals import app_authorized +from .mixins import OAuthLibMixin log = logging.getLogger("oauth2_provider") diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index 2a1219036..c9bbc6af4 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -1,7 +1,9 @@ from django.views.generic import View -from .mixins import ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin from ..settings import oauth2_settings +from .mixins import ( + ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin +) class ProtectedResourceView(ProtectedResourceMixin, View): diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 4096a62fe..530caa738 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -7,10 +7,7 @@ from oauth2_provider.backends import OAuth2Backend from oauth2_provider.middleware import OAuth2TokenMiddleware -from oauth2_provider.models import ( - get_access_token_model, - get_application_model, -) +from oauth2_provider.models import get_access_token_model, get_application_model UserModel = get_user_model() diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index fa7410216..824818450 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -12,13 +12,12 @@ from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView + from .utils import get_basic_auth_header diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 80a94e5ad..7ec49ed67 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -9,15 +9,13 @@ from oauthlib.oauth2 import BackendApplicationServer from oauth2_provider.compat import quote_plus -from oauth2_provider.models import ( - get_access_token_model, - get_application_model, -) +from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from oauth2_provider.views.mixins import OAuthLibMixin + from .utils import get_basic_auth_header diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 6443b9611..0732b2920 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -5,10 +5,7 @@ from django.utils import timezone from oauth2_provider.decorators import protected_resource, rw_protected_resource -from oauth2_provider.models import ( - get_access_token_model, - get_application_model, -) +from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 76aa6cec5..61fa2ec4d 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -6,7 +6,7 @@ from django.conf.urls import include, url from django.contrib.auth import get_user_model from django.http import HttpResponse -from django.test import override_settings, TestCase +from django.test import TestCase, override_settings from django.utils import timezone from oauthlib.common import Request @@ -15,6 +15,7 @@ from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ScopedProtectedResourceView + try: from unittest import mock except ImportError: diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 0bbcfa88c..0d9b2ce74 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -11,6 +11,7 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings + Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 4dd6bc329..a4a116555 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -3,12 +3,13 @@ from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.views.generic import View - from oauthlib.oauth2 import Server from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.views.mixins import OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin +from oauth2_provider.views.mixins import ( + OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin +) class BaseTest(TestCase): diff --git a/tests/test_models.py b/tests/test_models.py index 474b830e7..083cf1357 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -7,13 +7,12 @@ from django.utils import timezone from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_grant_model, - get_refresh_token_model, + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings + Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index a18e62a3a..d844da5f4 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,16 +1,17 @@ import json -try: - from unittest import mock -except ImportError: - import mock - from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore +try: + from unittest import mock +except ImportError: + import mock + + class TestOAuthLibCoreBackend(TestCase): def setUp(self): diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 929e70a9b..92f024f48 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,10 +1,5 @@ import datetime -try: - from unittest import mock -except ImportError: - import mock - from django.contrib.auth import get_user_model from django.test import TransactionTestCase from django.utils import timezone @@ -12,13 +7,17 @@ from oauth2_provider.exceptions import FatalClientError from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_refresh_token_model, + get_access_token_model, get_application_model, get_refresh_token_model ) from oauth2_provider.oauth2_validators import OAuth2Validator +try: + from unittest import mock +except ImportError: + import mock + + UserModel = get_user_model() Application = get_application_model() AccessToken = get_access_token_model() diff --git a/tests/test_password.py b/tests/test_password.py index 40dcd1f15..9a295c9b2 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -9,6 +9,7 @@ from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView + from .utils import get_basic_auth_header diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 6c910bbfe..61090a502 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -8,10 +8,7 @@ from django.test.utils import override_settings from django.utils import timezone -from oauth2_provider.models import ( - get_access_token_model, - get_application_model, -) +from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 75eb557df..daccfed00 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -9,12 +9,12 @@ from oauth2_provider.compat import parse_qs, urlparse from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_grant_model, + get_access_token_model, get_application_model, get_grant_model ) from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView +from oauth2_provider.views import ( + ReadWriteScopedResourceView, ScopedProtectedResourceView +) from .utils import get_basic_auth_header diff --git a/tests/test_scopes_backend.py b/tests/test_scopes_backend.py index 26ca7ee85..06d45b0ed 100644 --- a/tests/test_scopes_backend.py +++ b/tests/test_scopes_backend.py @@ -1,5 +1,4 @@ -from __future__ import absolute_import -from __future__ import unicode_literals +from __future__ import absolute_import, unicode_literals from oauth2_provider.scopes import SettingsScopes diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 60d4456b1..8870b9d7b 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -9,9 +9,7 @@ from oauth2_provider.compat import urlencode from oauth2_provider.models import ( - get_access_token_model, - get_application_model, - get_refresh_token_model, + get_access_token_model, get_application_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings diff --git a/tests/test_token_view.py b/tests/test_token_view.py index e64d3e0f2..5c0a92d47 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -7,10 +7,7 @@ from django.urls import reverse from django.utils import timezone -from oauth2_provider.models import ( - get_access_token_model, - get_application_model, -) +from oauth2_provider.models import get_access_token_model, get_application_model Application = get_application_model() diff --git a/tests/urls.py b/tests/urls.py index 96f95e7c7..16dcf6ded 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,6 +1,7 @@ from django.conf.urls import include, url from django.contrib import admin + admin.autodiscover() diff --git a/tox.ini b/tox.ini index 6919bdef9..9cbcede4b 100644 --- a/tox.ini +++ b/tox.ini @@ -33,11 +33,13 @@ commands = make html deps = sphinx [testenv:flake8] -commands = flake8 +commands = + flake8 {toxinidir} {posargs} + isort {toxinidir} -c deps = flake8 - flake8-import-order flake8-quotes + isort [coverage:run] source = oauth2_provider @@ -48,7 +50,14 @@ django_find_project = false [flake8] max-line-length = 110 -exclude = docs/, migrations/, .tox/ -import-order-style = smarkets +exclude = docs/, oauth2_provider/migrations/, .tox/ application-import-names = oauth2_provider inline-quotes = " + +[isort] +lines_after_imports = 2 +known_first_party = oauth2_provider +multi_line_output = 5 +skip = oauth2_provider/migrations/, .tox/ +line_length = 80 +balanced_wrapping = True From e055fa432d8182d8a78ecc31c368b903726adf8e Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 03:26:06 +0300 Subject: [PATCH 188/722] travis: Run check on flake8 env --- .travis.yml | 1 + oauth2_provider/signals.py | 2 +- oauth2_provider/views/base.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 8e09ce681..1d1afa730 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ env: - TOXENV=py36-django111 - TOXENV=py36-djangomaster - TOXENV=docs + - TOXENV=flake8 matrix: fast_finish: true diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py index af7f4ae2f..060db8cd0 100644 --- a/oauth2_provider/signals.py +++ b/oauth2_provider/signals.py @@ -1,4 +1,4 @@ from django.dispatch import Signal -app_authorized = Signal(providing_args=['request', 'token']) +app_authorized = Signal(providing_args=["request", "token"]) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index f56f7eb3a..69eb87a74 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -190,7 +190,7 @@ class TokenView(OAuthLibMixin, View): def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) if status == 200: - access_token = json.loads(body).get('access_token') + access_token = json.loads(body).get("access_token") if access_token is not None: token = get_access_token_model().objects.get( token=access_token) From 2cd1f0dccadb8e74919a059d9b4985f9ecb1d59f Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 03:27:13 +0300 Subject: [PATCH 189/722] travis: Stop testing on py3.5 (only 2.7, 3.4, 3.6) --- .travis.yml | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1d1afa730..22bc084a0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,14 @@ +# https://travis-ci.org/evonove/django-oauth-toolkit +sudo: false language: python -python: - - "3.5" -sudo: false +python: "3.6" env: - TOXENV=py27-django110 - TOXENV=py27-django111 - TOXENV=py34-django110 - TOXENV=py34-django111 - - TOXENV=py35-django110 - - TOXENV=py35-django111 - - TOXENV=py35-djangomaster - TOXENV=py36-django111 - TOXENV=py36-djangomaster - TOXENV=docs @@ -19,19 +16,8 @@ env: matrix: fast_finish: true - include: - - python: "3.6" - env: TOXENV=py36-django111 - - python: "3.6" - env: TOXENV=py36-djangomaster - exclude: - - python: "3.5" - env: TOXENV=py36-django111 - - python: "3.5" - env: TOXENV=py36-djangomaster allow_failures: - - env: TOXENV=py35-djangomaster - env: TOXENV=py36-djangomaster cache: From 50e4df7d97af90439d27a73c5923f2c06a4961f2 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Fri, 15 Sep 2017 06:02:02 +0300 Subject: [PATCH 190/722] Import SAFE_METHODS from rest_framework.permissions --- oauth2_provider/contrib/rest_framework/permissions.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index e5df053d0..5c52a0e68 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -1,7 +1,7 @@ import logging from django.core.exceptions import ImproperlyConfigured -from rest_framework.permissions import BasePermission, IsAuthenticated +from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS from ...settings import oauth2_settings from .authentication import OAuth2Authentication @@ -9,8 +9,6 @@ log = logging.getLogger("oauth2_provider") -SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] - class TokenHasScope(BasePermission): """ @@ -54,7 +52,7 @@ def get_scopes(self, request, view): required_scopes = [] # TODO: code duplication!! see dispatch in ReadWriteScopedResourceMixin - if request.method.upper() in SAFE_HTTP_METHODS: + if request.method.upper() in SAFE_METHODS: read_write_scope = oauth2_settings.READ_SCOPE else: read_write_scope = oauth2_settings.WRITE_SCOPE @@ -75,7 +73,7 @@ def get_scopes(self, request, view): except ImproperlyConfigured: view_scopes = [] - if request.method.upper() in SAFE_HTTP_METHODS: + if request.method.upper() in SAFE_METHODS: scope_type = oauth2_settings.READ_SCOPE else: scope_type = oauth2_settings.WRITE_SCOPE From 65af7372a0fb208a19899fa75982163bdff713f9 Mon Sep 17 00:00:00 2001 From: Rivo Laks Date: Tue, 19 Sep 2017 16:00:47 +0300 Subject: [PATCH 191/722] Fix RefreshToken model setting name --- oauth2_provider/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index f7242487a..a85957093 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -28,7 +28,7 @@ APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") -REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_MODEL", "oauth2_provider.RefreshToken") +REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") DEFAULTS = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", From ef7909b5f6053b43f0220b0a63eaad0ab63eeda8 Mon Sep 17 00:00:00 2001 From: Hugo Duroux Date: Fri, 22 Sep 2017 16:34:10 +0200 Subject: [PATCH 192/722] Fix TypeError in Multiple Grants example --- docs/advanced_topics.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 0595e315c..24a97c3f7 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -66,7 +66,7 @@ to support the authorization code *and* client credentials grants, you might do class MyApplication(AbstractApplication): def allows_grant_type(self, *grant_types): # Assume, for this example, that self.authorization_grant_type is set to self.GRANT_AUTHORIZATION_CODE - return bool( set(self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS) & grant_types ) + return bool( set([self.authorization_grant_type, self.GRANT_CLIENT_CREDENTIALS]) & grant_types ) .. _skip-auth-form: From fa33444e81b3c95432999d51cdb2acdb98fc16bf Mon Sep 17 00:00:00 2001 From: Tom Forbes Date: Thu, 26 Oct 2017 15:44:33 +0100 Subject: [PATCH 193/722] Add missing default to docs --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 9ac4b9ac4..a4d8d9264 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -118,7 +118,7 @@ The strategy used to display the authorization form. Refer to :ref:`skip-auth-fo SCOPES_BACKEND_CLASS ~~~~~~~~~~~~~~~~~~~~ **New in 0.12.0**. The import string for the scopes backend class. -Defaults to , which reads scopes through the settings defined below. +Defaults to ``oauth2_provider.scopes.SettingsScopes``, which reads scopes through the settings defined below. SCOPES ~~~~~~ From 502ebcde6cfa0aa4284bd397c438e9b81177c27f Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 3 Dec 2017 02:59:09 +0200 Subject: [PATCH 194/722] Drop support for Django 1.10 --- .travis.yml | 12 +++++------- setup.cfg | 3 +-- tox.ini | 16 +++++++--------- 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/.travis.yml b/.travis.yml index 22bc084a0..2570c8f65 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,26 +5,24 @@ language: python python: "3.6" env: - - TOXENV=py27-django110 - TOXENV=py27-django111 - - TOXENV=py34-django110 - TOXENV=py34-django111 - TOXENV=py36-django111 - TOXENV=py36-djangomaster - TOXENV=docs - TOXENV=flake8 +cache: + directories: + - $HOME/.cache/pip + - $TRAVIS_BUILD_DIR/.tox + matrix: fast_finish: true allow_failures: - env: TOXENV=py36-djangomaster -cache: - directories: - - $HOME/.cache/pip - - $TRAVIS_BUILD_DIR/.tox - install: - pip install coveralls tox diff --git a/setup.cfg b/setup.cfg index fd556aea3..d1ebf5333 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 1.10 Framework :: Django :: 1.11 Intended Audience :: Developers License :: OSI Approved :: BSD License @@ -28,7 +27,7 @@ packages = find: include_package_data = True zip_safe = False install_requires = - django >= 1.10 + django >= 1.11 oauthlib >= 2.0.3 requests >= 2.13.0 diff --git a/tox.ini b/tox.ini index 9cbcede4b..acc10c8e9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,14 @@ [tox] envlist = - py27-django{110,111}, - py35-django{110,111,master}, - py36-djangomaster, + py27-django{111}, + py35-django{111,master}, + py36-django{111,master}, docs, flake8 +[pytest] +django_find_project = false + [testenv] commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} setenv = @@ -13,7 +16,6 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - django110: Django >=1.10, <1.11 django111: Django >=1.11, <2.0 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework >=3.5 @@ -24,7 +26,6 @@ deps = pytest-xdist py27: mock - [testenv:docs] basepython = python changedir = docs @@ -45,14 +46,11 @@ deps = source = oauth2_provider omit = */migrations/* -[pytest] -django_find_project = false - [flake8] max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, .tox/ application-import-names = oauth2_provider -inline-quotes = " +inline-quotes = double [isort] lines_after_imports = 2 From 4e2dfa062dacd8248c7993ba4a1b6ea5f60de9b5 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 3 Dec 2017 03:02:00 +0200 Subject: [PATCH 195/722] Run tests on Django 2.0 Closes #530 --- .travis.yml | 1 + setup.cfg | 1 + tox.ini | 8 +++++--- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 2570c8f65..41b7f888c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ env: - TOXENV=py27-django111 - TOXENV=py34-django111 - TOXENV=py36-django111 + - TOXENV=py36-django20 - TOXENV=py36-djangomaster - TOXENV=docs - TOXENV=flake8 diff --git a/setup.cfg b/setup.cfg index d1ebf5333..672f0fe05 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,7 @@ classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 1.11 + Framework :: Django :: 2.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/tox.ini b/tox.ini index acc10c8e9..96f2bbd18 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = py27-django{111}, - py35-django{111,master}, - py36-django{111,master}, + py35-django{111,20,master}, + py36-django{111,20,master}, docs, flake8 @@ -16,7 +16,8 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - django111: Django >=1.11, <2.0 + django111: Django>=1.11,<2.0 + django20: Django>=2.0,<2.1 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework >=3.5 coverage @@ -34,6 +35,7 @@ commands = make html deps = sphinx [testenv:flake8] +skip_install = True commands = flake8 {toxinidir} {posargs} isort {toxinidir} -c From 043e5d345d12ad5de959ec86a9b4527f32ad7511 Mon Sep 17 00:00:00 2001 From: Michael Fladischer Date: Thu, 20 Jul 2017 10:30:24 +0200 Subject: [PATCH 196/722] Parse URI only once in AbstractApplication.redirect_uri_allowed. Just a minor inconvenience but it seems unnecessary to me to parse the same parameter value multiple times in case there are more than one permitted redirect URIs. --- oauth2_provider/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index b2967c7c1..fc8caeee1 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -108,16 +108,16 @@ def redirect_uri_allowed(self, uri): :param uri: Url to check """ + parsed_uri = urlparse(uri) + uqs_set = set(parse_qsl(parsed_uri.query)) for allowed_uri in self.redirect_uris.split(): parsed_allowed_uri = urlparse(allowed_uri) - parsed_uri = urlparse(uri) if (parsed_allowed_uri.scheme == parsed_uri.scheme and parsed_allowed_uri.netloc == parsed_uri.netloc and parsed_allowed_uri.path == parsed_uri.path): aqs_set = set(parse_qsl(parsed_allowed_uri.query)) - uqs_set = set(parse_qsl(parsed_uri.query)) if aqs_set.issubset(uqs_set): return True From 231efffdcec27d4535032f5ba75e2f6a8aae1328 Mon Sep 17 00:00:00 2001 From: Mattia Procopio Date: Mon, 4 Dec 2017 12:11:31 +0100 Subject: [PATCH 197/722] Remove dependencies to models that create circular errors --- oauth2_provider/migrations/0001_initial.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py index 4b5601003..f415cb622 100644 --- a/oauth2_provider/migrations/0001_initial.py +++ b/oauth2_provider/migrations/0001_initial.py @@ -11,11 +11,7 @@ class Migration(migrations.Migration): dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - migrations.swappable_dependency(oauth2_settings.APPLICATION_MODEL), - migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL), - migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL), - migrations.swappable_dependency(oauth2_settings.GRANT_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL) ] operations = [ From fd596a0bf08b3f1e2189fd5457256704efff39f7 Mon Sep 17 00:00:00 2001 From: samgensburg-gov Date: Sat, 3 Feb 2018 16:04:22 -0500 Subject: [PATCH 198/722] Small grammatical change in tutorial_02.rst (#548) --- docs/tutorial/tutorial_02.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 1992a08c6..b8c2133db 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -3,7 +3,7 @@ Part 2 - protect your APIs Scenario -------- -It's very common for an :term:`Authorization Server` being also the :term:`Resource Server`, usually exposing an API to +It's very common for an :term:`Authorization Server` to also be the :term:`Resource Server`, usually exposing an API to let others access its own resources. Django OAuth Toolkit implements an easy way to protect the views of a Django application with OAuth2, in this tutorial we will see how to do it. From f41d0df0c283ef96bdae95c955c02074d3a48609 Mon Sep 17 00:00:00 2001 From: Ash Berlin-Taylor Date: Sat, 3 Feb 2018 21:22:12 +0000 Subject: [PATCH 199/722] Expire AccessTokens even when REFRESH_TOKEN_EXPIRE_SECONDS is not set (#551) If refresh tokens are not being used or if REFRESH_TOKEN_EXPIRE_SECONDS is not set in the settings then access tokens are never cleaned up by the `cleartokens` command. --- oauth2_provider/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index fc8caeee1..a56a8f6f8 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -389,5 +389,5 @@ def clear_expired(): with transaction.atomic(): if refresh_expire_at: refresh_token_model.objects.filter(access_token__expires__lt=refresh_expire_at).delete() - access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() + access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() grant_model.objects.filter(expires__lt=now).delete() From 859c39d45168c398c036f616a770a5c3e8705bae Mon Sep 17 00:00:00 2001 From: Niek Hoekstra Date: Wed, 17 Jan 2018 11:51:11 +0100 Subject: [PATCH 200/722] Delete Refresh token before Access Token to avoid cascade deadlock --- oauth2_provider/models.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a56a8f6f8..5f3b96e7d 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -335,8 +335,10 @@ def revoke(self): Delete this refresh token along with related access token """ access_token_model = get_access_token_model() - access_token_model.objects.get(id=self.access_token.id).revoke() + token = access_token_model.objects.get(id=self.access_token.id) + # Avoid cascade by deleting self first. self.delete() + token.revoke() def __str__(self): return self.token From 107e5eae6bec42b127d419a6440919db0c8977bb Mon Sep 17 00:00:00 2001 From: Simon Schmidt Date: Wed, 16 Aug 2017 17:32:16 +0300 Subject: [PATCH 201/722] Includes required scopes in 403 response Requires django rest framework >3.5.0 Makes it easier for a client to know which extra scopes to request Resolves #504 --- docs/settings.rst | 5 ++++ .../contrib/rest_framework/permissions.py | 20 +++++++++++++- oauth2_provider/settings.py | 1 + tests/test_rest_framework.py | 27 +++++++++++++++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index a4d8d9264..fb567ebfa 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -152,6 +152,11 @@ WRITE_SCOPE The name of the *write* scope. +ERROR_RESPONSE_WITH_SCOPES +~~~~~~~~~~~~~~~~~~~~~~~~~~ +When authorization fails due to insufficient scopes include the required scopes in the response. +Only applicable when used with `Django REST Framework `_ + RESOURCE_SERVER_INTROSPECTION_URL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The introspection endpoint for validating token remotely (RFC7662). diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 5c52a0e68..00a1ca0ca 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -1,6 +1,7 @@ import logging from django.core.exceptions import ImproperlyConfigured +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission, IsAuthenticated, SAFE_METHODS from ...settings import oauth2_settings @@ -25,7 +26,24 @@ def has_permission(self, request, view): required_scopes = self.get_scopes(request, view) log.debug("Required scopes to access resource: {0}".format(required_scopes)) - return token.is_valid(required_scopes) + if token.is_valid(required_scopes): + return True + + # Provide information about required scope? + include_required_scope = ( + oauth2_settings.ERROR_RESPONSE_WITH_SCOPES and + required_scopes and + not token.is_expired() and + not token.allow_scopes(required_scopes) + ) + + if include_required_scope: + self.message = { + "detail": PermissionDenied.default_detail, + "required_scopes": list(required_scopes), + } + + return False assert False, ("TokenHasScope requires the" "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index a85957093..a3ef0b4b1 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -46,6 +46,7 @@ "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "ROTATE_REFRESH_TOKEN": True, + "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 61090a502..71fbda072 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -12,6 +12,12 @@ from oauth2_provider.settings import oauth2_settings +try: + from unittest import mock +except ImportError: + import mock + + Application = get_application_model() AccessToken = get_access_token_model() UserModel = get_user_model() @@ -243,3 +249,24 @@ def test_resource_scoped_permission_post_denied(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) + + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") + @mock.patch.object(oauth2_settings, "ERROR_RESPONSE_WITH_SCOPES", new=True) + def test_required_scope_in_response(self): + self.access_token.scope = "scope2" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + self.assertEqual(response.data["required_scopes"], ["scope1"]) + + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") + def test_required_scope_not_in_response_by_default(self): + self.access_token.scope = "scope2" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + self.assertNotIn("required_scopes", response.data) From 2e4d15ee9372eb65289382dae0ee3c82a54cac06 Mon Sep 17 00:00:00 2001 From: Phillip Baker Date: Thu, 15 Feb 2018 19:16:52 -0500 Subject: [PATCH 202/722] Revoke refresh tokens instead of deleting them To handle the case where a refresh token is used to generate a new access token but the new access/refresh token is never received, allow a refresh token to be re-used within a grace period, returning the same access token (appearing as if the request/response was 'cached'). This now revokes the token with a timestamp instead of deleting it. Refs #497 --- docs/settings.rst | 10 ++ .../migrations/0006_auto_20171214_2232.py | 44 +++++++++ oauth2_provider/models.py | 24 +++-- oauth2_provider/oauth2_validators.py | 72 ++++++++++----- oauth2_provider/settings.py | 1 + tests/test_authorization_code.py | 91 ++++++++++++++++++- tests/test_oauth2_validators.py | 2 +- tests/test_token_revocation.py | 3 +- 8 files changed, 216 insertions(+), 31 deletions(-) create mode 100644 oauth2_provider/migrations/0006_auto_20171214_2232.py diff --git a/docs/settings.rst b/docs/settings.rst index fb567ebfa..d7d07c4fe 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -100,6 +100,16 @@ REFRESH_TOKEN_EXPIRE_SECONDS The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +REFRESH_TOKEN_GRACE_PERIOD_SECONDS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The number of seconds between when a refresh token is first used when it is +expired. The most common case of this for this is native mobile applications +that run into issues of network connectivity during the refresh cycle and are +unable to complete the full request/response life cycle. Without a grace +period the application, the app then has only a consumed refresh token and the +only recourse is to have the user re-authenticate. A suggested value, if this +is enabled, is 2 minutes. + REFRESH_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh tokens. Overwrite diff --git a/oauth2_provider/migrations/0006_auto_20171214_2232.py b/oauth2_provider/migrations/0006_auto_20171214_2232.py new file mode 100644 index 000000000..4622f096d --- /dev/null +++ b/oauth2_provider/migrations/0006_auto_20171214_2232.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.1 on 2017-05-14 11:41 +from __future__ import unicode_literals + +from oauth2_provider.settings import oauth2_settings +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20170514_1141'), + ] + + operations = [ + migrations.AddField( + model_name='accesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name='refreshed_access_token'), + preserve_default=False, + ), + migrations.AddField( + model_name='refreshtoken', + name='revoked', + field=models.DateTimeField(null=True, default=django.utils.timezone.now), + preserve_default=False, + ), + migrations.AlterField( + model_name='refreshtoken', + name='token', + field=models.CharField(max_length=255), + ), + migrations.AlterField( + model_name='refreshtoken', + name='access_token', + field=models.OneToOneField(blank=True, null=True, related_name='refresh_token', to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL), + ), + migrations.AlterUniqueTogether( + name='refreshtoken', + unique_together=set([('token', 'revoked')]), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 5f3b96e7d..6836fe35f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -221,6 +221,7 @@ class AbstractAccessToken(models.Model): Fields: * :attr:`user` The Django user representing resources' owner + * :attr:`source_refresh_token` If from a refresh, the consumed RefeshToken * :attr:`token` Access token * :attr:`application` Application instance * :attr:`expires` Date and time of token expiration, in DateTime format @@ -231,6 +232,11 @@ class AbstractAccessToken(models.Model): settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, related_name="%(app_label)s_%(class)s" ) + source_refresh_token = models.OneToOneField( + # unique=True implied by the OneToOneField + oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, + related_name="refreshed_access_token" + ) token = models.CharField(max_length=255, unique=True, ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, @@ -313,38 +319,41 @@ class AbstractRefreshToken(models.Model): * :attr:`application` Application instance * :attr:`access_token` AccessToken instance this refresh token is bounded to + * :attr:`revoked` Timestamp of when this refresh token was revoked """ id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) - token = models.CharField(max_length=255, unique=True) + token = models.CharField(max_length=255) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) access_token = models.OneToOneField( - oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.CASCADE, + oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, related_name="refresh_token" ) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + revoked = models.DateTimeField(null=True) def revoke(self): """ - Delete this refresh token along with related access token + Mark this refresh token revoked and revoke related access token """ access_token_model = get_access_token_model() - token = access_token_model.objects.get(id=self.access_token.id) - # Avoid cascade by deleting self first. - self.delete() - token.revoke() + access_token_model.objects.get(id=self.access_token_id).revoke() + self.access_token = None + self.revoked = timezone.now() + self.save() def __str__(self): return self.token class Meta: abstract = True + unique_together = ("token", "revoked",) class RefreshToken(AbstractRefreshToken): @@ -390,6 +399,7 @@ def clear_expired(): with transaction.atomic(): if refresh_expire_at: + refresh_token_model.objects.filter(revoked__lt=refresh_expire_at).delete() refresh_token_model.objects.filter(access_token__expires__lt=refresh_expire_at).delete() access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() grant_model.objects.filter(expires__lt=now).delete() diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 2cc4b4b20..dd0cef903 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -10,6 +10,7 @@ from django.contrib.auth import authenticate, get_user_model from django.core.exceptions import ObjectDoesNotExist from django.db import transaction +from django.db.models import Q from django.utils import timezone from django.utils.timezone import make_aware from oauthlib.oauth2 import RequestValidator @@ -449,24 +450,43 @@ def save_bearer_token(self, token, request, *args, **kwargs): # else create fresh with access & refresh tokens else: - # revoke existing tokens if possible + # revoke existing tokens if possible to allow reuse of grant if isinstance(refresh_token_instance, RefreshToken): + previous_access_token = AccessToken.objects.filter( + source_refresh_token=refresh_token_instance + ).first() try: refresh_token_instance.revoke() except (AccessToken.DoesNotExist, RefreshToken.DoesNotExist): pass else: setattr(request, "refresh_token_instance", None) + else: + previous_access_token = None + + # If the refresh token has already been used to create an + # access token (ie it's within the grace period), return that + # access token + if not previous_access_token: + access_token = self._create_access_token( + expires, + request, + token, + source_refresh_token=refresh_token_instance, + ) - access_token = self._create_access_token(expires, request, token) - - refresh_token = RefreshToken( - user=request.user, - token=refresh_token_code, - application=request.client, - access_token=access_token - ) - refresh_token.save() + refresh_token = RefreshToken( + user=request.user, + token=refresh_token_code, + application=request.client, + access_token=access_token + ) + refresh_token.save() + else: + # make sure that the token data we're returning matches + # the existing token + token["access_token"] = previous_access_token.token + token["scope"] = previous_access_token.scope # No refresh token should be created, just access token else: @@ -475,13 +495,14 @@ def save_bearer_token(self, token, request, *args, **kwargs): # TODO: check out a more reliable way to communicate expire time to oauthlib token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - def _create_access_token(self, expires, request, token): + def _create_access_token(self, expires, request, token, source_refresh_token=None): access_token = AccessToken( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], - application=request.client + application=request.client, + source_refresh_token=source_refresh_token, ) access_token.save() return access_token @@ -524,6 +545,9 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs): # Avoid second query for RefreshToken since this method is invoked *after* # validate_refresh_token. rt = request.refresh_token_instance + if not rt.access_token_id: + return AccessToken.objects.get(source_refresh_token_id=rt.id).scope + return rt.access_token.scope def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): @@ -531,13 +555,19 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs Check refresh_token exists and refers to the right client. Also attach User instance to the request object """ - try: - rt = RefreshToken.objects.get(token=refresh_token) - request.user = rt.user - request.refresh_token = rt.token - # Temporary store RefreshToken instance to be reused by get_original_scopes. - request.refresh_token_instance = rt - return rt.application == client - - except RefreshToken.DoesNotExist: + + null_or_recent = Q(revoked__isnull=True) | Q( + revoked__gt=timezone.now() - timedelta( + seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS + ) + ) + rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).first() + + if not rt: return False + + request.user = rt.user + request.refresh_token = rt.token + # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. + request.refresh_token_instance = rt + return rt.application == client diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index a3ef0b4b1..8799a3956 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -45,6 +45,7 @@ "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, + "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 824818450..0a7f82c59 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -580,6 +580,54 @@ def test_refresh(self): content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) + def test_refresh_with_grace_period(self): + """ + Request an access token using a refresh token + """ + oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("refresh_token" in content) + + # make a second token request to be sure the previous refresh token remains valid, see #65 + authorization_code = self.get_auth() + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("access_token" in content) + first_access_token = content["access_token"] + + # check refresh token returns same data if used twice, see #497 + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("access_token" in content) + self.assertEqual(content["access_token"], first_access_token) + oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 + def test_refresh_invalidates_old_tokens(self): """ Ensure existing refresh tokens are cleaned up when issuing new ones @@ -608,7 +656,8 @@ def test_refresh_invalidates_old_tokens(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - self.assertFalse(RefreshToken.objects.filter(token=rt).exists()) + refresh_token = RefreshToken.objects.filter(token=rt).first() + self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(token=at).exists()) def test_refresh_no_scopes(self): @@ -693,6 +742,46 @@ def test_refresh_fail_repeating_requests(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) + def test_refresh_repeating_requests(self): + """ + Trying to refresh an access token with the same refresh token more than + once succeeds in the grace period and fails outside + """ + oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("refresh_token" in content) + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + # try refreshing outside the refresh window, see #497 + rt = RefreshToken.objects.get(token=content["refresh_token"]) + self.assertIsNotNone(rt.revoked) + rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime + rt.save() + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 + def test_refresh_repeating_requests_non_rotating_tokens(self): """ Try refreshing an access token with the same refresh token more than once when not rotating tokens. diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 92f024f48..d8ab2349b 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -236,7 +236,7 @@ def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_ self.validator.save_bearer_token(token, self.request) - self.assertEqual(1, RefreshToken.objects.count()) + self.assertEqual(1, RefreshToken.objects.filter(revoked__isnull=True).count()) self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__with_no_refresh_token__creates_new_access_token_only(self): diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 8870b9d7b..c7520641a 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -149,7 +149,8 @@ def test_revoke_refresh_token(self): url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) response = self.client.post(url) self.assertEqual(response.status_code, 200) - self.assertFalse(RefreshToken.objects.filter(id=rtok.id).exists()) + refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(id=rtok.access_token.id).exists()) def test_revoke_token_with_wrong_hint(self): From 281b46c250ab9d0e565490bbd75728cf4b9dad0c Mon Sep 17 00:00:00 2001 From: Phillip Baker Date: Thu, 15 Feb 2018 22:21:22 -0500 Subject: [PATCH 203/722] Prevent racecondition on consuming refresh token. --- oauth2_provider/models.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6836fe35f..07a6bdfcb 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -343,10 +343,18 @@ def revoke(self): Mark this refresh token revoked and revoke related access token """ access_token_model = get_access_token_model() - access_token_model.objects.get(id=self.access_token_id).revoke() - self.access_token = None - self.revoked = timezone.now() - self.save() + refresh_token_model = get_refresh_token_model() + with transaction.atomic(): + self = refresh_token_model.objects.filter( + pk=self.pk, revoked__isnull=True + ).select_for_update().first() + if not self: + return + + access_token_model.objects.get(id=self.access_token_id).revoke() + self.access_token = None + self.revoked = timezone.now() + self.save() def __str__(self): return self.token From c2ca9ccdfc0a0fb5e03ce4d83dafbf2e32545bd3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Mon, 19 Feb 2018 11:11:03 -0500 Subject: [PATCH 204/722] Implement basic auth for Introspection endpoint --- CHANGELOG.md | 4 +++ docs/resource_server.rst | 13 ++++++--- oauth2_provider/oauth2_validators.py | 41 +++++++++++++++++++++++----- oauth2_provider/settings.py | 1 + tests/test_introspection_auth.py | 6 ++-- 5 files changed, 52 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4611e0dad..52be33b7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### 1.1.0 [unreleased] + +* **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. + ### 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. diff --git a/docs/resource_server.rst b/docs/resource_server.rst index d0d5d2335..e19e542a8 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,7 +1,7 @@ Separate Resource Server ======================== Django OAuth Toolkit allows to separate the :term:`Authentication Server` and the :term:`Resource Server.` -Based one the `RFC 7662 `_ Django OAuth Toolkit provides +Based on the `RFC 7662 `_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. @@ -50,7 +50,8 @@ Example Response:: Setup the Resource Server ------------------------- Setup the :term:`Resource Server` like the :term:`Authentication Server` as described in the :ref:`tutorial`. -Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and ``RESOURCE_SERVER_AUTH_TOKEN`` to your settings. +Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` +**or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. The :term:`Resource Server` will try to verify its requests on the :term:`Authentication Server`. .. code-block:: python @@ -58,11 +59,15 @@ The :term:`Resource Server` will try to verify its requests on the :term:`Authen OAUTH2_PROVIDER = { ... 'RESOURCE_SERVER_INTROSPECTION_URL': 'https://example.org/o/introspect/', - 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', + 'RESOURCE_SERVER_AUTH_TOKEN': '3yUqsWtwKYKHnfivFcJu', # OR this but not both: + # 'RESOURCE_SERVER_INTROSPECTION_CREDENTIALS': ('rs_client_id','rs_client_secret'), ... } ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the :term:`Authentication Server`. - +As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. +For these, use: +``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead +of ``RESOURCE_SERVER_AUTH_TOKEN``. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index dd0cef903..2e941ce59 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -241,13 +241,37 @@ def validate_client_id(self, client_id, request, *args, **kwargs): def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri - def _get_token_from_authentication_server(self, token, introspection_url, introspection_token): - bearer = "Bearer {}".format(introspection_token) + def _get_token_from_authentication_server( + self, token, introspection_url, introspection_token, introspection_credentials + ): + """Use external introspection endpoint to "crack open" the token. + :param introspection_url: introspection endpoint URL + :param introspection_token: Bearer token + :param introspection_credentials: Basic Auth credentials (id,secret) + :return: :class:`models.AccessToken` + + Some RFC 7662 implementations (including this one) use a Bearer token while others use Basic + Auth. Depending on the external AS's implementation, provide either the introspection_token + or the introspection_credentials. + + If the resulting access_token identifies a username (e.g. Authorization Code grant), add + that user to the UserModel. Also cache the access_token up until its expiry time or a + configured maximum time. + + """ + headers = None + if introspection_token: + headers = {"Authorization": "Bearer {}".format(introspection_token)} + elif introspection_credentials: + client_id = introspection_credentials[0].encode("utf-8") + client_secret = introspection_credentials[1].encode("utf-8") + basic_auth = base64.b64encode(client_id + b":" + client_secret) + headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} try: response = requests.post( introspection_url, - data={"token": token}, headers={"Authorization": bearer} + data={"token": token}, headers=headers ) except requests.exceptions.RequestException: log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) @@ -307,16 +331,18 @@ def validate_bearer_token(self, token, scopes, request): introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS try: access_token = AccessToken.objects.select_related("application", "user").get(token=token) # if there is a token but invalid then look up the token - if introspection_url and introspection_token: + if introspection_url and (introspection_token or introspection_credentials): if not access_token.is_valid(scopes): access_token = self._get_token_from_authentication_server( token, introspection_url, - introspection_token + introspection_token, + introspection_credentials ) if access_token and access_token.is_valid(scopes): request.client = access_token.application @@ -329,11 +355,12 @@ def validate_bearer_token(self, token, scopes, request): return False except AccessToken.DoesNotExist: # there is no initial token, look up the token - if introspection_url and introspection_token: + if introspection_url and (introspection_token or introspection_credentials): access_token = self._get_token_from_authentication_server( token, introspection_url, - introspection_token + introspection_token, + introspection_credentials ) if access_token and access_token.is_valid(scopes): request.client = access_token.application diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 8799a3956..0bf03101f 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -62,6 +62,7 @@ # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, + "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, } diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 61fa2ec4d..b1537fc7f 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -129,7 +129,8 @@ def test_get_token_from_authentication_server_not_existing_token(self, mock_get) token = self.validator._get_token_from_authentication_server( self.resource_server_token.token, oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS ) self.assertIsNone(token) @@ -141,7 +142,8 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): token = self.validator._get_token_from_authentication_server( "foo", oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS ) self.assertIsInstance(token, AccessToken) self.assertEqual(token.user.username, "foo_user") From ab84df7e7e113e5b300e937c15a1dbad9346cc2f Mon Sep 17 00:00:00 2001 From: Tobias Gall Date: Sun, 18 Mar 2018 10:48:26 +0100 Subject: [PATCH 205/722] Don't require write scope for introspection Replacing `ReadWriteScopedResourceView` with `ScopedProtectedResourceView` fixes #555 --- .gitignore | 1 + oauth2_provider/views/introspect.py | 4 ++-- tests/test_introspection_auth.py | 2 +- tests/test_introspection_view.py | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index bf1a049e6..af644d1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,7 @@ pip-log.txt .cache .coverage .tox +.pytest_cache/ nosetests.xml # Translations diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 2fbaf2ce7..0f3780c8c 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -9,11 +9,11 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views import ReadWriteScopedResourceView +from oauth2_provider.views import ScopedProtectedResourceView @method_decorator(csrf_exempt, name="dispatch") -class IntrospectTokenView(ReadWriteScopedResourceView): +class IntrospectTokenView(ScopedProtectedResourceView): """ Implements an endpoint for token introspection based on RFC 7662 https://tools.ietf.org/html/rfc7662 diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index b1537fc7f..1c02c320c 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -96,7 +96,7 @@ def setUp(self): user=self.resource_server_user, token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write introspection" + scope="introspection" ) self.invalid_token = AccessToken.objects.create( diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 0d9b2ce74..4c2695a22 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -38,7 +38,7 @@ def setUp(self): user=self.resource_server_user, token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write introspection" + scope="introspection" ) self.valid_token = AccessToken.objects.create( From cf24e907aa7abb298a1ebc61497a70209b4c11fb Mon Sep 17 00:00:00 2001 From: Phil Ratcliffe Date: Tue, 20 Mar 2018 17:00:27 +0000 Subject: [PATCH 206/722] Updates to get example code to work with latest Django and DRF Modified the example code so it now works Django 2.0.3 and Django REST Framework 3.7.7 --- docs/rest-framework/getting_started.rst | 45 ++++++++++++------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index e929b2310..449dd206a 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -6,7 +6,7 @@ This tutorial is based on the Django REST Framework example and shows you how to **NOTE** -The following code has been tested with django 1.7.7 and Django REST Framework 3.1.1 +The following code has been tested with Django 2.0.3 and Django REST Framework 3.7.7 Step 1: Minimal setup --------------------- @@ -48,19 +48,22 @@ Here's our project's root `urls.py` module: .. code-block:: python - from django.conf.urls import url, include + from django.urls import path, include from django.contrib.auth.models import User, Group from django.contrib import admin admin.autodiscover() - from rest_framework import permissions, routers, serializers, viewsets + from rest_framework import generics, permissions, serializers from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope # first we define the serializers class UserSerializer(serializers.ModelSerializer): - class Meta: + class Metaclass UserList(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] + queryset = User.objects.all() + serializer_class = UserSerializer: model = User fields = ("username", "email", "first_name", "last_name", ) @@ -71,31 +74,27 @@ Here's our project's root `urls.py` module: fields = ("name", ) - # ViewSets define the view behavior. - class UserViewSet(viewsets.ModelViewSet): - permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] - queryset = User.objects.all() - serializer_class = UserSerializer - - - class GroupViewSet(viewsets.ModelViewSet): - permission_classes = [permissions.IsAuthenticated, TokenHasScope] - required_scopes = ['groups'] - queryset = Group.objects.all() - serializer_class = GroupSerializer - + # Create the API views + class UserList(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] + queryset = User.objects.all() + serializer_class = UserSerializer - # Routers provide an easy way of automatically determining the URL conf - router = routers.DefaultRouter() - router.register(r'users', UserViewSet) - router.register(r'groups', GroupViewSet) + class GroupList(generics.ListAPIView): + permission_classes = [permissions.IsAuthenticated, TokenHasScope] + required_scopes = ['groups'] + queryset = Group.objects.all() + serializer_class = GroupSerializer + # Wire up our API using automatic URL routing. # Additionally, we include login URLs for the browseable API. urlpatterns = [ - url(r'^', include(router.urls)), - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('admin/', admin.site.urls), + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('users/', UserList.as_view()), + path('groups/', GroupList.as_view()), # ... ] From 6cb3a7d3ab788b1699b5f42155e9fc2be0fcdb83 Mon Sep 17 00:00:00 2001 From: Phil Ratcliffe Date: Fri, 23 Mar 2018 11:26:22 +0000 Subject: [PATCH 207/722] Further update to DRF example --- docs/rest-framework/getting_started.rst | 31 +++++++++---------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 449dd206a..402644269 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -57,39 +57,30 @@ Here's our project's root `urls.py` module: from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope - # first we define the serializers class UserSerializer(serializers.ModelSerializer): - class Metaclass UserList(generics.ListAPIView): - permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] - queryset = User.objects.all() - serializer_class = UserSerializer: + class Meta: model = User - fields = ("username", "email", "first_name", "last_name", ) - + fields = ('username', 'email', "first_name", "last_name") class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group fields = ("name", ) - # Create the API views class UserList(generics.ListAPIView): - permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] - queryset = User.objects.all() - serializer_class = UserSerializer - + permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] + queryset = User.objects.all() + serializer_class = UserSerializer class GroupList(generics.ListAPIView): - permission_classes = [permissions.IsAuthenticated, TokenHasScope] - required_scopes = ['groups'] - queryset = Group.objects.all() - serializer_class = GroupSerializer - - - # Wire up our API using automatic URL routing. - # Additionally, we include login URLs for the browseable API. + permission_classes = [permissions.IsAuthenticated, TokenHasScope] + required_scopes = ['groups'] + queryset = Group.objects.all() + serializer_class = GroupSerializer + + # Setup the URLs and include login URLs for the browsable API. urlpatterns = [ path('admin/', admin.site.urls), path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), From ce9833255c4776558684c0f4c0ca5181b4da9299 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Sun, 8 Apr 2018 07:54:02 +0200 Subject: [PATCH 208/722] Added jazzband badges --- CONTRIBUTING.md | 4 ++++ README.rst | 2 ++ 2 files changed, 6 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f1703ac0d..49518f460 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,3 +1,7 @@ +[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) + +This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). + # Contribute to Django OAuth Toolkit Thanks for your interest, we love contributions! diff --git a/README.rst b/README.rst index 4592aa3f5..2b0c96add 100644 --- a/README.rst +++ b/README.rst @@ -1,6 +1,8 @@ Django OAuth Toolkit ==================== +[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) + *OAuth2 goodies for the Djangonauts!* .. image:: https://badge.fury.io/py/django-oauth-toolkit.png From 778bb975060c3983e915a2659c34c40a06092343 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Sun, 8 Apr 2018 08:20:05 +0200 Subject: [PATCH 209/722] Fixed jazzband badge in README.rst --- README.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 2b0c96add..8b7fcff1c 100644 --- a/README.rst +++ b/README.rst @@ -1,7 +1,9 @@ Django OAuth Toolkit ==================== -[![Jazzband](https://jazzband.co/static/img/badge.svg)](https://jazzband.co/) +.. image:: https://jazzband.co/static/img/badge.svg + :target: https://jazzband.co/ + :alt: Jazzband *OAuth2 goodies for the Djangonauts!* From 66bbcb81ad53c7a26a3a4173fe4606de5851be40 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Mon, 9 Apr 2018 12:35:02 +0300 Subject: [PATCH 210/722] Drop reference to Google Group The group is no more. Use the Github issue tracker. --- README.rst | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.rst b/README.rst index 8b7fcff1c..d8835baf4 100644 --- a/README.rst +++ b/README.rst @@ -27,11 +27,6 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o `OAuthLib `_, so that everything is `rfc-compliant `_. -Support -------- - -If you need support please send a message to the `Django OAuth Toolkit Google Group `_ - Contributing ------------ From 54c39f0f0b376629948a3f27febcad11e2ff6423 Mon Sep 17 00:00:00 2001 From: Phil Ratcliffe Date: Mon, 2 Apr 2018 12:30:21 +0100 Subject: [PATCH 211/722] Remove incorrect reference to requirements from docs The requirements files were deleted on 9th March 2017 when test requirements were moved to tox.ini. The issue of no requirements/optional.txt file was raised in issue 535, which can be closed if this PR is merged. --- docs/contributing.rst | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 6de828be3..fe613f990 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -9,7 +9,6 @@ Fork `django-oauth-toolkit` repository on `GitHub Date: Mon, 2 Oct 2017 19:59:40 -0400 Subject: [PATCH 212/722] Add client_id as a natural key to the default Application model Uses client_id as a natural key when serializing the default Application model, so that when things like fixtures are loaded, references are maintained even when the environment that the fixture is being loaded to doesn't have the same primary keys as the environment that the fixture dumped from. --- oauth2_provider/models.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 07a6bdfcb..18da57bb1 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -151,10 +151,20 @@ def is_usable(self, request): return True +class ApplicationManager(models.Manager): + def get_by_natural_key(self, client_id): + return self.get(client_id=client_id) + + class Application(AbstractApplication): + objects = ApplicationManager() + class Meta(AbstractApplication.Meta): swappable = "OAUTH2_PROVIDER_APPLICATION_MODEL" + def natural_key(self): + return (self.client_id,) + @python_2_unicode_compatible class AbstractGrant(models.Model): From 0531bd675f123ba091196113a8e35845f643f4d5 Mon Sep 17 00:00:00 2001 From: Rigel Trajano Date: Mon, 9 Apr 2018 09:41:47 +0000 Subject: [PATCH 213/722] Fix documentation grammar and spelling --- docs/advanced_topics.rst | 2 +- docs/rest-framework/permissions.rst | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 24a97c3f7..09e0d7e0b 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -28,7 +28,7 @@ logo, acceptance of some user agreement and so on. Django OAuth Toolkit lets you extend the AbstractApplication model in a fashion like Django's custom user models. -If you need, let's say, application logo and user agreement acceptance field, you can to this in +If you need, let's say, application logo and user agreement acceptance field, you can do this in your Django app (provided that your app is in the list of the INSTALLED_APPS in your settings module):: diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index d10c4a9b5..b84c0a0f3 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -11,7 +11,7 @@ More details on how to add custom permissions to your API Endpoints can be found TokenHasScope ------------- -The `TokenHasScope` permission class allows the access only when the current access token has been +The `TokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view. For example: @@ -29,13 +29,13 @@ The `required_scopes` attribute is mandatory. TokenHasReadWriteScope ---------------------- -The `TokenHasReadWriteScope` permission class allows the access based on the `READ_SCOPE` and `WRITE_SCOPE` configured in the settings. +The `TokenHasReadWriteScope` permission class allows access based on the `READ_SCOPE` and `WRITE_SCOPE` configured in the settings. When the current request's method is one of the "safe" methods `GET`, `HEAD`, `OPTIONS` the access is allowed only if the access token has been authorized for the `READ_SCOPE` scope. When the request's method is one of `POST`, `PUT`, `PATCH`, `DELETE` the access is allowed if the access token has been authorized for the `WRITE_SCOPE`. -The `required_scopes` attribute is optional and can be used to other scopes needed by the view. +The `required_scopes` attribute is optional and can be used by other scopes needed in the view. For example: @@ -50,10 +50,10 @@ When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' s TokenHasResourceScope ---------------------- -The `TokenHasResourceScope` permission class allows the access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. +The `TokenHasResourceScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. When the current request's method is one of the "safe" methods, the access is allowed only if the access token has been authorized for the `scope:read` scope (for example `music:read`). -When the request's method is one of "non safe" methods, the access is allowed only if the access token has been authorizes for the `scope:write` scope (for example `music:write`). +When the request's method is one of "non safe" methods, the access is allowed only if the access token has been authorized for the `scope:write` scope (for example `music:write`). .. code-block:: python @@ -67,9 +67,9 @@ The `required_scopes` attribute is mandatory (you just need inform the resource IsAuthenticatedOrTokenHasScope ------------------------------ -The `TokenHasResourceScope` permission class allows the access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. -And also allows access to Authenticated users who are authenticated in django, but were not authenticated trought the OAuth2Authentication class. -This allows for protection of the api using scopes, but still let's users browse the full browseable API. +The `IsAuthenticatedOrTokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according to the request's method. +It also allows access to Authenticated users who are authenticated in django, but were not authenticated through the OAuth2Authentication class. +This allows for protection of the API using scopes, but still let's users browse the full browseable API. To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. For example: From f85d5c29ad9592f04249afb450b92807a1cc0fc2 Mon Sep 17 00:00:00 2001 From: Phil Ratcliffe Date: Sun, 1 Apr 2018 09:27:23 +0100 Subject: [PATCH 214/722] Add support for detail and create views in DRF sample code Update DRF sample code to support getting a User instance and also creating a new User. --- docs/rest-framework/getting_started.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 402644269..3d3b07620 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -69,7 +69,12 @@ Here's our project's root `urls.py` module: fields = ("name", ) # Create the API views - class UserList(generics.ListAPIView): + class UserList(generics.ListCreateAPIView): + permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] + queryset = User.objects.all() + serializer_class = UserSerializer + + class UserDetails(generics.RetrieveAPIView): permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] queryset = User.objects.all() serializer_class = UserSerializer @@ -85,6 +90,7 @@ Here's our project's root `urls.py` module: path('admin/', admin.site.urls), path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), path('users/', UserList.as_view()), + path('users//', UserDetails.as_view()), path('groups/', GroupList.as_view()), # ... ] From e8b09f89cc609e71904427cc35d6edefd8d29924 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Mon, 9 Apr 2018 12:43:54 +0300 Subject: [PATCH 215/722] Update Django version reference in README Closes #572 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index d8835baf4..7ccbde528 100644 --- a/README.rst +++ b/README.rst @@ -47,7 +47,7 @@ Requirements ------------ * Python 2.7, 3.4, 3.5, 3.6 -* Django 1.10, 1.11 +* Django 1.11, 2.0 Installation ------------ From 2dc377dc21da3ac1797d551075c0ed2e5a1adf8a Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 10 Apr 2018 13:26:03 +0300 Subject: [PATCH 216/722] Update security notice --- README.rst | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 7ccbde528..3c4980b7f 100644 --- a/README.rst +++ b/README.rst @@ -36,12 +36,7 @@ guidelines . Do not file an issue on the tracker. Requirements ------------ From 30afef207cbf3776204a9227409dfef3f9d735f1 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Tue, 10 Apr 2018 16:02:06 +0200 Subject: [PATCH 217/722] Fixed travis and changelog links in README.rst --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 3c4980b7f..153c67475 100644 --- a/README.rst +++ b/README.rst @@ -10,9 +10,9 @@ Django OAuth Toolkit .. image:: https://badge.fury.io/py/django-oauth-toolkit.png :target: http://badge.fury.io/py/django-oauth-toolkit -.. image:: https://travis-ci.org/evonove/django-oauth-toolkit.png +.. image:: https://travis-ci.org/jazzband/django-oauth-toolkit.png :alt: Build Status - :target: https://travis-ci.org/evonove/django-oauth-toolkit + :target: https://travis-ci.org/jazzband/django-oauth-toolkit .. image:: https://coveralls.io/repos/evonove/django-oauth-toolkit/badge.png :alt: Coverage Status @@ -74,7 +74,7 @@ Notice that `oauth2_provider` namespace is mandatory. Changelog --------- -See `CHANGELOG.md `_. +See `CHANGELOG.md `_. Documentation From cf68355f150d0b70c97634e546f88abbf29c1413 Mon Sep 17 00:00:00 2001 From: Federico Frenguelli Date: Tue, 10 Apr 2018 16:09:56 +0200 Subject: [PATCH 218/722] Fixed old urls --- README.rst | 5 +++-- docs/advanced_topics.rst | 6 +++--- docs/contributing.rst | 6 +++--- docs/settings.rst | 2 +- setup.cfg | 4 ++-- tests/settings.py | 2 +- tests/test_authorization_code.py | 2 +- tests/test_models.py | 2 +- 8 files changed, 15 insertions(+), 14 deletions(-) diff --git a/README.rst b/README.rst index 153c67475..2df9e2aff 100644 --- a/README.rst +++ b/README.rst @@ -14,9 +14,10 @@ Django OAuth Toolkit :alt: Build Status :target: https://travis-ci.org/jazzband/django-oauth-toolkit -.. image:: https://coveralls.io/repos/evonove/django-oauth-toolkit/badge.png +.. image:: https://coveralls.io/repos/github/jazzband/django-oauth-toolkit/badge.svg?branch=master :alt: Coverage Status - :target: https://coveralls.io/r/evonove/django-oauth-toolkit + :target: https://coveralls.io/github/jazzband/django-oauth-toolkit?branch=master + If you are facing one or more of the following: * Your Django app exposes a web API you want to protect with OAuth2 authentication, diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 09e0d7e0b..ea65cbe50 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -44,15 +44,15 @@ Write something like this in your settings module:: OAUTH2_PROVIDER_APPLICATION_MODEL='your_app_name.MyApplication' -Be aware that, when you intend to swap the application model, you should create and run the -migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. +Be aware that, when you intend to swap the application model, you should create and run the +migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. You'll run into models.E022 in Core system checks if you don't get the order right. That's all, now Django OAuth Toolkit will use your model wherever an Application instance is needed. **Notice:** `OAUTH2_PROVIDER_APPLICATION_MODEL` is the only setting variable that is not namespaced, this is because of the way Django currently implements swappable models. - See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) for details + See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details Multiple Grants ~~~~~~~~~~~~~~~ diff --git a/docs/contributing.rst b/docs/contributing.rst index fe613f990..48cb043ed 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -5,7 +5,7 @@ Contributing Setup ===== -Fork `django-oauth-toolkit` repository on `GitHub `_ and follow these steps: +Fork `django-oauth-toolkit` repository on `GitHub `_ and follow these steps: * Create a virtualenv and activate it * Clone your repository locally @@ -14,7 +14,7 @@ Issues ====== You can find the list of bugs, enhancements and feature requests on the -`issue tracker `_. If you want to fix an issue, pick up one and +`issue tracker `_. If you want to fix an issue, pick up one and add a comment stating you're working on it. If the resolution implies a discussion or if you realize the comments on the issue are growing pretty fast, move the discussion to the `Google Group `_. @@ -51,7 +51,7 @@ outdated code and your changes diverge too far from master, the pull request has To pull in upstream changes:: - git remote add upstream https://github.com/evonove/django-oauth-toolkit.git + git remote add upstream https://github.com/jazzband/django-oauth-toolkit.git git fetch upstream Then merge the changes that you fetched:: diff --git a/docs/settings.rst b/docs/settings.rst index d7d07c4fe..091d9c1fd 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -4,7 +4,7 @@ Settings Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the exception of `OAUTH2_PROVIDER_APPLICATION_MODEL, OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, OAUTH2_PROVIDER_GRANT_MODEL, OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL`: this is because of the way Django currently implements -swappable models. See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) for details. +swappable models. See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details. For example: diff --git a/setup.cfg b/setup.cfg index 672f0fe05..82a12e102 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,8 +4,8 @@ version = 1.0.0 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com -url = https://github.com/evonove/django-oauth-toolkit -download_url = https://github.com/evonove/django-oauth-toolkit/tarball/master +url = https://github.com/jazzband/django-oauth-toolkit +download_url = https://github.com/jazzband/django-oauth-toolkit/tarball/master keywords = django, oauth, oauth2, oauthlib classifiers = Development Status :: 5 - Production/Stable diff --git a/tests/settings.py b/tests/settings.py index b7a228262..5e145ac3b 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -35,7 +35,7 @@ ) # Make this unique, and don"t share it with anybody. -SECRET_KEY = "1234567890evonove" +SECRET_KEY = "1234567890jazzband" TEMPLATES = [ { diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 0a7f82c59..85829df12 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -460,7 +460,7 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ Test that in case of error the querystring of the redirection uri is preserved - See https://github.com/evonove/django-oauth-toolkit/issues/238 + See https://github.com/jazzband/django-oauth-toolkit/issues/238 """ self.client.login(username="test_user", password="123456") diff --git a/tests/test_models.py b/tests/test_models.py index 083cf1357..13afb09e5 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -128,7 +128,7 @@ def test_custom_application_model(self): If a custom application model is installed, it should be present in the related objects and not the swapped out one. - See issue #90 (https://github.com/evonove/django-oauth-toolkit/issues/90) + See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ f.name for f in UserModel._meta.get_fields() From fa48540691742dcd7ecded8ed371b7c008473ebe Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Mon, 9 Apr 2018 12:48:49 +0300 Subject: [PATCH 219/722] Implement per-application "allowed schemes" functionality --- CHANGELOG.md | 2 + docs/settings.rst | 4 ++ oauth2_provider/http.py | 36 +++++++++++- oauth2_provider/models.py | 11 +++- oauth2_provider/views/base.py | 107 +++++++++++++++++++--------------- tests/models.py | 9 +++ 6 files changed, 118 insertions(+), 51 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52be33b7e..78559e4ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,8 @@ ### 1.1.0 [unreleased] * **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. +* **New feature**: Individual applications may now override the `ALLOWED_REDIRECT_URI_SCHEMES` + setting by returning a list of allowed redirect uri schemes in `Application.get_allowed_schemes()`. ### 1.0.0 [2017-06-07] diff --git a/docs/settings.rst b/docs/settings.rst index 091d9c1fd..506d57d3e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -47,6 +47,10 @@ Default: ``["http", "https"]`` A list of schemes that the ``redirect_uri`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. +Note that you may override ``Application.get_allowed_schemes()`` to set this on +a per-application basis. + + APPLICATION_MODEL ~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your applications. Overwrite diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py index 2621a6cca..781f2f8d2 100644 --- a/oauth2_provider/http.py +++ b/oauth2_provider/http.py @@ -1,7 +1,37 @@ -from django.http import HttpResponseRedirect +from django.core.exceptions import DisallowedRedirect +from django.http import HttpResponse +from django.utils.encoding import iri_to_uri +from .compat import urlparse + + +class OAuth2ResponseRedirect(HttpResponse): + """ + An HTTP 302 redirect with an explicit list of allowed schemes. + Works like django.http.HttpResponseRedirect but we customize it + to give us more flexibility on allowed scheme validation. + """ + status_code = 302 -class HttpResponseUriRedirect(HttpResponseRedirect): def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): + super(OAuth2ResponseRedirect, self).__init__(*args, **kwargs) + self["Location"] = iri_to_uri(redirect_to) self.allowed_schemes = allowed_schemes - super(HttpResponseUriRedirect, self).__init__(redirect_to, *args, **kwargs) + self.validate_redirect(redirect_to) + + @property + def url(self): + return self["Location"] + + def validate_redirect(self, redirect_to): + parsed = urlparse(str(redirect_to)) + if not parsed.scheme: + raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") + if parsed.scheme not in self.allowed_schemes: + raise DisallowedRedirect( + "Redirect to scheme {!r} is not permitted".format(parsed.scheme) + ) + + +# Backwards compatibility (as of 1.0.0) +HttpResponseUriRedirect = OAuth2ResponseRedirect diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 18da57bb1..a4576a720 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -87,6 +87,9 @@ class AbstractApplication(models.Model): class Meta: abstract = True + def __str__(self): + return self.name or self.client_id + @property def default_redirect_uri(self): """ @@ -136,8 +139,12 @@ def clean(self): def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) - def __str__(self): - return self.name or self.client_id + def get_allowed_schemes(self): + """ + Returns the list of redirect schemes allowed by the Application. + By default, returns `ALLOWED_REDIRECT_URI_SCHEMES`. + """ + return oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES def allows_grant_type(self, *grant_types): return self.authorization_grant_type in grant_types diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 69eb87a74..40c6e662c 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -11,7 +11,7 @@ from ..exceptions import OAuthToolkitError from ..forms import AllowForm -from ..http import HttpResponseUriRedirect +from ..http import OAuth2ResponseRedirect from ..models import get_access_token_model, get_application_model from ..scopes import get_scopes_backend from ..settings import oauth2_settings @@ -36,7 +36,7 @@ def dispatch(self, request, *args, **kwargs): self.oauth2_data = {} return super(BaseAuthorizationView, self).dispatch(request, *args, **kwargs) - def error_response(self, error, **kwargs): + def error_response(self, error, application, **kwargs): """ Handle errors either by redirecting to redirect_uri with a json in the body containing error details or providing an error response @@ -44,14 +44,19 @@ def error_response(self, error, **kwargs): redirect, error_response = super(BaseAuthorizationView, self).error_response(error, **kwargs) if redirect: - return self.redirect(error_response["url"]) + return self.redirect(error_response["url"], application) status = error_response["error"].status_code return self.render_to_response(error_response, status=status) - def redirect(self, redirect_to): - allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES - return HttpResponseUriRedirect(redirect_to, allowed_schemes) + def redirect(self, redirect_to, application): + if application is None: + # The application can be None in case of an error during app validation + # In such cases, fall back to default ALLOWED_REDIRECT_URI_SCHEMES + allowed_schemes = oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES + else: + allowed_schemes = application.get_allowed_schemes() + return OAuth2ResponseRedirect(redirect_to, allowed_schemes) class AuthorizationView(BaseAuthorizationView, FormView): @@ -96,51 +101,59 @@ def get_initial(self): return initial_data def form_valid(self, form): + client_id = form.cleaned_data["client_id"] + application = get_application_model().objects.get(client_id=client_id) + credentials = { + "client_id": form.cleaned_data.get("client_id"), + "redirect_uri": form.cleaned_data.get("redirect_uri"), + "response_type": form.cleaned_data.get("response_type", None), + "state": form.cleaned_data.get("state", None), + } + scopes = form.cleaned_data.get("scope") + allow = form.cleaned_data.get("allow") + try: - credentials = { - "client_id": form.cleaned_data.get("client_id"), - "redirect_uri": form.cleaned_data.get("redirect_uri"), - "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None), - } - - scopes = form.cleaned_data.get("scope") - allow = form.cleaned_data.get("allow") uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=scopes, credentials=credentials, allow=allow) - self.success_url = uri - log.debug("Success url for the request: {0}".format(self.success_url)) - return self.redirect(self.success_url) - + request=self.request, scopes=scopes, credentials=credentials, allow=allow + ) except OAuthToolkitError as error: - return self.error_response(error) + return self.error_response(error, application) + + self.success_url = uri + log.debug("Success url for the request: {0}".format(self.success_url)) + return self.redirect(self.success_url, application) def get(self, request, *args, **kwargs): try: scopes, credentials = self.validate_authorization_request(request) - all_scopes = get_scopes_backend().get_all_scopes() - kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] - kwargs["scopes"] = scopes - # at this point we know an Application instance with such client_id exists in the database + except OAuthToolkitError as error: + # Application is not available at this time. + return self.error_response(error, application=None) - # TODO: Cache this! - application = get_application_model().objects.get(client_id=credentials["client_id"]) + all_scopes = get_scopes_backend().get_all_scopes() + kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] + kwargs["scopes"] = scopes + # at this point we know an Application instance with such client_id exists in the database - kwargs["application"] = application - kwargs["client_id"] = credentials["client_id"] - kwargs["redirect_uri"] = credentials["redirect_uri"] - kwargs["response_type"] = credentials["response_type"] - kwargs["state"] = credentials["state"] + # TODO: Cache this! + application = get_application_model().objects.get(client_id=credentials["client_id"]) - self.oauth2_data = kwargs - # following two loc are here only because of https://code.djangoproject.com/ticket/17795 - form = self.get_form(self.get_form_class()) - kwargs["form"] = form + kwargs["application"] = application + kwargs["client_id"] = credentials["client_id"] + kwargs["redirect_uri"] = credentials["redirect_uri"] + kwargs["response_type"] = credentials["response_type"] + kwargs["state"] = credentials["state"] - # Check to see if the user has already granted access and return - # a successful response depending on "approval_prompt" url parameter - require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + self.oauth2_data = kwargs + # following two loc are here only because of https://code.djangoproject.com/ticket/17795 + form = self.get_form(self.get_form_class()) + kwargs["form"] = form + # Check to see if the user has already granted access and return + # a successful response depending on "approval_prompt" url parameter + require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + + try: # If skip_authorization field is True, skip the authorization screen even # if this is the first use of the application and there was no previous authorization. # This is useful for in-house applications-> assume an in-house applications @@ -148,8 +161,9 @@ def get(self, request, *args, **kwargs): if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True) - return self.redirect(uri) + credentials=credentials, allow=True + ) + return self.redirect(uri, application) elif require_approval == "auto": tokens = get_access_token_model().objects.filter( @@ -163,13 +177,14 @@ def get(self, request, *args, **kwargs): if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True) - return self.redirect(uri) - - return self.render_to_response(self.get_context_data(**kwargs)) + credentials=credentials, allow=True + ) + return self.redirect(uri, application) except OAuthToolkitError as error: - return self.error_response(error) + return self.error_response(error, application) + + return self.render_to_response(self.get_context_data(**kwargs)) @method_decorator(csrf_exempt, name="dispatch") diff --git a/tests/models.py b/tests/models.py index 1c57d2e63..8b78e77af 100644 --- a/tests/models.py +++ b/tests/models.py @@ -6,6 +6,15 @@ ) +class BaseTestApplication(AbstractApplication): + allowed_schemes = models.TextField(blank=True) + + def get_allowed_schemes(self): + if self.allowed_schemes: + return self.allowed_schemes.split() + return super(BaseTestApplication, self).get_allowed_schemes() + + class SampleApplication(AbstractApplication): custom_field = models.CharField(max_length=255) From 35f1612ee705dbfe91d838e016329de417b4f9ae Mon Sep 17 00:00:00 2001 From: Christopher D'Cunha Date: Thu, 12 Apr 2018 17:27:11 +0100 Subject: [PATCH 220/722] Return 'error' and 'error_description' via request object --- oauth2_provider/oauth2_validators.py | 28 +++++++ tests/test_oauth2_validators.py | 120 +++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 2e941ce59..6eaddaade 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -4,6 +4,7 @@ import binascii import logging from datetime import datetime, timedelta +from collections import OrderedDict import requests from django.conf import settings @@ -13,6 +14,7 @@ from django.db.models import Q from django.utils import timezone from django.utils.timezone import make_aware +from django.utils.translation import ugettext_lazy as _ from oauthlib.oauth2 import RequestValidator from .compat import unquote_plus @@ -155,6 +157,30 @@ def _load_application(self, client_id, request): log.debug("Failed body authentication: Application %r does not exist" % (client_id)) return None + def _set_oauth2_error_on_request(self, request, access_token, scopes): + if access_token is None: + error = OrderedDict([ + ("error", "invalid_token", ), + ("error_description", _("The access token is invalid."), ), + ]) + elif access_token.is_expired(): + error = OrderedDict([ + ("error", "invalid_token", ), + ("error_description", _("The access token has expired."), ), + ]) + elif not access_token.allow_scopes(scopes): + error = OrderedDict([ + ("error", "insufficient_scope", ), + ("error_description", _("The access token is valid but does not have enough scope."), ), + ]) + else: + log.warning("OAuth2 access token is invalid for an unknown reason.") + error = OrderedDict([ + ("error", "invalid_token", ), + ]) + request.oauth2_error = error + return request + def client_authentication_required(self, request, *args, **kwargs): """ Determine if the client has to be authenticated @@ -352,6 +378,7 @@ def validate_bearer_token(self, token, scopes, request): # this is needed by django rest framework request.access_token = access_token return True + self._set_oauth2_error_on_request(request, access_token, scopes) return False except AccessToken.DoesNotExist: # there is no initial token, look up the token @@ -370,6 +397,7 @@ def validate_bearer_token(self, token, scopes, request): # this is needed by django rest framework request.access_token = access_token return True + self._set_oauth2_error_on_request(request, None, scopes) return False def validate_code(self, client_id, code, client, request, *args, **kwargs): diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index d8ab2349b..4f25b34d7 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,4 +1,5 @@ import datetime +import contextlib from django.contrib.auth import get_user_model from django.test import TransactionTestCase @@ -24,6 +25,19 @@ RefreshToken = get_refresh_token_model() +@contextlib.contextmanager +def always_invalid_token(): + # NOTE: This can happen if someone swaps the AccessToken model and + # updates `is_valid` such that it has some criteria on top of + # `is_expired` and `allow_scopes`. + original_is_valid = AccessToken.is_valid + AccessToken.is_valid = mock.MagicMock(return_value=False) + try: + yield + finally: + AccessToken.is_valid = original_is_valid + + class TestOAuth2Validator(TransactionTestCase): def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") @@ -249,3 +263,109 @@ def test_save_bearer_token__with_no_refresh_token__creates_new_access_token_only self.assertEqual(0, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) + + +class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): + """These test cases check that the recommended error codes are returned + when token authentication fails. + + RFC-6750: https://tools.ietf.org/html/rfc6750 + + > If the protected resource request does not include authentication + > credentials or does not contain an access token that enables access + > to the protected resource, the resource server MUST include the HTTP + > "WWW-Authenticate" response header field[.] + > + > ... + > + > If the request lacks any authentication information..., the + > resource server SHOULD NOT include an error code or other error + > information. + > + > ... + > + > If the protected resource request included an access token and failed + > authentication, the resource server SHOULD include the "error" + > attribute to provide the client with the reason why the access + > request was declined. + + See https://tools.ietf.org/html/rfc6750#section-3.1 for the allowed error + codes. + """ + + def setUp(self): + self.user = UserModel.objects.create_user( + "user", "test@example.com", "123456", + ) + self.request = mock.MagicMock(wraps=Request) + self.request.user = self.user + self.request.grant_type = "not client" + self.validator = OAuth2Validator() + self.application = Application.objects.create( + client_id="client_id", + client_secret="client_secret", + user=self.user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, + ) + self.request.client = self.application + + def test_validate_bearer_token_does_not_add_error_when_no_token_is_provided(self): + self.assertFalse(self.validator.validate_bearer_token(None, ["dolphin"], self.request)) + with self.assertRaises(AttributeError): + self.request.oauth2_error + + def test_validate_bearer_token_adds_error_to_the_request_when_an_invalid_token_is_provided(self): + access_token = mock.MagicMock(token="some_invalid_token") + self.assertFalse(self.validator.validate_bearer_token( + access_token.token, [], self.request, + )) + self.assertDictEqual(self.request.oauth2_error, { + "error": "invalid_token", + "error_description": "The access token is invalid.", + }) + + def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_is_provided(self): + access_token = AccessToken.objects.create( + token="some_valid_token", + user=self.user, + expires=timezone.now() - datetime.timedelta(seconds=1), + application=self.application, + ) + self.assertFalse(self.validator.validate_bearer_token( + access_token.token, [], self.request, + )) + self.assertDictEqual(self.request.oauth2_error, { + "error": "invalid_token", + "error_description": "The access token has expired.", + }) + + def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_insufficient_scope(self): + access_token = AccessToken.objects.create( + token="some_valid_token", + user=self.user, + expires=timezone.now() + datetime.timedelta(seconds=1), + application=self.application, + ) + self.assertFalse(self.validator.validate_bearer_token( + access_token.token, ["some_extra_scope"], self.request, + )) + self.assertDictEqual(self.request.oauth2_error, { + "error": "insufficient_scope", + "error_description": "The access token is valid but does not have enough scope.", + }) + + def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_token_is_provided(self): + access_token = AccessToken.objects.create( + token="some_valid_token", + user=self.user, + expires=timezone.now() + datetime.timedelta(seconds=1), + application=self.application, + ) + with always_invalid_token(): + self.assertFalse(self.validator.validate_bearer_token( + access_token.token, [], self.request, + )) + self.assertDictEqual(self.request.oauth2_error, { + "error": "invalid_token", + }) From 8d84582f1a67c6a1f75c772e05e764e596f04c91 Mon Sep 17 00:00:00 2001 From: Christopher D'Cunha Date: Thu, 12 Apr 2018 18:42:43 +0100 Subject: [PATCH 221/722] Return error attributes in WWW-Authenticate header --- .../contrib/rest_framework/authentication.py | 23 ++++++++++++++++--- tests/test_rest_framework.py | 13 +++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 2383078a3..30a2d52c4 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from rest_framework.authentication import BaseAuthentication from ...oauth2_backends import get_oauthlib_core @@ -9,6 +11,15 @@ class OAuth2Authentication(BaseAuthentication): """ www_authenticate_realm = "api" + def _dict_to_string(self, my_dict): + """ + Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). + """ + return ",".join([ + '{k}="{v}"'.format(k=k, v=v) + for k, v in my_dict.items() + ]) + def authenticate(self, request): """ Returns two-tuple of (user, token) if authentication succeeds, @@ -18,11 +29,17 @@ def authenticate(self, request): valid, r = oauthlib_core.verify_request(request, scopes=[]) if valid: return r.user, r.access_token - else: - return None + request.oauth2_error = getattr(r, "oauth2_error", {}) + return None def authenticate_header(self, request): """ Bearer is the only finalized type currently """ - return 'Bearer realm="%s"' % self.www_authenticate_realm + www_authenticate_attributes = OrderedDict([ + ("realm", self.www_authenticate_realm,), + ]) + www_authenticate_attributes.update(request.oauth2_error) + return "Bearer {attributes}".format( + attributes=self._dict_to_string(www_authenticate_attributes), + ) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 71fbda072..df726df20 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -111,9 +111,22 @@ def test_authentication_allow(self): @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_denied(self): + response = self.client.get("/oauth2-test/") + self.assertEqual(response.status_code, 401) + self.assertEqual( + response["WWW-Authenticate"], + 'Bearer realm="api"', + ) + + @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") + def test_authentication_denied_because_of_invalid_token(self): auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) + self.assertEqual( + response["WWW-Authenticate"], + 'Bearer realm="api",error="invalid_token",error_description="The access token is invalid."', + ) @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_or_scope_denied(self): From dfd6a8cd92bc6d278dd3a66a8353e04793f10fe5 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 01:56:18 +0300 Subject: [PATCH 222/722] Require Django Rest Framework to run the tests --- tests/test_rest_framework.py | 116 +++++++++++++++-------------------- 1 file changed, 49 insertions(+), 67 deletions(-) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index df726df20..d5a18bf23 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,4 +1,3 @@ -import unittest from datetime import timedelta from django.conf.urls import include, url @@ -7,7 +6,14 @@ from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone - +from rest_framework import permissions +from rest_framework.test import APIRequestFactory, force_authenticate +from rest_framework.views import APIView + +from oauth2_provider.contrib.rest_framework import ( + IsAuthenticatedOrTokenHasScope, OAuth2Authentication, + TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope +) from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings @@ -23,54 +29,47 @@ UserModel = get_user_model() -try: - from rest_framework import permissions - from rest_framework.views import APIView - from rest_framework.test import force_authenticate, APIRequestFactory - from oauth2_provider.contrib.rest_framework import ( - IsAuthenticatedOrTokenHasScope, OAuth2Authentication, TokenHasScope, - TokenHasReadWriteScope, TokenHasResourceScope - ) - - class MockView(APIView): - permission_classes = (permissions.IsAuthenticated,) - - def get(self, request): - return HttpResponse({"a": 1, "b": 2, "c": 3}) - - def post(self, request): - return HttpResponse({"a": 1, "b": 2, "c": 3}) - - class OAuth2View(MockView): - authentication_classes = [OAuth2Authentication] - - class ScopedView(OAuth2View): - permission_classes = [permissions.IsAuthenticated, TokenHasScope] - required_scopes = ["scope1"] - - class AuthenticatedOrScopedView(OAuth2View): - permission_classes = [IsAuthenticatedOrTokenHasScope] - required_scopes = ["scope1"] - - class ReadWriteScopedView(OAuth2View): - permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] - - class ResourceScopedView(OAuth2View): - permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope] - required_scopes = ["resource1"] - - urlpatterns = [ - url(r"^oauth2/", include("oauth2_provider.urls")), - url(r"^oauth2-test/$", OAuth2View.as_view()), - url(r"^oauth2-scoped-test/$", ScopedView.as_view()), - url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), - url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), - url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), - ] - - rest_framework_installed = True -except ImportError: - rest_framework_installed = False +class MockView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def get(self, request): + return HttpResponse({"a": 1, "b": 2, "c": 3}) + + def post(self, request): + return HttpResponse({"a": 1, "b": 2, "c": 3}) + + +class OAuth2View(MockView): + authentication_classes = [OAuth2Authentication] + + +class ScopedView(OAuth2View): + permission_classes = [permissions.IsAuthenticated, TokenHasScope] + required_scopes = ["scope1"] + + +class AuthenticatedOrScopedView(OAuth2View): + permission_classes = [IsAuthenticatedOrTokenHasScope] + required_scopes = ["scope1"] + + +class ReadWriteScopedView(OAuth2View): + permission_classes = [permissions.IsAuthenticated, TokenHasReadWriteScope] + + +class ResourceScopedView(OAuth2View): + permission_classes = [permissions.IsAuthenticated, TokenHasResourceScope] + required_scopes = ["resource1"] + + +urlpatterns = [ + url(r"^oauth2/", include("oauth2_provider.urls")), + url(r"^oauth2-test/$", OAuth2View.as_view()), + url(r"^oauth2-scoped-test/$", ScopedView.as_view()), + url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), + url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), + url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), +] @override_settings(ROOT_URLCONF=__name__) @@ -103,13 +102,11 @@ def tearDown(self): def _create_authorization_header(self, token): return "Bearer {0}".format(token) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_allow(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_denied(self): response = self.client.get("/oauth2-test/") self.assertEqual(response.status_code, 401) @@ -118,7 +115,6 @@ def test_authentication_denied(self): 'Bearer realm="api"', ) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_denied_because_of_invalid_token(self): auth = self._create_authorization_header("fake-token") response = self.client.get("/oauth2-test/", HTTP_AUTHORIZATION=auth) @@ -128,7 +124,6 @@ def test_authentication_denied_because_of_invalid_token(self): 'Bearer realm="api",error="invalid_token",error_description="The access token is invalid."', ) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authentication_or_scope_denied(self): # user is not authenticated # not a correct token @@ -146,7 +141,6 @@ def test_authentication_or_scope_denied(self): # authenticated but wrong scope, this is 403, not 401 self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_scoped_permission_allow(self): self.access_token.scope = "scope1" self.access_token.save() @@ -155,7 +149,6 @@ def test_scoped_permission_allow(self): response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_authenticated_or_scoped_permission_allow(self): self.access_token.scope = "scope1" self.access_token.save() @@ -182,7 +175,6 @@ def test_authenticated_or_scoped_permission_allow(self): response = AuthenticatedOrScopedView.as_view()(request) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_scoped_permission_deny(self): self.access_token.scope = "scope2" self.access_token.save() @@ -191,7 +183,6 @@ def test_scoped_permission_deny(self): response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_get_allow(self): self.access_token.scope = "read" self.access_token.save() @@ -200,7 +191,6 @@ def test_read_write_permission_get_allow(self): response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_post_allow(self): self.access_token.scope = "write" self.access_token.save() @@ -209,7 +199,6 @@ def test_read_write_permission_post_allow(self): response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_get_deny(self): self.access_token.scope = "write" self.access_token.save() @@ -218,7 +207,6 @@ def test_read_write_permission_get_deny(self): response = self.client.get("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_read_write_permission_post_deny(self): self.access_token.scope = "read" self.access_token.save() @@ -227,7 +215,6 @@ def test_read_write_permission_post_deny(self): response = self.client.post("/oauth2-read-write-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_get_allow(self): self.access_token.scope = "resource1:read" self.access_token.save() @@ -236,7 +223,6 @@ def test_resource_scoped_permission_get_allow(self): response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_post_allow(self): self.access_token.scope = "resource1:write" self.access_token.save() @@ -245,7 +231,6 @@ def test_resource_scoped_permission_post_allow(self): response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_get_denied(self): self.access_token.scope = "resource1:write" self.access_token.save() @@ -254,7 +239,6 @@ def test_resource_scoped_permission_get_denied(self): response = self.client.get("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_resource_scoped_permission_post_denied(self): self.access_token.scope = "resource1:read" self.access_token.save() @@ -263,7 +247,6 @@ def test_resource_scoped_permission_post_denied(self): response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") @mock.patch.object(oauth2_settings, "ERROR_RESPONSE_WITH_SCOPES", new=True) def test_required_scope_in_response(self): self.access_token.scope = "scope2" @@ -274,7 +257,6 @@ def test_required_scope_in_response(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.data["required_scopes"], ["scope1"]) - @unittest.skipUnless(rest_framework_installed, "djangorestframework not installed") def test_required_scope_not_in_response_by_default(self): self.access_token.scope = "scope2" self.access_token.save() From 09d2569ebd2c5c41433bc3ea4833beba793ac972 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 02:04:00 +0300 Subject: [PATCH 223/722] Update CHANGELOG.md --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78559e4ff..6c32a0062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,16 @@ ### 1.1.0 [unreleased] +* **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. +* **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. +* **Compatibility**: This will be the last release to support Django 1.11 and Python 2.7. * **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. * **New feature**: Individual applications may now override the `ALLOWED_REDIRECT_URI_SCHEMES` setting by returning a list of allowed redirect uri schemes in `Application.get_allowed_schemes()`. +* **New feature**: The new setting `ERROR_RESPONSE_WITH_SCOPES` can now be set to True to include required + scopes when DRF authorization fails due to improper scopes. +* **New feature**: The new setting `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` controls a grace period during which + refresh tokens may be re-used. +* An `app_authorized` signal is fired when a token is generated. ### 1.0.0 [2017-06-07] From e7c35583ec9388afdfe28bab2910d396914345df Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 02:08:44 +0300 Subject: [PATCH 224/722] Release 1.1.0 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 82a12e102..84305a805 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.0.0 +version = 1.1.0 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com From f652646d870be3d3ffed14dee9893365100ab39d Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 02:49:50 +0300 Subject: [PATCH 225/722] Fix unnecessary variable member --- oauth2_provider/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a4576a720..e7823354e 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -67,9 +67,9 @@ class AbstractApplication(models.Model): null=True, blank=True, on_delete=models.CASCADE ) - help_text = _("Allowed URIs list, space separated") redirect_uris = models.TextField( - blank=True, help_text=help_text, validators=[validate_uris] + blank=True, help_text=_("Allowed URIs list, space separated"), + validators=[validate_uris] ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField( From d40624033824166a6bab1fd3010b9a0f7b2ded67 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 11:46:10 +0300 Subject: [PATCH 226/722] Drop HttpResponseUriRedirect backwards-compatible name --- oauth2_provider/http.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py index 781f2f8d2..12f26c86f 100644 --- a/oauth2_provider/http.py +++ b/oauth2_provider/http.py @@ -31,7 +31,3 @@ def validate_redirect(self, redirect_to): raise DisallowedRedirect( "Redirect to scheme {!r} is not permitted".format(parsed.scheme) ) - - -# Backwards compatibility (as of 1.0.0) -HttpResponseUriRedirect = OAuth2ResponseRedirect From 42331aab504f9bb9e97a16f199f1753c47379ab4 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 12:00:26 +0300 Subject: [PATCH 227/722] Drop Python 2.7 support --- .travis.yml | 4 +-- CHANGELOG.md | 7 +++- README.rst | 4 +-- docs/conf.py | 3 -- oauth2_provider/compat.py | 25 +------------- .../contrib/rest_framework/permissions.py | 10 +++--- oauth2_provider/exceptions.py | 2 +- oauth2_provider/generators.py | 4 +-- oauth2_provider/http.py | 6 ++-- oauth2_provider/middleware.py | 4 +-- oauth2_provider/migrations/0001_initial.py | 10 +++--- oauth2_provider/migrations/0002_08_updates.py | 24 +++++--------- .../migrations/0003_auto_20160316_1503.py | 13 +++----- .../migrations/0004_auto_20160525_1623.py | 17 ++++------ .../migrations/0005_auto_20170514_1141.py | 8 ++--- .../migrations/0006_auto_20171214_2232.py | 33 +++++++++---------- oauth2_provider/models.py | 9 +---- oauth2_provider/oauth2_backends.py | 4 +-- oauth2_provider/oauth2_validators.py | 9 ++--- oauth2_provider/scopes.py | 2 -- oauth2_provider/settings.py | 2 -- oauth2_provider/urls.py | 2 -- oauth2_provider/validators.py | 10 +++--- oauth2_provider/views/application.py | 2 +- oauth2_provider/views/base.py | 4 +-- oauth2_provider/views/introspect.py | 2 -- oauth2_provider/views/mixins.py | 12 +++---- oauth2_provider/views/token.py | 9 +++-- setup.cfg | 4 +-- tests/test_application_views.py | 2 -- tests/test_authorization_code.py | 4 +-- tests/test_client_credential.py | 4 +-- tests/test_generator.py | 2 -- tests/test_implicit.py | 3 +- tests/test_introspection_auth.py | 2 -- tests/test_introspection_view.py | 2 -- tests/test_mixins.py | 2 -- tests/test_models.py | 2 -- tests/test_oauth2_validators.py | 2 +- tests/test_password.py | 2 -- tests/test_scopes.py | 4 +-- tests/test_scopes_backend.py | 2 -- tests/test_token_revocation.py | 4 +-- tests/test_token_view.py | 2 -- tests/test_validators.py | 2 -- tests/utils.py | 2 -- tox.ini | 9 +++-- 47 files changed, 97 insertions(+), 200 deletions(-) diff --git a/.travis.yml b/.travis.yml index 41b7f888c..02b9d9ce9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,9 +5,7 @@ language: python python: "3.6" env: - - TOXENV=py27-django111 - - TOXENV=py34-django111 - - TOXENV=py36-django111 + - TOXENV=py34-django20 - TOXENV=py36-django20 - TOXENV=py36-djangomaster - TOXENV=docs diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c32a0062..d802fc959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,9 @@ -### 1.1.0 [unreleased] +### 1.2.0 [unreleased] + +* **Compatibility**: Python 3.4 is the new minimum required version. +* **Compatibility**: Django 2.0 is the new minimum required version. + +### 1.1.0 [2018-04-13] * **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. * **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. diff --git a/README.rst b/README.rst index 2df9e2aff..aea56eb8b 100644 --- a/README.rst +++ b/README.rst @@ -42,8 +42,8 @@ Please report any security issues to the JazzBand security team at 1: raise ValidationError("Redirect URIs must not contain fragments") diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 0bd0dd691..c925493f5 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -38,7 +38,7 @@ def get_form_class(self): def form_valid(self, form): form.instance.user = self.request.user - return super(ApplicationRegistration, self).form_valid(form) + return super().form_valid(form) class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 40c6e662c..f4978605f 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -34,14 +34,14 @@ class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View): """ def dispatch(self, request, *args, **kwargs): self.oauth2_data = {} - return super(BaseAuthorizationView, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def error_response(self, error, application, **kwargs): """ Handle errors either by redirecting to redirect_uri with a json in the body containing error details or providing an error response """ - redirect, error_response = super(BaseAuthorizationView, self).error_response(error, **kwargs) + redirect, error_response = super().error_response(error, **kwargs) if redirect: return self.redirect(error_response["url"], application) diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 0f3780c8c..5d5fcea76 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import calendar import json diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index f89c3ea63..00065644a 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import logging from django.core.exceptions import ImproperlyConfigured @@ -204,13 +202,13 @@ class ProtectedResourceMixin(OAuthLibMixin): def dispatch(self, request, *args, **kwargs): # let preflight OPTIONS requests pass if request.method.upper() == "OPTIONS": - return super(ProtectedResourceMixin, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) # check if the request is valid and the protected resource may be accessed valid, r = self.verify_request(request) if valid: request.resource_owner = r.user - return super(ProtectedResourceMixin, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) else: return HttpResponseForbidden() @@ -232,7 +230,7 @@ def __new__(cls, *args, **kwargs): ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) ) - return super(ReadWriteScopedResourceMixin, cls).__new__(cls, *args, **kwargs) + return super().__new__(cls, *args, **kwargs) def dispatch(self, request, *args, **kwargs): if request.method.upper() in SAFE_HTTP_METHODS: @@ -240,10 +238,10 @@ def dispatch(self, request, *args, **kwargs): else: self.read_write_scope = oauth2_settings.WRITE_SCOPE - return super(ReadWriteScopedResourceMixin, self).dispatch(request, *args, **kwargs) + return super().dispatch(request, *args, **kwargs) def get_scopes(self, *args, **kwargs): - scopes = super(ReadWriteScopedResourceMixin, self).get_scopes(*args, **kwargs) + scopes = super().get_scopes(*args, **kwargs) # this returns a copy so that self.required_scopes is not modified return scopes + [self.read_write_scope] diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index ebb42856e..399953fcd 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, unicode_literals - from django.contrib.auth.mixins import LoginRequiredMixin from django.urls import reverse_lazy from django.views.generic import DeleteView, ListView @@ -19,8 +17,9 @@ def get_queryset(self): """ Show only user"s tokens """ - return super(AuthorizedTokensListView, self).get_queryset()\ - .select_related("application").filter(user=self.request.user) + return super().get_queryset().select_related("application").filter( + user=self.request.user + ) class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): @@ -32,4 +31,4 @@ class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): model = get_access_token_model() def get_queryset(self): - return super(AuthorizedTokenDeleteView, self).get_queryset().filter(user=self.request.user) + return super().get_queryset().filter(user=self.request.user) diff --git a/setup.cfg b/setup.cfg index 84305a805..e862c2ba7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,12 +11,10 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 1.11 Framework :: Django :: 2.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent - Programming Language :: Python :: 2.7 Programming Language :: Python :: 3 Programming Language :: Python :: 3.4 Programming Language :: Python :: 3.5 @@ -28,7 +26,7 @@ packages = find: include_package_data = True zip_safe = False install_requires = - django >= 1.11 + django >= 2.0 oauthlib >= 2.0.3 requests >= 2.13.0 diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 4e09cb789..6130876ce 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 85829df12..182ba80f5 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1,8 +1,7 @@ -from __future__ import unicode_literals - import base64 import datetime import json +from urllib.parse import parse_qs, urlencode, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -10,7 +9,6 @@ from django.utils import timezone from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors -from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 7ec49ed67..f4fa4c045 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,5 @@ -from __future__ import unicode_literals - import json +from urllib.parse import quote_plus from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -8,7 +7,6 @@ from django.views.generic import View from oauthlib.oauth2 import BackendApplicationServer -from oauth2_provider.compat import quote_plus from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator diff --git a/tests/test_generator.py b/tests/test_generator.py index 3e810c65b..211713b07 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.test import TestCase from oauth2_provider.generators import ( diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 6a979fc74..548592377 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals +from urllib.parse import parse_qs, urlencode, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse -from oauth2_provider.compat import parse_qs, urlencode, urlparse from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 1c02c320c..fd7504bac 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import calendar import datetime diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 4c2695a22..c4c6d1554 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import calendar import datetime diff --git a/tests/test_mixins.py b/tests/test_mixins.py index a4a116555..79988c9fc 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.views.generic import View diff --git a/tests/test_models.py b/tests/test_models.py index 13afb09e5..45533d1a7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib.auth import get_user_model from django.core.exceptions import ValidationError from django.test import TestCase diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 4f25b34d7..0c075646a 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,5 +1,5 @@ -import datetime import contextlib +import datetime from django.contrib.auth import get_user_model from django.test import TransactionTestCase diff --git a/tests/test_password.py b/tests/test_password.py index 9a295c9b2..bf161bf44 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import json from django.contrib.auth import get_user_model diff --git a/tests/test_scopes.py b/tests/test_scopes.py index daccfed00..4a3daf89d 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals - import json +from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.urls import reverse -from oauth2_provider.compat import parse_qs, urlparse from oauth2_provider.models import ( get_access_token_model, get_application_model, get_grant_model ) diff --git a/tests/test_scopes_backend.py b/tests/test_scopes_backend.py index 06d45b0ed..5f629613e 100644 --- a/tests/test_scopes_backend.py +++ b/tests/test_scopes_backend.py @@ -1,5 +1,3 @@ -from __future__ import absolute_import, unicode_literals - from oauth2_provider.scopes import SettingsScopes diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index c7520641a..793c2dc78 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -1,13 +1,11 @@ -from __future__ import unicode_literals - import datetime +from urllib.parse import urlencode from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone -from oauth2_provider.compat import urlencode from oauth2_provider.models import ( get_access_token_model, get_application_model, get_refresh_token_model ) diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 5c0a92d47..67fa1a55c 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import datetime from django.contrib.auth import get_user_model diff --git a/tests/test_validators.py b/tests/test_validators.py index 052e3bc71..ab27a66ad 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.core.validators import ValidationError from django.test import TestCase diff --git a/tests/utils.py b/tests/utils.py index 29bdb588a..9e29c48d3 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import base64 diff --git a/tox.ini b/tox.ini index 96f2bbd18..0e7a8e32f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = - py27-django{111}, - py35-django{111,20,master}, - py36-django{111,20,master}, + py34-django20, + py35-django{20,master}, + py36-django{20,master}, docs, flake8 @@ -16,10 +16,9 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - django111: Django>=1.11,<2.0 django20: Django>=2.0,<2.1 djangomaster: https://github.com/django/django/archive/master.tar.gz - djangorestframework >=3.5 + djangorestframework coverage pytest pytest-cov From 83ec4ac1e774641670d4641483e622f9b60b50ae Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 14 Apr 2018 17:44:59 +0300 Subject: [PATCH 228/722] Remove default from refresh token migration --- oauth2_provider/migrations/0006_auto_20171214_2232.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/migrations/0006_auto_20171214_2232.py b/oauth2_provider/migrations/0006_auto_20171214_2232.py index 2b9568f5c..7fb773bf5 100644 --- a/oauth2_provider/migrations/0006_auto_20171214_2232.py +++ b/oauth2_provider/migrations/0006_auto_20171214_2232.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name="refreshtoken", name="revoked", - field=models.DateTimeField(null=True, default=django.utils.timezone.now), + field=models.DateTimeField(null=True, default=None), preserve_default=False, ), migrations.AlterField( From a0188b6222e3c2352b86f661ee24ccad66537bc4 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 8 May 2018 14:22:45 +0300 Subject: [PATCH 229/722] tox: Use flake8-isort --- tox.ini | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index 0e7a8e32f..9dcf6a8df 100644 --- a/tox.ini +++ b/tox.ini @@ -36,12 +36,11 @@ deps = sphinx [testenv:flake8] skip_install = True commands = - flake8 {toxinidir} {posargs} - isort {toxinidir} -c + flake8 {toxinidir} deps = flake8 + flake8-isort flake8-quotes - isort [coverage:run] source = oauth2_provider @@ -54,9 +53,10 @@ application-import-names = oauth2_provider inline-quotes = double [isort] -lines_after_imports = 2 +balanced_wrapping = True +default_section = THIRDPARTY known_first_party = oauth2_provider +line_length = 80 +lines_after_imports = 2 multi_line_output = 5 skip = oauth2_provider/migrations/, .tox/ -line_length = 80 -balanced_wrapping = True From a4cb67b511adec42e67ad1fb5370cec9c0b7ed3b Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Tue, 8 May 2018 14:35:52 +0300 Subject: [PATCH 230/722] Add release notes for 1.1.1 Refs #589 --- CHANGELOG.md | 8 ++++++++ setup.cfg | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d802fc959..a64b3b7c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. +### 1.1.1 [2018-05-08] + +* **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing + RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. + If you have already ran it in production, please see the following issue for more details: + https://github.com/jazzband/django-oauth-toolkit/issues/589 + + ### 1.1.0 [2018-04-13] * **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. diff --git a/setup.cfg b/setup.cfg index e862c2ba7..32c512cce 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.1.0 +version = 1.1.1 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com From 323de5c6a1eee48f6c97bb3eecd8fa976c218eae Mon Sep 17 00:00:00 2001 From: Debashis Dip Date: Wed, 9 May 2018 01:40:55 +0600 Subject: [PATCH 231/722] Fail authentication on bad authorization base64 encoding Closes #591 --- oauth2_provider/oauth2_validators.py | 6 +++++- tests/test_oauth2_validators.py | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index dbd0f97fa..450a04fb5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -96,7 +96,11 @@ def _authenticate_basic_auth(self, request): ) return False - client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) + try: + client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) + except ValueError: + log.debug("Failed basic auth, Invalid base64 encoding.") + return False if self._load_application(client_id, request) is None: log.debug("Failed basic auth: Application %s does not exist" % client_id) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 0c075646a..5f91af5ce 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -106,6 +106,12 @@ def test_authenticate_basic_auth_not_b64_auth_string(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic not_base64"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_invalid_b64_string(self): + self.request.encoding = "utf-8" + # client_id:wrong_secret + self.request.headers = {"HTTP_AUTHORIZATION": "Basic ZHVtbXk=:ZHVtbXk=\n"} + self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_not_utf8(self): self.request.encoding = "utf-8" # b64decode("test") will become b"\xb5\xeb-", it can"t be decoded as utf-8 From 9e1f3cb12cfad578380b814a232c80819425117c Mon Sep 17 00:00:00 2001 From: AlanCoding Date: Thu, 10 May 2018 14:14:33 -0400 Subject: [PATCH 232/722] handle case broken in error handling - IPv6 URL --- oauth2_provider/validators.py | 5 ++++- tests/test_validators.py | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index c1467f84c..4046717e5 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -27,7 +27,10 @@ def __call__(self, value): # Trivial case failed. Try for possible IDN domain if value: value = force_text(value) - scheme, netloc, path, query, fragment = urlsplit(value) + try: + scheme, netloc, path, query, fragment = urlsplit(value) + except ValueError as e: + raise ValidationError("Cannot parse Redirect URI. Error: {}".format(e)) try: netloc = netloc.encode("idna").decode("ascii") # IDN -> ACE except UnicodeError: # invalid domain part diff --git a/tests/test_validators.py b/tests/test_validators.py index ab27a66ad..b24e0e2d8 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -28,6 +28,9 @@ def test_validate_bad_uris(self): self.assertRaises(ValidationError, validate_uris, bad_uri) bad_uri = "http:/example.com" self.assertRaises(ValidationError, validate_uris, bad_uri) + # Bad IPv6 URL, urlparse behaves differently for these + bad_uri = "https://[\">" + self.assertRaises(ValidationError, validate_uris, bad_uri) bad_uri = "my-scheme://example.com" self.assertRaises(ValidationError, validate_uris, bad_uri) bad_uri = "sdklfsjlfjljdflksjlkfjsdkl" From be659caca98b1675960a38f3565005d27c00a5f0 Mon Sep 17 00:00:00 2001 From: David Gage Date: Fri, 11 May 2018 23:48:09 -0400 Subject: [PATCH 233/722] Return state with authorization denied error Conforms to RFC6749 section 4.1.2.1: https://tools.ietf.org/html/rfc6749#section-4.1.2.1 REQUIRED if a "state" parameter was present in the client authorization request. The exact value received from the client. --- oauth2_provider/oauth2_backends.py | 3 ++- tests/test_authorization_code.py | 27 ++++++++++++++++++++++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index b327f7c2e..62791098c 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -108,7 +108,8 @@ def create_authorization_response(self, request, scopes, credentials, allow): """ try: if not allow: - raise oauth2.AccessDeniedError() + raise oauth2.AccessDeniedError( + state=credentials.get("state", None)) # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS credentials["user"] = request.user diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 182ba80f5..a10e19c46 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -333,6 +333,26 @@ def test_code_post_auth_deny(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + def test_code_post_auth_deny_no_state(self): + """ + Test optional state when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + self.assertNotIn("state", response["Location"]) def test_code_post_auth_bad_responsetype(self): """ @@ -431,6 +451,7 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) def test_code_post_auth_redirection_uri_with_querystring(self): """ @@ -453,6 +474,7 @@ def test_code_post_auth_redirection_uri_with_querystring(self): self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ @@ -473,7 +495,10 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) - self.assertEqual("http://example.com?foo=bar&error=access_denied", response["Location"]) + self.assertIn("http://example.com?", response["Location"]) + self.assertIn("error=access_denied", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("foo=bar", response["Location"]) def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): """ From b4378b3d3197c4fb7b13b0f7ba624616f4241f28 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Fri, 11 May 2018 23:58:56 -0400 Subject: [PATCH 234/722] Add TokenHasMethodScopeAlternative --- CHANGELOG.md | 2 + docs/rest-framework/openapi.yaml | 49 ++++++ docs/rest-framework/permissions.rst | 34 +++++ .../contrib/rest_framework/__init__.py | 6 +- .../contrib/rest_framework/permissions.py | 55 +++++++ tests/test_rest_framework.py | 142 +++++++++++++++++- 6 files changed, 282 insertions(+), 6 deletions(-) create mode 100644 docs/rest-framework/openapi.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index a64b3b7c4..6b527ee25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. +* **New feature**: Added TokenHasMethodScopeAlternative Permissions. ### 1.1.1 [2018-05-08] @@ -25,6 +26,7 @@ refresh tokens may be re-used. * An `app_authorized` signal is fired when a token is generated. + ### 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. diff --git a/docs/rest-framework/openapi.yaml b/docs/rest-framework/openapi.yaml new file mode 100644 index 000000000..5c2e9a5df --- /dev/null +++ b/docs/rest-framework/openapi.yaml @@ -0,0 +1,49 @@ +openapi: "3.0.0" +info: + title: songs + version: v1 +components: + securitySchemes: + song_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: http://localhost:8000/o/authorize + scopes: + read: read about a song + create: create a new song + update: update an existing song + delete: delete a song + post: create a new song + widget: widget scope + scope2: scope too + scope3: another scope +paths: + /songs: + get: + security: + - song_auth: [read] + responses: + '200': + description: A list of songs. + post: + security: + - song_auth: [create] + - song_auth: [post, widget] + responses: + '201': + description: new song added + put: + security: + - song_auth: [update] + - song_auth: [put, widget] + responses: + '204': + description: song updated + delete: + security: + - song_auth: [delete] + - song_auth: [scope2, scope3] + responses: + '200': + description: song deleted diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index b84c0a0f3..0346f772c 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -48,6 +48,7 @@ For example: When a request is performed both the `READ_SCOPE` \\ `WRITE_SCOPE` and 'music' scopes are required to be authorized for the current access token. + TokenHasResourceScope ---------------------- The `TokenHasResourceScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according of request's method. @@ -81,3 +82,36 @@ For example: required_scopes = ['music'] The `required_scopes` attribute is mandatory. + + +TokenHasMethodScopeAlternative +------------------------------ + +The `TokenHasMethodScopeAlternative` permission class allows the access based on a per-method basis +and with alternative lists of required scopes. This permission provides full functionality +required by REST API specifications like the +`OpenAPI Specification (OAS) security requirement object `_. + +The `required_alternate_scopes` attribute is a required map keyed by HTTP method name where each value is +a list of alternative lists of required scopes. + +In the follow example GET requires "read" scope, POST requires either "create" scope **OR** "post" and "widget" scopes, +etc. + +.. code-block:: python + + class SongView(views.APIView): + authentication_classes = [OAuth2Authentication] + permission_classes = [TokenHasMethodScopeAlternative] + required_alternate_scopes = { + "GET": [["read"]], + "POST": [["create"], ["post", "widget"]], + "PUT": [["update"], ["put", "widget"]], + "DELETE": [["delete"], ["scope2", "scope3"]], + } + +The following is a minimal OAS declaration that shows the same required alternate scopes. It is complete enough +to try it in the `swagger editor `_. + +.. literalinclude:: openapi.yaml + :language: YAML \ No newline at end of file diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py index 4b826720c..64ca7baa3 100644 --- a/oauth2_provider/contrib/rest_framework/__init__.py +++ b/oauth2_provider/contrib/rest_framework/__init__.py @@ -1,4 +1,6 @@ # flake8: noqa from .authentication import OAuth2Authentication -from .permissions import TokenHasScope, TokenHasReadWriteScope, TokenHasResourceScope -from .permissions import IsAuthenticatedOrTokenHasScope +from .permissions import ( + TokenHasScope, TokenHasReadWriteScope, TokenHasMethodScopeAlternative, + TokenHasResourceScope, IsAuthenticatedOrTokenHasScope +) diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index ad5ac056e..f2cec2057 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -121,3 +121,58 @@ def has_permission(self, request, view): token_has_scope = TokenHasScope() return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) + + +class TokenHasMethodScopeAlternative(BasePermission): + """ + :attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists + + This fulfills the [Open API Specification (OAS; formerly Swagger)](https://www.openapis.org/) + list of alternative Security Requirements Objects for oauth2 or openIdConnect: + When a list of Security Requirement Objects is defined on the Open API object or Operation Object, + only one of Security Requirement Objects in the list needs to be satisfied to authorize the request. + [1](https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securityRequirementObject) + + For each method, a list of lists of allowed scopes is tried in order and the first to match succeeds. + + @example + required_alternate_scopes = { + 'GET': [['read']], + 'POST': [['create1','scope2'], ['alt-scope3'], ['alt-scope4','alt-scope5']], + } + + TODO: DRY: subclass TokenHasScope and iterate over values of required_scope? + """ + + def has_permission(self, request, view): + token = request.auth + + if not token: + return False + + if hasattr(token, "scope"): # OAuth 2 + required_alternate_scopes = self.get_required_alternate_scopes(request, view) + + m = request.method.upper() + if m in required_alternate_scopes: + log.debug("Required scopes alternatives to access resource: {0}" + .format(required_alternate_scopes[m])) + for alt in required_alternate_scopes[m]: + if token.is_valid(alt): + return True + return False + else: + log.warning("no scope alternates defined for method {0}".format(m)) + return False + + assert False, ("TokenHasMethodScope requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used.") + + def get_required_alternate_scopes(self, request, view): + try: + return getattr(view, "required_alternate_scopes") + except AttributeError: + raise ImproperlyConfigured( + "TokenHasMethodScopeAlternative requires the view to" + " define the required_alternate_scopes attribute") diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index d5a18bf23..c8f4369ce 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -2,17 +2,20 @@ from django.conf.urls import include, url from django.contrib.auth import get_user_model +from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone from rest_framework import permissions +from rest_framework.authentication import BaseAuthentication from rest_framework.test import APIRequestFactory, force_authenticate from rest_framework.views import APIView from oauth2_provider.contrib.rest_framework import ( IsAuthenticatedOrTokenHasScope, OAuth2Authentication, - TokenHasReadWriteScope, TokenHasResourceScope, TokenHasScope + TokenHasMethodScopeAlternative, TokenHasReadWriteScope, + TokenHasResourceScope, TokenHasScope ) from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings @@ -38,6 +41,9 @@ def get(self, request): def post(self, request): return HttpResponse({"a": 1, "b": 2, "c": 3}) + def put(self, request): + return HttpResponse({"a": 1, "b": 2, "c": 3}) + class OAuth2View(MockView): authentication_classes = [OAuth2Authentication] @@ -45,7 +51,7 @@ class OAuth2View(MockView): class ScopedView(OAuth2View): permission_classes = [permissions.IsAuthenticated, TokenHasScope] - required_scopes = ["scope1"] + required_scopes = ["scope1", "another"] class AuthenticatedOrScopedView(OAuth2View): @@ -62,13 +68,48 @@ class ResourceScopedView(OAuth2View): required_scopes = ["resource1"] +class MethodScopeAltView(OAuth2View): + permission_classes = [TokenHasMethodScopeAlternative] + required_alternate_scopes = { + "GET": [["read"]], + "POST": [["create"]], + "PUT": [["update", "put"], ["update", "edit"]], + "DELETE": [["delete"], ["deleter", "write"]], + } + + +class MethodScopeAltViewBad(OAuth2View): + permission_classes = [TokenHasMethodScopeAlternative] + + +class MissingAuthentication(BaseAuthentication): + def authenticate(self, request): + return ("junk", "junk",) + + +class BrokenOAuth2View(MockView): + authentication_classes = [MissingAuthentication] + + +class TokenHasScopeViewWrongAuth(BrokenOAuth2View): + permission_classes = [TokenHasScope] + + +class MethodScopeAltViewWrongAuth(BrokenOAuth2View): + permission_classes = [TokenHasMethodScopeAlternative] + + urlpatterns = [ url(r"^oauth2/", include("oauth2_provider.urls")), url(r"^oauth2-test/$", OAuth2View.as_view()), url(r"^oauth2-scoped-test/$", ScopedView.as_view()), + url(r"^oauth2-scoped-missing-auth/$", TokenHasScopeViewWrongAuth.as_view()), url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), + url(r"^oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), + url(r"^oauth2-method-scope-fail/$", MethodScopeAltViewBad.as_view()), + url(r"^oauth2-method-scope-missing-auth/$", MethodScopeAltViewWrongAuth.as_view()), ] @@ -142,13 +183,19 @@ def test_authentication_or_scope_denied(self): self.assertEqual(response.status_code, 403) def test_scoped_permission_allow(self): - self.access_token.scope = "scope1" + self.access_token.scope = "scope1 another" self.access_token.save() auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 200) + def test_scope_missing_scope_attr(self): + auth = self._create_authorization_header("fake-token") + with self.assertRaises(AssertionError) as e: + self.client.get("/oauth2-scoped-missing-auth/", HTTP_AUTHORIZATION=auth) + self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception)) + def test_authenticated_or_scoped_permission_allow(self): self.access_token.scope = "scope1" self.access_token.save() @@ -255,7 +302,7 @@ def test_required_scope_in_response(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - self.assertEqual(response.data["required_scopes"], ["scope1"]) + self.assertEqual(response.data["required_scopes"], ["scope1", "another"]) def test_required_scope_not_in_response_by_default(self): self.access_token.scope = "scope2" @@ -265,3 +312,90 @@ def test_required_scope_not_in_response_by_default(self): response = self.client.get("/oauth2-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) self.assertNotIn("required_scopes", response.data) + + def test_method_scope_alt_permission_get_allow(self): + self.access_token.scope = "read" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_method_scope_alt_permission_post_allow(self): + self.access_token.scope = "create" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_method_scope_alt_permission_put_allow(self): + self.access_token.scope = "edit update" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 200) + + def test_method_scope_alt_permission_put_fail(self): + self.access_token.scope = "edit" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.put("/oauth2-method-scope-test/123", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_permission_get_deny(self): + self.access_token.scope = "write" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_permission_post_deny(self): + self.access_token.scope = "read" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_no_token(self): + self.access_token.scope = "" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + self.access_token = None + response = self.client.post("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_missing_attr(self): + self.access_token.scope = "read" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + with self.assertRaises(ImproperlyConfigured): + self.client.post("/oauth2-method-scope-fail/", HTTP_AUTHORIZATION=auth) + + def test_method_scope_alt_missing_patch_method(self): + self.access_token.scope = "update" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_empty_scope(self): + self.access_token.scope = "" + self.access_token.save() + + auth = self._create_authorization_header(self.access_token.token) + response = self.client.patch("/oauth2-method-scope-test/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 403) + + def test_method_scope_alt_missing_scope_attr(self): + auth = self._create_authorization_header("fake-token") + with self.assertRaises(AssertionError) as e: + self.client.get("/oauth2-method-scope-missing-auth/", HTTP_AUTHORIZATION=auth) + self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception)) From b3c1cee36b8058cbe92697254fece5e2174db408 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Wed, 16 May 2018 00:21:53 -0400 Subject: [PATCH 235/722] Rename TokenHasMethodScopeAlternative to TokenMatchesOASRequirements --- CHANGELOG.md | 2 +- docs/rest-framework/permissions.rst | 6 +++--- oauth2_provider/contrib/rest_framework/__init__.py | 2 +- oauth2_provider/contrib/rest_framework/permissions.py | 6 +++--- tests/test_rest_framework.py | 10 +++++----- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b527ee25..9e91186dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. -* **New feature**: Added TokenHasMethodScopeAlternative Permissions. +* **New feature**: Added TokenMatchesOASRequirements Permissions. ### 1.1.1 [2018-05-08] diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index 0346f772c..1058aed3f 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -84,10 +84,10 @@ For example: The `required_scopes` attribute is mandatory. -TokenHasMethodScopeAlternative +TokenMatchesOASRequirements ------------------------------ -The `TokenHasMethodScopeAlternative` permission class allows the access based on a per-method basis +The `TokenMatchesOASRequirements` permission class allows the access based on a per-method basis and with alternative lists of required scopes. This permission provides full functionality required by REST API specifications like the `OpenAPI Specification (OAS) security requirement object `_. @@ -102,7 +102,7 @@ etc. class SongView(views.APIView): authentication_classes = [OAuth2Authentication] - permission_classes = [TokenHasMethodScopeAlternative] + permission_classes = [TokenMatchesOASRequirements] required_alternate_scopes = { "GET": [["read"]], "POST": [["create"], ["post", "widget"]], diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py index 64ca7baa3..a004c1872 100644 --- a/oauth2_provider/contrib/rest_framework/__init__.py +++ b/oauth2_provider/contrib/rest_framework/__init__.py @@ -1,6 +1,6 @@ # flake8: noqa from .authentication import OAuth2Authentication from .permissions import ( - TokenHasScope, TokenHasReadWriteScope, TokenHasMethodScopeAlternative, + TokenHasScope, TokenHasReadWriteScope, TokenMatchesOASRequirements, TokenHasResourceScope, IsAuthenticatedOrTokenHasScope ) diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index f2cec2057..7ba1c5c71 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -123,7 +123,7 @@ def has_permission(self, request, view): return (is_authenticated and not oauth2authenticated) or token_has_scope.has_permission(request, view) -class TokenHasMethodScopeAlternative(BasePermission): +class TokenMatchesOASRequirements(BasePermission): """ :attr:alternate_required_scopes: dict keyed by HTTP method name with value: iterable alternate scope lists @@ -165,7 +165,7 @@ def has_permission(self, request, view): log.warning("no scope alternates defined for method {0}".format(m)) return False - assert False, ("TokenHasMethodScope requires the" + assert False, ("TokenMatchesOASRequirements requires the" "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " "class to be used.") @@ -174,5 +174,5 @@ def get_required_alternate_scopes(self, request, view): return getattr(view, "required_alternate_scopes") except AttributeError: raise ImproperlyConfigured( - "TokenHasMethodScopeAlternative requires the view to" + "TokenMatchesOASRequirements requires the view to" " define the required_alternate_scopes attribute") diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index c8f4369ce..c250022c6 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -14,8 +14,8 @@ from oauth2_provider.contrib.rest_framework import ( IsAuthenticatedOrTokenHasScope, OAuth2Authentication, - TokenHasMethodScopeAlternative, TokenHasReadWriteScope, - TokenHasResourceScope, TokenHasScope + TokenHasReadWriteScope, TokenHasResourceScope, + TokenHasScope, TokenMatchesOASRequirements ) from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings @@ -69,7 +69,7 @@ class ResourceScopedView(OAuth2View): class MethodScopeAltView(OAuth2View): - permission_classes = [TokenHasMethodScopeAlternative] + permission_classes = [TokenMatchesOASRequirements] required_alternate_scopes = { "GET": [["read"]], "POST": [["create"]], @@ -79,7 +79,7 @@ class MethodScopeAltView(OAuth2View): class MethodScopeAltViewBad(OAuth2View): - permission_classes = [TokenHasMethodScopeAlternative] + permission_classes = [TokenMatchesOASRequirements] class MissingAuthentication(BaseAuthentication): @@ -96,7 +96,7 @@ class TokenHasScopeViewWrongAuth(BrokenOAuth2View): class MethodScopeAltViewWrongAuth(BrokenOAuth2View): - permission_classes = [TokenHasMethodScopeAlternative] + permission_classes = [TokenMatchesOASRequirements] urlpatterns = [ From c018f5028b199eb4df61bcefd5b8963e8650d805 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 26 May 2018 22:27:48 +0300 Subject: [PATCH 236/722] Add release notes for 1.1.2 --- CHANGELOG.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e91186dd..1d108da98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ * **Compatibility**: Django 2.0 is the new minimum required version. * **New feature**: Added TokenMatchesOASRequirements Permissions. + +### 1.1.2 [2018-05-12] + +* Return state with Authorization Denied error (RFC6749 section 4.1.2.1) +* Fix a crash with malformed base64 authentication headers +* Fix a crash with malformed IPv6 redirect URIs + ### 1.1.1 [2018-05-08] * **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing @@ -26,7 +33,6 @@ refresh tokens may be re-used. * An `app_authorized` signal is fired when a token is generated. - ### 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. From f78003545e80833bf0fe3cada7ac58e8f72ea32d Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sat, 26 May 2018 23:12:21 +0300 Subject: [PATCH 237/722] Clean up tests for RedirectURIValidator, add more checks --- tests/test_validators.py | 66 +++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/tests/test_validators.py b/tests/test_validators.py index b24e0e2d8..0e8e770df 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,40 +2,58 @@ from django.test import TestCase from oauth2_provider.settings import oauth2_settings -from oauth2_provider.validators import validate_uris +from oauth2_provider.validators import RedirectURIValidator, validate_uris class TestValidators(TestCase): def test_validate_good_uris(self): - good_uris = "http://example.com/ http://example.org/?key=val http://example" - # Check ValidationError not thrown - validate_uris(good_uris) + validator = RedirectURIValidator(allowed_schemes=["https"]) + good_uris = [ + "https://example.com/", + "https://example.org/?key=val", + "https://example", + "https://localhost", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) def test_validate_custom_uri_scheme(self): - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["my-scheme", "http"] - good_uris = "my-scheme://example.com http://example.com" - # Check ValidationError not thrown - validate_uris(good_uris) + validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https"]) + good_uris = [ + "my-scheme://example.com", + "my-scheme://example", + "my-scheme://localhost", + "https://example.com", + "HTTPS://example.com", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) def test_validate_whitespace_separators(self): # Check that whitespace can be used as a separator - good_uris = "http://example\r\nhttp://example\thttp://example" + good_uris = "https://example.com\r\nhttps://example.com\thttps://example.com" # Check ValidationError not thrown validate_uris(good_uris) def test_validate_bad_uris(self): - bad_uri = "http://example.com/#fragment" - self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = "http:/example.com" - self.assertRaises(ValidationError, validate_uris, bad_uri) - # Bad IPv6 URL, urlparse behaves differently for these - bad_uri = "https://[\">" - self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = "my-scheme://example.com" - self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = "sdklfsjlfjljdflksjlkfjsdkl" - self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = " " - self.assertRaises(ValidationError, validate_uris, bad_uri) - bad_uri = "" - self.assertRaises(ValidationError, validate_uris, bad_uri) + validator = RedirectURIValidator(allowed_schemes=["https"]) + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + bad_uris = [ + "http:/example.com", + "HTTP://localhost", + "HTTP://example.com", + "HTTP://example.com.", + "http://example.com/#fragment", + "my-scheme://example.com" + "uri-without-a-scheme", + " ", + "", + # Bad IPv6 URL, urlparse behaves differently for these + 'https://[">', + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) From fe89a35019f34e138dd64b261f94d0b478b50967 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 27 May 2018 00:05:46 +0300 Subject: [PATCH 238/722] Subclass URLValidator for URIValidator --- CHANGELOG.md | 1 + oauth2_provider/validators.py | 49 +++++++++++------------------------ tests/test_validators.py | 15 ++++++++--- 3 files changed, 28 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d108da98..ee9bc59c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. * **New feature**: Added TokenMatchesOASRequirements Permissions. +* validators.URIValidator has been updated to match URLValidator behaviour more closely. ### 1.1.2 [2018-05-12] diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 4046717e5..c8f8f99fa 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -2,50 +2,31 @@ from urllib.parse import urlsplit, urlunsplit from django.core.exceptions import ValidationError -from django.core.validators import RegexValidator +from django.core.validators import URLValidator from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ from .settings import oauth2_settings -class URIValidator(RegexValidator): - regex = re.compile( - r"^(?:[a-z][a-z0-9\.\-\+]*)://" # scheme... - r"(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|" # domain... - r"(?!-)[A-Z\d-]{1,63}(? ACE - except UnicodeError: # invalid domain part - raise e - url = urlunsplit((scheme, netloc, path, query, fragment)) - super().__call__(url) - else: - raise - else: - url = value + dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(? 1: raise ValidationError("Redirect URIs must not contain fragments") scheme, netloc, path, query, fragment = urlsplit(value) - if scheme.lower() not in self.allowed_schemes: + if scheme.lower() not in self.schemes: raise ValidationError("Redirect URI scheme is not allowed.") diff --git a/tests/test_validators.py b/tests/test_validators.py index 0e8e770df..121c19704 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -13,19 +13,23 @@ def test_validate_good_uris(self): "https://example.org/?key=val", "https://example", "https://localhost", + "https://1.1.1.1", + "https://127.0.0.1", + "https://255.255.255.255", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) def test_validate_custom_uri_scheme(self): - validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https"]) + validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https", "git+ssh"]) good_uris = [ "my-scheme://example.com", "my-scheme://example", "my-scheme://localhost", "https://example.com", "HTTPS://example.com", + "git+ssh://example.com", ] for uri in good_uris: # Check ValidationError not thrown @@ -39,15 +43,20 @@ def test_validate_whitespace_separators(self): def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] bad_uris = [ "http:/example.com", "HTTP://localhost", "HTTP://example.com", "HTTP://example.com.", "http://example.com/#fragment", - "my-scheme://example.com" + "123://example.com", + "http://fe80::1", + "git+ssh://example.com", + "my-scheme://example.com", "uri-without-a-scheme", + "https://example.com/#fragment", + "good://example.com/#fragment", " ", "", # Bad IPv6 URL, urlparse behaves differently for these From 39a2a4e1fbbba707b8998f07042b6a0382efc5f1 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 27 May 2018 00:17:54 +0300 Subject: [PATCH 239/722] Add allow_fragments argument to RedirectURIValidator --- oauth2_provider/validators.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index c8f8f99fa..730c87aa7 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -25,15 +25,16 @@ class URIValidator(URLValidator): class RedirectURIValidator(URIValidator): - def __init__(self, allowed_schemes): + def __init__(self, allowed_schemes, allow_fragments=False): super().__init__(schemes=allowed_schemes) + self.allow_fragments = allow_fragments def __call__(self, value): super().__call__(value) value = force_text(value) - if len(value.split("#")) > 1: - raise ValidationError("Redirect URIs must not contain fragments") scheme, netloc, path, query, fragment = urlsplit(value) + if fragment and not self.allow_fragments: + raise ValidationError("Redirect URIs must not contain fragments") if scheme.lower() not in self.schemes: raise ValidationError("Redirect URI scheme is not allowed.") From 96538876d0d7ea0319ba5286f9bde842a906e1c5 Mon Sep 17 00:00:00 2001 From: Jerome Leclanche Date: Sun, 27 May 2018 01:21:06 +0300 Subject: [PATCH 240/722] Move scheme validation to OAuth2Application.clean() --- CHANGELOG.md | 1 + oauth2_provider/migrations/0001_initial.py | 2 +- oauth2_provider/models.py | 32 ++++++++++++++++------ oauth2_provider/validators.py | 26 ++++++++---------- tests/test_validators.py | 8 +----- 5 files changed, 38 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee9bc59c9..ce2164e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * **Compatibility**: Django 2.0 is the new minimum required version. * **New feature**: Added TokenMatchesOASRequirements Permissions. * validators.URIValidator has been updated to match URLValidator behaviour more closely. +* Moved `redirect_uris` validation to the application clean() method. ### 1.1.2 [2018-05-12] diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py index 777fc509c..5a831a005 100644 --- a/oauth2_provider/migrations/0001_initial.py +++ b/oauth2_provider/migrations/0001_initial.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), ('client_id', models.CharField(default=oauth2_provider.generators.generate_client_id, unique=True, max_length=100, db_index=True)), - ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True, validators=[oauth2_provider.validators.validate_uris])), + ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True)), ('client_type', models.CharField(max_length=32, choices=[('confidential', 'Confidential'), ('public', 'Public')])), ('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])), ('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)), diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e96a5a1aa..0afd8f89d 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -12,7 +12,7 @@ from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend from .settings import oauth2_settings -from .validators import validate_uris +from .validators import RedirectURIValidator, WildcardSet class AbstractApplication(models.Model): @@ -65,7 +65,6 @@ class AbstractApplication(models.Model): redirect_uris = models.TextField( blank=True, help_text=_("Allowed URIs list, space separated"), - validators=[validate_uris] ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField( @@ -125,12 +124,29 @@ def redirect_uri_allowed(self, uri): def clean(self): from django.core.exceptions import ValidationError - if not self.redirect_uris \ - and self.authorization_grant_type \ - in (AbstractApplication.GRANT_AUTHORIZATION_CODE, - AbstractApplication.GRANT_IMPLICIT): - error = _("Redirect_uris could not be empty with {grant_type} grant_type") - raise ValidationError(error.format(grant_type=self.authorization_grant_type)) + + grant_types = ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_IMPLICIT, + ) + + redirect_uris = self.redirect_uris.strip().split() + allowed_schemes = set(s.lower() for s in self.get_allowed_schemes()) + + if redirect_uris: + validator = RedirectURIValidator(WildcardSet()) + for uri in redirect_uris: + validator(uri) + scheme = urlparse(uri).scheme + if scheme not in allowed_schemes: + raise ValidationError(_( + "Unauthorized redirect scheme: {scheme}" + ).format(scheme=scheme)) + + elif self.authorization_grant_type in grant_types: + raise ValidationError(_( + "redirect_uris cannot be empty with grant_type {grant_type}" + ).format(grant_type=self.authorization_grant_type)) def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 730c87aa7..a6f3a33b6 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -1,12 +1,9 @@ import re -from urllib.parse import urlsplit, urlunsplit +from urllib.parse import urlsplit from django.core.exceptions import ValidationError from django.core.validators import URLValidator from django.utils.encoding import force_text -from django.utils.translation import ugettext_lazy as _ - -from .settings import oauth2_settings class URIValidator(URLValidator): @@ -14,7 +11,7 @@ class URIValidator(URLValidator): dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(? Date: Sun, 3 Jun 2018 07:53:19 +0300 Subject: [PATCH 241/722] Release 1.2.0 --- CHANGELOG.md | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce2164e2e..afbf34f3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -### 1.2.0 [unreleased] +### 1.2.0 [2018-06-03] * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. diff --git a/setup.cfg b/setup.cfg index 32c512cce..a28f39290 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.1.1 +version = 1.2.0 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com From c4e765efbf116beed4933db2536bbae7ea9f27c4 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Fri, 22 Jun 2018 05:05:05 +0430 Subject: [PATCH 242/722] Ignoring requests containing list instead of dict If a request's body is a json list instead of a json object django-oauth-toolkit raises and exception. It breaks bulk requests for drf for example. This commit fixes it. --- oauth2_provider/oauth2_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 62791098c..9609321d4 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -180,7 +180,7 @@ def extract_body(self, request): """ try: body = json.loads(request.body.decode("utf-8")).items() - except ValueError: + except AttributeError, ValueError: body = "" return body From a44f536404dd20b4097c11b33efcc8d126354c42 Mon Sep 17 00:00:00 2001 From: Sassan Haradji Date: Fri, 22 Jun 2018 05:09:06 +0430 Subject: [PATCH 243/722] Fixed syntax error --- oauth2_provider/oauth2_backends.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 9609321d4..8a1c239b5 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -180,7 +180,9 @@ def extract_body(self, request): """ try: body = json.loads(request.body.decode("utf-8")).items() - except AttributeError, ValueError: + except AttributeError: + body = "" + except ValueError: body = "" return body From ad07c9959593e4a415e63ccd25bcc5113267ef00 Mon Sep 17 00:00:00 2001 From: Robert Raposa Date: Sat, 7 Jul 2018 23:48:33 -0400 Subject: [PATCH 244/722] Fix Python requirement as 3.4+ --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index aea56eb8b..a647f5464 100644 --- a/README.rst +++ b/README.rst @@ -42,7 +42,7 @@ Please report any security issues to the JazzBand security team at Date: Mon, 30 Jul 2018 16:28:25 -0400 Subject: [PATCH 245/722] fix race condition for AccessToken creation in oauth2_validators (#611) * work around race condition in which two threads both try to create the same AccessToken using update_or_create instead of two steps go get_or_create then update + save * update changelog --- CHANGELOG.md | 4 ++ oauth2_provider/oauth2_validators.py | 67 ++++++++++------------------ 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index afbf34f3c..d98912479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +### 1.2.x [unrealeased] + +* Fix a race condition in creation of AccessToken with external oauth2 server. + ### 1.2.0 [2018-06-03] * **Compatibility**: Python 3.4 is the new minimum required version. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 450a04fb5..2385be055 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -332,20 +332,15 @@ def _get_token_from_authentication_server( scope = content.get("scope", "") expires = make_aware(expires) - try: - access_token = AccessToken.objects.select_related("application", "user").get(token=token) - except AccessToken.DoesNotExist: - access_token = AccessToken.objects.create( - token=token, - user=user, - application=None, - scope=scope, - expires=expires - ) - else: - access_token.expires = expires - access_token.scope = scope - access_token.save() + access_token, _created = AccessToken\ + .objects.select_related("application", "user")\ + .update_or_create(token=token, + defaults={ + "user": user, + "application": None, + "scope": scope, + "expires": expires, + }) return access_token @@ -362,27 +357,11 @@ def validate_bearer_token(self, token, scopes, request): try: access_token = AccessToken.objects.select_related("application", "user").get(token=token) - # if there is a token but invalid then look up the token - if introspection_url and (introspection_token or introspection_credentials): - if not access_token.is_valid(scopes): - access_token = self._get_token_from_authentication_server( - token, - introspection_url, - introspection_token, - introspection_credentials - ) - if access_token and access_token.is_valid(scopes): - request.client = access_token.application - request.user = access_token.user - request.scopes = scopes - - # this is needed by django rest framework - request.access_token = access_token - return True - self._set_oauth2_error_on_request(request, access_token, scopes) - return False except AccessToken.DoesNotExist: - # there is no initial token, look up the token + access_token = None + + # if there is no token or it's invalid then introspect the token if there's an external OAuth server + if not access_token or not access_token.is_valid(scopes): if introspection_url and (introspection_token or introspection_credentials): access_token = self._get_token_from_authentication_server( token, @@ -390,15 +369,17 @@ def validate_bearer_token(self, token, scopes, request): introspection_token, introspection_credentials ) - if access_token and access_token.is_valid(scopes): - request.client = access_token.application - request.user = access_token.user - request.scopes = scopes - - # this is needed by django rest framework - request.access_token = access_token - return True - self._set_oauth2_error_on_request(request, None, scopes) + + if access_token and access_token.is_valid(scopes): + request.client = access_token.application + request.user = access_token.user + request.scopes = scopes + + # this is needed by django rest framework + request.access_token = access_token + return True + else: + self._set_oauth2_error_on_request(request, access_token, scopes) return False def validate_code(self, client_id, code, client, request, *args, **kwargs): From 115736106b814d3cc3f6eab8e79d5ab9274e4c68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20K=C3=A4ufl?= Date: Tue, 2 Oct 2018 13:29:03 +0200 Subject: [PATCH 246/722] Run tests against Django 2.1 --- .travis.yml | 1 + setup.cfg | 1 + tox.ini | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.travis.yml b/.travis.yml index 02b9d9ce9..09419dda2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,6 +7,7 @@ python: "3.6" env: - TOXENV=py34-django20 - TOXENV=py36-django20 + - TOXENV=py36-django21 - TOXENV=py36-djangomaster - TOXENV=docs - TOXENV=flake8 diff --git a/setup.cfg b/setup.cfg index a28f39290..533bb083e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,6 +12,7 @@ classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 2.0 + Framework :: Django :: 2.1 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/tox.ini b/tox.ini index 9dcf6a8df..bc57cdabb 100644 --- a/tox.ini +++ b/tox.ini @@ -1,8 +1,8 @@ [tox] envlist = py34-django20, - py35-django{20,master}, - py36-django{20,master}, + py35-django{20,21,master}, + py36-django{20,21,master}, docs, flake8 @@ -17,6 +17,7 @@ setenv = PYTHONWARNINGS = all deps = django20: Django>=2.0,<2.1 + django21: Django>=2.1,<2.2 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework coverage From 0f2b149c7b5b473e586ff1143d2f69ca277d3c5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Mon, 27 Aug 2018 15:37:20 +0200 Subject: [PATCH 247/722] fix(#638): concurrency issue on new token from refresh token (cherry picked from commit 199e81846b185f9d932ebcad5097b99a79eac590) --- oauth2_provider/oauth2_validators.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 2385be055..3713e6d48 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -489,6 +489,12 @@ def save_bearer_token(self, token, request, *args, **kwargs): else: # revoke existing tokens if possible to allow reuse of grant if isinstance(refresh_token_instance, RefreshToken): + # First, to ensure we don't have concurrency issues, we refresh the refresth token + # from the db while acquiring a lock on it + refresh_token_instance = RefreshToken.objects.select_for_update().get( + id=refresh_token_instance.id + ) + previous_access_token = AccessToken.objects.filter( source_refresh_token=refresh_token_instance ).first() From ee8cb080ff13b3fad8fd68a47ae7d5a671222a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9gory=20Bataille?= Date: Wed, 5 Sep 2018 10:19:12 +0200 Subject: [PATCH 248/722] fix(): the cached instance needs to be updated too (cherry picked from commit 0f6252b3901d39f7cc6a3bc3f3e8908308c246f1) --- oauth2_provider/oauth2_validators.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 3713e6d48..d9d74d4f2 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -489,11 +489,13 @@ def save_bearer_token(self, token, request, *args, **kwargs): else: # revoke existing tokens if possible to allow reuse of grant if isinstance(refresh_token_instance, RefreshToken): - # First, to ensure we don't have concurrency issues, we refresh the refresth token + # First, to ensure we don't have concurrency issues, we refresh the refresh token # from the db while acquiring a lock on it + # We also put it in the "request cache" refresh_token_instance = RefreshToken.objects.select_for_update().get( id=refresh_token_instance.id ) + request.refresh_token_instance = refresh_token_instance previous_access_token = AccessToken.objects.filter( source_refresh_token=refresh_token_instance From 07f6430b05658f10164b7248773bbb38cdd7fca5 Mon Sep 17 00:00:00 2001 From: Ignacio Santolin Date: Sat, 8 Sep 2018 17:39:16 -0300 Subject: [PATCH 249/722] Sync index.rst with README.rst --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index af62f266c..85b959347 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,8 +21,8 @@ If you need support please send a message to the `Django OAuth Toolkit Google Gr Requirements ------------ -* Python 2.7, 3.4, 3.5, 3.6 -* Django 1.8, 1.9, 1.10, 1.11 +* Python 3.4+ +* Django 2.0+ Index ===== From 5b51da74019046ef4c8c81c9975db029a2113d52 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 6 Aug 2018 12:00:43 -0400 Subject: [PATCH 250/722] Fix Refresh Token revocation when the access token does not exist Fixes #625 --- oauth2_provider/models.py | 5 ++++- tests/test_token_revocation.py | 25 +++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 0afd8f89d..6f7cc096e 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -377,7 +377,10 @@ def revoke(self): if not self: return - access_token_model.objects.get(id=self.access_token_id).revoke() + try: + access_token_model.objects.get(id=self.access_token_id).revoke() + except access_token_model.DoesNotExist: + pass self.access_token = None self.revoked = timezone.now() self.save() diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 793c2dc78..dc9f91018 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -151,6 +151,31 @@ def test_revoke_refresh_token(self): self.assertIsNotNone(refresh_token.revoked) self.assertFalse(AccessToken.objects.filter(id=rtok.access_token.id).exists()) + def test_revoke_refresh_token_with_revoked_access_token(self): + tok = AccessToken.objects.create( + user=self.test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + rtok = RefreshToken.objects.create( + user=self.test_user, token="999999999", + application=self.application, access_token=tok + ) + for token in (tok.token, rtok.token): + query_string = urlencode({ + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "token": token, + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) + response = self.client.post(url) + self.assertEqual(response.status_code, 200) + + self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + self.assertIsNotNone(refresh_token.revoked) + def test_revoke_token_with_wrong_hint(self): """ From the revocation rfc, `Section 4.1.2`_ : From 3e8176a2260f728968086c0a7da2b5601677e391 Mon Sep 17 00:00:00 2001 From: AmirReza Date: Mon, 29 Oct 2018 17:00:45 -0400 Subject: [PATCH 251/722] use the refresh_token of the previous token in case of being in the grace period of re-using refresh token --- oauth2_provider/oauth2_validators.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d9d74d4f2..0c417ebfe 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -530,6 +530,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): else: # make sure that the token data we're returning matches # the existing token + token["refresh_token"] = previous_access_token.refresh_token.token token["access_token"] = previous_access_token.token token["scope"] = previous_access_token.scope From 4a8e1c5ff3d680044ffc545dba2f782199a7fb56 Mon Sep 17 00:00:00 2001 From: AmirReza Date: Tue, 30 Oct 2018 14:19:28 -0400 Subject: [PATCH 252/722] - use "source_refresh_token" for getting the refresh_token of an access_token - add an assertion for checking if it's returning the same refresh_token for the grace period or not. --- oauth2_provider/oauth2_validators.py | 2 +- tests/test_authorization_code.py | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 0c417ebfe..9c2dec4d1 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -530,8 +530,8 @@ def save_bearer_token(self, token, request, *args, **kwargs): else: # make sure that the token data we're returning matches # the existing token - token["refresh_token"] = previous_access_token.refresh_token.token token["access_token"] = previous_access_token.token + token["refresh_token"] = previous_access_token.source_refresh_token.token token["scope"] = previous_access_token.scope # No refresh token should be created, just access token diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index a10e19c46..03d7f5c4b 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -636,6 +636,7 @@ def test_refresh_with_grace_period(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } + refresh_token = content["refresh_token"] response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -643,12 +644,15 @@ def test_refresh_with_grace_period(self): self.assertTrue("access_token" in content) first_access_token = content["access_token"] - # check refresh token returns same data if used twice, see #497 + # check access token returns same data if used twice, see #497 response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) self.assertEqual(content["access_token"], first_access_token) + # refresh token should be the same as well + self.assertTrue("refresh_token" in content) + self.assertEqual(content["refresh_token"], refresh_token) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_invalidates_old_tokens(self): From 560a7f357dbfcf52140c8a21693ab33f597adfb7 Mon Sep 17 00:00:00 2001 From: Jonathan Huot Date: Wed, 5 Dec 2018 09:42:53 +0100 Subject: [PATCH 253/722] Pin oauthlib to avoid breakage with 3.0.0 release. We're going to release OAuthLib 3.0.0 soon, which contains some breaking changes. This pinpoint is to avoid problems before we are able to address the compatibility. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 533bb083e..5d9c449fd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,7 +28,7 @@ include_package_data = True zip_safe = False install_requires = django >= 2.0 - oauthlib >= 2.0.3 + oauthlib >= 2.0.3, < 3.0.0 requests >= 2.13.0 [options.packages.find] From 13f9e4b1c30cdabd4787949cee650332b5bcf2c9 Mon Sep 17 00:00:00 2001 From: Kevin Brown Date: Fri, 4 Jan 2019 23:32:17 -0500 Subject: [PATCH 254/722] Update Travis testing matrix (#679) * Set up tox-travis This will allow us to keep our matrix configuration in Tox instead of having to replicate it within Travis. Right now this does not fully match the Tox configuration, since it didn't previously, but that will be corrected later. * Add testing on Python 3.5 This is in the supported versions and the Tox matrix, but not in the Travis matrix. * Fix running flake8 and docs tox configurations * Test on Python 3.7 * Add Python 3.7 to tox matrix Forgot to do this, so Travis will know what environments to execute. The docs and flake8 remain at Python 3.6 since that's where they were before. --- .travis.yml | 24 ++++++++---------------- tox.ini | 9 +++++---- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/.travis.yml b/.travis.yml index 09419dda2..11cc844c1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,30 +1,22 @@ # https://travis-ci.org/evonove/django-oauth-toolkit sudo: false -language: python +dist: xenial -python: "3.6" +language: python -env: - - TOXENV=py34-django20 - - TOXENV=py36-django20 - - TOXENV=py36-django21 - - TOXENV=py36-djangomaster - - TOXENV=docs - - TOXENV=flake8 +python: + - "3.4" + - "3.5" + - "3.6" + - "3.7" cache: directories: - $HOME/.cache/pip - $TRAVIS_BUILD_DIR/.tox -matrix: - fast_finish: true - - allow_failures: - - env: TOXENV=py36-djangomaster - install: - - pip install coveralls tox + - pip install coveralls tox tox-travis script: - tox diff --git a/tox.ini b/tox.ini index bc57cdabb..b1056509e 100644 --- a/tox.ini +++ b/tox.ini @@ -3,8 +3,9 @@ envlist = py34-django20, py35-django{20,21,master}, py36-django{20,21,master}, - docs, - flake8 + py37-django{20,21,master}, + py36-docs, + py36-flake8 [pytest] django_find_project = false @@ -27,14 +28,14 @@ deps = pytest-xdist py27: mock -[testenv:docs] +[testenv:py36-docs] basepython = python changedir = docs whitelist_externals = make commands = make html deps = sphinx -[testenv:flake8] +[testenv:py36-flake8] skip_install = True commands = flake8 {toxinidir} From 26a3c3e7cb6ab1a98b6eac3e637557b5bc0cdbb6 Mon Sep 17 00:00:00 2001 From: Reece Dunham Date: Sat, 12 Jan 2019 09:30:40 -0500 Subject: [PATCH 255/722] Update .travis.yml * Remove deprecated sudo option * Temp fix for failing builds --- .travis.yml | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/.travis.yml b/.travis.yml index 11cc844c1..1715b596c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,14 +1,10 @@ -# https://travis-ci.org/evonove/django-oauth-toolkit -sudo: false +# https://travis-ci.org/jazzband/django-oauth-toolkit dist: xenial language: python python: - "3.4" - - "3.5" - - "3.6" - - "3.7" cache: directories: From e70638846afa956b45d999ff9d7990fd70d43f21 Mon Sep 17 00:00:00 2001 From: Ben Johnston <6936084+doc-E-brown@users.noreply.github.com> Date: Thu, 21 Feb 2019 21:19:20 +1100 Subject: [PATCH 256/722] Added documentation for using request token in Getting Started (#641) --- docs/rest-framework/getting_started.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 3d3b07620..8028a412f 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -189,6 +189,26 @@ Grab your access_token and start using your new OAuth2 API: # Insert a new user curl -H "Authorization: Bearer " -X POST -d"username=foo&password=bar" http://localhost:8000/users/ +Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`: + +:: + + curl -X POST -d "grant_type=refresh_token&refresh_token=&client_id=&client_secret=" http://localhost:8000/o/token/ + +Your response should be similar to your first access_token request, containing a new access_token and refresh_token: + +.. code-block:: javascript + + { + "access_token": "", + "token_type": "Bearer", + "expires_in": 36000, + "refresh_token": "", + "scope": "read write groups" + } + + + Step 5: Testing Restricted Access --------------------------------- From 32bba99bb6bd39f05981430ec3381ad66b096f15 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 21 Feb 2019 05:27:17 -0500 Subject: [PATCH 257/722] squash migrations (#668) --- CHANGELOG.md | 5 +- oauth2_provider/migrations/0001_initial.py | 55 +++++++--- oauth2_provider/migrations/0002_08_updates.py | 30 ------ .../migrations/0003_auto_20160316_1503.py | 17 --- .../migrations/0004_auto_20160525_1623.py | 26 ----- .../migrations/0005_auto_20170514_1141.py | 100 ------------------ .../migrations/0006_auto_20171214_2232.py | 41 ------- 7 files changed, 45 insertions(+), 229 deletions(-) delete mode 100644 oauth2_provider/migrations/0002_08_updates.py delete mode 100644 oauth2_provider/migrations/0003_auto_20160316_1503.py delete mode 100644 oauth2_provider/migrations/0004_auto_20160525_1623.py delete mode 100644 oauth2_provider/migrations/0005_auto_20170514_1141.py delete mode 100644 oauth2_provider/migrations/0006_auto_20171214_2232.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d98912479..56cabbf59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ -### 1.2.x [unrealeased] +### 1.3.0 [unreleased] * Fix a race condition in creation of AccessToken with external oauth2 server. +* **Backwards-incompatible** squashed migrations: + If you are currently on a release < 1.2.0, you will need to first install 1.2.x then `manage.py migrate` before + upgrading to >= 1.3.0. ### 1.2.0 [2018-06-03] diff --git a/oauth2_provider/migrations/0001_initial.py b/oauth2_provider/migrations/0001_initial.py index 5a831a005..1d1a38e0e 100644 --- a/oauth2_provider/migrations/0001_initial.py +++ b/oauth2_provider/migrations/0001_initial.py @@ -1,4 +1,5 @@ from django.conf import settings +import django.db.models.deletion from django.db import migrations, models import oauth2_provider.generators @@ -7,7 +8,15 @@ class Migration(migrations.Migration): - + """ + The following migrations are squashed here: + - 0001_initial.py + - 0002_08_updates.py + - 0003_auto_20160316_1503.py + - 0004_auto_20160525_1623.py + - 0005_auto_20170514_1141.py + - 0006_auto_20171214_2232.py + """ dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL) ] @@ -16,14 +25,17 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Application', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.BigAutoField(serialize=False, primary_key=True)), ('client_id', models.CharField(default=oauth2_provider.generators.generate_client_id, unique=True, max_length=100, db_index=True)), ('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated', blank=True)), ('client_type', models.CharField(max_length=32, choices=[('confidential', 'Confidential'), ('public', 'Public')])), ('authorization_grant_type', models.CharField(max_length=32, choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')])), ('client_secret', models.CharField(default=oauth2_provider.generators.generate_client_secret, max_length=255, db_index=True, blank=True)), ('name', models.CharField(max_length=255, blank=True)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(related_name="oauth2_provider_application", blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), ], options={ 'abstract': False, @@ -33,12 +45,16 @@ class Migration(migrations.Migration): migrations.CreateModel( name='AccessToken', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('token', models.CharField(max_length=255, db_index=True)), + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('token', models.CharField(unique=True, max_length=255)), ('expires', models.DateTimeField()), ('scope', models.TextField(blank=True)), - ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + # Circular reference. Can't add it here. + #('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token")), ], options={ 'abstract': False, @@ -48,13 +64,15 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Grant', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('code', models.CharField(max_length=255, db_index=True)), + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('code', models.CharField(unique=True, max_length=255)), ('expires', models.DateTimeField()), ('redirect_uri', models.CharField(max_length=255)), ('scope', models.TextField(blank=True)), ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), ], options={ 'abstract': False, @@ -64,15 +82,24 @@ class Migration(migrations.Migration): migrations.CreateModel( name='RefreshToken', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('token', models.CharField(max_length=255, db_index=True)), - ('access_token', models.OneToOneField(related_name='refresh_token', to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.CASCADE)), + ('id', models.BigAutoField(serialize=False, primary_key=True)), + ('token', models.CharField(max_length=255)), + ('access_token', models.OneToOneField(blank=True, null=True, related_name="refresh_token", to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL)), ('application', models.ForeignKey(to=oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE)), - ('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), ], options={ 'abstract': False, 'swappable': 'OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL', + 'unique_together': set([("token", "revoked")]), }, ), + migrations.AddField( + model_name='AccessToken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token"), + ), ] diff --git a/oauth2_provider/migrations/0002_08_updates.py b/oauth2_provider/migrations/0002_08_updates.py deleted file mode 100644 index 79d2279bd..000000000 --- a/oauth2_provider/migrations/0002_08_updates.py +++ /dev/null @@ -1,30 +0,0 @@ -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("oauth2_provider", "0001_initial"), - ] - - operations = [ - migrations.AddField( - model_name="Application", - name="skip_authorization", - field=models.BooleanField(default=False), - preserve_default=True, - ), - migrations.AlterField( - model_name="Application", - name="user", - field=models.ForeignKey(related_name="oauth2_provider_application", to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE), - preserve_default=True, - ), - migrations.AlterField( - model_name="AccessToken", - name="user", - field=models.ForeignKey(blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), - preserve_default=True, - ), - ] diff --git a/oauth2_provider/migrations/0003_auto_20160316_1503.py b/oauth2_provider/migrations/0003_auto_20160316_1503.py deleted file mode 100644 index cd0b21965..000000000 --- a/oauth2_provider/migrations/0003_auto_20160316_1503.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("oauth2_provider", "0002_08_updates"), - ] - - operations = [ - migrations.AlterField( - model_name="application", - name="user", - field=models.ForeignKey(related_name="oauth2_provider_application", blank=True, to=settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE), - ), - ] diff --git a/oauth2_provider/migrations/0004_auto_20160525_1623.py b/oauth2_provider/migrations/0004_auto_20160525_1623.py deleted file mode 100644 index 052a0ad59..000000000 --- a/oauth2_provider/migrations/0004_auto_20160525_1623.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("oauth2_provider", "0003_auto_20160316_1503"), - ] - - operations = [ - migrations.AlterField( - model_name="accesstoken", - name="token", - field=models.CharField(unique=True, max_length=255), - ), - migrations.AlterField( - model_name="grant", - name="code", - field=models.CharField(unique=True, max_length=255), - ), - migrations.AlterField( - model_name="refreshtoken", - name="token", - field=models.CharField(unique=True, max_length=255), - ), - ] diff --git a/oauth2_provider/migrations/0005_auto_20170514_1141.py b/oauth2_provider/migrations/0005_auto_20170514_1141.py deleted file mode 100644 index 81d8343c9..000000000 --- a/oauth2_provider/migrations/0005_auto_20170514_1141.py +++ /dev/null @@ -1,100 +0,0 @@ -# Generated by Django 1.11.1 on 2017-05-14 11:41 -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0004_auto_20160525_1623'), - ] - - operations = [ - migrations.AlterField( - model_name='accesstoken', - name='application', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL), - ), - migrations.AlterField( - model_name='accesstoken', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='accesstoken', - name='user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_accesstoken', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='application', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='grant', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='grant', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_grant', to=settings.AUTH_USER_MODEL), - ), - migrations.AlterField( - model_name='refreshtoken', - name='id', - field=models.BigAutoField(primary_key=True, serialize=False), - ), - migrations.AlterField( - model_name='refreshtoken', - name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_refreshtoken', to=settings.AUTH_USER_MODEL), - ), - migrations.AddField( - model_name='accesstoken', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='accesstoken', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='application', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='application', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='grant', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='grant', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - migrations.AddField( - model_name='refreshtoken', - name='created', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - migrations.AddField( - model_name='refreshtoken', - name='updated', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/oauth2_provider/migrations/0006_auto_20171214_2232.py b/oauth2_provider/migrations/0006_auto_20171214_2232.py deleted file mode 100644 index 7fb773bf5..000000000 --- a/oauth2_provider/migrations/0006_auto_20171214_2232.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 1.11.1 on 2017-05-14 11:41 -import django.db.models.deletion -from django.db import migrations, models - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - ("oauth2_provider", "0005_auto_20170514_1141"), - ] - - operations = [ - migrations.AddField( - model_name="accesstoken", - name="source_refresh_token", - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=oauth2_settings.REFRESH_TOKEN_MODEL, related_name="refreshed_access_token"), - preserve_default=False, - ), - migrations.AddField( - model_name="refreshtoken", - name="revoked", - field=models.DateTimeField(null=True, default=None), - preserve_default=False, - ), - migrations.AlterField( - model_name="refreshtoken", - name="token", - field=models.CharField(max_length=255), - ), - migrations.AlterField( - model_name="refreshtoken", - name="access_token", - field=models.OneToOneField(blank=True, null=True, related_name="refresh_token", to=oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL), - ), - migrations.AlterUniqueTogether( - name="refreshtoken", - unique_together=set([("token", "revoked")]), - ), - ] From 510ebd0d92310d0c6541dbe4f898e1aae54c15ab Mon Sep 17 00:00:00 2001 From: Armands Vijups Date: Thu, 21 Feb 2019 19:01:28 +0200 Subject: [PATCH 258/722] Feature/test for clear expired (#680) * document force migration sequence forr application model * add tests for clear_expired * add tests for clear_tokens * add tests for clear_expired * fix isort * Update tests/test_models.py Co-Authored-By: ecilveks * CR changes * fix isort for upstream * add logging about what is to be deleted * also log when no refresh tokens are to be deleted --- docs/advanced_topics.rst | 9 ++++++ oauth2_provider/models.py | 34 +++++++++++++++++++--- tests/test_models.py | 61 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index ea65cbe50..3fa1519b1 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -48,6 +48,15 @@ Be aware that, when you intend to swap the application model, you should create migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. You'll run into models.E022 in Core system checks if you don't get the order right. +You can force your migration providing the custom model to run in the right order by +adding:: + + run_before = [ + ('oauth2_provider', '0001_initial'), + ] + +to the migration class. + That's all, now Django OAuth Toolkit will use your model wherever an Application instance is needed. **Notice:** `OAUTH2_PROVIDER_APPLICATION_MODEL` is the only setting variable that is not namespaced, this diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 6f7cc096e..7d08f88fb 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,5 +1,6 @@ from datetime import timedelta from urllib.parse import parse_qsl, urlparse +import logging from django.apps import apps from django.conf import settings @@ -14,6 +15,8 @@ from .settings import oauth2_settings from .validators import RedirectURIValidator, WildcardSet +logger = logging.getLogger(__name__) + class AbstractApplication(models.Model): """ @@ -436,7 +439,30 @@ def clear_expired(): with transaction.atomic(): if refresh_expire_at: - refresh_token_model.objects.filter(revoked__lt=refresh_expire_at).delete() - refresh_token_model.objects.filter(access_token__expires__lt=refresh_expire_at).delete() - access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now).delete() - grant_model.objects.filter(expires__lt=now).delete() + revoked = refresh_token_model.objects.filter( + revoked__lt=refresh_expire_at, + ) + expired = refresh_token_model.objects.filter( + access_token__expires__lt=refresh_expire_at, + ) + + logger.info('%s Revoked refresh tokens to be deleted', revoked.count()) + logger.info('%s Expired refresh tokens to be deleted', expired.count()) + + revoked.delete() + expired.delete() + else: + logger.info('refresh_expire_at is %s. No refresh tokens deleted.', + refresh_expire_at) + + access_tokens = access_token_model.objects.filter( + refresh_token__isnull=True, + expires__lt=now + ) + grants = grant_model.objects.filter(expires__lt=now) + + logger.info('%s Expired access tokens to be deleted', access_tokens.count()) + logger.info('%s Expired grant tokens to be deleted', grants.count()) + + access_tokens.delete() + grants.delete() diff --git a/tests/test_models.py b/tests/test_models.py index 45533d1a7..8adc3b099 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,11 +1,14 @@ +from datetime import datetime as dt + +import pytest from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone from oauth2_provider.models import ( - get_access_token_model, get_application_model, + clear_expired, get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model ) from oauth2_provider.settings import oauth2_settings @@ -19,6 +22,7 @@ class TestModels(TestCase): + def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -118,6 +122,7 @@ def test_scopes_property(self): OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" ) class TestCustomModels(TestCase): + def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -260,6 +265,7 @@ def test_expires_can_be_none(self): class TestAccessTokenModel(TestCase): + def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -289,3 +295,54 @@ class TestRefreshTokenModel(TestCase): def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) + + +class TestClearExpired(TestCase): + + def setUp(self): + self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + # Insert two tokens on database. + AccessToken.objects.create( + id=1, + token="555", + expires=dt.now(), + scope=2, + application_id=3, + user_id=1, + created=dt.now(), + updated=dt.now(), + source_refresh_token_id="0", + ) + AccessToken.objects.create( + id=2, + token="666", + expires=dt.now(), + scope=2, + application_id=3, + user_id=1, + created=dt.now(), + updated=dt.now(), + source_refresh_token_id="1", + ) + + def test_clear_expired_tokens(self): + oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 + assert clear_expired() is None + + def test_clear_expired_tokens_incorect_timetype(self): + oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" + with pytest.raises(ImproperlyConfigured) as excinfo: + clear_expired() + result = excinfo.value.__class__.__name__ + assert result == "ImproperlyConfigured" + + def test_clear_expired_tokens_with_tokens(self): + self.client.login(username="test_user", password="123456") + oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 + ttokens = AccessToken.objects.count() + expiredt = AccessToken.objects.filter(expires__lte=dt.now()).count() + assert ttokens == 2 + assert expiredt == 2 + clear_expired() + expiredt = AccessToken.objects.filter(expires__lte=dt.now()).count() + assert expiredt == 0 From fed914c993eb51d7ec0d65798139da19082cf260 Mon Sep 17 00:00:00 2001 From: Mattia Procopio Date: Thu, 4 Apr 2019 21:06:38 +0200 Subject: [PATCH 259/722] Add hook for creating custom refresh tokens (#695) --- oauth2_provider/oauth2_validators.py | 17 ++++++++++------- tests/test_oauth2_validators.py | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 9c2dec4d1..57d4f2277 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -520,13 +520,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - refresh_token = RefreshToken( - user=request.user, - token=refresh_token_code, - application=request.client, - access_token=access_token - ) - refresh_token.save() + self._create_refresh_token(request, refresh_token_code, access_token) else: # make sure that the token data we're returning matches # the existing token @@ -553,6 +547,15 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non access_token.save() return access_token + def _create_refresh_token(self, request, refresh_token_code, access_token): + refresh_token = RefreshToken( + user=request.user, + token=refresh_token_code, + application=request.client, + access_token=access_token + ) + refresh_token.save() + def revoke_token(self, token, token_type_hint, request, *args, **kwargs): """ Revoke an access or refresh token. diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 5f91af5ce..dd07d37a1 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -270,6 +270,23 @@ def test_save_bearer_token__with_no_refresh_token__creates_new_access_token_only self.assertEqual(0, RefreshToken.objects.count()) self.assertEqual(1, AccessToken.objects.count()) + def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_refresh_tokens(self): + token = { + "scope": "foo bar", + "refresh_token": "abc", + "access_token": "123", + } + # Mock private methods to create access and refresh tokens + create_access_token_mock = mock.MagicMock() + create_refresh_token_mock = mock.MagicMock() + self.validator._create_refresh_token = create_refresh_token_mock + self.validator._create_access_token = create_access_token_mock + + self.validator.save_bearer_token(token, self.request) + + create_access_token_mock.assert_called_once() + create_refresh_token_mock.asert_called_once() + class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned From dc429adbce4eea14f02c8b10c9e1b0f5dd4d0186 Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Sat, 6 Apr 2019 16:00:41 -0300 Subject: [PATCH 260/722] PKCE support with oauthlib 3 (#678) * add basic pkce support * fixed flak8 errors * documented test cases * added new tests and fixed old ones * fixed invalid pkce algorithm test * removed bad quotes * revert to non-nullable charfields * squashed migrations * fix dependencies and tests to work with oauthlib 3 * add support for oauthlib 3's PKCE implementation * added oauthlib dep to docs toxenv * remove sublime file * remove vestigial implementation of code challenge verification * flake errors * pinned oauthlib to a pypi released version * oauthlib 3 * removed broken migration --- docs/settings.rst | 7 + oauth2_provider/forms.py | 2 + .../migrations/0002_auto_20190406_1805.py | 23 ++ oauth2_provider/models.py | 13 + oauth2_provider/oauth2_backends.py | 1 - oauth2_provider/oauth2_validators.py | 32 +- oauth2_provider/settings.py | 3 + oauth2_provider/views/base.py | 10 +- setup.cfg | 2 +- tests/test_authorization_code.py | 353 +++++++++++++++++- tests/test_password.py | 2 +- tox.ini | 2 + 12 files changed, 437 insertions(+), 13 deletions(-) create mode 100644 oauth2_provider/migrations/0002_auto_20190406_1805.py diff --git a/docs/settings.rst b/docs/settings.rst index 506d57d3e..49a060851 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -185,3 +185,10 @@ RESOURCE_SERVER_TOKEN_CACHING_SECONDS The number of seconds an authorization token received from the introspection endpoint remains valid. If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time will be used. + + +PKCE_REQUIRED +~~~~~~~~~~~~~ +Default: ``False`` + +Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool. diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 484720223..2e465959a 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -8,3 +8,5 @@ class AllowForm(forms.Form): client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) + code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) + code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) diff --git a/oauth2_provider/migrations/0002_auto_20190406_1805.py b/oauth2_provider/migrations/0002_auto_20190406_1805.py new file mode 100644 index 000000000..8ca177abf --- /dev/null +++ b/oauth2_provider/migrations/0002_auto_20190406_1805.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2 on 2019-04-06 18:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='grant', + name='code_challenge', + field=models.CharField(blank=True, default='', max_length=128), + ), + migrations.AddField( + model_name='grant', + name='code_challenge_method', + field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 7d08f88fb..1489a8845 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -202,7 +202,16 @@ class AbstractGrant(models.Model): :data:`settings.AUTHORIZATION_CODE_EXPIRE_SECONDS` * :attr:`redirect_uri` Self explained * :attr:`scope` Required scopes, optional + * :attr:`code_challenge` PKCE code challenge + * :attr:`code_challenge_method` PKCE code challenge transform algorithm """ + CODE_CHALLENGE_PLAIN = "plain" + CODE_CHALLENGE_S256 = "S256" + CODE_CHALLENGE_METHODS = ( + (CODE_CHALLENGE_PLAIN, "plain"), + (CODE_CHALLENGE_S256, "S256") + ) + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -219,6 +228,10 @@ class AbstractGrant(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + code_challenge = models.CharField(max_length=128, blank=True, default="") + code_challenge_method = models.CharField( + max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS) + def is_expired(self): """ Check token expiration with timezone awareness diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 8a1c239b5..f71f46e9b 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -85,7 +85,6 @@ def validate_authorization_request(self, request): """ try: uri, http_method, body, headers = self._extract_params(request) - scopes, credentials = self.server.validate_authorization_request( uri, http_method=http_method, body=body, headers=headers) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 57d4f2277..096702db8 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -427,12 +427,38 @@ def get_default_scopes(self, client_id, request, *args, **kwargs): def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): return request.client.redirect_uri_allowed(redirect_uri) + def is_pkce_required(self, client_id, request): + """ + Enables or disables PKCE verification. + + Uses the setting PKCE_REQUIRED, which can be either a bool or a callable that + receives the client id and returns a bool. + """ + if callable(oauth2_settings.PKCE_REQUIRED): + return oauth2_settings.PKCE_REQUIRED(client_id) + return oauth2_settings.PKCE_REQUIRED + + def get_code_challenge(self, code, request): + grant = Grant.objects.get(code=code, application=request.client) + return grant.code_challenge or None + + def get_code_challenge_method(self, code, request): + grant = Grant.objects.get(code=code, application=request.client) + return grant.code_challenge_method or None + def save_authorization_code(self, client_id, code, request, *args, **kwargs): expires = timezone.now() + timedelta( seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - g = Grant(application=request.client, user=request.user, code=code["code"], - expires=expires, redirect_uri=request.redirect_uri, - scope=" ".join(request.scopes)) + g = Grant( + application=request.client, + user=request.user, + code=code["code"], + expires=expires, + redirect_uri=request.redirect_uri, + scope=" ".join(request.scopes), + code_challenge=request.code_challenge or "", + code_challenge_method=request.code_challenge_method or "" + ) g.save() def rotate_refresh_token(self, request): diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index c8551b1ae..53f163142 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -62,6 +62,9 @@ "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, + + # Whether or not PKCE is required + "PKCE_REQUIRED": False } # List of settings that cannot be empty diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index f4978605f..459f48511 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -97,6 +97,8 @@ def get_initial(self): "client_id": self.oauth2_data.get("client_id", None), "state": self.oauth2_data.get("state", None), "response_type": self.oauth2_data.get("response_type", None), + "code_challenge": self.oauth2_data.get("code_challenge", None), + "code_challenge_method": self.oauth2_data.get("code_challenge_method", None), } return initial_data @@ -107,8 +109,12 @@ def form_valid(self, form): "client_id": form.cleaned_data.get("client_id"), "redirect_uri": form.cleaned_data.get("redirect_uri"), "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None), + "state": form.cleaned_data.get("state", None) } + if form.cleaned_data.get("code_challenge", False): + credentials["code_challenge"] = form.cleaned_data.get("code_challenge") + if form.cleaned_data.get("code_challenge_method", False): + credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method") scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") @@ -143,6 +149,8 @@ def get(self, request, *args, **kwargs): kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] + kwargs["code_challenge"] = credentials.get("code_challenge", None) + kwargs["code_challenge_method"] = credentials.get("code_challenge_method", None) self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 diff --git a/setup.cfg b/setup.cfg index 5d9c449fd..1901b5e36 100644 --- a/setup.cfg +++ b/setup.cfg @@ -28,8 +28,8 @@ include_package_data = True zip_safe = False install_requires = django >= 2.0 - oauthlib >= 2.0.3, < 3.0.0 requests >= 2.13.0 + oauthlib >= 3.0.1 [options.packages.find] exclude = tests diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 03d7f5c4b..25dc4adb5 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1,5 +1,6 @@ import base64 import datetime +import hashlib import json from urllib.parse import parse_qs, urlencode, urlparse @@ -7,6 +8,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +from django.utils.crypto import get_random_string from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( @@ -537,6 +539,40 @@ def get_auth(self): query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() + def generate_pkce_codes(self, algorithm, length=43): + """ + Helper method to generate pkce codes + """ + code_verifier = get_random_string(length) + if algorithm == "S256": + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).decode().rstrip("=") + else: + code_challenge = code_verifier + return code_verifier, code_challenge + + def get_pkce_auth(self, code_challenge, code_challenge_method): + """ + Helper method to retrieve a valid authorization code using pkce + """ + oauth2_settings.PKCE_REQUIRED = True + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": code_challenge_method, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + oauth2_settings.PKCE_REQUIRED = False + return query_dict["code"].pop() + def test_basic_auth(self): """ Request an access token using basic authentication for client authentication @@ -599,7 +635,7 @@ def test_refresh(self): # check refresh token cannot be used twice response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) @@ -739,7 +775,7 @@ def test_refresh_bad_scopes(self): "scope": "read write nuke", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): """ @@ -767,7 +803,7 @@ def test_refresh_fail_repeating_requests(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): """ @@ -806,7 +842,7 @@ def test_refresh_repeating_requests(self): rt.save() response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_repeating_requests_non_rotating_tokens(self): @@ -855,7 +891,7 @@ def test_basic_auth_bad_authcode(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): """ @@ -891,7 +927,7 @@ def test_basic_auth_grant_expired(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): """ @@ -980,6 +1016,311 @@ def test_public(self): self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + def test_public_pkce_S256_authorize_get(self): + """ + Request an access token using client_type: public + and PKCE enabled. Tests if the authorize get is successfull + for the S256 algorithm + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + oauth2_settings.PKCE_REQUIRED = True + + query_string = urlencode({ + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "S256" + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_plain_authorize_get(self): + """ + Request an access token using client_type: public + and PKCE enabled. Tests if the authorize get is successfull + for the plain algorithm + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("plain") + oauth2_settings.PKCE_REQUIRED = True + + query_string = urlencode({ + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "plain" + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + print(code_challenge) + print(response.context_data) + print(url) + self.assertEqual(response.status_code, 200) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_S256(self): + """ + Request an access token using client_type: public + and PKCE enabled with the S256 algorithm + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + authorization_code = self.get_pkce_auth(code_challenge, "S256") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "code_verifier": code_verifier + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_plain(self): + """ + Request an access token using client_type: public + and PKCE enabled with the plain algorithm + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("plain") + authorization_code = self.get_pkce_auth(code_challenge, "plain") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "code_verifier": code_verifier + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + print(response.content) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_invalid_algorithm(self): + """ + Request an access token using client_type: public + and PKCE enabled with an invalid algorithm + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("invalid") + oauth2_settings.PKCE_REQUIRED = True + + query_string = urlencode({ + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge, + "code_challenge_method": "invalid", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_request", response["Location"]) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_missing_code_challenge(self): + """ + Request an access token using client_type: public + and PKCE enabled but with the code_challenge missing + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.skip_authorization = True + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + oauth2_settings.PKCE_REQUIRED = True + + query_string = urlencode({ + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge_method": "S256" + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_request", response["Location"]) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_missing_code_challenge_method(self): + """ + Request an access token using client_type: public + and PKCE enabled but with the code_challenge_method missing + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + oauth2_settings.PKCE_REQUIRED = True + + query_string = urlencode({ + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + "code_challenge": code_challenge + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_S256_invalid_code_verifier(self): + """ + Request an access token using client_type: public + and PKCE enabled with the S256 algorithm and an invalid code_verifier + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + authorization_code = self.get_pkce_auth(code_challenge, "S256") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "code_verifier": "invalid" + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_plain_invalid_code_verifier(self): + """ + Request an access token using client_type: public + and PKCE enabled with the plain algorithm and an invalid code_verifier + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("plain") + authorization_code = self.get_pkce_auth(code_challenge, "plain") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "code_verifier": "invalid" + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_S256_missing_code_verifier(self): + """ + Request an access token using client_type: public + and PKCE enabled with the S256 algorithm and the code_verifier missing + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("S256") + authorization_code = self.get_pkce_auth(code_challenge, "S256") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + oauth2_settings.PKCE_REQUIRED = False + + def test_public_pkce_plain_missing_code_verifier(self): + """ + Request an access token using client_type: public + and PKCE enabled with the plain algorithm and the code_verifier missing + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + code_verifier, code_challenge = self.generate_pkce_codes("plain") + authorization_code = self.get_pkce_auth(code_challenge, "plain") + oauth2_settings.PKCE_REQUIRED = True + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + oauth2_settings.PKCE_REQUIRED = False + def test_malicious_redirect_uri(self): """ Request an access token using client_type: public and ensure redirect_uri is diff --git a/tests/test_password.py b/tests/test_password.py index bf161bf44..a6f3f5dd6 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -76,7 +76,7 @@ def test_bad_credentials(self): auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 400) class TestPasswordProtectedResource(BaseTest): diff --git a/tox.ini b/tox.ini index b1056509e..a492aeaf0 100644 --- a/tox.ini +++ b/tox.ini @@ -21,6 +21,7 @@ deps = django21: Django>=2.1,<2.2 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework + oauthlib>=3.0.1 coverage pytest pytest-cov @@ -34,6 +35,7 @@ changedir = docs whitelist_externals = make commands = make html deps = sphinx + oauthlib>=3.0.1 [testenv:py36-flake8] skip_install = True From 6d0c51325663aba5349a957aff12da0cc48f318a Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Wed, 10 Apr 2019 03:23:48 -0300 Subject: [PATCH 261/722] return new refresh token during grace period (#703) --- oauth2_provider/oauth2_validators.py | 4 +++- tests/test_authorization_code.py | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 096702db8..d4085d952 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -551,7 +551,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = previous_access_token.source_refresh_token.token + token["refresh_token"] = RefreshToken.objects.filter( + access_token=previous_access_token + ).first().token token["scope"] = previous_access_token.scope # No refresh token should be created, just access token diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 25dc4adb5..1a3a400db 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -672,13 +672,14 @@ def test_refresh_with_grace_period(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - refresh_token = content["refresh_token"] + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) first_access_token = content["access_token"] + first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) @@ -688,7 +689,7 @@ def test_refresh_with_grace_period(self): self.assertEqual(content["access_token"], first_access_token) # refresh token should be the same as well self.assertTrue("refresh_token" in content) - self.assertEqual(content["refresh_token"], refresh_token) + self.assertEqual(content["refresh_token"], first_refresh_token) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_invalidates_old_tokens(self): From 2fdb0fea4efec9bc79708f0ac1b4b24884f26f97 Mon Sep 17 00:00:00 2001 From: Gustavo Maronato Date: Sat, 20 Apr 2019 09:39:47 -0300 Subject: [PATCH 262/722] Fix PKCE credentials not being captured during authorize requests (#707) * fix pkce credentials not being passed by oauthlib * support oauthlibs native implementation when added --- oauth2_provider/views/base.py | 15 +++++++++++++-- tests/test_authorization_code.py | 4 ---- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 459f48511..51a1ecccb 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -32,6 +32,7 @@ class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View): * Implicit grant """ + def dispatch(self, request, *args, **kwargs): self.oauth2_data = {} return super().dispatch(request, *args, **kwargs) @@ -132,6 +133,16 @@ def form_valid(self, form): def get(self, request, *args, **kwargs): try: scopes, credentials = self.validate_authorization_request(request) + # TODO: Remove the two following lines after oauthlib updates its implementation + # https://github.com/jazzband/django-oauth-toolkit/pull/707#issuecomment-485011945 + credentials["code_challenge"] = credentials.get( + "code_challenge", + request.GET.get("code_challenge", None) + ) + credentials["code_challenge_method"] = credentials.get( + "code_challenge_method", + request.GET.get("code_challenge_method", None) + ) except OAuthToolkitError as error: # Application is not available at this time. return self.error_response(error, application=None) @@ -149,8 +160,8 @@ def get(self, request, *args, **kwargs): kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] - kwargs["code_challenge"] = credentials.get("code_challenge", None) - kwargs["code_challenge_method"] = credentials.get("code_challenge_method", None) + kwargs["code_challenge"] = credentials["code_challenge"] + kwargs["code_challenge_method"] = credentials["code_challenge_method"] self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 1a3a400db..26788a6e5 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1072,9 +1072,6 @@ def test_public_pkce_plain_authorize_get(self): url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) - print(code_challenge) - print(response.context_data) - print(url) self.assertEqual(response.status_code, 200) oauth2_settings.PKCE_REQUIRED = False @@ -1130,7 +1127,6 @@ def test_public_pkce_plain(self): } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - print(response.content) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) From 846ab0ba8acaa3e4870b424700544aa6329511e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ege=20G=C3=BCne=C5=9F?= Date: Thu, 9 May 2019 12:16:46 +0300 Subject: [PATCH 263/722] Fix not supported FOR UPDATE query for Postgresql, fixes #714 (#715) --- oauth2_provider/oauth2_validators.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d4085d952..1e80a5cb9 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -332,15 +332,14 @@ def _get_token_from_authentication_server( scope = content.get("scope", "") expires = make_aware(expires) - access_token, _created = AccessToken\ - .objects.select_related("application", "user")\ - .update_or_create(token=token, - defaults={ - "user": user, - "application": None, - "scope": scope, - "expires": expires, - }) + access_token, _created = AccessToken.objects.update_or_create( + token=token, + defaults={ + "user": user, + "application": None, + "scope": scope, + "expires": expires, + }) return access_token From 1efe210b69b313f50dcd3db1b9a05a0e3a5a5b54 Mon Sep 17 00:00:00 2001 From: safaer <30508702+ermissa@users.noreply.github.com> Date: Thu, 16 May 2019 03:55:54 +0300 Subject: [PATCH 264/722] add login warning for application registration to docs (#713) --- docs/tutorial/tutorial_01.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 9f32c87ab..eaaab05a7 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -85,7 +85,8 @@ the API, subject to approval by its users. Let's register your application. -Point your browser to http://localhost:8000/o/applications/ and add an Application instance. +You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that +point your browser to http://localhost:8000/o/applications/ and add an Application instance. `Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: * `User`: the owner of the Application (e.g. a developer, or the currently logged in user.) From d5da62b2278632b45bc0c983a380ee106e0af1c3 Mon Sep 17 00:00:00 2001 From: Mattia Procopio Date: Tue, 21 May 2019 12:24:55 +0200 Subject: [PATCH 265/722] Added management command to create applications (#704) --- .../management/commands/createapplication.py | 85 ++++++++++++ tests/test_commands.py | 127 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 oauth2_provider/management/commands/createapplication.py create mode 100644 tests/test_commands.py diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py new file mode 100644 index 000000000..e63d54280 --- /dev/null +++ b/oauth2_provider/management/commands/createapplication.py @@ -0,0 +1,85 @@ +from django.core.exceptions import ValidationError +from django.core.management.base import BaseCommand + +from oauth2_provider.models import get_application_model + +Application = get_application_model() + + +class Command(BaseCommand): + help = "Shortcut to create a new application in a programmatic way" + + def add_arguments(self, parser): + parser.add_argument( + 'client_type', + type=str, + help='The client type, can be confidential or public', + ) + parser.add_argument( + 'authorization_grant_type', + type=str, + help='The type of authorization grant to be used', + ) + parser.add_argument( + '--client-id', + type=str, + help='The ID of the new application', + ) + parser.add_argument( + '--user', + type=str, + help='The user the application belongs to', + ) + parser.add_argument( + '--redirect-uris', + type=str, + help='The redirect URIs, this must be a space separated string e.g "URI1 URI2', + ) + parser.add_argument( + '--client-secret', + type=str, + help='The secret for this application', + ) + parser.add_argument( + '--name', + type=str, + help='The name this application', + ) + parser.add_argument( + '--skip-authorization', + action='store_true', + help='The ID of the new application', + ) + + def handle(self, *args, **options): + # Extract all fields related to the application, this will work now and in the future + # and also with custom application models. + application_fields = [field.name for field in Application._meta.fields] + application_data = {} + for key, value in options.items(): + # Data in options must be cleaned because there are unneded key-value like + # verbosity and others. Also do not pass any None to the Application + # instance so default values will be generated for those fields + if key in application_fields and value: + if key == 'user': + application_data.update({'user_id': value}) + else: + application_data.update({key: value}) + + new_application = Application(**application_data) + + try: + new_application.full_clean() + except ValidationError as exc: + errors = "\n ".join(['- ' + err_key + ': ' + str(err_value) for err_key, + err_value in exc.message_dict.items()]) + self.stdout.write( + self.style.ERROR( + 'Please correct the following errors:\n %s' % errors + ) + ) + else: + new_application.save() + self.stdout.write( + self.style.SUCCESS('New application created successfully') + ) diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 000000000..8f1ddc27f --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,127 @@ +from io import StringIO + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase + +from oauth2_provider.models import get_application_model + +Application = get_application_model() + + +class CreateApplicationTest(TestCase): + + def test_command_creates_application(self): + output = StringIO() + self.assertEqual(Application.objects.count(), 0) + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + stdout=output, + ) + self.assertEqual(Application.objects.count(), 1) + self.assertIn('New application created successfully', output.getvalue()) + + def test_missing_required_args(self): + self.assertEqual(Application.objects.count(), 0) + with self.assertRaises(CommandError) as ctx: + call_command( + 'createapplication', + '--redirect-uris=http://example.com http://example2.com', + ) + + self.assertIn('client_type', ctx.exception.args[0]) + self.assertIn('authorization_grant_type', ctx.exception.args[0]) + self.assertEqual(Application.objects.count(), 0) + + def test_command_creates_application_with_skipped_auth(self): + self.assertEqual(Application.objects.count(), 0) + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--skip-authorization', + ) + app = Application.objects.get() + + self.assertTrue(app.skip_authorization) + + def test_application_created_normally_with_no_skipped_auth(self): + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + ) + app = Application.objects.get() + + self.assertFalse(app.skip_authorization) + + def test_application_created_with_name(self): + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--name=TEST', + ) + app = Application.objects.get() + + self.assertEqual(app.name, 'TEST') + + def test_application_created_with_client_secret(self): + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--client-secret=SECRET', + ) + app = Application.objects.get() + + self.assertEqual(app.client_secret, 'SECRET') + + def test_application_created_with_client_id(self): + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--client-id=someId', + ) + app = Application.objects.get() + + self.assertEqual(app.client_id, 'someId') + + def test_application_created_with_user(self): + User = get_user_model() + user = User.objects.create() + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--user=%s' % user.pk, + ) + app = Application.objects.get() + + self.assertEqual(app.user, user) + + def test_validation_failed_message(self): + output = StringIO() + call_command( + 'createapplication', + 'confidential', + 'authorization-code', + '--redirect-uris=http://example.com http://example2.com', + '--user=783', + stdout=output, + ) + + self.assertIn('user', output.getvalue()) + self.assertIn('783', output.getvalue()) + self.assertIn('does not exist', output.getvalue()) From f86dfb8a7f20065850fe3b3629e18723658f835d Mon Sep 17 00:00:00 2001 From: George Pearson Date: Fri, 7 Jun 2019 12:27:42 +0100 Subject: [PATCH 266/722] Use getattr for oauth2_error access (#633) (#716) * Use getattr for oauth2_error access (#633) If the request doesn't have a oauth2_error property the authenticate_header method errors. This can happen when the oauthlib_core.verify_request method raises exceptions in authenticate. It is useful to be able to raise AuthenticationFailed exceptions from within a custom validate_bearer_token method which causes this. * Add Test OAuth2Authentication authenticate override Added a test for if an authenticate method that returns None is used. This should result in a HTTP 401 response for any request. --- .../contrib/rest_framework/authentication.py | 3 ++- tests/test_rest_framework.py | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 30a2d52c4..228361967 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -39,7 +39,8 @@ def authenticate_header(self, request): www_authenticate_attributes = OrderedDict([ ("realm", self.www_authenticate_realm,), ]) - www_authenticate_attributes.update(request.oauth2_error) + oauth2_error = getattr(request, "oauth2_error", {}) + www_authenticate_attributes.update(oauth2_error) return "Bearer {attributes}".format( attributes=self._dict_to_string(www_authenticate_attributes), ) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index c250022c6..0251d98fe 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -98,6 +98,13 @@ class TokenHasScopeViewWrongAuth(BrokenOAuth2View): class MethodScopeAltViewWrongAuth(BrokenOAuth2View): permission_classes = [TokenMatchesOASRequirements] +class AuthenticationNone(OAuth2Authentication): + def authenticate(self, request): + return None + +class AuthenticationNoneOAuth2View(MockView): + authentication_classes = [AuthenticationNone] + urlpatterns = [ url(r"^oauth2/", include("oauth2_provider.urls")), @@ -110,6 +117,7 @@ class MethodScopeAltViewWrongAuth(BrokenOAuth2View): url(r"^oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), url(r"^oauth2-method-scope-fail/$", MethodScopeAltViewBad.as_view()), url(r"^oauth2-method-scope-missing-auth/$", MethodScopeAltViewWrongAuth.as_view()), + url(r"^oauth2-authentication-none/$", AuthenticationNoneOAuth2View.as_view()), ] @@ -399,3 +407,8 @@ def test_method_scope_alt_missing_scope_attr(self): with self.assertRaises(AssertionError) as e: self.client.get("/oauth2-method-scope-missing-auth/", HTTP_AUTHORIZATION=auth) self.assertTrue("`oauth2_provider.rest_framework.OAuth2Authentication`" in str(e.exception)) + + def test_authentication_none(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-authentication-none/", HTTP_AUTHORIZATION=auth) + self.assertEqual(response.status_code, 401) From 392257a77473b7eb75899dd2475482a7c3729e3f Mon Sep 17 00:00:00 2001 From: Rudolf Olah Date: Mon, 26 Aug 2019 13:56:02 -0400 Subject: [PATCH 267/722] getting_started.rst: add JSONOAuthLibCore as part of tutorial (#734) I had trouble getting `grant_type=password` working with the Django REST Framework example in the DOT tutorial. The answer was to set the OAuth2 backend class to `'oauth2_provider.oauth2_backends.JSONOAuthLibCore'`: https://github.com/jazzband/django-oauth-toolkit/issues/127#issuecomment-94439287 I was trying to use Angular framework with its HttpClient and I think the POST requests were sending over content with `application/json`. It would have been a better experience to see this documented in the tutorial. --- docs/rest-framework/getting_started.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 8028a412f..b92c08e4f 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -100,6 +100,8 @@ Also add the following to your `settings.py` module: .. code-block:: python OAUTH2_PROVIDER = { + # parses OAuth2 data from application/json requests + 'OAUTH2_BACKEND_CLASS': 'oauth2_provider.oauth2_backends.JSONOAuthLibCore', # this is the list of available scopes 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'} } @@ -112,6 +114,10 @@ Also add the following to your `settings.py` module: ) } +`OAUTH2_PROVIDER` setting parameter sets the backend class that is used to parse OAuth2 requests. +The `JSONOAuthLibCore` class extends the default OAuthLibCore to parse correctly +`application/json` requests. + `OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, so we can use them for permission check. From 6bd1f8f1583b9a6a3d1e8541b6ecd1fdd07bbe09 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Sun, 20 Oct 2019 01:56:43 +0300 Subject: [PATCH 268/722] Fix revocation tests (#748) oauthlib 3.1 comes with a few changes that break certain non-standard behavior, in particular, passing token to revocation endpoint in query instead of body. This fixes the tests so they are not relying on this non-standard behavior. --- tests/test_token_revocation.py | 56 +++++++++++++++++----------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index dc9f91018..83fb627f4 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -52,13 +52,13 @@ def test_revoke_access_token(self): expires=timezone.now() + datetime.timedelta(days=1), scope="read write" ) - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"") self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) @@ -79,13 +79,13 @@ def test_revoke_access_token_public(self): scope="read write" ) - query_string = urlencode({ + data = { "client_id": public_app.client_id, "token": tok.token, - }) + } - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) def test_revoke_access_token_with_hint(self): @@ -98,14 +98,14 @@ def test_revoke_access_token_with_hint(self): expires=timezone.now() + datetime.timedelta(days=1), scope="read write" ) - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, "token_type_hint": "access_token" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) @@ -117,14 +117,14 @@ def test_revoke_access_token_with_invalid_hint(self): scope="read write" ) # invalid hint should have no effect - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, "token_type_hint": "bad_hint" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) @@ -139,13 +139,13 @@ def test_revoke_refresh_token(self): user=self.test_user, token="999999999", application=self.application, access_token=tok ) - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": rtok.token, - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(id=rtok.id).first() self.assertIsNotNone(refresh_token.revoked) @@ -163,13 +163,13 @@ def test_revoke_refresh_token_with_revoked_access_token(self): application=self.application, access_token=tok ) for token in (tok.token, rtok.token): - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": token, - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) @@ -191,13 +191,13 @@ def test_revoke_token_with_wrong_hint(self): scope="read write" ) - query_string = urlencode({ + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, "token_type_hint": "refresh_token" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:revoke-token"), qs=query_string) - response = self.client.post(url) + } + url = reverse("oauth2_provider:revoke-token") + response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) From 67a1755b4d10f9b30c9f52f21f363d120f78df05 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Sun, 20 Oct 2019 02:06:30 +0300 Subject: [PATCH 269/722] Fix timezone warnings in models tests (#748) Use timezone-aware datetimes. --- tests/test_models.py | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 8adc3b099..1296c6d30 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,5 +1,3 @@ -from datetime import datetime as dt - import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError @@ -305,23 +303,23 @@ def setUp(self): AccessToken.objects.create( id=1, token="555", - expires=dt.now(), + expires=timezone.now(), scope=2, application_id=3, user_id=1, - created=dt.now(), - updated=dt.now(), + created=timezone.now(), + updated=timezone.now(), source_refresh_token_id="0", ) AccessToken.objects.create( id=2, token="666", - expires=dt.now(), + expires=timezone.now(), scope=2, application_id=3, user_id=1, - created=dt.now(), - updated=dt.now(), + created=timezone.now(), + updated=timezone.now(), source_refresh_token_id="1", ) @@ -340,9 +338,9 @@ def test_clear_expired_tokens_with_tokens(self): self.client.login(username="test_user", password="123456") oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 ttokens = AccessToken.objects.count() - expiredt = AccessToken.objects.filter(expires__lte=dt.now()).count() + expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert ttokens == 2 assert expiredt == 2 clear_expired() - expiredt = AccessToken.objects.filter(expires__lte=dt.now()).count() + expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert expiredt == 0 From 0ff567cb71811f064ba6ea79bff1b604532c32f8 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Sun, 20 Oct 2019 02:36:50 +0300 Subject: [PATCH 270/722] Fix IntegrityError in models tests (#748) Fix data used for token fixtures. --- tests/test_models.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 1296c6d30..95e8eb414 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -300,27 +300,30 @@ class TestClearExpired(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") # Insert two tokens on database. + app = Application.objects.create( + name="test_app", + redirect_uris="http://localhost http://example.com http://example.org", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) AccessToken.objects.create( - id=1, token="555", expires=timezone.now(), scope=2, - application_id=3, - user_id=1, + application=app, + user=self.user, created=timezone.now(), updated=timezone.now(), - source_refresh_token_id="0", ) AccessToken.objects.create( - id=2, token="666", expires=timezone.now(), scope=2, - application_id=3, - user_id=1, + application=app, + user=self.user, created=timezone.now(), updated=timezone.now(), - source_refresh_token_id="1", ) def test_clear_expired_tokens(self): From e218d3817a2fdab36b553870cf68d916306efa84 Mon Sep 17 00:00:00 2001 From: MeiK Date: Mon, 30 Sep 2019 15:59:26 +0800 Subject: [PATCH 271/722] Fixed unpaired parentheses --- docs/tutorial/tutorial_01.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index eaaab05a7..58e1e4208 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -34,7 +34,7 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python urlpatterns = [ - url(r"^admin/", admin.site.urls)), + url(r"^admin/", admin.site.urls), url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), # ... ] From d5e6645184edc1b91371fc17b73c3e11cd33b0c3 Mon Sep 17 00:00:00 2001 From: W0126 Date: Sun, 20 Oct 2019 10:16:44 +0800 Subject: [PATCH 272/722] Fix issue #636, pass request object to authenticate function. (#643) --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 1e80a5cb9..35d7c893c 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -611,7 +611,7 @@ def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User """ - u = authenticate(username=username, password=password) + u = authenticate(request, username=username, password=password) if u is not None and u.is_active: request.user = u return True From 1499048dd644575251df27590d5283ba8495a277 Mon Sep 17 00:00:00 2001 From: Adam Johnson Date: Thu, 17 Oct 2019 11:05:58 +0100 Subject: [PATCH 273/722] Use Model.objects.create where applicable --- oauth2_provider/oauth2_validators.py | 10 +++------- tests/test_authorization_code.py | 3 +-- tests/test_client_credential.py | 6 ++---- tests/test_implicit.py | 3 +-- tests/test_introspection_auth.py | 3 +-- tests/test_introspection_view.py | 3 +-- tests/test_password.py | 3 +-- tests/test_scopes.py | 3 +-- tests/test_token_revocation.py | 3 +-- tests/test_token_view.py | 3 +-- 10 files changed, 13 insertions(+), 27 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 35d7c893c..3ec1702dc 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -448,7 +448,7 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): expires = timezone.now() + timedelta( seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - g = Grant( + Grant.objects.create( application=request.client, user=request.user, code=code["code"], @@ -458,7 +458,6 @@ def save_authorization_code(self, client_id, code, request, *args, **kwargs): code_challenge=request.code_challenge or "", code_challenge_method=request.code_challenge_method or "" ) - g.save() def rotate_refresh_token(self, request): """ @@ -563,7 +562,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS def _create_access_token(self, expires, request, token, source_refresh_token=None): - access_token = AccessToken( + return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, @@ -571,17 +570,14 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non application=request.client, source_refresh_token=source_refresh_token, ) - access_token.save() - return access_token def _create_refresh_token(self, request, refresh_token_code, access_token): - refresh_token = RefreshToken( + return RefreshToken.objects.create( user=request.user, token=refresh_token_code, application=request.client, access_token=access_token ) - refresh_token.save() def revoke_token(self, token, token_type_hint, request, *args, **kwargs): """ diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 26788a6e5..35a4d47b1 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -42,7 +42,7 @@ def setUp(self): oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" @@ -51,7 +51,6 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index f4fa4c045..09401cf0e 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -34,13 +34,12 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="test_client_credentials_app", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] @@ -152,13 +151,12 @@ def test_client_resource_password_based(self): """ self.application.delete() - self.application = Application( + self.application = Application.objects.create( name="test_client_credentials_app", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_PASSWORD, ) - self.application.save() token_request_data = { "grant_type": "password", diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 548592377..c2fd83a5a 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -25,14 +25,13 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="Test Implicit Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read"] diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index fd7504bac..db37f6c72 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -81,14 +81,13 @@ def setUp(self): "resource_server", "test@example.com", "123456" ) - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.resource_server_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() self.resource_server_token = AccessToken.objects.create( user=self.resource_server_user, token="12345678900", diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index c4c6d1554..a06a73e52 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -23,14 +23,13 @@ def setUp(self): self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() self.resource_server_token = AccessToken.objects.create( user=self.resource_server_user, token="12345678900", diff --git a/tests/test_password.py b/tests/test_password.py index a6f3f5dd6..f50404f9f 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -27,13 +27,12 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="Test Password Application", user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 4a3daf89d..f744d673f 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -52,14 +52,13 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "scope3"] oauth2_settings.READ_SCOPE = "read" diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 83fb627f4..d1ab591d2 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -24,14 +24,13 @@ def setUp(self): self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() oauth2_settings._SCOPES = ["read", "write"] diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 67fa1a55c..fc3044cbb 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -21,14 +21,13 @@ def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") - self.application = Application( + self.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", user=self.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.application.save() def tearDown(self): self.foo_user.delete() From a1dcd37f2fc367b13fc63a751f1fdea48206c191 Mon Sep 17 00:00:00 2001 From: Mad Price Ball Date: Thu, 24 Oct 2019 04:21:43 -0700 Subject: [PATCH 274/722] Auto-authorize if valid refresh tokens exist (#754) * Auto-authorize if valid refresh tokens exist * Add test for auto auth from refresh token --- oauth2_provider/views/base.py | 8 +++++++- tests/test_authorization_code.py | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 51a1ecccb..e236f9064 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -12,7 +12,7 @@ from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect -from ..models import get_access_token_model, get_application_model +from ..models import get_access_token_model, get_application_model, get_refresh_token_model from ..scopes import get_scopes_backend from ..settings import oauth2_settings from ..signals import app_authorized @@ -191,6 +191,12 @@ def get(self, request, *args, **kwargs): expires__gt=timezone.now() ).all() + refresh_tokens = get_refresh_token_model().objects.filter( + user=request.user, + application=kwargs["application"] + ).exclude(revoked__lt=timezone.now()).all() + tokens = list(tokens) + [r.access_token for r in refresh_tokens] + # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 35a4d47b1..45116dad6 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -197,6 +197,16 @@ def test_pre_auth_approval_prompt(self): url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) + # access token expired but valid refresh token exists + tok.expires = timezone.now() - datetime.timedelta(days=1) + tok.save() + reftok = RefreshToken.objects.create( + user=self.test_user, token="0123456789", + application=self.application, + access_token=tok + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() From 6c7260d881c3f95047459c76e8879e0f7919edb2 Mon Sep 17 00:00:00 2001 From: Ivan Anishchuk Date: Wed, 30 Oct 2019 22:21:06 +0300 Subject: [PATCH 275/722] Add extension points for custom token generators (#732) (#749) Pass variables generated based on settings when initializing Server. Add a property to settings class for convenience. Update settings documentation to reflect additions. Fix tests. --- .travis.yml | 4 + docs/settings.rst | 25 ++++++ oauth2_provider/oauth2_backends.py | 20 +++-- oauth2_provider/oauth2_validators.py | 9 ++- oauth2_provider/settings.py | 30 +++++++ oauth2_provider/views/mixins.py | 3 +- tests/migrations/0001_initial.py | 117 +++++++++++++++++++++++++++ tests/migrations/__init__.py | 0 tests/models.py | 10 +++ tests/settings.py | 8 +- tests/test_application_views.py | 1 - tests/test_oauth2_validators.py | 4 +- tox.ini | 26 +++--- 13 files changed, 234 insertions(+), 23 deletions(-) create mode 100644 tests/migrations/0001_initial.py create mode 100644 tests/migrations/__init__.py diff --git a/.travis.yml b/.travis.yml index 1715b596c..c28122fe7 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,6 +4,10 @@ dist: xenial language: python python: + - "3.8" + - "3.7" + - "3.6" + - "3.5" - "3.4" cache: diff --git a/docs/settings.rst b/docs/settings.rst index 49a060851..d0bc62e9a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -39,6 +39,11 @@ The import string of the class (model) representing your access tokens. Overwrit this value if you wrote your own implementation (subclass of ``oauth2_provider.models.AccessToken``). +ACCESS_TOKEN_GENERATOR +~~~~~~~~~~~~~~~~~~~~~~ +Import path of a callable used to generate access tokens. +oauthlib.oauth2.tokens.random_token_generator is (normally) used if not provided. + ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -78,6 +83,14 @@ CLIENT_SECRET_GENERATOR_LENGTH The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. +EXTRA_SERVER_KWARGS +~~~~~~~~~~~~~~~~~~~ +A dictionary to be passed to oauthlib's Server class. Three options +are natively supported: token_expires_in, token_generator, +refresh_token_generator. There's no extra processing so callables (every one +of those three can be a callable) must be passed here directly and classes +must be instantiated (callables should accept request as their only argument). + GRANT_MODEL ~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite @@ -103,6 +116,9 @@ REFRESH_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +NOTE: This value is completely ignored when validating refresh tokens. +If you don't change the validator code and don't run cleartokens all refresh +tokens will last until revoked or the end of time. REFRESH_TOKEN_GRACE_PERIOD_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -123,6 +139,15 @@ this value if you wrote your own implementation (subclass of ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. +Known bugs: `False` currently has a side effect of immediately revoking both access and refresh token on refreshing. +See also: validator's rotate_refresh_token method can be overridden to make this variable +(could be usable with expiring refresh tokens, in particular, so that they are rotated +when close to expiration, theoretically). + +REFRESH_TOKEN_GENERATOR +~~~~~~~~~~~~~~~~~~~~~~~~~~ +See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. +Defaults to access token generator if not provided. REQUEST_APPROVAL_PROMPT ~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index f71f46e9b..f8710fdb0 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -10,13 +10,21 @@ class OAuthLibCore(object): """ - TODO: add docs + Wrapper for oauth Server providing django-specific interfaces. + + Meant for things like extracting request data and converting + everything to formats more palatable for oauthlib's Server. """ def __init__(self, server=None): """ :params server: An instance of oauthlib.oauth2.Server class """ - self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(oauth2_settings.OAUTH2_VALIDATOR_CLASS()) + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + server_kwargs = oauth2_settings.server_kwargs + self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS( + validator, **server_kwargs + ) def _get_escaped_full_path(self, request): """ @@ -189,9 +197,11 @@ def extract_body(self, request): def get_oauthlib_core(): """ - Utility function that take a request and returns an instance of + Utility function that returns an instance of `oauth2_provider.backends.OAuthLibCore` """ - validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() - server = oauth2_settings.OAUTH2_SERVER_CLASS(validator) + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + server_kwargs = oauth2_settings.server_kwargs + server = oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) return oauth2_settings.OAUTH2_BACKEND_CLASS(server) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 3ec1702dc..84e701378 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -477,7 +477,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): if "scope" not in token: raise FatalClientError("Failed to renew access token: missing scope") - expires = timezone.now() + timedelta(seconds=oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + # expires_in is passed to Server on initialization + # custom server class can have logic to override this + expires = timezone.now() + timedelta(seconds=token.get( + 'expires_in', oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + )) if request.grant_type == "client_credentials": request.user = None @@ -558,9 +562,6 @@ def save_bearer_token(self, token, request, *args, **kwargs): else: self._create_access_token(expires, request, token) - # TODO: check out a more reliable way to communicate expire time to oauthlib - token["expires_in"] = oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - def _create_access_token(self, expires, request, token, source_refresh_token=None): return AccessToken.objects.create( user=request.user, diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 53f163142..2e513928c 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -32,6 +32,9 @@ "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "ACCESS_TOKEN_GENERATOR": None, + "REFRESH_TOKEN_GENERATOR": None, + "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", @@ -82,6 +85,8 @@ IMPORT_STRINGS = ( "CLIENT_ID_GENERATOR_CLASS", "CLIENT_SECRET_GENERATOR_CLASS", + "ACCESS_TOKEN_GENERATOR", + "REFRESH_TOKEN_GENERATOR", "OAUTH2_SERVER_CLASS", "OAUTH2_VALIDATOR_CLASS", "OAUTH2_BACKEND_CLASS", @@ -171,5 +176,30 @@ def validate_setting(self, attr, val): if not val and attr in self.mandatory: raise AttributeError("OAuth2Provider setting: %r is mandatory" % (attr)) + @property + def server_kwargs(self): + """ + This is used to communicate settings to oauth server. + + Takes relevant settings and format them accordingly. + There's also EXTRA_SERVER_KWARGS that can override every value + and is more flexible regarding keys and acceptable values + but doesn't have import string magic or any additional + processing, callables have to be assigned directly. + For the likes of signed_token_generator it means something like + + {'token_generator': signed_token_generator(privkey, **kwargs)} + """ + kwargs = { + key: getattr(self, value) + for key, value in [ + ('token_expires_in', 'ACCESS_TOKEN_EXPIRE_SECONDS'), + ('refresh_token_expires_in', 'REFRESH_TOKEN_EXPIRE_SECONDS'), + ('token_generator', 'ACCESS_TOKEN_GENERATOR'), + ('refresh_token_generator', 'REFRESH_TOKEN_GENERATOR'), + ] + } + kwargs.update(self.EXTRA_SERVER_KWARGS) + return kwargs oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 00065644a..0cc9bd589 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -74,7 +74,8 @@ def get_server(cls): """ server_class = cls.get_server_class() validator_class = cls.get_validator_class() - return server_class(validator_class()) + server_kwargs = oauth2_settings.server_kwargs + return server_class(validator_class(), **server_kwargs) @classmethod def get_oauthlib_core(cls): diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py new file mode 100644 index 000000000..60b17f2ae --- /dev/null +++ b/tests/migrations/0001_initial.py @@ -0,0 +1,117 @@ +# Generated by Django 2.2.6 on 2019-10-24 20:21 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='SampleGrant', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('code', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('redirect_uri', models.CharField(max_length=255)), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('code_challenge', models.CharField(blank=True, default='', max_length=128)), + ('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)), + ('custom_field', models.CharField(max_length=255)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SampleApplication', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), + ('name', models.CharField(blank=True, max_length=255)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('custom_field', models.CharField(max_length=255)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SampleAccessToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255, unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('custom_field', models.CharField(max_length=255)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='BaseTestApplication', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), + ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), + ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), + ('name', models.CharField(blank=True, max_length=255)), + ('skip_authorization', models.BooleanField(default=False)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('allowed_schemes', models.TextField(blank=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SampleRefreshToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.CharField(max_length=255)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('revoked', models.DateTimeField(null=True)), + ('custom_field', models.CharField(max_length=255)), + ('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), + ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplerefreshtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'unique_together': {('token', 'revoked')}, + }, + ), + ] diff --git a/tests/migrations/__init__.py b/tests/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/models.py b/tests/models.py index 8b78e77af..cbbc50ba9 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,5 +1,6 @@ from django.db import models +from oauth2_provider.settings import oauth2_settings from oauth2_provider.models import ( AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractRefreshToken @@ -21,10 +22,19 @@ class SampleApplication(AbstractApplication): class SampleAccessToken(AbstractAccessToken): custom_field = models.CharField(max_length=255) + source_refresh_token = models.OneToOneField( + # unique=True implied by the OneToOneField + oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, + related_name="s_refreshed_access_token" + ) class SampleRefreshToken(AbstractRefreshToken): custom_field = models.CharField(max_length=255) + access_token = models.OneToOneField( + oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, + related_name="s_refresh_token" + ) class SampleGrant(AbstractGrant): diff --git a/tests/settings.py b/tests/settings.py index 5e145ac3b..1b7ba8db6 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -5,10 +5,15 @@ DATABASES = { "default": { "ENGINE": "django.db.backends.sqlite3", - "NAME": "example.sqlite", + "NAME": ":memory:", } } +AUTH_USER_MODEL = 'auth.User' +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" + ALLOWED_HOSTS = [] TIME_ZONE = "UTC" @@ -74,6 +79,7 @@ "django.contrib.sites", "django.contrib.staticfiles", "django.contrib.admin", + "django.contrib.messages", "oauth2_provider", "tests", diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 6130876ce..74162f087 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -8,7 +8,6 @@ from .models import SampleApplication - Application = get_application_model() UserModel = get_user_model() diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index dd07d37a1..d9248230b 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -284,8 +284,8 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r self.validator.save_bearer_token(token, self.request) - create_access_token_mock.assert_called_once() - create_refresh_token_mock.asert_called_once() + assert create_access_token_mock.call_count == 1 + assert create_refresh_token_mock.call_count == 1 class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): diff --git a/tox.ini b/tox.ini index a492aeaf0..ed13accbd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,16 @@ [tox] envlist = + py37-flake8, + py37-docs, + py38-django{30,22,21,20}, + py37-django{30,22,21,20}, + py36-django{22,21,20}, + py35-django{22,21,20}, py34-django20, - py35-django{20,21,master}, - py36-django{20,21,master}, - py37-django{20,21,master}, - py36-docs, - py36-flake8 +# FIXME: something is broken in DRF integration, enable once fixed +# py38-djangomaster, +# py37-djangomaster, +# py36-djangomaster, [pytest] django_find_project = false @@ -19,6 +24,8 @@ setenv = deps = django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 + django22: Django>=2.2,<3 + django30: Django>=3.0a1,<3.1 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.0.1 @@ -28,8 +35,9 @@ deps = pytest-django pytest-xdist py27: mock + requests -[testenv:py36-docs] +[testenv:py37-docs] basepython = python changedir = docs whitelist_externals = make @@ -37,14 +45,14 @@ commands = make html deps = sphinx oauthlib>=3.0.1 -[testenv:py36-flake8] +[testenv:py37-flake8] skip_install = True commands = - flake8 {toxinidir} + flake8 --exit-zero {toxinidir} deps = flake8 flake8-isort - flake8-quotes +# flake8-quotes [coverage:run] source = oauth2_provider From 8338002011eb7ebf704a42293c8778744a2a3201 Mon Sep 17 00:00:00 2001 From: bigsfoot Date: Fri, 1 Nov 2019 13:09:03 +0100 Subject: [PATCH 276/722] Update changelogs in the docs with last information from github changelog (#758) --- docs/changelog.rst | 70 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/docs/changelog.rst b/docs/changelog.rst index 96e37ab30..7b9901d0a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,6 +1,76 @@ Changelog ========= +1.3.0 [unreleased] +------------------ + +* Fix a race condition in creation of AccessToken with external oauth2 server. +* **Backwards-incompatible** squashed migrations: + If you are currently on a release < 1.2.0, you will need to first install 1.2.x then `manage.py migrate` before + upgrading to >= 1.3.0. + + +1.2.0 [2018-06-03] +------------------ + +* **Compatibility**: Python 3.4 is the new minimum required version. +* **Compatibility**: Django 2.0 is the new minimum required version. +* **New feature**: Added TokenMatchesOASRequirements Permissions. +* validators.URIValidator has been updated to match URLValidator behaviour more closely. +* Moved `redirect_uris` validation to the application clean() method. + + +1.1.2 [2018-05-12] +------------------ + +* Return state with Authorization Denied error (RFC6749 section 4.1.2.1) +* Fix a crash with malformed base64 authentication headers +* Fix a crash with malformed IPv6 redirect URIs + + +1.1.1 [2018-05-08] +------------------ + +* **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing + RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. + If you have already ran it in production, please see the following issue for more details: + https://github.com/jazzband/django-oauth-toolkit/issues/589 + + +1.1.0 [2018-04-13] +------------------ + +* **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. +* **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. +* **Compatibility**: This will be the last release to support Django 1.11 and Python 2.7. +* **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. +* **New feature**: Individual applications may now override the `ALLOWED_REDIRECT_URI_SCHEMES` + setting by returning a list of allowed redirect uri schemes in `Application.get_allowed_schemes()`. +* **New feature**: The new setting `ERROR_RESPONSE_WITH_SCOPES` can now be set to True to include required + scopes when DRF authorization fails due to improper scopes. +* **New feature**: The new setting `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` controls a grace period during which + refresh tokens may be re-used. +* An `app_authorized` signal is fired when a token is generated. + + +1.0.0 [2017-06-07] +------------------ + +* **New feature**: AccessToken, RefreshToken and Grant models are now swappable. +* #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) +* **Compatibility**: Django 1.10 is the new minimum required version +* **Compatibility**: Django 1.11 is now supported +* **Backwards-incompatible**: The `oauth2_provider.ext.rest_framework` module + has been moved to `oauth2_provider.contrib.rest_framework` +* #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) +* #321: Added `created` and `updated` auto fields to Application, AccessToken, RefreshToken and Grant +* #476: Disallow empty redirect URIs +* Fixed bad `url` parameter in some error responses. +* Django 2.0 compatibility fixes. +* The dependency on django-braces has been dropped. +* The oauthlib dependency is no longer pinned. + + 0.12.0 [2017-02-24] ------------------- From 8e7be0089aabe017dcaffbc91faa4ab9cd600950 Mon Sep 17 00:00:00 2001 From: Hebert Date: Mon, 9 Dec 2019 05:34:09 -0300 Subject: [PATCH 277/722] Add id in list_display on AplicationAdmin. (#767) --- oauth2_provider/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index c6bbe44b7..1ca718a91 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -7,7 +7,7 @@ class ApplicationAdmin(admin.ModelAdmin): - list_display = ("name", "user", "client_type", "authorization_grant_type") + list_display = ("id", "name", "user", "client_type", "authorization_grant_type") list_filter = ("client_type", "authorization_grant_type", "skip_authorization") radio_fields = { "client_type": admin.HORIZONTAL, From bf1525e85a06929016b1fe35d863e62e58124a2f Mon Sep 17 00:00:00 2001 From: Rafael Henter Date: Mon, 30 Dec 2019 14:48:23 -0300 Subject: [PATCH 278/722] Change ugettext_lazy by gettext_lazy to remove django DeprecationWarning (#773) --- oauth2_provider/models.py | 2 +- oauth2_provider/oauth2_validators.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1489a8845..c29faaa83 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -8,7 +8,7 @@ from django.db import models, transaction from django.urls import reverse from django.utils import timezone -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 84e701378..3595d12fc 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -13,7 +13,7 @@ from django.db.models import Q from django.utils import timezone from django.utils.timezone import make_aware -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from oauthlib.oauth2 import RequestValidator from .exceptions import FatalClientError From b8f4d78f53485be23480c3b3ae5656ffe0702372 Mon Sep 17 00:00:00 2001 From: Marnanel Thurman Date: Thu, 23 Jan 2020 07:17:29 +0000 Subject: [PATCH 279/722] Fix for issue #235 (urn:ietf:wg:oauth:2.0:oob support) (#774) * First draft of a possible fix for issue #235, adding support for urn:ietf:wg:oauth:2.0:oob and urn:ietf:wg:oauth:2.0:oob:auto redirect URLs. * rm debug code * Added extra fields for the out-of-band JSON response. See https://github.com/googleapis/oauth2client/blob/master/oauth2client/client.py#L618 for more information about these fields. * basic tests for the new JSON oob response fields * Add brief docstrings to OOB tests * Add auth code to the tag for OOB requests * Remove token fields from the oob:auto JSON response: it's not used when we're returning a token * test_oob_as_html() and test_oob_as_json() tests added to test_authorization_code.py. test_oob_authorization.py, which contained earlier versions of these, deleted. --- .../oauth2_provider/authorized-oob.html | 23 +++++ oauth2_provider/views/base.py | 38 +++++++- tests/test_authorization_code.py | 92 +++++++++++++++++++ 3 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 oauth2_provider/templates/oauth2_provider/authorized-oob.html diff --git a/oauth2_provider/templates/oauth2_provider/authorized-oob.html b/oauth2_provider/templates/oauth2_provider/authorized-oob.html new file mode 100644 index 000000000..78399da7c --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/authorized-oob.html @@ -0,0 +1,23 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} + +{% block title %} +Success code={{code}} +{% endblock %} + +{% block content %} + <div class="block-center"> + {% if not error %} + <h2>{% trans "Success" %}</h2> + + <p>{% trans "Please return to your application and enter this code:" %}</p> + + <p><code>{{ code }}</code></p> + + {% else %} + <h2>Error: {{ error.error }}</h2> + <p>{{ error.description }}</p> + {% endif %} + </div> +{% endblock %} diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e236f9064..41c2a6c67 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,13 +1,16 @@ import json import logging +import urllib.parse from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse +from django.http import HttpResponse, JsonResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from django.shortcuts import render +from django.urls import reverse from ..exceptions import OAuthToolkitError from ..forms import AllowForm @@ -18,7 +21,6 @@ from ..signals import app_authorized from .mixins import OAuthLibMixin - log = logging.getLogger("oauth2_provider") @@ -59,6 +61,7 @@ def redirect(self, redirect_to, application): allowed_schemes = application.get_allowed_schemes() return OAuth2ResponseRedirect(redirect_to, allowed_schemes) +RFC3339 = '%Y-%m-%dT%H:%M:%SZ' class AuthorizationView(BaseAuthorizationView, FormView): """ @@ -204,13 +207,42 @@ def get(self, request, *args, **kwargs): request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True ) - return self.redirect(uri, application) + return self.redirect(uri, application, token) except OAuthToolkitError as error: return self.error_response(error, application) return self.render_to_response(self.get_context_data(**kwargs)) + def redirect(self, redirect_to, application, + token = None): + + if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): + return super().redirect(redirect_to, application) + + parsed_redirect = urllib.parse.urlparse(redirect_to) + code = urllib.parse.parse_qs(parsed_redirect.query)['code'][0] + + if redirect_to.startswith('urn:ietf:wg:oauth:2.0:oob:auto'): + + response = { + 'access_token': code, + 'token_uri': redirect_to, + 'client_id': application.client_id, + 'client_secret': application.client_secret, + 'revoke_uri': reverse('oauth2_provider:revoke-token'), + } + + return JsonResponse(response) + + else: + return render( + request=self.request, + template_name="oauth2_provider/authorized-oob.html", + context={ + 'code': code, + }, + ) @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 45116dad6..69dcfd93a 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -2,6 +2,7 @@ import datetime import hashlib import json +import re from urllib.parse import parse_qs, urlencode, urlparse from django.contrib.auth import get_user_model @@ -27,6 +28,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +URI_OOB = "urn:ietf:wg:oauth:2.0:oob" +URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -46,6 +49,7 @@ def setUp(self): name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" + " " + URI_OOB + " " + URI_OOB_AUTO ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, @@ -1456,6 +1460,94 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + def test_oob_as_html(self): + """ + Test out-of-band authentication. + """ + self.client.login(username="test_user", password="123456") + + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": URI_OOB, + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + self.assertEqual(response.status_code, 200) + self.assertRegex(response['Content-Type'], r'^text/html') + + content = response.content.decode("utf-8") + + # "A lot of applications, for legacy reasons, use this and regex + # to extract the token, risking summoning zalgo in the process." + # -- https://github.com/jazzband/django-oauth-toolkit/issues/235 + + matches = re.search(r'.*<code>([^<>]*)</code>', + content) + self.assertIsNotNone(matches, + msg="OOB response contains code inside <code> tag") + self.assertEqual(len(matches.groups()), 1, + msg="OOB response contains multiple <code> tags") + authorization_code = matches.groups()[0] + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": URI_OOB, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_oob_as_json(self): + """ + Test out-of-band authentication, with a JSON response. + """ + self.client.login(username="test_user", password="123456") + + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": URI_OOB_AUTO, + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + self.assertEqual(response.status_code, 200) + self.assertRegex(response['Content-Type'], '^application/json') + + parsed_response = json.loads(response.content.decode("utf-8")) + + self.assertIn('access_token', parsed_response) + authorization_code = parsed_response['access_token'] + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": URI_OOB_AUTO, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): From 6c7d2221e5cc3afc9ff2252c6fe68d887ea067ba Mon Sep 17 00:00:00 2001 From: Tim Gates <tim.gates@iress.com> Date: Sun, 2 Feb 2020 03:14:13 +1100 Subject: [PATCH 280/722] Fix simple typo: containg -> containing (#762) Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Mariano ramirez <marianoramirez353@gmail.com> --- tests/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/utils.py b/tests/utils.py index 9e29c48d3..ec2590512 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -3,7 +3,8 @@ def get_basic_auth_header(user, password): """ - Return a dict containg the correct headers to set to make HTTP Basic Auth request + Return a dict containing the correct headers to set to make HTTP Basic + Auth request """ user_pass = "{0}:{1}".format(user, password) auth_string = base64.b64encode(user_pass.encode("utf-8")) From 28f3f5ebbbbce2ce784f65de275133f3120322f6 Mon Sep 17 00:00:00 2001 From: Armaan Tobaccowalla <13340433+ArmaanT@users.noreply.github.com> Date: Fri, 7 Feb 2020 00:24:58 -0500 Subject: [PATCH 281/722] Add source_refresh_token to raw_id_fields (#783) --- oauth2_provider/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 1ca718a91..8b963d981 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -23,7 +23,7 @@ class GrantAdmin(admin.ModelAdmin): class AccessTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application", "expires") - raw_id_fields = ("user", ) + raw_id_fields = ("user", "source_refresh_token") class RefreshTokenAdmin(admin.ModelAdmin): From fb6f34064ba9a6b471f88bd2547e860833c432f1 Mon Sep 17 00:00:00 2001 From: VJ Magar <vjmagar.59@gmail.com> Date: Tue, 5 Nov 2019 16:12:45 +0530 Subject: [PATCH 282/722] Updated 'url' with 'path' in documentation --- docs/install.rst | 5 ++++- docs/tutorial/tutorial_01.rst | 4 ++-- docs/tutorial/tutorial_02.rst | 24 ++++++++++++------------ docs/tutorial/tutorial_03.rst | 2 +- 4 files changed, 19 insertions(+), 16 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 462e2d536..ccff17742 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -21,7 +21,10 @@ If you need an OAuth2 provider you'll want to add the following to your urls.py urlpatterns = [ ... - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + + # using re_path + re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] Sync your database diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 58e1e4208..4c31d7b46 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -34,8 +34,8 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python urlpatterns = [ - url(r"^admin/", admin.site.urls), - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path("admin", admin.site.urls), + path("o/", include('oauth2_provider.urls', namespace='oauth2_provider')), # ... ] diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index b8c2133db..7beb606ce 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -41,32 +41,32 @@ URL this view will respond to: # OAuth2 provider endpoints oauth2_endpoint_views = [ - url(r'^authorize/$', oauth2_views.AuthorizationView.as_view(), name="authorize"), - url(r'^token/$', oauth2_views.TokenView.as_view(), name="token"), - url(r'^revoke-token/$', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), + path('authorize/', oauth2_views.AuthorizationView.as_view(), name="authorize"), + path('token/', oauth2_views.TokenView.as_view(), name="token"), + path('revoke-token/', oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), ] if settings.DEBUG: # OAuth2 Application Management endpoints oauth2_endpoint_views += [ - url(r'^applications/$', oauth2_views.ApplicationList.as_view(), name="list"), - url(r'^applications/register/$', oauth2_views.ApplicationRegistration.as_view(), name="register"), - url(r'^applications/(?P<pk>\d+)/$', oauth2_views.ApplicationDetail.as_view(), name="detail"), - url(r'^applications/(?P<pk>\d+)/delete/$', oauth2_views.ApplicationDelete.as_view(), name="delete"), - url(r'^applications/(?P<pk>\d+)/update/$', oauth2_views.ApplicationUpdate.as_view(), name="update"), + path('applications/', oauth2_views.ApplicationList.as_view(), name="list"), + path('applications/register/', oauth2_views.ApplicationRegistration.as_view(), name="register"), + path('applications/<pk>/', oauth2_views.ApplicationDetail.as_view(), name="detail"), + path('applications/<pk>/delete/', oauth2_views.ApplicationDelete.as_view(), name="delete"), + path('applications/<pk>/update/', oauth2_views.ApplicationUpdate.as_view(), name="update"), ] # OAuth2 Token Management endpoints oauth2_endpoint_views += [ - url(r'^authorized-tokens/$', oauth2_views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - url(r'^authorized-tokens/(?P<pk>\d+)/delete/$', oauth2_views.AuthorizedTokenDeleteView.as_view(), + path('authorized-tokens/', oauth2_views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + path('authorized-tokens/<pk>/delete/', oauth2_views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] urlpatterns = [ # OAuth 2 endpoints: - url(r'^o/', include(oauth2_endpoint_views, namespace="oauth2_provider")), - url(r'^api/hello', ApiEndpoint.as_view()), # an example resource endpoint + path('o/', include(oauth2_endpoint_views, namespace="oauth2_provider")), + path('api/hello', ApiEndpoint.as_view()), # an example resource endpoint ] You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 6877f5070..d79be9951 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -74,7 +74,7 @@ To check everything works properly, mount the view above to some url: .. code-block:: python urlpatterns = [ - url(r'^secret$', 'my.views.secret_page', name='secret'), + path('secret', 'my.views.secret_page', name='secret'), '...', ] From 1654a8f4fd1e35c53b60f93405182d835b3a43b2 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Fri, 28 Feb 2020 23:24:19 +0600 Subject: [PATCH 283/722] update python and django versions (#787) --- tox.ini | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/tox.ini b/tox.ini index ed13accbd..49cd4a726 100644 --- a/tox.ini +++ b/tox.ini @@ -2,11 +2,10 @@ envlist = py37-flake8, py37-docs, - py38-django{30,22,21,20}, - py37-django{30,22,21,20}, - py36-django{22,21,20}, - py35-django{22,21,20}, - py34-django20, + py38-django{30,22,21}, + py37-django{30,22,21}, + py36-django{22,21}, + py35-django{22,21}, # FIXME: something is broken in DRF integration, enable once fixed # py38-djangomaster, # py37-djangomaster, @@ -22,7 +21,6 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 django22: Django>=2.2,<3 django30: Django>=3.0a1,<3.1 From 2f325f97038f30b292ea907859883b492ec22038 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Fri, 28 Feb 2020 23:47:22 +0600 Subject: [PATCH 284/722] drop py 34 (#788) Co-authored-by: Alan Crosswell <alan@crosswell.us> --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index c28122fe7..f13e72b70 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,7 +8,6 @@ python: - "3.7" - "3.6" - "3.5" - - "3.4" cache: directories: From bad009a03bfa6cacabe49b43ea46676a6d9bd8e8 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Sat, 29 Feb 2020 00:58:28 +0600 Subject: [PATCH 285/722] New (#789) * update setup.cfg * update tox * update readme * update changelog * added myself as contributor * updated travis --- .travis.yml | 2 +- AUTHORS | 1 + CHANGELOG.md | 2 ++ README.rst | 6 +++--- setup.cfg | 8 +++++--- tox.ini | 2 +- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.travis.yml b/.travis.yml index f13e72b70..1cf0c8852 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ # https://travis-ci.org/jazzband/django-oauth-toolkit -dist: xenial +dist: bionic language: python diff --git a/AUTHORS b/AUTHORS index 88dabd036..cc6bb2032 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,6 +8,7 @@ Contributors ============ Alessandro De Angelis +Asif Saif Uddin Ash Christopher Aristóbulo Meneses Bart Merenda diff --git a/CHANGELOG.md b/CHANGELOG.md index 56cabbf59..add96ac5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ * **Backwards-incompatible** squashed migrations: If you are currently on a release < 1.2.0, you will need to first install 1.2.x then `manage.py migrate` before upgrading to >= 1.3.0. +* Bump django minimum to 2.1 +* Dropped Python 3.4 ### 1.2.0 [2018-06-03] diff --git a/README.rst b/README.rst index a647f5464..67cc43a76 100644 --- a/README.rst +++ b/README.rst @@ -42,8 +42,8 @@ Please report any security issues to the JazzBand security team at <security@jaz Requirements ------------ -* Python 3.4+ -* Django 2.0+ +* Python 3.5+ +* Django 2.1+ Installation ------------ @@ -69,7 +69,7 @@ Notice that `oauth2_provider` namespace is mandatory. urlpatterns = [ ... - url(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] Changelog diff --git a/setup.cfg b/setup.cfg index 1901b5e36..060e1d04e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,15 +11,17 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 2.0 Framework :: Django :: 2.1 + Framework :: Django :: 2.2 + Framework :: Django :: 3.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.4 Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 Topic :: Internet :: WWW/HTTP [options] @@ -27,7 +29,7 @@ packages = find: include_package_data = True zip_safe = False install_requires = - django >= 2.0 + django >= 2.1 requests >= 2.13.0 oauthlib >= 3.0.1 diff --git a/tox.ini b/tox.ini index 49cd4a726..d0f79a62f 100644 --- a/tox.ini +++ b/tox.ini @@ -23,7 +23,7 @@ setenv = deps = django21: Django>=2.1,<2.2 django22: Django>=2.2,<3 - django30: Django>=3.0a1,<3.1 + django30: Django>=3.0,<3.1 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.0.1 From f37ee0db1115b0fa6f3e24803f61953af0913f9f Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 28 Feb 2020 16:30:10 -0500 Subject: [PATCH 286/722] make docs/changelog.rst include CHANGELOG.md --- CHANGELOG.md | 1 + docs/changelog.rst | 296 +-------------------------------------------- docs/conf.py | 2 +- tox.ini | 1 + 4 files changed, 4 insertions(+), 296 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index add96ac5f..75df2cd2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +## Changelog ### 1.3.0 [unreleased] * Fix a race condition in creation of AccessToken with external oauth2 server. diff --git a/docs/changelog.rst b/docs/changelog.rst index 7b9901d0a..6336d7666 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -1,295 +1 @@ -Changelog -========= - -1.3.0 [unreleased] ------------------- - -* Fix a race condition in creation of AccessToken with external oauth2 server. -* **Backwards-incompatible** squashed migrations: - If you are currently on a release < 1.2.0, you will need to first install 1.2.x then `manage.py migrate` before - upgrading to >= 1.3.0. - - -1.2.0 [2018-06-03] ------------------- - -* **Compatibility**: Python 3.4 is the new minimum required version. -* **Compatibility**: Django 2.0 is the new minimum required version. -* **New feature**: Added TokenMatchesOASRequirements Permissions. -* validators.URIValidator has been updated to match URLValidator behaviour more closely. -* Moved `redirect_uris` validation to the application clean() method. - - -1.1.2 [2018-05-12] ------------------- - -* Return state with Authorization Denied error (RFC6749 section 4.1.2.1) -* Fix a crash with malformed base64 authentication headers -* Fix a crash with malformed IPv6 redirect URIs - - -1.1.1 [2018-05-08] ------------------- - -* **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing - RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. - If you have already ran it in production, please see the following issue for more details: - https://github.com/jazzband/django-oauth-toolkit/issues/589 - - -1.1.0 [2018-04-13] ------------------- - -* **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. -* **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. -* **Compatibility**: This will be the last release to support Django 1.11 and Python 2.7. -* **New feature**: Option for RFC 7662 external AS that uses HTTP Basic Auth. -* **New feature**: Individual applications may now override the `ALLOWED_REDIRECT_URI_SCHEMES` - setting by returning a list of allowed redirect uri schemes in `Application.get_allowed_schemes()`. -* **New feature**: The new setting `ERROR_RESPONSE_WITH_SCOPES` can now be set to True to include required - scopes when DRF authorization fails due to improper scopes. -* **New feature**: The new setting `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` controls a grace period during which - refresh tokens may be re-used. -* An `app_authorized` signal is fired when a token is generated. - - -1.0.0 [2017-06-07] ------------------- - -* **New feature**: AccessToken, RefreshToken and Grant models are now swappable. -* #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) -* **Compatibility**: Django 1.10 is the new minimum required version -* **Compatibility**: Django 1.11 is now supported -* **Backwards-incompatible**: The `oauth2_provider.ext.rest_framework` module - has been moved to `oauth2_provider.contrib.rest_framework` -* #177: Changed `id` field on Application, AccessToken, RefreshToken and Grant to BigAutoField (bigint/bigserial) -* #321: Added `created` and `updated` auto fields to Application, AccessToken, RefreshToken and Grant -* #476: Disallow empty redirect URIs -* Fixed bad `url` parameter in some error responses. -* Django 2.0 compatibility fixes. -* The dependency on django-braces has been dropped. -* The oauthlib dependency is no longer pinned. - - -0.12.0 [2017-02-24] -------------------- - -* **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes - is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. - By default, this is set to `oauth2_provider.scopes.SettingsScopes` which implements the - legacy settings-based scope behaviour. No changes are necessary. -* **Dropped support for Python 3.2 and Python 3.3**, added support for Python 3.6 -* Support for the `scopes` query parameter, deprecated in 0.6.1, has been dropped -* #448: Added support for customizing applications' allowed grant types -* #141: The `is_usable(request)` method on the Application model can be overridden to dynamically - enable or disable applications. -* #434: Relax URL patterns to allow for UUID primary keys - - -0.11.0 [2016-12-1] ------------------- - -* #424: Added a ROTATE_REFRESH_TOKEN setting to control whether refresh tokens are reused or not -* #315: AuthorizationView does not overwrite requests on get -* #425: Added support for Django 1.10 -* #396: Added an IsAuthenticatedOrTokenHasScope Permission -* #357: Support multiple-user clients by allowing User to be NULL for Applications -* #389: Reuse refresh tokens if enabled. - - -0.10.0 [2015-12-14] -------------------- - -* **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** -* #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant -* #333: Added possibility to specify the default list of scopes returned when scope parameter is missing -* #325: Added management views of issued tokens -* #249: Added a command to clean expired tokens -* #323: Application registration view uses custom application model in form class -* #299: 'server_class' is now pluggable through Django settings -* #309: Add the py35-django19 env to travis -* #308: Use compact syntax for tox envs -* #306: Django 1.9 compatibility -* #288: Put additional information when generating token responses -* #297: Fixed doc about SessionAuthenticationMiddleware -* #273: Generic read write scope by resource - - -0.9.0 [2015-07-28] ------------------- - -* ``oauthlib_backend_class`` is now pluggable through Django settings -* #127: ``application/json`` Content-Type is now supported using ``JSONOAuthLibCore`` -* #238: Fixed redirect uri handling in case of error -* #229: Invalidate access tokens when getting a new refresh token -* added support for oauthlib 1.0 - - -0.8.2 [2015-06-25] ------------------- - -* Fix the migrations to be two-step and allow upgrade from 0.7.2 - - -0.8.1 [2015-04-27] ------------------- - -* South migrations fixed. Added new django migrations. - - -0.8.0 [2015-03-27] ------------------- - -* Several docs improvements and minor fixes -* #185: fixed vulnerabilities on Basic authentication -* #173: ProtectResourceMixin now allows OPTIONS requests -* Fixed client_id and client_secret characters set -* #169: hide sensitive informations in error emails -* #161: extend search to all token types when revoking a token -* #160: return empty response on successful token revocation -* #157: skip authorization form with ``skip_authorization_completely`` class field -* #155: allow custom uri schemes -* fixed ``get_application_model`` on Django 1.7 -* fixed non rotating refresh tokens -* #137: fixed base template -* customized ``client_secret`` lenght -* #38: create access tokens not bound to a user instance for *client credentials* flow - - -0.7.2 [2014-07-02] ------------------- - -* Don't pin oauthlib - - -0.7.0 [2014-03-01] ------------------- - -* Created a setting for the default value for approval prompt. -* Improved docs -* Don't pin django-braces and six versions - -**Backwards incompatible changes in 0.7.0** - -* Make Application model truly "swappable" (introduces a new non-namespaced setting OAUTH2_PROVIDER_APPLICATION_MODEL) - - -0.6.1 [2014-02-05] ------------------- - - * added support for `scope` query parameter keeping backwards compatibility for the original `scopes` parameter. - * __str__ method in Application model returns name when available - - -0.6.0 [2014-01-26] ------------------- - - * oauthlib 0.6.1 support - * Django dev branch support - * Python 2.6 support - * Skip authorization form via `approval_prompt` parameter - -**Bugfixes** - - * Several fixes to the docs - * Issue #71: Fix migrations - * Issue #65: Use OAuth2 password grant with multiple devices - * Issue #84: Add information about login template to tutorial. - * Issue #64: Fix urlencode clientid secret - - -0.5.0 [2013-09-17] ------------------- - - * oauthlib 0.6.0 support - -**Backwards incompatible changes in 0.5.0** - - * backends.py module has been renamed to oauth2_backends.py so you should change your imports whether you're extending this module - -**Bugfixes** - - * Issue #54: Auth backend proposal to address #50 - * Issue #61: Fix contributing page - * Issue #55: Add support for authenticating confidential client with request body params - * Issue #53: Quote characters in the url query that are safe for Django but not for oauthlib - -0.4.1 [2013-09-06] ------------------- - - * Optimize queries on access token validation - -0.4.0 [2013-08-09] ------------------- - -**New Features** - - * Add Application management views, you no more need the admin to register, update and delete your application. - * Add support to configurable application model - * Add support for function based views - -**Backwards incompatible changes in 0.4.0** - - * `SCOPE` attribute in settings is now a dictionary to store `{'scope_name': 'scope_description'}` - * Namespace 'oauth2_provider' is mandatory in urls. See issue #36 - -**Bugfixes** - - * Issue #25: Bug in the Basic Auth parsing in Oauth2RequestValidator - * Issue #24: Avoid generation of client_id with ":" colon char when using HTTP Basic Auth - * Issue #21: IndexError when trying to authorize an application - * Issue #9: Default_redirect_uri is mandatory when grant_type is implicit, authorization_code or all-in-one - * Issue #22: Scopes need a verbose description - * Issue #33: Add django-oauth-toolkit version on example main page - * Issue #36: Add mandatory namespace to urls - * Issue #31: Add docstring to OAuthToolkitError and FatalClientError - * Issue #32: Add docstring to validate_uris - * Issue #34: Documentation tutorial part1 needs corsheaders explanation - * Issue #36: Add mandatory namespace to urls - * Issue #45: Add docs for AbstractApplication - * Issue #47: Add docs for views decorators - -0.3.2 [2013-07-10] ------------------- - - * Bugfix #37: Error in migrations with custom user on Django 1.5 - -0.3.1 [2013-07-10] ------------------- - - * Bugfix #27: OAuthlib refresh token refactoring - -0.3.0 [2013-06-14] ------------------- - - * `Django REST Framework <http://django-rest-framework.org/>`_ integration layer - * Bugfix #13: Populate request with client and user in validate_bearer_token - * Bugfix #12: Fix paths in documentation - -**Backwards incompatible changes in 0.3.0** - - * `requested_scopes` parameter in ScopedResourceMixin changed to `required_scopes` - -0.2.1 [2013-06-06] ------------------- - - * Core optimizations - -0.2.0 [2013-06-05] ------------------- - - * Add support for Django1.4 and Django1.6 - * Add support for Python 3.3 - * Add a default ReadWriteScoped view - * Add tutorial to docs - -0.1.0 [2013-05-31] ------------------- - - * Support OAuth2 Authorization Flows - -0.0.0 [2013-05-17] ------------------- - - * Discussion with Daniel Greenfeld at Django Circus - * Ignition +.. mdinclude:: ../CHANGELOG.md diff --git a/docs/conf.py b/docs/conf.py index a0eee6fc8..628fb4bed 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -36,7 +36,7 @@ # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'rfc',] +extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'rfc', 'm2r',] # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] diff --git a/tox.ini b/tox.ini index d0f79a62f..66a59d5d6 100644 --- a/tox.ini +++ b/tox.ini @@ -42,6 +42,7 @@ whitelist_externals = make commands = make html deps = sphinx oauthlib>=3.0.1 + m2r>=0.2.1 [testenv:py37-flake8] skip_install = True From 935a6647bd4da611c7d5d7c7f3e5ebe3a71516a2 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 29 Feb 2020 12:04:49 -0500 Subject: [PATCH 287/722] add a pull request template (#790) Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- .github/pull_request_template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 .github/pull_request_template.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..d5160d662 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,12 @@ +Fixes # + +## Description of the Change + +## Checklist + +- [ ] PR only contains one change (considered splitting up PR) +- [ ] unit-test added +- [ ] documentation updated +- [ ] `CHANGELOG.md` updated (only for user relevant changes) +- [ ] `docs/changelog.rst` updated to match. +- [ ] author name in `AUTHORS` From 462c2eada587ee4155b1fabbc6f78b8b91263a9f Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 29 Feb 2020 12:09:07 -0500 Subject: [PATCH 288/722] remove checklist item to edit changelog.rst --- .github/pull_request_template.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d5160d662..f7edc0b89 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -8,5 +8,4 @@ Fixes # - [ ] unit-test added - [ ] documentation updated - [ ] `CHANGELOG.md` updated (only for user relevant changes) -- [ ] `docs/changelog.rst` updated to match. - [ ] author name in `AUTHORS` From 324bfcd6f146619159ac0ef1574cc3d70f2ed039 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 29 Feb 2020 12:11:51 -0500 Subject: [PATCH 289/722] add self to AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index cc6bb2032..cbcefa213 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,6 +8,7 @@ Contributors ============ Alessandro De Angelis +Alan Crosswell Asif Saif Uddin Ash Christopher Aristóbulo Meneses From 6194247b769dff355cf9be4d98303c2631cae796 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Sun, 1 Mar 2020 21:00:05 +0600 Subject: [PATCH 290/722] Revert "Auto-authorize if valid refresh tokens exist (#754)" (#793) This reverts commit a1dcd37f2fc367b13fc63a751f1fdea48206c191. --- oauth2_provider/views/base.py | 8 +------- tests/test_authorization_code.py | 10 ---------- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 41c2a6c67..02c32c6aa 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -15,7 +15,7 @@ from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect -from ..models import get_access_token_model, get_application_model, get_refresh_token_model +from ..models import get_access_token_model, get_application_model from ..scopes import get_scopes_backend from ..settings import oauth2_settings from ..signals import app_authorized @@ -194,12 +194,6 @@ def get(self, request, *args, **kwargs): expires__gt=timezone.now() ).all() - refresh_tokens = get_refresh_token_model().objects.filter( - user=request.user, - application=kwargs["application"] - ).exclude(revoked__lt=timezone.now()).all() - tokens = list(tokens) + [r.access_token for r in refresh_tokens] - # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 69dcfd93a..793cca2d9 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -201,16 +201,6 @@ def test_pre_auth_approval_prompt(self): url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) response = self.client.get(url) self.assertEqual(response.status_code, 302) - # access token expired but valid refresh token exists - tok.expires = timezone.now() - datetime.timedelta(days=1) - tok.save() - reftok = RefreshToken.objects.create( - user=self.test_user, token="0123456789", - application=self.application, - access_token=tok - ) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() From 891f837290acc0c6c8813a8d2f6453bc677f76bb Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 09:31:39 -0500 Subject: [PATCH 291/722] restore commented-out tests --- tox.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tox.ini b/tox.ini index 66a59d5d6..9c476eab8 100644 --- a/tox.ini +++ b/tox.ini @@ -6,10 +6,9 @@ envlist = py37-django{30,22,21}, py36-django{22,21}, py35-django{22,21}, -# FIXME: something is broken in DRF integration, enable once fixed -# py38-djangomaster, -# py37-djangomaster, -# py36-djangomaster, + py38-djangomaster, + py37-djangomaster, + py36-djangomaster, [pytest] django_find_project = false @@ -51,6 +50,7 @@ commands = deps = flake8 flake8-isort +# TODO: restore this: # flake8-quotes [coverage:run] From a3e9dcd1e5862cca6cf42a9de530bcb2a987bc0a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 09:49:32 -0500 Subject: [PATCH 292/722] explicit tox matrix to enable allowed failures --- .travis.yml | 47 +++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/.travis.yml b/.travis.yml index 1cf0c8852..2aef56d6f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,17 +3,52 @@ dist: bionic language: python -python: - - "3.8" - - "3.7" - - "3.6" - - "3.5" - cache: directories: - $HOME/.cache/pip - $TRAVIS_BUILD_DIR/.tox +# Make sure to coordinate changes to envlist in tox.ini. +matrix: + allow_failures: + - env: TOXENV=py36-djangomaster + - env: TOXENV=py37-djangomaster + - env: TOXENV=py38-djangomaster + + include: + - python: 3.7 + env: TOXENV=py37-flake8 + - python: 3.7 + env: TOXENV=py37-docs + + - python: 3.8 + env: TOXENV=py38-django30 + - python: 3.8 + env: TOXENV=py38-django22 + - python: 3.8 + env: TOXENV=py38-django21 + - python: 3.8 + env: TOXENV=py38-djangomaster + + - python: 3.7 + env: TOXENV=py37-django30 + - python: 3.7 + env: TOXENV=py37-django22 + - python: 3.7 + env: TOXENV=py37-django21 + - python: 3.7 + env: TOXENV=py37-djangomaster + + - python: 3.6 + env: TOXENV=py36-django22 + - python: 3.6 + env: TOXENV=py36-django21 + + - python: 3.5 + env: TOXENV=py35-django22 + - python: 3.5 + env: TOXENV=py35-django21 + install: - pip install coveralls tox tox-travis From 48569080d0db32e57841fe2c9c19bc102a5c7a41 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 29 Feb 2020 22:13:33 -0500 Subject: [PATCH 293/722] try to document undocumented PRs since 1.2.0 --- CHANGELOG.md | 118 ++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 84 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75df2cd2e..47b281a33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,64 @@ -## Changelog -### 1.3.0 [unreleased] - -* Fix a race condition in creation of AccessToken with external oauth2 server. +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [unreleased] +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security + +## [1.3.0] 2020-03-TBD + +### Added +* Add support for Python 3.7 & 3.8 +* Add support for Django>=2.1,<=3.0 +* Add requirement for oauthlib>=3.0.1 +* Add support for [Proof Key for Code Exchange (PKCE, RFC 7636)](https://tools.ietf.org/html/rfc7636). +* Add support for custom token generators (e.g. to create JWT tokens). +* Add new `OAUTH2_PROVIDER` [settings](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html): + - `ACCESS_TOKEN_GENERATOR` to override the default access token generator. + - `REFRESH_TOKEN_GENERATOR` to override the default refresh token generator. + - `EXTRA_SERVER_KWARGS` options dictionary for oauthlib's Server class. + - `PKCE_REQUIRED` to require PKCE. +* Add `createapplication` management command to create an application. +* Add `id` in toolkit admin console applications list. +* Add nonstandard Google support for [urn:ietf:wg:oauth:2.0:oob] `redirect_uri` + for [Google OAuth2](https://developers.google.com/identity/protocols/OAuth2InstalledApp) "manual copy/paste". + **N.B.** this feature appears to be deprecated and replaced with methods described in + [RFC 8252: OAuth2 for Native Apps](https://tools.ietf.org/html/rfc8252) and *may* be deprecated and/or removed + from a future release of Django-oauth-toolkit. + +### Changed +* Change this change log to use [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. * **Backwards-incompatible** squashed migrations: - If you are currently on a release < 1.2.0, you will need to first install 1.2.x then `manage.py migrate` before + If you are currently on a release < 1.2.0, you will need to first install 1.2.0 then `manage.py migrate` before upgrading to >= 1.3.0. -* Bump django minimum to 2.1 -* Dropped Python 3.4 +* Improved the [tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial.html). -### 1.2.0 [2018-06-03] +### Removed +* Remove support for Python 3.4 +* Remove support for Django<=2.0 +* Remove requirement for oauthlib<3.0 + +### Fixed +* Fix a race condition in creation of AccessToken with external oauth2 server. +* Fix several concurrency issues. (#[638](https://github.com/jazzband/django-oauth-toolkit/issues/638)) +* Fix to pass `request` to `django.contrib.auth.authenticate()` (#[636](https://github.com/jazzband/django-oauth-toolkit/issues/636)) +* Fix missing `oauth2_error` property exception oauthlib_core.verify_request method raises exceptions in authenticate. + (#[633](https://github.com/jazzband/django-oauth-toolkit/issues/633)) +* Fix "django.db.utils.NotSupportedError: FOR UPDATE cannot be applied to the nullable side of an outer join" for postgresql. + (#[714](https://github.com/jazzband/django-oauth-toolkit/issues/714)) +* Fix to return a new refresh token during grace period rather than the recently-revoked one. + (#[702](https://github.com/jazzband/django-oauth-toolkit/issues/702)) +* Fix a bug in refresh token revocation. + (#[625](https://github.com/jazzband/django-oauth-toolkit/issues/625)) + +## 1.2.0 [2018-06-03] * **Compatibility**: Python 3.4 is the new minimum required version. * **Compatibility**: Django 2.0 is the new minimum required version. @@ -17,13 +67,13 @@ * Moved `redirect_uris` validation to the application clean() method. -### 1.1.2 [2018-05-12] +## 1.1.2 [2018-05-12] * Return state with Authorization Denied error (RFC6749 section 4.1.2.1) * Fix a crash with malformed base64 authentication headers * Fix a crash with malformed IPv6 redirect URIs -### 1.1.1 [2018-05-08] +## 1.1.1 [2018-05-08] * **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. @@ -31,7 +81,7 @@ https://github.com/jazzband/django-oauth-toolkit/issues/589 -### 1.1.0 [2018-04-13] +## 1.1.0 [2018-04-13] * **Notice**: The Django OAuth Toolkit project is now hosted by JazzBand. * **Compatibility**: Django 1.11 is the new minimum required version. Django 1.10 is no longer supported. @@ -45,7 +95,7 @@ refresh tokens may be re-used. * An `app_authorized` signal is fired when a token is generated. -### 1.0.0 [2017-06-07] +## 1.0.0 [2017-06-07] * **New feature**: AccessToken, RefreshToken and Grant models are now swappable. * #477: **New feature**: Add support for RFC 7662 (IntrospectTokenView, introspect scope) @@ -61,7 +111,7 @@ * The dependency on django-braces has been dropped. * The oauthlib dependency is no longer pinned. -### 0.12.0 [2017-02-24] +## 0.12.0 [2017-02-24] * **New feature**: Class-based scopes backends. Listing scopes, available scopes and default scopes is now done through the class that the `SCOPES_BACKEND_CLASS` setting points to. @@ -75,7 +125,7 @@ * #434: Relax URL patterns to allow for UUID primary keys -### 0.11.0 [2016-12-1] +## 0.11.0 [2016-12-1] * #315: AuthorizationView does not overwrite requests on get * #425: Added support for Django 1.10 @@ -84,7 +134,7 @@ * #389: Reuse refresh tokens if enabled. -### 0.10.0 [2015-12-14] +## 0.10.0 [2015-12-14] * **#322: dropping support for python 2.6 and django 1.4, 1.5, 1.6** * #310: Fixed error that could occur sometimes when checking validity of incomplete AccessToken/Grant @@ -101,7 +151,7 @@ * #273: Generic read write scope by resource -### 0.9.0 [2015-07-28] +## 0.9.0 [2015-07-28] * ``oauthlib_backend_class`` is now pluggable through Django settings * #127: ``application/json`` Content-Type is now supported using ``JSONOAuthLibCore`` @@ -110,15 +160,15 @@ * added support for oauthlib 1.0 -### 0.8.2 [2015-06-25] +## 0.8.2 [2015-06-25] * Fix the migrations to be two-step and allow upgrade from 0.7.2 -### 0.8.1 [2015-04-27] +## 0.8.1 [2015-04-27] * South migrations fixed. Added new django migrations. -### 0.8.0 [2015-03-27] +## 0.8.0 [2015-03-27] * Several docs improvements and minor fixes * #185: fixed vulnerabilities on Basic authentication @@ -136,17 +186,17 @@ * #38: create access tokens not bound to a user instance for *client credentials* flow -### 0.7.2 [2014-07-02] +## 0.7.2 [2014-07-02] * Don't pin oauthlib -### 0.7.1 [2014-04-27] +## 0.7.1 [2014-04-27] * Added database indexes to the OAuth2 related models to improve performances. **Warning: schema migration does not work for sqlite3 database, migration should be performed manually** -### 0.7.0 [2014-03-01] +## 0.7.0 [2014-03-01] * Created a setting for the default value for approval prompt. * Improved docs @@ -157,12 +207,12 @@ * Make Application model truly "swappable" (introduces a new non-namespaced setting `OAUTH2_PROVIDER_APPLICATION_MODEL`) -### 0.6.1 [2014-02-05] +## 0.6.1 [2014-02-05] * added support for `scope` query parameter keeping backwards compatibility for the original `scopes` parameter. * __str__ method in Application model returns content of `name` field when available -### 0.6.0 [2014-01-26] +## 0.6.0 [2014-01-26] * oauthlib 0.6.1 support * Django dev branch support @@ -178,7 +228,7 @@ * Issue #64: Fix urlencode clientid secret -### 0.5.0 [2013-09-17] +## 0.5.0 [2013-09-17] * oauthlib 0.6.0 support @@ -195,11 +245,11 @@ * Issue #53: Quote characters in the url query that are safe for Django but not for oauthlib -### 0.4.1 [2013-09-06] +## 0.4.1 [2013-09-06] * Optimize queries on access token validation -### 0.4.0 [2013-08-09] +## 0.4.0 [2013-08-09] **New Features** @@ -229,15 +279,15 @@ * Issue #47: Add docs for views decorators -### 0.3.2 [2013-07-10] +## 0.3.2 [2013-07-10] * Bugfix #37: Error in migrations with custom user on Django 1.5 -### 0.3.1 [2013-07-10] +## 0.3.1 [2013-07-10] * Bugfix #27: OAuthlib refresh token refactoring -### 0.3.0 [2013-06-14] +## 0.3.0 [2013-06-14] * [Django REST Framework](http://django-rest-framework.org/) integration layer * Bugfix #13: Populate request with client and user in `validate_bearer_token` @@ -248,11 +298,11 @@ * `requested_scopes` parameter in ScopedResourceMixin changed to `required_scopes` -### 0.2.1 [2013-06-06] +## 0.2.1 [2013-06-06] * Core optimizations -### 0.2.0 [2013-06-05] +## 0.2.0 [2013-06-05] * Add support for Django1.4 and Django1.6 * Add support for Python 3.3 @@ -260,12 +310,12 @@ * Add tutorial to docs -### 0.1.0 [2013-05-31] +## 0.1.0 [2013-05-31] * Support OAuth2 Authorization Flows -### 0.0.0 [2013-05-17] +## 0.0.0 [2013-05-17] * Discussion with Daniel Greenfeld at Django Circus * Ignition From 553265868ab9f27b8285a0c0ebe61c0cd7dd30a3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 08:52:20 -0500 Subject: [PATCH 294/722] correct Django support versions --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47b281a33..a063ca990 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Add support for Python 3.7 & 3.8 -* Add support for Django>=2.1,<=3.0 +* Add support for Django>=2.1,<3.1 * Add requirement for oauthlib>=3.0.1 * Add support for [Proof Key for Code Exchange (PKCE, RFC 7636)](https://tools.ietf.org/html/rfc7636). * Add support for custom token generators (e.g. to create JWT tokens). From 977414b682b650dc16192749459157a5349e2c46 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 15:51:30 -0500 Subject: [PATCH 295/722] sync up requirements documentation --- README.rst | 1 + docs/index.rst | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 67cc43a76..c5473646f 100644 --- a/README.rst +++ b/README.rst @@ -44,6 +44,7 @@ Requirements * Python 3.5+ * Django 2.1+ +* oauthlib 3.0+ Installation ------------ diff --git a/docs/index.rst b/docs/index.rst index 85b959347..8716eb90b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,8 +21,9 @@ If you need support please send a message to the `Django OAuth Toolkit Google Gr Requirements ------------ -* Python 3.4+ -* Django 2.0+ +* Python 3.5+ +* Django 2.1+ +* oauthlib 3.0+ Index ===== From 38c7c4ca687b988f0756c9f7940f491e6d820f8d Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 14:59:16 -0500 Subject: [PATCH 296/722] restore flake8 tests (#796) - remove '--exit-zero' so flake8 errors will actually cause a travis failure. - restore flake8-quotes. (I may prefer single quotes but @jlelanche has decreed they shall be double. Obey!) - fix various isort and flake8 (mostly single vs. double quotes) errors that crept in since the tests were disabled. --- .../management/commands/createapplication.py | 45 ++++----- oauth2_provider/models.py | 25 ++--- oauth2_provider/oauth2_validators.py | 2 +- oauth2_provider/settings.py | 17 ++-- oauth2_provider/views/base.py | 29 +++--- tests/models.py | 2 +- tests/settings.py | 2 +- tests/test_application_views.py | 1 + tests/test_authorization_code.py | 19 ++-- tests/test_commands.py | 99 ++++++++++--------- tests/test_rest_framework.py | 2 + tests/test_token_revocation.py | 1 - tox.ini | 7 +- 13 files changed, 129 insertions(+), 122 deletions(-) diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index e63d54280..95cb2d865 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -3,6 +3,7 @@ from oauth2_provider.models import get_application_model + Application = get_application_model() @@ -11,44 +12,44 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - 'client_type', + "client_type", type=str, - help='The client type, can be confidential or public', + help="The client type, can be confidential or public", ) parser.add_argument( - 'authorization_grant_type', + "authorization_grant_type", type=str, - help='The type of authorization grant to be used', + help="The type of authorization grant to be used", ) parser.add_argument( - '--client-id', + "--client-id", type=str, - help='The ID of the new application', + help="The ID of the new application", ) parser.add_argument( - '--user', + "--user", type=str, - help='The user the application belongs to', + help="The user the application belongs to", ) parser.add_argument( - '--redirect-uris', + "--redirect-uris", type=str, - help='The redirect URIs, this must be a space separated string e.g "URI1 URI2', + help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'", ) parser.add_argument( - '--client-secret', + "--client-secret", type=str, - help='The secret for this application', + help="The secret for this application", ) parser.add_argument( - '--name', + "--name", type=str, - help='The name this application', + help="The name this application", ) parser.add_argument( - '--skip-authorization', - action='store_true', - help='The ID of the new application', + "--skip-authorization", + action="store_true", + help="The ID of the new application", ) def handle(self, *args, **options): @@ -61,8 +62,8 @@ def handle(self, *args, **options): # verbosity and others. Also do not pass any None to the Application # instance so default values will be generated for those fields if key in application_fields and value: - if key == 'user': - application_data.update({'user_id': value}) + if key == "user": + application_data.update({"user_id": value}) else: application_data.update({key: value}) @@ -71,15 +72,15 @@ def handle(self, *args, **options): try: new_application.full_clean() except ValidationError as exc: - errors = "\n ".join(['- ' + err_key + ': ' + str(err_value) for err_key, + errors = "\n ".join(["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()]) self.stdout.write( self.style.ERROR( - 'Please correct the following errors:\n %s' % errors + "Please correct the following errors:\n %s" % errors ) ) else: new_application.save() self.stdout.write( - self.style.SUCCESS('New application created successfully') + self.style.SUCCESS("New application created successfully") ) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index c29faaa83..cfb2d7ede 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,6 +1,6 @@ +import logging from datetime import timedelta from urllib.parse import parse_qsl, urlparse -import logging from django.apps import apps from django.conf import settings @@ -15,13 +15,14 @@ from .settings import oauth2_settings from .validators import RedirectURIValidator, WildcardSet + logger = logging.getLogger(__name__) class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. - Usually an Application is created manually by client's developers after + Usually an Application is created manually by client"s developers after logging in on an Authorization Server. Fields: @@ -259,11 +260,11 @@ class Meta(AbstractGrant.Meta): class AbstractAccessToken(models.Model): """ An AccessToken instance represents the actual access token to - access user's resources, as in :rfc:`5`. + access user"s resources, as in :rfc:`5`. Fields: - * :attr:`user` The Django user representing resources' owner + * :attr:`user` The Django user representing resources" owner * :attr:`source_refresh_token` If from a refresh, the consumed RefeshToken * :attr:`token` Access token * :attr:`application` Application instance @@ -323,7 +324,7 @@ def allow_scopes(self, scopes): def revoke(self): """ - Convenience method to uniform tokens' interface, for now + Convenience method to uniform tokens" interface, for now simply remove this token from the database in order to revoke it. """ self.delete() @@ -356,7 +357,7 @@ class AbstractRefreshToken(models.Model): Fields: - * :attr:`user` The Django user representing resources' owner + * :attr:`user` The Django user representing resources" owner * :attr:`token` Token value * :attr:`application` Application instance * :attr:`access_token` AccessToken instance this refresh token is @@ -459,14 +460,14 @@ def clear_expired(): access_token__expires__lt=refresh_expire_at, ) - logger.info('%s Revoked refresh tokens to be deleted', revoked.count()) - logger.info('%s Expired refresh tokens to be deleted', expired.count()) + logger.info("%s Revoked refresh tokens to be deleted", revoked.count()) + logger.info("%s Expired refresh tokens to be deleted", expired.count()) revoked.delete() expired.delete() else: - logger.info('refresh_expire_at is %s. No refresh tokens deleted.', - refresh_expire_at) + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", + refresh_expire_at) access_tokens = access_token_model.objects.filter( refresh_token__isnull=True, @@ -474,8 +475,8 @@ def clear_expired(): ) grants = grant_model.objects.filter(expires__lt=now) - logger.info('%s Expired access tokens to be deleted', access_tokens.count()) - logger.info('%s Expired grant tokens to be deleted', grants.count()) + logger.info("%s Expired access tokens to be deleted", access_tokens.count()) + logger.info("%s Expired grant tokens to be deleted", grants.count()) access_tokens.delete() grants.delete() diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 3595d12fc..9027a4841 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -480,7 +480,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): # expires_in is passed to Server on initialization # custom server class can have logic to override this expires = timezone.now() + timedelta(seconds=token.get( - 'expires_in', oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + "expires_in", oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, )) if request.grant_type == "client_credentials": diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 2e513928c..6fa55ef4e 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -2,7 +2,7 @@ This module is largely inspired by django-rest-framework settings. Settings for the OAuth2 Provider are all namespaced in the OAUTH2_PROVIDER setting. -For example your project's `settings.py` file might look like this: +For example your project"s `settings.py` file might look like this: OAUTH2_PROVIDER = { "CLIENT_ID_GENERATOR_CLASS": @@ -182,24 +182,25 @@ def server_kwargs(self): This is used to communicate settings to oauth server. Takes relevant settings and format them accordingly. - There's also EXTRA_SERVER_KWARGS that can override every value + There"s also EXTRA_SERVER_KWARGS that can override every value and is more flexible regarding keys and acceptable values - but doesn't have import string magic or any additional + but doesn"t have import string magic or any additional processing, callables have to be assigned directly. For the likes of signed_token_generator it means something like - {'token_generator': signed_token_generator(privkey, **kwargs)} + {"token_generator": signed_token_generator(privkey, **kwargs)} """ kwargs = { key: getattr(self, value) for key, value in [ - ('token_expires_in', 'ACCESS_TOKEN_EXPIRE_SECONDS'), - ('refresh_token_expires_in', 'REFRESH_TOKEN_EXPIRE_SECONDS'), - ('token_generator', 'ACCESS_TOKEN_GENERATOR'), - ('refresh_token_generator', 'REFRESH_TOKEN_GENERATOR'), + ("token_expires_in", "ACCESS_TOKEN_EXPIRE_SECONDS"), + ("refresh_token_expires_in", "REFRESH_TOKEN_EXPIRE_SECONDS"), + ("token_generator", "ACCESS_TOKEN_GENERATOR"), + ("refresh_token_generator", "REFRESH_TOKEN_GENERATOR"), ] } kwargs.update(self.EXTRA_SERVER_KWARGS) return kwargs + oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 02c32c6aa..8a3a59c25 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -4,13 +4,13 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.http import HttpResponse, JsonResponse +from django.shortcuts import render +from django.urls import reverse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View -from django.shortcuts import render -from django.urls import reverse from ..exceptions import OAuthToolkitError from ..forms import AllowForm @@ -21,6 +21,7 @@ from ..signals import app_authorized from .mixins import OAuthLibMixin + log = logging.getLogger("oauth2_provider") @@ -61,7 +62,9 @@ def redirect(self, redirect_to, application): allowed_schemes = application.get_allowed_schemes() return OAuth2ResponseRedirect(redirect_to, allowed_schemes) -RFC3339 = '%Y-%m-%dT%H:%M:%SZ' + +RFC3339 = "%Y-%m-%dT%H:%M:%SZ" + class AuthorizationView(BaseAuthorizationView, FormView): """ @@ -208,23 +211,22 @@ def get(self, request, *args, **kwargs): return self.render_to_response(self.get_context_data(**kwargs)) - def redirect(self, redirect_to, application, - token = None): + def redirect(self, redirect_to, application, token=None): if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): return super().redirect(redirect_to, application) parsed_redirect = urllib.parse.urlparse(redirect_to) - code = urllib.parse.parse_qs(parsed_redirect.query)['code'][0] + code = urllib.parse.parse_qs(parsed_redirect.query)["code"][0] - if redirect_to.startswith('urn:ietf:wg:oauth:2.0:oob:auto'): + if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): response = { - 'access_token': code, - 'token_uri': redirect_to, - 'client_id': application.client_id, - 'client_secret': application.client_secret, - 'revoke_uri': reverse('oauth2_provider:revoke-token'), + "access_token": code, + "token_uri": redirect_to, + "client_id": application.client_id, + "client_secret": application.client_secret, + "revoke_uri": reverse("oauth2_provider:revoke-token"), } return JsonResponse(response) @@ -234,10 +236,11 @@ def redirect(self, redirect_to, application, request=self.request, template_name="oauth2_provider/authorized-oob.html", context={ - 'code': code, + "code": code, }, ) + @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): """ diff --git a/tests/models.py b/tests/models.py index cbbc50ba9..7ca0c57c5 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,10 +1,10 @@ from django.db import models -from oauth2_provider.settings import oauth2_settings from oauth2_provider.models import ( AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractRefreshToken ) +from oauth2_provider.settings import oauth2_settings class BaseTestApplication(AbstractApplication): diff --git a/tests/settings.py b/tests/settings.py index 1b7ba8db6..40eef5ebd 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -9,7 +9,7 @@ } } -AUTH_USER_MODEL = 'auth.User' +AUTH_USER_MODEL = "auth.User" OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 74162f087..6130876ce 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -8,6 +8,7 @@ from .models import SampleApplication + Application = get_application_model() UserModel = get_user_model() diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 793cca2d9..9a95bc269 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -31,6 +31,7 @@ URI_OOB = "urn:ietf:wg:oauth:2.0:oob" URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" + # mocking a protected resource view class ResourceView(ProtectedResourceView): def get(self, request, *args, **kwargs): @@ -1467,7 +1468,7 @@ def test_oob_as_html(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) self.assertEqual(response.status_code, 200) - self.assertRegex(response['Content-Type'], r'^text/html') + self.assertRegex(response["Content-Type"], r"^text/html") content = response.content.decode("utf-8") @@ -1475,12 +1476,9 @@ def test_oob_as_html(self): # to extract the token, risking summoning zalgo in the process." # -- https://github.com/jazzband/django-oauth-toolkit/issues/235 - matches = re.search(r'.*<code>([^<>]*)</code>', - content) - self.assertIsNotNone(matches, - msg="OOB response contains code inside <code> tag") - self.assertEqual(len(matches.groups()), 1, - msg="OOB response contains multiple <code> tags") + matches = re.search(r".*<code>([^<>]*)</code>", content) + self.assertIsNotNone(matches, msg="OOB response contains code inside <code> tag") + self.assertEqual(len(matches.groups()), 1, msg="OOB response contains multiple <code> tags") authorization_code = matches.groups()[0] token_request_data = { @@ -1516,12 +1514,12 @@ def test_oob_as_json(self): response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) self.assertEqual(response.status_code, 200) - self.assertRegex(response['Content-Type'], '^application/json') + self.assertRegex(response["Content-Type"], "^application/json") parsed_response = json.loads(response.content.decode("utf-8")) - self.assertIn('access_token', parsed_response) - authorization_code = parsed_response['access_token'] + self.assertIn("access_token", parsed_response) + authorization_code = parsed_response["access_token"] token_request_data = { "grant_type": "authorization_code", @@ -1539,6 +1537,7 @@ def test_oob_as_json(self): self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") diff --git a/tests/test_commands.py b/tests/test_commands.py index 8f1ddc27f..274eccec5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -7,6 +7,7 @@ from oauth2_provider.models import get_application_model + Application = get_application_model() @@ -16,35 +17,35 @@ def test_command_creates_application(self): output = StringIO() self.assertEqual(Application.objects.count(), 0) call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", stdout=output, ) self.assertEqual(Application.objects.count(), 1) - self.assertIn('New application created successfully', output.getvalue()) + self.assertIn("New application created successfully", output.getvalue()) def test_missing_required_args(self): self.assertEqual(Application.objects.count(), 0) with self.assertRaises(CommandError) as ctx: call_command( - 'createapplication', - '--redirect-uris=http://example.com http://example2.com', + "createapplication", + "--redirect-uris=http://example.com http://example2.com", ) - self.assertIn('client_type', ctx.exception.args[0]) - self.assertIn('authorization_grant_type', ctx.exception.args[0]) + self.assertIn("client_type", ctx.exception.args[0]) + self.assertIn("authorization_grant_type", ctx.exception.args[0]) self.assertEqual(Application.objects.count(), 0) def test_command_creates_application_with_skipped_auth(self): self.assertEqual(Application.objects.count(), 0) call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--skip-authorization', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--skip-authorization", ) app = Application.objects.get() @@ -52,10 +53,10 @@ def test_command_creates_application_with_skipped_auth(self): def test_application_created_normally_with_no_skipped_auth(self): call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", ) app = Application.objects.get() @@ -63,49 +64,49 @@ def test_application_created_normally_with_no_skipped_auth(self): def test_application_created_with_name(self): call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--name=TEST', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--name=TEST", ) app = Application.objects.get() - self.assertEqual(app.name, 'TEST') + self.assertEqual(app.name, "TEST") def test_application_created_with_client_secret(self): call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--client-secret=SECRET', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--client-secret=SECRET", ) app = Application.objects.get() - self.assertEqual(app.client_secret, 'SECRET') + self.assertEqual(app.client_secret, "SECRET") def test_application_created_with_client_id(self): call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--client-id=someId', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--client-id=someId", ) app = Application.objects.get() - self.assertEqual(app.client_id, 'someId') + self.assertEqual(app.client_id, "someId") def test_application_created_with_user(self): User = get_user_model() user = User.objects.create() call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--user=%s' % user.pk, + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--user=%s" % user.pk, ) app = Application.objects.get() @@ -114,14 +115,14 @@ def test_application_created_with_user(self): def test_validation_failed_message(self): output = StringIO() call_command( - 'createapplication', - 'confidential', - 'authorization-code', - '--redirect-uris=http://example.com http://example2.com', - '--user=783', + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--user=783", stdout=output, ) - self.assertIn('user', output.getvalue()) - self.assertIn('783', output.getvalue()) - self.assertIn('does not exist', output.getvalue()) + self.assertIn("user", output.getvalue()) + self.assertIn("783", output.getvalue()) + self.assertIn("does not exist", output.getvalue()) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 0251d98fe..21a6ccd71 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -98,10 +98,12 @@ class TokenHasScopeViewWrongAuth(BrokenOAuth2View): class MethodScopeAltViewWrongAuth(BrokenOAuth2View): permission_classes = [TokenMatchesOASRequirements] + class AuthenticationNone(OAuth2Authentication): def authenticate(self, request): return None + class AuthenticationNoneOAuth2View(MockView): authentication_classes = [AuthenticationNone] diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index d1ab591d2..fdbc07229 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -1,5 +1,4 @@ import datetime -from urllib.parse import urlencode from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase diff --git a/tox.ini b/tox.ini index 9c476eab8..7fce944af 100644 --- a/tox.ini +++ b/tox.ini @@ -46,12 +46,11 @@ deps = sphinx [testenv:py37-flake8] skip_install = True commands = - flake8 --exit-zero {toxinidir} + flake8 {toxinidir} deps = flake8 flake8-isort -# TODO: restore this: -# flake8-quotes + flake8-quotes [coverage:run] source = oauth2_provider @@ -59,7 +58,7 @@ omit = */migrations/* [flake8] max-line-length = 110 -exclude = docs/, oauth2_provider/migrations/, .tox/ +exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ application-import-names = oauth2_provider inline-quotes = double From 991e48025b690de2ea7f3a9340861802af2d3365 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 15:17:04 -0500 Subject: [PATCH 297/722] replace deprecated force_text with force_str --- oauth2_provider/validators.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index a6f3a33b6..f3f82102c 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from django.core.validators import URLValidator -from django.utils.encoding import force_text +from django.utils.encoding import force_str class URIValidator(URLValidator): @@ -28,7 +28,7 @@ def __init__(self, allowed_schemes, allow_fragments=False): def __call__(self, value): super().__call__(value) - value = force_text(value) + value = force_str(value) scheme, netloc, path, query, fragment = urlsplit(value) if fragment and not self.allow_fragments: raise ValidationError("Redirect URIs must not contain fragments") From 26c1f2bd2901edd1ced30271d7fa5b90af34c030 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 15:30:43 -0500 Subject: [PATCH 298/722] possesives and contractions inadvertently changed to double-quote --- oauth2_provider/models.py | 4 ++-- oauth2_provider/settings.py | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index cfb2d7ede..f87a51691 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -22,7 +22,7 @@ class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. - Usually an Application is created manually by client"s developers after + Usually an Application is created manually by client's developers after logging in on an Authorization Server. Fields: @@ -260,7 +260,7 @@ class Meta(AbstractGrant.Meta): class AbstractAccessToken(models.Model): """ An AccessToken instance represents the actual access token to - access user"s resources, as in :rfc:`5`. + access user's resources, as in :rfc:`5`. Fields: diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 6fa55ef4e..858efdbe7 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -2,7 +2,7 @@ This module is largely inspired by django-rest-framework settings. Settings for the OAuth2 Provider are all namespaced in the OAUTH2_PROVIDER setting. -For example your project"s `settings.py` file might look like this: +For example your project's `settings.py` file might look like this: OAUTH2_PROVIDER = { "CLIENT_ID_GENERATOR_CLASS": @@ -182,9 +182,9 @@ def server_kwargs(self): This is used to communicate settings to oauth server. Takes relevant settings and format them accordingly. - There"s also EXTRA_SERVER_KWARGS that can override every value + There's also EXTRA_SERVER_KWARGS that can override every value and is more flexible regarding keys and acceptable values - but doesn"t have import string magic or any additional + but doesn't have import string magic or any additional processing, callables have to be assigned directly. For the likes of signed_token_generator it means something like From 0c626927d9d12286d2fd2a5d8bd7cd22aef3711b Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 1 Mar 2020 14:16:33 -0500 Subject: [PATCH 299/722] improve contributing guidelines - remove reference to no longer-existant google group. - update to reflect the PR template checklist items. --- docs/contributing.rst | 54 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 48cb043ed..021895e38 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -15,8 +15,7 @@ Issues You can find the list of bugs, enhancements and feature requests on the `issue tracker <https://github.com/jazzband/django-oauth-toolkit/issues>`_. If you want to fix an issue, pick up one and -add a comment stating you're working on it. If the resolution implies a discussion or if you realize the comments on the -issue are growing pretty fast, move the discussion to the `Google Group <http://groups.google.com/group/django-oauth-toolkit>`_. +add a comment stating you're working on it. Pull requests ============= @@ -36,7 +35,46 @@ Now you can go to your repository dashboard on GitHub and open a pull request st apply your pull request to the `master` branch of django-oauth-toolkit (this should be the default behaviour of GitHub user interface). -Next you should add a comment about your branch, and if the pull request refers to a certain issue, insert a link to it. +When you begin your PR, you'll be asked to provide the following: + +* Identify the issue number that this PR fixes (if any). + That issue will automatically be closed when your PR is accepted and merged. + +* Provide a high-level description of the change. A reviewer should be able to tell what your PR does without having + to read the commit(s). + +* Make sure the PR only contains one change. Try to keep the PR as small and focused as you can. You can always + submit additional PRs. + +* Any new or changed code requires that a unit test be added or updated. Make sure your tests check for + correct error behavior as well as normal expected behavior. Strive for 100% code coverage of any new + code you contribute! Improving unit tests is always a welcome contribution. + If your change reduces coverage, you'll be warned by `coveralls <https://coveralls.io/>`_. + +* Update the documentation (in `docs/`) to describe the new or changed functionality. + +* Update `CHANGELOG.md` (only for user relevant changes). We use `Keep A Changelog <https://keepachangelog.com/en/1.0.0/>`_ + format which categorizes the changes as: + + * `Added` for new features. + + * `Changed` for changes in existing functionality. + + * `Deprecated` for soon-to-be removed features. + + * `Removed` for now removed features. + + * `Fixed` for any bug fixes. + + * `Security` in case of vulnerabilities. (Please report any security issues to the + JazzBand security team `<security@jazzband.co>`. Do not file an issue on the tracker + or submit a PR until directed to do so.) + +* Make sure your name is in `AUTHORS`. + +If your PR is not yet ready to be merged mark it as a Work-in-Progress +By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. + The repo managers will be notified of your pull request and it will be reviewed, in the meantime you can continue to add commits to your topic branch (and push them up to GitHub) either if you see something that needs changing, or in response to a reviewer's comments. If a reviewer asks for changes, you do not need to close the pull and reissue it @@ -87,6 +125,15 @@ Whenever you add code, you have to add tests as well. We cannot accept untested situation you previously discussed with the core committers, if your pull request reduces the test coverage it will be **immediately rejected**. +You can check your coverage locally with the `coverage <https://pypi.org/project/coverage/>`_ package after running tox:: + + pip install coverage + coverage html -d mycoverage + +Open mycoverage/index.html in your browser and you can see a coverage summary and coverage details for each file. + +There's no need to wait for coveralls to complain after you submit your PR. + Code conventions matter ----------------------- @@ -95,4 +142,5 @@ Try reading our code and grasp the overall philosophy regarding method and varia the sake of readability, keep in mind that *simple is better than complex*. If you feel the code is not straightforward, add a comment. If you think a function is not trivial, add a docstrings. + The contents of this page are heavily based on the docs from `django-admin2 <https://github.com/twoscoops/django-admin2>`_ From 0b6232ad680aa67f26cae597d21471490e5f39e6 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 2 Mar 2020 09:30:57 -0500 Subject: [PATCH 300/722] release 1.3.0 --- CHANGELOG.md | 4 +++- setup.cfg | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a063ca990..3b23d5364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +<!-- ## [unreleased] ### Added ### Changed @@ -11,8 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed ### Fixed ### Security + --> -## [1.3.0] 2020-03-TBD +## [1.3.0] 2020-03-02 ### Added * Add support for Python 3.7 & 3.8 diff --git a/setup.cfg b/setup.cfg index 060e1d04e..d9d724c43 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.2.0 +version = 1.3.0 description = OAuth2 Provider for Django author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com From 22d64fc1fd8ac289a97f5b14426b3ce5ce549725 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 3 Mar 2020 15:42:28 -0500 Subject: [PATCH 301/722] add some issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 28 +++++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 21 +++++++++++++++++ .github/ISSUE_TEMPLATE/question.md | 12 ++++++++++ 3 files changed, 61 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/ISSUE_TEMPLATE/question.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..3dff61eab --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug, docs, api +assignees: '' + +--- + +**Describe the bug** +<!-- A clear and concise description of what the bug is. --> + +**To Reproduce** +<!-- Steps to reproduce the behavior --> + +**Expected behavior** +<!-- A clear and concise description of what you expected to happen. --> + +**Version** +<!-- Version of django-oauth-toolkit --> + +<!-- Have you tested with the latest version and/or master branch? --> +- [ ] I have tested with the latest published release and it's still a problem. +- [ ] I have tested with the master branch and it's still a problem. + +**Additional context** +<!-- Add any other context about the problem here. --> + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..83290d898 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,21 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement, good first issue, docs, help-wanted +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +<!-- A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] --> + +**Describe the solution you'd like** +<!-- A clear and concise description of what you want to happen. --> + +**Describe alternatives you've considered** +<!-- A clear and concise description of any alternative solutions or features you've considered. --> + +**Additional context** +<!-- Add any other context or screenshots about the feature request here. --> + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..b1f99e0fc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,12 @@ +--- +name: Question +about: Ask a question about using django-oauth-toolkit +title: '' +labels: question, help-wanted +assignees: '' + +--- + +<!-- What is your question? --> + + From a09eac877fb9f97b38e7a625197fb31976734383 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 4 Mar 2020 12:01:01 -0500 Subject: [PATCH 302/722] add on modules for rtd to install --- .readthedocs.yml | 15 +++++++++++++++ docs/requirements.txt | 2 ++ 2 files changed, 17 insertions(+) create mode 100644 .readthedocs.yml create mode 100644 docs/requirements.txt diff --git a/.readthedocs.yml b/.readthedocs.yml new file mode 100644 index 000000000..eef926c3b --- /dev/null +++ b/.readthedocs.yml @@ -0,0 +1,15 @@ +# .readthedocs.yml +# Read the Docs configuration file +# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details + +# Required +version: 2 + +# Build documentation in the docs/ directory with Sphinx +sphinx: + configuration: docs/conf.py + +python: + version: 3.7 + install: + - requirements: docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt new file mode 100644 index 000000000..64b48e434 --- /dev/null +++ b/docs/requirements.txt @@ -0,0 +1,2 @@ +oauthlib>=3.0.1 +m2r>=0.2.1 From 28d7d89defb95fb000cf440d23ea0b5146558e50 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 4 Mar 2020 12:23:02 -0500 Subject: [PATCH 303/722] add django to docs build requirements --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 64b48e434..aa4db0073 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,3 @@ +Django>=3.0,<3.1 oauthlib>=3.0.1 m2r>=0.2.1 From 38ac5179c4eb309bb0db99f6d87bbd76c2de1681 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 4 Mar 2020 12:26:33 -0500 Subject: [PATCH 304/722] need to add self (oauth2_provider) as well --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index aa4db0073..63d82768f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ Django>=3.0,<3.1 oauthlib>=3.0.1 m2r>=0.2.1 +. From eb381c30affa60a0465a8a2f2c5ca0890ece5362 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 6 Mar 2020 10:49:05 -0500 Subject: [PATCH 305/722] pypi improvements - add long_description (README.rst) - add 'tox -e install' as a reminder of how to 'twine upload'. --- setup.cfg | 2 ++ tox.ini | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/setup.cfg b/setup.cfg index d9d724c43..71a69a99d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,8 @@ name = django-oauth-toolkit version = 1.3.0 description = OAuth2 Provider for Django +long_description = file: README.rst +long_description_content_type = text/x-rst author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com url = https://github.com/jazzband/django-oauth-toolkit diff --git a/tox.ini b/tox.ini index 7fce944af..210106f57 100644 --- a/tox.ini +++ b/tox.ini @@ -52,6 +52,19 @@ deps = flake8-isort flake8-quotes +[testenv:install] +deps = + twine + setuptools>=39.0 + wheel +whitelist_externals= + rm +commands = + rm -rf dist + python setup.py sdist bdist_wheel + twine upload dist/* + + [coverage:run] source = oauth2_provider omit = */migrations/* From bca1f917ae43efb2fb88037385e5c620dcbb6705 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@crosswell.us> Date: Sat, 14 Mar 2020 14:53:25 -0400 Subject: [PATCH 306/722] Revert "Fix issue #636, pass request object to authenticate function. (#643)" This reverts commit d5e6645184edc1b91371fc17b73c3e11cd33b0c3. --- oauth2_provider/oauth2_validators.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 9027a4841..b02b98075 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -608,7 +608,7 @@ def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User """ - u = authenticate(request, username=username, password=password) + u = authenticate(username=username, password=password) if u is not None and u.is_active: request.user = u return True From 9bd4c1bfcf26eecf8fde793baa48c386885c19be Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 14 Mar 2020 15:04:22 -0400 Subject: [PATCH 307/722] document revert of #643 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b23d5364..83ba76062 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [1.3.1] unrelease +### Fixed +* #812: Reverts #643 pass wrong request object to authenticate function. + +### Security + ## [1.3.0] 2020-03-02 ### Added From b4aa49f55936bc8b9bbd1d40486f9dfe86ab0d25 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 14 Mar 2020 15:06:12 -0400 Subject: [PATCH 308/722] fix sloppy changelog edits --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ba76062..cfbc66190 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [1.3.1] unrelease +## [1.3.1] unreleased ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. -### Security - ## [1.3.0] 2020-03-02 ### Added From 6b2f5f81f74e1f0cf0e81a02d5fd497c4b671139 Mon Sep 17 00:00:00 2001 From: Retief Visser <retief.visser@uptickhq.com> Date: Fri, 13 Mar 2020 14:22:20 +1100 Subject: [PATCH 309/722] Fix fetching old access_token from refresh_token --- oauth2_provider/oauth2_validators.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b02b98075..162112d21 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -634,7 +634,9 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS ) ) - rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).first() + rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( + "access_token" + ).first() if not rt: return False From fd0a0d8a260f9ca41785d26a03925811c9462ad2 Mon Sep 17 00:00:00 2001 From: Retief Visser <retief.visser@uptickhq.com> Date: Tue, 17 Mar 2020 10:48:33 +1100 Subject: [PATCH 310/722] Add changelog for PR #810 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cfbc66190..7fe3221de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [1.3.1] unreleased ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. +* Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) ## [1.3.0] 2020-03-02 From f4d960bde00693440941acd3d58af4a94e58623a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@crosswell.us> Date: Fri, 20 Mar 2020 09:10:30 -0400 Subject: [PATCH 311/722] Revert "getting_started.rst: add JSONOAuthLibCore as part of tutorial (#734)" This reverts commit 392257a77473b7eb75899dd2475482a7c3729e3f. --- docs/rest-framework/getting_started.rst | 6 ------ 1 file changed, 6 deletions(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index b92c08e4f..8028a412f 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -100,8 +100,6 @@ Also add the following to your `settings.py` module: .. code-block:: python OAUTH2_PROVIDER = { - # parses OAuth2 data from application/json requests - 'OAUTH2_BACKEND_CLASS': 'oauth2_provider.oauth2_backends.JSONOAuthLibCore', # this is the list of available scopes 'SCOPES': {'read': 'Read scope', 'write': 'Write scope', 'groups': 'Access to your groups'} } @@ -114,10 +112,6 @@ Also add the following to your `settings.py` module: ) } -`OAUTH2_PROVIDER` setting parameter sets the backend class that is used to parse OAuth2 requests. -The `JSONOAuthLibCore` class extends the default OAuthLibCore to parse correctly -`application/json` requests. - `OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, so we can use them for permission check. From 775690154fe89114e14be8d3ff2916bbde6a1497 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 20 Mar 2020 09:15:22 -0400 Subject: [PATCH 312/722] tutorial docs errata --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fe3221de..400bc13b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. * Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) +* #817: Reverts #734 tutorial documentation error. ## [1.3.0] 2020-03-02 From 9bb0703bd16e1a87fc83af687a350a331a0f12fa Mon Sep 17 00:00:00 2001 From: Abhishek Patel <5524161+Abhishek8394@users.noreply.github.com> Date: Mon, 23 Mar 2020 06:57:01 -0700 Subject: [PATCH 313/722] HTTP Basic Auth support for introspection (Fix issue #709) (#725) * fix issue #709 - Add a new mixin that allows authenticating with HTTP basic auth, credentials in body or access tokens - Introduce and abstraction in views.generic to initialize the OauthLibMixin - Change parent class of IntrospectTokenView from 'ScopedProtectedResourceView' to 'ClientProtectedScopedResourceView' * fix failing tests after master merge - test failed because they sent url query params in a post request. That is no longer allowed for security purposes. - Fix: send query params as POST body instead of query params * add newline * update AUTHORS and CHANGELOG * fix flake8 failing tests * document RESOURCE_SERVER_INTROSPECTION_CREDENTIALS Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Mariano ramirez <marianoramirez353@gmail.com> Co-authored-by: Mattia Procopio <promat85@gmail.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 4 ++ docs/settings.rst | 8 +++- oauth2_provider/oauth2_backends.py | 21 +++++++-- oauth2_provider/views/__init__.py | 4 +- oauth2_provider/views/generic.py | 34 ++++++++++++-- oauth2_provider/views/introspect.py | 4 +- oauth2_provider/views/mixins.py | 43 +++++++++++++++++- tests/test_introspection_view.py | 69 ++++++++++++++++++++++++++++- tests/test_token_revocation.py | 9 ++++ 10 files changed, 182 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index cbcefa213..27d60fa8d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -7,6 +7,7 @@ Federico Frenguelli Contributors ============ +Abhishek Patel Alessandro De Angelis Alan Crosswell Asif Saif Uddin diff --git a/CHANGELOG.md b/CHANGELOG.md index 400bc13b7..bff59538b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,11 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [1.3.1] unreleased +### Added +* #725: HTTP Basic Auth support for introspection (Fix issue #709) + ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. * Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) * #817: Reverts #734 tutorial documentation error. + ## [1.3.0] 2020-03-02 ### Added diff --git a/docs/settings.rst b/docs/settings.rst index d0bc62e9a..eb7324672 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -198,12 +198,18 @@ Only applicable when used with `Django REST Framework <http://django-rest-framew RESOURCE_SERVER_INTROSPECTION_URL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -The introspection endpoint for validating token remotely (RFC7662). +The introspection endpoint for validating token remotely (RFC7662). This URL requires either an authorization +token (RESOURCE_SERVER_AUTH_TOKEN) +or HTTP Basic Auth client credentials (RESOURCE_SERVER_INTROSPECTION_CREDENTIALS): RESOURCE_SERVER_AUTH_TOKEN ~~~~~~~~~~~~~~~~~~~~~~~~~~ The bearer token to authenticate the introspection request towards the introspection endpoint (RFC7662). +RESOURCE_SERVER_INTROSPECTION_CREDENTIALS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The HTTP Basic Auth Client_ID and Client_Secret to authenticate the introspection request +towards the introspect endpoint (RFC7662) as a tuple: (client_id,client_secret). RESOURCE_SERVER_TOKEN_CACHING_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index f8710fdb0..04264f6a0 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse, urlunparse from oauthlib import oauth2 +from oauthlib.common import Request as OauthlibRequest from oauthlib.common import quote, urlencode, urlencoded from .exceptions import FatalClientError, OAuthToolkitError @@ -15,6 +16,7 @@ class OAuthLibCore(object): Meant for things like extracting request data and converting everything to formats more palatable for oauthlib's Server. """ + def __init__(self, server=None): """ :params server: An instance of oauthlib.oauth2.Server class @@ -128,9 +130,11 @@ def create_authorization_response(self, request, scopes, credentials, allow): return uri, headers, body, status except oauth2.FatalClientError as error: - raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) + raise FatalClientError( + error=error, redirect_uri=credentials["redirect_uri"]) except oauth2.OAuth2Error as error: - raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) + raise OAuthToolkitError( + error=error, redirect_uri=credentials["redirect_uri"]) def create_token_response(self, request): """ @@ -171,14 +175,25 @@ def verify_request(self, request, scopes): """ uri, http_method, body, headers = self._extract_params(request) - valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes) + valid, r = self.server.verify_request( + uri, http_method, body, headers, scopes=scopes) return valid, r + def authenticate_client(self, request): + """Wrapper to call `authenticate_client` on `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + oauth_request = OauthlibRequest(uri, http_method, body, headers) + return self.server.request_validator.authenticate_client(oauth_request) + class JSONOAuthLibCore(OAuthLibCore): """ Extends the default OAuthLibCore to parse correctly application/json requests """ + def extract_body(self, request): """ Extracts the JSON body from the Django request object diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 7bf60cece..7636bd9c7 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -2,6 +2,8 @@ from .base import AuthorizationView, TokenView, RevokeTokenView from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ ApplicationDelete, ApplicationUpdate -from .generic import ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView +from .generic import ( + ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView, + ClientProtectedResourceView, ClientProtectedScopedResourceView) from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView from .introspect import IntrospectTokenView diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index c9bbc6af4..5c0c760e5 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -2,19 +2,28 @@ from ..settings import oauth2_settings from .mixins import ( - ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin + ClientProtectedResourceMixin, OAuthLibMixin, ProtectedResourceMixin, + ReadWriteScopedResourceMixin, ScopedResourceMixin ) -class ProtectedResourceView(ProtectedResourceMixin, View): - """ - Generic view protecting resources by providing OAuth2 authentication out of the box +class InitializationMixin(OAuthLibMixin): + + """Initializer for OauthLibMixin """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS +class ProtectedResourceView(ProtectedResourceMixin, InitializationMixin, View): + """ + Generic view protecting resources by providing OAuth2 authentication out of the box + """ + pass + + class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): """ Generic view protecting resources by providing OAuth2 authentication and Scopes handling @@ -29,3 +38,20 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. """ pass + + +class ClientProtectedResourceView(ClientProtectedResourceMixin, InitializationMixin, View): + + """View for protecting a resource with client-credentials method. + This involves allowing access tokens, Basic Auth and plain credentials in request body. + """ + + pass + + +class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): + + """Impose scope restrictions if client protection fallsback to access token. + """ + + pass diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 5d5fcea76..d4afe4abb 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -7,11 +7,11 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views import ScopedProtectedResourceView +from oauth2_provider.views import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") -class IntrospectTokenView(ScopedProtectedResourceView): +class IntrospectTokenView(ClientProtectedScopedResourceView): """ Implements an endpoint for token introspection based on RFC 7662 https://tools.ietf.org/html/rfc7662 diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 0cc9bd589..851ec4cd5 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -174,6 +174,15 @@ def error_response(self, error, **kwargs): return redirect, error_response + def authenticate_client(self, request): + """Returns a boolean representing if client is authenticated with client credentials + method. Returns `True` if authenticated. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.authenticate_client(request) + class ScopedResourceMixin(object): """ @@ -200,6 +209,7 @@ class ProtectedResourceMixin(OAuthLibMixin): Helper mixin that implements OAuth2 protection on request dispatch, specially useful for Django Generic Views """ + def dispatch(self, request, *args, **kwargs): # let preflight OPTIONS requests pass if request.method.upper() == "OPTIONS": @@ -223,12 +233,14 @@ class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin): def __new__(cls, *args, **kwargs): provided_scopes = get_scopes_backend().get_all_scopes() - read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] + read_write_scopes = [oauth2_settings.READ_SCOPE, + oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "ReadWriteScopedResourceMixin requires following scopes {}" - ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) + ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format( + read_write_scopes) ) return super().__new__(cls, *args, **kwargs) @@ -246,3 +258,30 @@ def get_scopes(self, *args, **kwargs): # this returns a copy so that self.required_scopes is not modified return scopes + [self.read_write_scope] + + +class ClientProtectedResourceMixin(OAuthLibMixin): + + """Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1` + This involves authenticating with any of: HTTP Basic Auth, Client Credentials and + Access token in that order. Breaks off after first validation. + """ + + def dispatch(self, request, *args, **kwargs): + # let preflight OPTIONS requests pass + if request.method.upper() == "OPTIONS": + return super().dispatch(request, *args, **kwargs) + # Validate either with HTTP basic or client creds in request body. + # TODO: Restrict to POST. + valid = self.authenticate_client(request) + if not valid: + # Alternatively allow access tokens + # check if the request is valid and the protected resource may be accessed + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + else: + return HttpResponseForbidden() + else: + return super().dispatch(request, *args, **kwargs) diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index a06a73e52..061d213e6 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -9,6 +9,8 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings +from .utils import get_basic_auth_header + Application = get_application_model() AccessToken = get_access_token_model() @@ -19,9 +21,12 @@ class TestTokenIntrospectionViews(TestCase): """ Tests for Authorized Token Introspection Views """ + def setUp(self): - self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") - self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") + self.resource_server_user = UserModel.objects.create_user( + "resource_server", "test@example.com") + self.test_user = UserModel.objects.create_user( + "bar_user", "dev@example.com") self.application = Application.objects.create( name="Test Application", @@ -256,3 +261,63 @@ def test_view_post_notexisting_token(self): self.assertDictEqual(content, { "active": False, }) + + def test_view_post_valid_client_creds_basic_auth(self): + """Test HTTP basic auth working + """ + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret) + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token}, + **auth_headers) + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }) + + def test_view_post_invalid_client_creds_basic_auth(self): + """Must fail for invalid client credentials + """ + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + "_so_wrong") + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token}, + **auth_headers) + self.assertEqual(response.status_code, 403) + + def test_view_post_valid_client_creds_plaintext(self): + """Test introspecting with credentials in request body + """ + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret}) + self.assertEqual(response.status_code, 200) + content = response.json() + self.assertIsInstance(content, dict) + self.assertDictEqual(content, { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }) + + def test_view_post_invalid_client_creds_plaintext(self): + """Must fail for invalid creds in request body. + """ + response = self.client.post( + reverse("oauth2_provider:introspect"), + {"token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret + "_so_wrong"}) + self.assertEqual(response.status_code, 403) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index fdbc07229..0368ef283 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -50,6 +50,7 @@ def test_revoke_access_token(self): expires=timezone.now() + datetime.timedelta(days=1), scope="read write" ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, @@ -96,12 +97,14 @@ def test_revoke_access_token_with_hint(self): expires=timezone.now() + datetime.timedelta(days=1), scope="read write" ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, "token_type_hint": "access_token" } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -115,12 +118,14 @@ def test_revoke_access_token_with_invalid_hint(self): scope="read write" ) # invalid hint should have no effect + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, "token_type_hint": "bad_hint" } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -137,11 +142,13 @@ def test_revoke_refresh_token(self): user=self.test_user, token="999999999", application=self.application, access_token=tok ) + data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": rtok.token, } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -166,6 +173,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self): "client_secret": self.application.client_secret, "token": token, } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) @@ -195,6 +203,7 @@ def test_revoke_token_with_wrong_hint(self): "token": tok.token, "token_type_hint": "refresh_token" } + url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) From 1a9d4c115e26fff987b54236b8add8c7751933ea Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 23 Mar 2020 10:02:48 -0400 Subject: [PATCH 314/722] Release 1.3.1 --- CHANGELOG.md | 3 ++- setup.cfg | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bff59538b..583f0424d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [1.3.1] unreleased +## [1.3.1] 2020-03-23 + ### Added * #725: HTTP Basic Auth support for introspection (Fix issue #709) diff --git a/setup.cfg b/setup.cfg index 71a69a99d..4ef984c8b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.3.0 +version = 1.3.1 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From 18875796306e3077faa60920b7f1142ba0f16bb9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 25 Mar 2020 15:48:05 -0400 Subject: [PATCH 315/722] Improve PR and Issue templates. (#822) * Improve PR and Issue templates. - Add instructions as template comments. - Fix misunderstanding of how labels work. * insignificant change to try and unstick travis status --- .github/ISSUE_TEMPLATE/bug_report.md | 3 ++- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- .github/ISSUE_TEMPLATE/question.md | 2 +- .github/pull_request_template.md | 5 +++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 3dff61eab..d36e397a9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -2,7 +2,7 @@ name: Bug report about: Create a report to help us improve title: '' -labels: bug, docs, api +labels: bug assignees: '' --- @@ -20,6 +20,7 @@ assignees: '' <!-- Version of django-oauth-toolkit --> <!-- Have you tested with the latest version and/or master branch? --> +<!-- Replace '[ ]' with '[x]' to indicate that. --> - [ ] I have tested with the latest published release and it's still a problem. - [ ] I have tested with the master branch and it's still a problem. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 83290d898..dab872301 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: enhancement, good first issue, docs, help-wanted +labels: enhancement assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md index b1f99e0fc..b57572f3a 100644 --- a/.github/ISSUE_TEMPLATE/question.md +++ b/.github/ISSUE_TEMPLATE/question.md @@ -2,7 +2,7 @@ name: Question about: Ask a question about using django-oauth-toolkit title: '' -labels: question, help-wanted +labels: question assignees: '' --- diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f7edc0b89..9e41b33cf 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,9 +1,14 @@ +<!-- See https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html#pull-requests --> +<!-- If there's already an issue that this PR fixes, add that issue number below after 'Fixes #' --> Fixes # ## Description of the Change ## Checklist +<!-- Replace '[ ]' with '[x]' to indicate that the checklist item is completed. --> +<!-- You can check the boxes now or later by just clicking on them. --> + - [ ] PR only contains one change (considered splitting up PR) - [ ] unit-test added - [ ] documentation updated From 34b1a8ae0b997c5de3e8723e11ead1480c8778f8 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 24 Mar 2020 15:55:57 -0400 Subject: [PATCH 316/722] bump the version to make pypi happy --- CHANGELOG.md | 5 +++++ setup.cfg | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 583f0424d..37c7f68b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [1.3.2] 2020-03-24 + +### Fixed +* Fixes: 1.3.1 inadvertently uploaded to pypi with an extra migration (0003...) from a dev branch. + ## [1.3.1] 2020-03-23 ### Added diff --git a/setup.cfg b/setup.cfg index 4ef984c8b..bd15cadca 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.3.1 +version = 1.3.2 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From 265e40064788d65ef21bd72dd9cf314a24a365a2 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Wed, 8 Apr 2020 22:46:11 +0800 Subject: [PATCH 317/722] remove urlencode in tests --- tests/test_authorization_code.py | 121 +++++++++++++------------------ tests/test_implicit.py | 45 +++++------- 2 files changed, 70 insertions(+), 96 deletions(-) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 9a95bc269..e98f5b041 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -3,7 +3,7 @@ import hashlib import json import re -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -74,16 +74,13 @@ class TestRegressionIssue315(BaseTest): def test_request_is_not_overwritten(self): self.client.login(username="test_user", password="123456") - query_string = urlencode({ + response = self.client.get(reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data @@ -97,16 +94,13 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + response = self.client.get(reverse("oauth2_provider:authorize"), { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) self.assertEqual(response.status_code, 302) def test_pre_auth_invalid_client(self): @@ -115,13 +109,12 @@ def test_pre_auth_invalid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": "fakeclientid", "response_type": "code", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], @@ -134,16 +127,15 @@ def test_pre_auth_valid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -162,16 +154,15 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "custom-scheme://example.com", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -191,21 +182,22 @@ def test_pre_auth_approval_prompt(self): scope="read write" ) self.client.login(username="test_user", password="123456") - query_string = urlencode({ + + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", "approval_prompt": "auto", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) + } + url = reverse("oauth2_provider:authorize") + response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 302) # user already authorized the application, but with different scopes: prompt them. tok.scope = "read" tok.save() - response = self.client.get(url) + response = self.client.get(url, data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): @@ -218,15 +210,14 @@ def test_pre_auth_approval_prompt_default(self): scope="read write" ) self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) + } + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): @@ -239,15 +230,14 @@ def test_pre_auth_approval_prompt_default_override(self): scope="read write" ) self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) + } + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) def test_pre_auth_default_redirect(self): @@ -256,13 +246,12 @@ def test_pre_auth_default_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) form = response.context["form"] @@ -274,14 +263,13 @@ def test_pre_auth_forbibben_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "redirect_uri": "http://forbidden.it", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_pre_auth_wrong_response_type(self): @@ -290,13 +278,12 @@ def test_pre_auth_wrong_response_type(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "WRONG", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=unsupported_response_type", response["Location"]) @@ -1034,7 +1021,7 @@ def test_public_pkce_S256_authorize_get(self): code_verifier, code_challenge = self.generate_pkce_codes("S256") oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", @@ -1043,10 +1030,9 @@ def test_public_pkce_S256_authorize_get(self): "allow": True, "code_challenge": code_challenge, "code_challenge_method": "S256" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) oauth2_settings.PKCE_REQUIRED = False @@ -1063,7 +1049,7 @@ def test_public_pkce_plain_authorize_get(self): code_verifier, code_challenge = self.generate_pkce_codes("plain") oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", @@ -1072,10 +1058,9 @@ def test_public_pkce_plain_authorize_get(self): "allow": True, "code_challenge": code_challenge, "code_challenge_method": "plain" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) oauth2_settings.PKCE_REQUIRED = False @@ -1151,7 +1136,7 @@ def test_public_pkce_invalid_algorithm(self): code_verifier, code_challenge = self.generate_pkce_codes("invalid") oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", @@ -1160,10 +1145,9 @@ def test_public_pkce_invalid_algorithm(self): "allow": True, "code_challenge": code_challenge, "code_challenge_method": "invalid", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) oauth2_settings.PKCE_REQUIRED = False @@ -1181,7 +1165,7 @@ def test_public_pkce_missing_code_challenge(self): code_verifier, code_challenge = self.generate_pkce_codes("S256") oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", @@ -1189,10 +1173,9 @@ def test_public_pkce_missing_code_challenge(self): "response_type": "code", "allow": True, "code_challenge_method": "S256" - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) oauth2_settings.PKCE_REQUIRED = False @@ -1209,7 +1192,7 @@ def test_public_pkce_missing_code_challenge_method(self): code_verifier, code_challenge = self.generate_pkce_codes("S256") oauth2_settings.PKCE_REQUIRED = True - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "state": "random_state_string", "scope": "read write", @@ -1217,10 +1200,9 @@ def test_public_pkce_missing_code_challenge_method(self): "response_type": "code", "allow": True, "code_challenge": code_challenge - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) oauth2_settings.PKCE_REQUIRED = False @@ -1599,15 +1581,14 @@ def test_pre_auth_default_scopes(self): self.client.login(username="test_user", password="123456") oauth2_settings._DEFAULT_SCOPES = ["read"] - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "code", "state": "random_state_string", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid diff --git a/tests/test_implicit.py b/tests/test_implicit.py index c2fd83a5a..b51d0e1da 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,4 +1,4 @@ -from urllib.parse import parse_qs, urlencode, urlparse +from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase @@ -48,15 +48,14 @@ def test_pre_auth_valid_client_default_scopes(self): Test response for a valid client_id with response_type: token and default_scopes """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "redirect_uri": "http://example.org", - }) + } - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) self.assertIn("form", response.context) @@ -69,16 +68,15 @@ def test_pre_auth_valid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) # check form is in context and form params are valid @@ -96,13 +94,12 @@ def test_pre_auth_invalid_client(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": "fakeclientid", "response_type": "token", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_pre_auth_default_redirect(self): @@ -111,13 +108,12 @@ def test_pre_auth_default_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) form = response.context["form"] @@ -129,14 +125,13 @@ def test_pre_auth_forbibben_redirect(self): """ self.client.login(username="test_user", password="123456") - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "redirect_uri": "http://forbidden.it", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 400) def test_post_auth_allow(self): @@ -168,17 +163,15 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - query_string = urlencode({ + query_data = { "client_id": self.application.client_id, "response_type": "token", "state": "random_state_string", "scope": "read write", "redirect_uri": "http://example.org", - }) - - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + } - response = self.client.get(url) + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org#", response["Location"]) self.assertIn("access_token=", response["Location"]) From 2e75a7b578e121fcaba3957c9938233f290449ee Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 08:49:00 +0800 Subject: [PATCH 318/722] remove django 1.10 example from tutorial --- docs/tutorial/tutorial_01.rst | 7 ------- docs/tutorial/tutorial_03.rst | 8 -------- 2 files changed, 15 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 4c31d7b46..6b605c19f 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -51,13 +51,6 @@ CorsMiddleware should be placed as high as possible, especially before any middl # ... ) - # Or on Django < 1.10: - MIDDLEWARE_CLASSES = ( - # ... - 'corsheaders.middleware.CorsMiddleware', - # ... - ) - Allow CORS requests from all domains (just for the scope of this tutorial): .. code-block:: python diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index d79be9951..ad56e310a 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -31,14 +31,6 @@ which takes care of token verification. In your settings.py: '...', ) - # Or on Django<1.10: - MIDDLEWARE_CLASSES = ( - '...', - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', - 'oauth2_provider.middleware.OAuth2TokenMiddleware', - '...', - ) - You will likely use the `django.contrib.auth.backends.ModelBackend` along with the OAuth2 backend (or you might not be able to log in into the admin), only pay attention to the order in which Django processes authentication backends. From 1f2066049b1b6873b2f13178d3d67956c793c520 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Wed, 8 Apr 2020 18:31:21 +0800 Subject: [PATCH 319/722] pkce in oauthlib 3.1.0 --- docs/requirements.txt | 2 +- oauth2_provider/views/base.py | 10 ---------- setup.cfg | 2 +- tox.ini | 4 ++-- 4 files changed, 4 insertions(+), 14 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 63d82768f..c1f72699b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,4 @@ Django>=3.0,<3.1 -oauthlib>=3.0.1 +oauthlib>=3.1.0 m2r>=0.2.1 . diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 8a3a59c25..1a3a26f46 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -139,16 +139,6 @@ def form_valid(self, form): def get(self, request, *args, **kwargs): try: scopes, credentials = self.validate_authorization_request(request) - # TODO: Remove the two following lines after oauthlib updates its implementation - # https://github.com/jazzband/django-oauth-toolkit/pull/707#issuecomment-485011945 - credentials["code_challenge"] = credentials.get( - "code_challenge", - request.GET.get("code_challenge", None) - ) - credentials["code_challenge_method"] = credentials.get( - "code_challenge_method", - request.GET.get("code_challenge_method", None) - ) except OAuthToolkitError as error: # Application is not available at this time. return self.error_response(error, application=None) diff --git a/setup.cfg b/setup.cfg index bd15cadca..3c4e0badc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -33,7 +33,7 @@ zip_safe = False install_requires = django >= 2.1 requests >= 2.13.0 - oauthlib >= 3.0.1 + oauthlib >= 3.1.0 [options.packages.find] exclude = tests diff --git a/tox.ini b/tox.ini index 210106f57..a2a5549db 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ deps = django30: Django>=3.0,<3.1 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework - oauthlib>=3.0.1 + oauthlib>=3.1.0 coverage pytest pytest-cov @@ -40,7 +40,7 @@ changedir = docs whitelist_externals = make commands = make html deps = sphinx - oauthlib>=3.0.1 + oauthlib>=3.1.0 m2r>=0.2.1 [testenv:py37-flake8] From 01769da2cf6297f0270938770db7ceabb0a38148 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 13:44:34 +0800 Subject: [PATCH 320/722] oauthlib 3.1.0 --- README.rst | 2 +- docs/index.rst | 2 +- oauth2_provider/views/base.py | 2 -- 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index c5473646f..1a5adcd06 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Requirements * Python 3.5+ * Django 2.1+ -* oauthlib 3.0+ +* oauthlib 3.1+ Installation ------------ diff --git a/docs/index.rst b/docs/index.rst index 8716eb90b..5889fff28 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,7 +23,7 @@ Requirements * Python 3.5+ * Django 2.1+ -* oauthlib 3.0+ +* oauthlib 3.1+ Index ===== diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 1a3a26f46..b9b6ed7f9 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -156,8 +156,6 @@ def get(self, request, *args, **kwargs): kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] - kwargs["code_challenge"] = credentials["code_challenge"] - kwargs["code_challenge_method"] = credentials["code_challenge_method"] self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 From 68399fd8ee457306a59263cc8f4c792799246708 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 14:10:36 +0800 Subject: [PATCH 321/722] fix docs --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index a2a5549db..950a05a44 100644 --- a/tox.ini +++ b/tox.ini @@ -39,7 +39,7 @@ basepython = python changedir = docs whitelist_externals = make commands = make html -deps = sphinx +deps = sphinx<3.0.0 oauthlib>=3.1.0 m2r>=0.2.1 From 0f133a36ccf95c803681fb44d10a8067f3ef85d3 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 14:13:08 +0800 Subject: [PATCH 322/722] sphinx<3 --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 950a05a44..c984f8b99 100644 --- a/tox.ini +++ b/tox.ini @@ -39,7 +39,7 @@ basepython = python changedir = docs whitelist_externals = make commands = make html -deps = sphinx<3.0.0 +deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 From 202aeb013357981231f0c49b7118ccb3db4b3cf0 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 09:09:29 +0800 Subject: [PATCH 323/722] use re_path --- oauth2_provider/urls.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 86d97d053..28e712029 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import re_path from . import views @@ -7,23 +7,23 @@ base_urlpatterns = [ - url(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - url(r"^token/$", views.TokenView.as_view(), name="token"), - url(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), - url(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), + re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), + re_path(r"^token/$", views.TokenView.as_view(), name="token"), + re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views - url(r"^applications/$", views.ApplicationList.as_view(), name="list"), - url(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), - url(r"^applications/(?P<pk>[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), - url(r"^applications/(?P<pk>[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), - url(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), + re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), + re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), + re_path(r"^applications/(?P<pk>[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), + re_path(r"^applications/(?P<pk>[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), + re_path(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views - url(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - url(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), + re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] From 4ec4a92ad19b7146dbde4a00a3c39f76baad80de Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 13:02:56 +0800 Subject: [PATCH 324/722] fix indent --- oauth2_provider/urls.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 28e712029..960c17eb2 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -23,8 +23,7 @@ re_path(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), - name="authorized-token-delete"), + re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), ] From 0f5346ec08fcf3ad8a4269c1de852864129c3f7d Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 9 Apr 2020 13:06:30 +0800 Subject: [PATCH 325/722] fix flake8 --- oauth2_provider/urls.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 960c17eb2..4cf6d4c6d 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -23,7 +23,8 @@ re_path(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete"), + re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete"), ] From 558fb78d319210b274185c5e486a9efe406c3dc2 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 10 Apr 2020 13:09:59 +0800 Subject: [PATCH 326/722] reusable _create_authorization_code --- oauth2_provider/oauth2_validators.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 162112d21..711fdb1d6 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -448,16 +448,7 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): expires = timezone.now() + timedelta( seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - Grant.objects.create( - application=request.client, - user=request.user, - code=code["code"], - expires=expires, - redirect_uri=request.redirect_uri, - scope=" ".join(request.scopes), - code_challenge=request.code_challenge or "", - code_challenge_method=request.code_challenge_method or "" - ) + self._create_authorization_code(request, code, expires) def rotate_refresh_token(self, request): """ @@ -572,6 +563,18 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non source_refresh_token=source_refresh_token, ) + def _create_authorization_code(self, request, code, expires): + return Grant.objects.create( + application=request.client, + user=request.user, + code=code["code"], + expires=expires, + redirect_uri=request.redirect_uri, + scope=" ".join(request.scopes), + code_challenge=request.code_challenge or "", + code_challenge_method=request.code_challenge_method or "" + ) + def _create_refresh_token(self, request, refresh_token_code, access_token): return RefreshToken.objects.create( user=request.user, From e612c17adf03f7f21698b26386a500b137a0f7ee Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 10 Apr 2020 13:13:34 +0800 Subject: [PATCH 327/722] default expires in _create_authorization_code --- oauth2_provider/oauth2_validators.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 711fdb1d6..9848a92b9 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -446,9 +446,7 @@ def get_code_challenge_method(self, code, request): return grant.code_challenge_method or None def save_authorization_code(self, client_id, code, request, *args, **kwargs): - expires = timezone.now() + timedelta( - seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - self._create_authorization_code(request, code, expires) + self._create_authorization_code(request, code) def rotate_refresh_token(self, request): """ @@ -563,7 +561,10 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non source_refresh_token=source_refresh_token, ) - def _create_authorization_code(self, request, code, expires): + def _create_authorization_code(self, request, code, expires=None): + if not expires: + expires = timezone.now() + timedelta(seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) + return Grant.objects.create( application=request.client, user=request.user, From 06a6159c51f2ea7a19397dc50cdc86b5f4b581ec Mon Sep 17 00:00:00 2001 From: anveshagarwal <53469788+anveshagarwal@users.noreply.github.com> Date: Sat, 25 Apr 2020 16:00:00 +0530 Subject: [PATCH 328/722] select_related added to introspect view (#834) * select_related add to introspect * flake8 fixes * flake8 fix * added author and change log * test for select related * test for select related --- AUTHORS | 1 + CHANGELOG.md | 6 ++++++ oauth2_provider/views/introspect.py | 4 +++- tests/test_introspection_view.py | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 27d60fa8d..15c6eb2f3 100644 --- a/AUTHORS +++ b/AUTHORS @@ -10,6 +10,7 @@ Contributors Abhishek Patel Alessandro De Angelis Alan Crosswell +Anvesh Agarwal Asif Saif Uddin Ash Christopher Aristóbulo Meneses diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c7f68b4..75e55b0ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [unreleased] + +### added +* added `select_related` in intospect view for better query performance + + ## [1.3.2] 2020-03-24 ### Fixed diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index d4afe4abb..7d4381179 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -24,7 +24,9 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): @staticmethod def get_token_response(token_value=None): try: - token = get_access_token_model().objects.get(token=token_value) + token = get_access_token_model().objects.select_related( + "user", "application" + ).get(token=token_value) except ObjectDoesNotExist: return HttpResponse( content=json.dumps({"active": False}), diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 061d213e6..20196606e 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -321,3 +321,7 @@ def test_view_post_invalid_client_creds_plaintext(self): "client_id": self.application.client_id, "client_secret": self.application.client_secret + "_so_wrong"}) self.assertEqual(response.status_code, 403) + + def test_select_related_in_view_for_less_db_queries(self): + with self.assertNumQueries(1): + self.client.post(reverse("oauth2_provider:introspect")) From 96383d95d4ab243e9e686da1e71b49d8cae1e6ad Mon Sep 17 00:00:00 2001 From: Wiliam Souza <wiliamsouza83@gmail.com> Date: Sun, 24 May 2020 15:33:10 -0300 Subject: [PATCH 329/722] Change install doc to include missing imports and splited options --- docs/install.rst | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index ccff17742..2cb569fb2 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -19,11 +19,22 @@ If you need an OAuth2 provider you'll want to add the following to your urls.py .. code-block:: python + from django.urls import include, path + urlpatterns = [ ... path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + ] + +Or using `re_path()` + +.. code-block:: python + + from django.urls import include, re_path + + urlpatterns = [ + ... - # using re_path re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] From f412b0b340404aff053da49f4f5bc1c63eb68bc9 Mon Sep 17 00:00:00 2001 From: Wiliam Souza <wiliamsouza83@gmail.com> Date: Mon, 25 May 2020 17:20:12 -0300 Subject: [PATCH 330/722] Add getting started documentation section (#841) --- .../_images/application-authorize-web-app.png | Bin 0 -> 17399 bytes .../application-register-auth-code.png | Bin 0 -> 37074 bytes ...application-register-client-credential.png | Bin 0 -> 33524 bytes docs/getting_started.rst | 394 ++++++++++++++++++ docs/index.rst | 1 + docs/install.rst | 3 +- 6 files changed, 397 insertions(+), 1 deletion(-) create mode 100644 docs/_images/application-authorize-web-app.png create mode 100644 docs/_images/application-register-auth-code.png create mode 100644 docs/_images/application-register-client-credential.png create mode 100644 docs/getting_started.rst diff --git a/docs/_images/application-authorize-web-app.png b/docs/_images/application-authorize-web-app.png new file mode 100644 index 0000000000000000000000000000000000000000..64a0801ea0dba4e1bcb79c4e9d43c9828b456dd2 GIT binary patch literal 17399 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 E0e?27!~g&Q literal 0 HcmV?d00001 diff --git a/docs/_images/application-register-auth-code.png b/docs/_images/application-register-auth-code.png new file mode 100644 index 0000000000000000000000000000000000000000..d4ef8bd5a339e1ec19bd9e331e9e484a8601d074 GIT binary patch literal 37074 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ΜZW{a&l8&OmuYgg9lod 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 M9vuUN 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 literal 0 HcmV?d00001 diff --git a/docs/_images/application-register-client-credential.png b/docs/_images/application-register-client-credential.png new file mode 100644 index 0000000000000000000000000000000000000000..7f2d1cb606fba721441abc76bd6c6e291a787882 GIT binary patch literal 33524 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 J6ZL=D_iv2SDQN%z literal 0 HcmV?d00001 diff --git a/docs/getting_started.rst b/docs/getting_started.rst new file mode 100644 index 000000000..c5b5ec51c --- /dev/null +++ b/docs/getting_started.rst @@ -0,0 +1,394 @@ +Getting started +=============== + +Build a OAuth2 provider using Django, Django OAuth Toolkit, and OAuthLib. + +What we will build? +------------------- + +The plan is to build an OAuth2 provider from ground up. + +On this getting started we will: + +* Create the Django project. +* Install and configure Django OAuth Toolkit. +* Create two OAuth2 applications. +* Use Authorization code grant flow. +* Use Client Credential grant flow. + +What is OAuth? +---------------- + +OAuth is an open standard for access delegation, commonly used as a way for Internet users to grant websites or applications access to their information on other websites but without giving them the passwords. +-- `Whitson Gordon`_ + +Django +------ + +Django is a high-level Python Web framework that encourages rapid development and clean, pragmatic design. Built by experienced developers, it takes care of much of the hassle of Web development, so you can focus on writing your app without needing to reinvent the wheel. +-- `Django website`_ + +Let's get start by creating a virtual environment:: + + mkproject iam + +This will create, activate and change directory to the new Python virtual environment. + +Install Django:: + + pip install Django + +Create a Django project:: + + django-admin startproject iam + +This will create a mysite directory in your current directory. With the following estructure:: + + . + └── iam + ├── iam + │   ├── asgi.py + │   ├── __init__.py + │   ├── settings.py + │   ├── urls.py + │   └── wsgi.py + └── manage.py + +Create a Django application:: + + cd iam/ + python manage.py startapp users + +That’ll create a directory :file:`users`, which is laid out like this:: + + . + ├── iam + │   ├── asgi.py + │   ├── __init__.py + │   ├── settings.py + │   ├── urls.py + │   └── wsgi.py + ├── manage.py + └── users + ├── admin.py + ├── apps.py + ├── __init__.py + ├── migrations + │   └── __init__.py + ├── models.py + ├── tests.py + └── views.py + +If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default `User`_ model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to customize it in the future if the need arises. +-- `Django documentation`_ + +Edit :file:`users/models.py` adding the code bellow: + +.. code-block:: python + + from django.contrib.auth.models import AbstractUser + + class User(AbstractUser): + pass + +Change :file:`iam/settings.py` to add ``users`` application to ``INSTALLED_APPS``: + +.. code-block:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + ] + +Configure ``users.User`` to be the model used for the ``auth`` application adding ``AUTH_USER_MODEL`` to :file:`iam/settings.py`: + +.. code-block:: python + + AUTH_USER_MODEL='users.User' + +Create inital migration for ``users`` application ``User`` model:: + + python manage.py makemigrations + +The command above will create the migration:: + + Migrations for 'users': + users/migrations/0001_initial.py + - Create model User + +Finally execute the migration:: + + python manage.py migrate + +The ``migrate`` output:: + + Operations to perform: + Apply all migrations: admin, auth, contenttypes, sessions, users + Running migrations: + Applying contenttypes.0001_initial... OK + Applying contenttypes.0002_remove_content_type_name... OK + Applying auth.0001_initial... OK + Applying auth.0002_alter_permission_name_max_length... OK + Applying auth.0003_alter_user_email_max_length... OK + Applying auth.0004_alter_user_username_opts... OK + Applying auth.0005_alter_user_last_login_null... OK + Applying auth.0006_require_contenttypes_0002... OK + Applying auth.0007_alter_validators_add_error_messages... OK + Applying auth.0008_alter_user_username_max_length... OK + Applying auth.0009_alter_user_last_name_max_length... OK + Applying auth.0010_alter_group_name_max_length... OK + Applying auth.0011_update_proxy_permissions... OK + Applying users.0001_initial... OK + Applying admin.0001_initial... OK + Applying admin.0002_logentry_remove_auto_add... OK + Applying admin.0003_logentry_add_action_flag_choices... OK + Applying sessions.0001_initial... OK + +Django OAuth Toolkit +-------------------- + +Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 capabilities to your Django projects. + +Install Django OAuth Toolkit:: + + pip install django-oauth-toolkit + +Add ``oauth2_provider`` to ``INSTALLED_APPS`` in :file:`iam/settings.py`: + +.. code-block:: python + + INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'users', + 'oauth2_provider', + ] + +Execute the migration:: + + python manage.py migrate + +The ``migrate`` command output:: + + Operations to perform: + Apply all migrations: admin, auth, contenttypes, oauth2_provider, sessions, users + Running migrations: + Applying oauth2_provider.0001_initial... OK + Applying oauth2_provider.0002_auto_20190406_1805... OK + +Include ``oauth2_provider.urls`` to :file:`iam/urls.py` as follows: + +.. code-block:: python + + from django.contrib import admin + from django.urls import include, path + + urlpatterns = [ + path('admin/', admin.site.urls), + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + ] + +This will make available endpoints to authorize, generate token and create OAuth applications. + +Last change, add ``LOGIN_URL`` to :file:`iam/settings.py`: + +.. code-block:: python + + LOGIN_URL='/admin/login/' + +We will use Django Admin login to make our life easy. + +Create a user:: + + python manage.py createsuperuser + + Username: wiliam + Email address: me@wiliam.dev + Password: + Password (again): + Superuser created successfully. + +OAuth2 Authorization Grants +--------------------------- + +An authorization grant is a credential representing the resource owner's authorization (to access its protected resources) used by the client to obtain an access token. +-- `RFC6749`_ + +The OAuth framework specifies several grant types for different use cases. +-- `Grant types`_ + +We will start by given a try to the grant types listed below: + +* Authorization code +* Client credential + +This two grant types cover the most initially used uses cases. + +Authorization Code +------------------ + +The Authorization Code flow is best used in web and mobile apps. This is the flow used for third party integration, the user authorize your partner to access its products in your APIs. + +Start the development server:: + + python manage.py runserver + +Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. + +Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. + +.. image:: _images/application-register-auth-code.png + :alt: Authorization code application registration + +Export ``Client id`` and ``Client secret`` values as environment variable: + +.. sourcecode:: sh + + export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 + export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO + +To start the Authorization code flow got to this `URL`_ with is the same as show bellow:: + + http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + +Note the parameters we pass: + +* **response_type**: ``code`` +* **client_id**: ``vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8`` +* **redirect_uri**: ``http://127.0.0.1:8000/noexist/callback`` + +This identifies your application, the user is asked to authorize your application to access its resources. + +Go ahead and authorize the ``web-app`` + +.. image:: _images/application-authorize-web-app.png + :alt: Authorization code authorize web-app + +Remenber we used ``http://127.0.0.1:8000/noexist/callback`` as ``redirect_uri`` you will get a **Page not found (404)** but it worked if you get a url like:: + + http://127.0.0.1:8000/noexist/callback?code=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 + +This is the OAuth2 provider trying to give you a ``code`` in this case ``uVqLxiHDKIirldDZQfSnDsmYW1Abj2``. + +Export it as environment variable: + +.. code-block:: sh + + export CODE=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 + +Now that you have the user authorization is time to get an access token:: + + curl -X POST -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "client_id=${ID}" -d "client_secret=${SECRET}" -d "code=${CODE}" -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" + +To be more easy to visualize:: + + curl -X POST \ + -H "Cache-Control: no-cache" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "http://127.0.0.1:8000/o/token/" \ + -d "client_id=${ID}" \ + -d "client_secret=${SECRET}" \ + -d "code=${CODE}" \ + -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" \ + -d "grant_type=authorization_code" + +The OAuth2 provider will return the follow response: + +.. code-block:: javascript + + { + "access_token": "jooqrnOrNa0BrNWlg68u9sl6SkdFZg", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": "HNvDQjjsnvDySaK0miwG4lttJEl9yD" + } + +To access the user resources we just use the ``access_token``:: + + curl \ + -H "Authorization: Bearer jooqrnOrNa0BrNWlg68u9sl6SkdFZg" \ + -X GET http://localhost:8000/resource + +Client Credential +----------------- + +The Client Credential grant is suitable for machine-to-machine authentication. You authorize your own service or worker to change a bank account transaction status to accepted. + +Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. + +Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. + +.. image:: _images/application-register-client-credential.png + :alt: Client credential application registration + +Export ``Client id`` and ``Client secret`` values as environment variable: + +.. code-block:: sh + + export ID=axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u + export SECRET=1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ + +The Client Credential flow is simpler than the Authorization Code flow. + +We need to encode ``client_id`` and ``client_secret`` as HTTP base authentication encoded in ``base64`` I use the following code to do that. + +.. code-block:: python + + >>> import base64 + >>> client_id = "axXSSBVuvOyGVzh4PurvKaq5MHXMm7FtrHgDMi4u" + >>> secret = "1fuv5WVfR7A5BlF0o155H7s5bLgXlwWLhi3Y7pdJ9aJuCdl0XV5Cxgd0tri7nSzC80qyrovh8qFXFHgFAAc0ldPNn5ZYLanxSm1SI1rxlRrWUP591wpHDGa3pSpB6dCZ" + >>> credential = "{0}:{1}".format(client_id, secret) + >>> base64.b64encode(credential.encode("utf-8")) + b'YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg==' + >>> + +Export the credential as environment variable + +.. code-block:: sh + + export CREDENTIAL=YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg== + +To start the Client Credential flow you call ``/token/`` endpoint direct:: + + curl -X POST -H "Authorization: Basic ${CREDENTIAL}" -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "grant_type=client_credentials" + +To be more easy to visualize:: + + curl -X POST \ + -H "Authorization: Basic ${CREDENTIAL}" \ + -H "Cache-Control: no-cache" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + "http://127.0.0.1:8000/o/token/" \ + -d "grant_type=client_credentials" + +The OAuth2 provider will return the follow response: + +.. code-block:: javascript + + { + "access_token": "PaZDOD5UwzbGOFsQr34LQ7JUYOj3yK", + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write" + } + +Next step is :doc:`first tutorial <tutorial/tutorial_01>`. + +.. _Django website: https://www.djangoproject.com/ +.. _Whitson Gordon: https://en.wikipedia.org/wiki/OAuth#cite_note-1 +.. _User: https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.User +.. _Django documentation: https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project +.. _RFC6749: https://tools.ietf.org/html/rfc6749#section-1.3 +.. _Grant Types: https://oauth.net/2/grant-types/ +.. _URL: http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + diff --git a/docs/index.rst b/docs/index.rst index 5889fff28..635837832 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -32,6 +32,7 @@ Index :maxdepth: 2 install + getting_started tutorial/tutorial rest-framework/rest-framework views/views diff --git a/docs/install.rst b/docs/install.rst index 2cb569fb2..65dcb1d17 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -45,4 +45,5 @@ Sync your database $ python manage.py migrate oauth2_provider -Next step is our :doc:`first tutorial <tutorial/tutorial_01>`. +Next step is :doc:`getting started <getting_started>` or :doc:`first tutorial <tutorial/tutorial_01>`. + From d392c608a85a682f6adba111576efe12288e27f6 Mon Sep 17 00:00:00 2001 From: Jun Zhou <33321001+Chouvic@users.noreply.github.com> Date: Wed, 17 Jun 2020 14:24:38 +0100 Subject: [PATCH 331/722] Fix #847 Log an exception when response status is not OK (#848) * Fix #847 Log an exception if the response from authentication server is not successful By providing the status code and its reason to logger, the error message would be more explicit for debugger whenever there is an issue related to resource token or other errors. * Test to check logger information when auth response is not correct * Lint code to change bad quotes and add author information * Change import position * Add CHANGLOG for fix #847 --- AUTHORS | 1 + CHANGELOG.md | 2 ++ oauth2_provider/oauth2_validators.py | 9 +++++++++ tests/test_oauth2_validators.py | 25 ++++++++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 15c6eb2f3..671d31b54 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,3 +29,4 @@ pySilver Rodney Richardson Silvano Cerza Stéphane Raimbault +Jun Zhou diff --git a/CHANGELOG.md b/CHANGELOG.md index 75e55b0ef..f32d2eb3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### added * added `select_related` in intospect view for better query performance +### Fixed +* #847: Fix inappropriate message when response from authentication server is not OK. ## [1.3.2] 2020-03-24 diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 9848a92b9..515353d6f 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,5 +1,6 @@ import base64 import binascii +import http.client import logging from collections import OrderedDict from datetime import datetime, timedelta @@ -304,6 +305,14 @@ def _get_token_from_authentication_server( log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) return None + # Log an exception when response from auth server is not successful + if response.status_code != http.client.OK: + log.exception("Introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, + response.reason)) + return None + try: content = response.json() except ValueError: diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index d9248230b..7821148d5 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -2,7 +2,7 @@ import datetime from django.contrib.auth import get_user_model -from django.test import TransactionTestCase +from django.test import TestCase, TransactionTestCase from django.utils import timezone from oauthlib.common import Request @@ -392,3 +392,26 @@ def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_t self.assertDictEqual(self.request.oauth2_error, { "error": "invalid_token", }) + + +class TestOAuth2ValidatorErrorResourceToken(TestCase): + """The following tests check logger information when response from oauth2 + is unsuccessful. + """ + + def setUp(self): + self.token = "test_token" + self.introspection_url = "http://example.com/token/introspection/" + self.introspection_token = "test_introspection_token" + self.validator = OAuth2Validator() + + def test_response_when_auth_server_response_return_404(self): + with self.assertLogs(logger="oauth2_provider") as mock_log: + self.validator._get_token_from_authentication_server( + self.token, self.introspection_url, + self.introspection_token, None) + self.assertIn("ERROR:oauth2_provider:Introspection: Failed to " + "get a valid response from authentication server. " + "Status code: 404, Reason: " + "Not Found.\nNoneType: None", + mock_log.output) From dbd6128dcdf3e9e02cb7494ca2686a9f0349a6ff Mon Sep 17 00:00:00 2001 From: bastb <bas@tenberge-ict.nl> Date: Sat, 8 Aug 2020 13:38:02 +0200 Subject: [PATCH 332/722] Documentation update for issue #856 --- oauth2_provider/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index f87a51691..5676bc0c5 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -169,7 +169,7 @@ def is_usable(self, request): """ Determines whether the application can be used. - :param request: The HTTP request being processed. + :param request: The oauthlib.common.Request being processed. """ return True From 4655c030be15616ba6e0872253a2c15a897d9701 Mon Sep 17 00:00:00 2001 From: Dave Burkholder <dave@thinkwelldesigns.com> Date: Sat, 5 Sep 2020 21:40:05 -0400 Subject: [PATCH 333/722] Openid Connect Core support - Round 2 (#859) * Add OpenID connect hybrid grant type * Add OpenID connect algorithm type to Application model * Add OpenID connect id token model * Add nonce Authorization as required by OpenID connect Implicit Flow * Add body to create_authorization_response to pass nonce and future OpenID parameters to oauthlib.common.Request * Add OpenID connect ID token creation and validation methods and scopes * Add OpenID connect response types * Add OpenID connect authorization code flow test * Add OpenID connect implicit flow tests * Add validate_user_match method to OAuth2Validator * Add RSA_PRIVATE_KEY setting with blank value * Update tox * Add get_jwt_bearer_token to OAuth2Validator * Add validate_jwt_bearer_token to OAuth2Validator * Change OAuth2Validator.validate_id_token default return value to False to avoid validation security breach * Change to use .encode to avoid py2.7 tox test error * Add OpenID connect hybrid flow tests * Change to use .encode to avoid py2.7 tox test error * Add RSA_PRIVATE_KEY to the list of settings that cannot be empt * Add support for oidc connect discovery * Use double quotes for strings * Rename migrations to avoid name and order conflict * Remove commando to install OAuthLib from master and removed jwcrypto duplication * Remove python 2 compatible code * Change errors access_denied/unauthorized_client/consent_required/login_required to be 400 as changed in oauthlib/pull/623 * Change iss claim value to come from settings * Change to use openid connect code server class * Change test to include missing state * Add id_token relation to AbstractAccessToken * Add claims property to AbstractIDToken * Change OAuth2Validator._create_access_token to save id_token to access_token * Add userinfo endpoint * Update migrations and remove oauthlib duplication * Remove old generated migrations * Add new migrations * Fix tests * Add nonce to hybrid tests * Add missing new attributes to test migration * Rebase fixing conflicts and tests * Remove auto generate message * Fix flake8 issues * Fix test doc deps * Add project settings to be ignored in coverage * Tweak migrations to support non-overidden models * OIDC_USERINFO_ENDPOINT is not mandatory * refresh_token grant should be support for OpenID hybrid * Fix the user info view, and remove hard dependency on DRF * Use proper URL generation for OIDC endpoints * Support rich ID tokens and userinfo claims Extend the validator and override get_additional_claims based on your own user model. * Bug fix for at_hash generation See https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample to prove algorithm * OIDC_ISS_ENDPOINT is an optional setting * Support OIDC urls from issuer url if provided * Test for generated OIDC urls * Flake * Rebase on master and migrate url function to re_path * Handle invalid token format exceptions as invalid tokens * Merge migrations and sort imports isort for flake8 lint check Co-authored-by: Wiliam Souza <wiliamsouza83@gmail.com> Co-authored-by: Allisson Azevedo <allisson@gmail.com> Co-authored-by: fvlima <frederico.vieira@gmail.com> Co-authored-by: Shaun Stanworth <shaun.stanworth@googlemail.com> --- .gitignore | 2 +- oauth2_provider/admin.py | 9 +- oauth2_provider/forms.py | 1 + .../migrations/0002_auto_20190406_1805.py | 2 - .../migrations/0003_auto_20200902_2022.py | 48 + oauth2_provider/models.py | 114 ++ oauth2_provider/oauth2_backends.py | 26 +- oauth2_provider/oauth2_validators.py | 376 ++++- oauth2_provider/settings.py | 62 +- oauth2_provider/urls.py | 9 +- oauth2_provider/views/__init__.py | 16 +- oauth2_provider/views/application.py | 4 +- oauth2_provider/views/base.py | 74 +- oauth2_provider/views/introspect.py | 2 +- oauth2_provider/views/mixins.py | 31 +- oauth2_provider/views/oidc.py | 95 ++ setup.cfg | 1 + tests/migrations/0001_initial.py | 7 +- tests/settings.py | 27 + tests/test_application_views.py | 1 + tests/test_authorization_code.py | 682 +++++++-- tests/test_hybrid.py | 1264 +++++++++++++++++ tests/test_implicit.py | 198 ++- tests/test_oauth2_backends.py | 4 +- tests/test_oauth2_validators.py | 7 + tests/test_oidc_views.py | 77 + tests/urls.py | 8 +- tox.ini | 9 +- 28 files changed, 2897 insertions(+), 259 deletions(-) create mode 100644 oauth2_provider/migrations/0003_auto_20200902_2022.py create mode 100644 oauth2_provider/views/oidc.py create mode 100644 tests/test_hybrid.py create mode 100644 tests/test_oidc_views.py diff --git a/.gitignore b/.gitignore index af644d1e3..c22ef00fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ __pycache__ pip-log.txt # Unit test / coverage reports -.cache +.pytest_cache .coverage .tox .pytest_cache/ diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 8b963d981..a8d69e623 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -2,7 +2,7 @@ from .models import ( get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_grant_model, get_id_token_model, get_refresh_token_model ) @@ -26,6 +26,11 @@ class AccessTokenAdmin(admin.ModelAdmin): raw_id_fields = ("user", "source_refresh_token") +class IDTokenAdmin(admin.ModelAdmin): + list_display = ("token", "user", "application", "expires") + raw_id_fields = ("user", ) + + class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") @@ -34,9 +39,11 @@ class RefreshTokenAdmin(admin.ModelAdmin): Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() +IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() admin.site.register(Application, ApplicationAdmin) admin.site.register(Grant, GrantAdmin) admin.site.register(AccessToken, AccessTokenAdmin) +admin.site.register(IDToken, IDTokenAdmin) admin.site.register(RefreshToken, RefreshTokenAdmin) diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 2e465959a..41129c449 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -5,6 +5,7 @@ class AllowForm(forms.Form): allow = forms.BooleanField(required=False) redirect_uri = forms.CharField(widget=forms.HiddenInput()) scope = forms.CharField(widget=forms.HiddenInput()) + nonce = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) diff --git a/oauth2_provider/migrations/0002_auto_20190406_1805.py b/oauth2_provider/migrations/0002_auto_20190406_1805.py index 8ca177abf..bcacc23ce 100644 --- a/oauth2_provider/migrations/0002_auto_20190406_1805.py +++ b/oauth2_provider/migrations/0002_auto_20190406_1805.py @@ -1,5 +1,3 @@ -# Generated by Django 2.2 on 2019-04-06 18:05 - from django.db import migrations, models diff --git a/oauth2_provider/migrations/0003_auto_20200902_2022.py b/oauth2_provider/migrations/0003_auto_20200902_2022.py new file mode 100644 index 000000000..684949c9d --- /dev/null +++ b/oauth2_provider/migrations/0003_auto_20200902_2022.py @@ -0,0 +1,48 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0002_auto_20190406_1805'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), + ), + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('token', models.TextField(unique=True)), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 5676bc0c5..7135192db 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,3 +1,4 @@ +import json import logging from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -9,6 +10,7 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from jwcrypto import jwk, jwt from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend @@ -50,11 +52,20 @@ class AbstractApplication(models.Model): GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" + GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), + (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), + ) + + RS256_ALGORITHM = "RS256" + HS256_ALGORITHM = "HS256" + ALGORITHM_TYPES = ( + (RS256_ALGORITHM, _("RSA with SHA-2 256")), + (HS256_ALGORITHM, _("HMAC with SHA-2 256")), ) id = models.BigAutoField(primary_key=True) @@ -82,6 +93,7 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=RS256_ALGORITHM) class Meta: abstract = True @@ -282,6 +294,10 @@ class AbstractAccessToken(models.Model): related_name="refreshed_access_token" ) token = models.CharField(max_length=255, unique=True, ) + id_token = models.OneToOneField( + oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True, + related_name="access_token" + ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, ) @@ -415,6 +431,99 @@ class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" +class AbstractIDToken(models.Model): + """ + An IDToken instance represents the actual token to + access user's resources, as in :openid:`2`. + + Fields: + + * :attr:`user` The Django user representing resources' owner + * :attr:`token` ID token + * :attr:`application` Application instance + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`scope` Allowed scopes + """ + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, + related_name="%(app_label)s_%(class)s" + ) + token = models.TextField(unique=True) + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, + ) + expires = models.DateTimeField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def is_valid(self, scopes=None): + """ + Checks if the access token is valid. + + :param scopes: An iterable containing the scopes to check or None + """ + return not self.is_expired() and self.allow_scopes(scopes) + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def allow_scopes(self, scopes): + """ + Check if the token allows the provided scopes + + :param scopes: An iterable containing the scopes to check + """ + if not scopes: + return True + + provided_scopes = set(self.scope.split()) + resource_scopes = set(scopes) + + return resource_scopes.issubset(provided_scopes) + + def revoke(self): + """ + Convenience method to uniform tokens' interface, for now + simply remove this token from the database in order to revoke it. + """ + self.delete() + + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + + @property + def claims(self): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + jwt_token = jwt.JWT(key=key, jwt=self.token) + return json.loads(jwt_token.claims) + + def __str__(self): + return self.token + + class Meta: + abstract = True + + +class IDToken(AbstractIDToken): + class Meta(AbstractIDToken.Meta): + swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" + + def get_application_model(): """ Return the Application model that is active in this project. """ return apps.get_model(oauth2_settings.APPLICATION_MODEL) @@ -430,6 +539,11 @@ def get_access_token_model(): return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) +def get_id_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) + + def get_refresh_token_model(): """ Return the RefreshToken model that is active in this project. """ return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 04264f6a0..0263f63ac 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -104,7 +104,7 @@ def validate_authorization_request(self, request): except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error) - def create_authorization_response(self, request, scopes, credentials, allow): + def create_authorization_response(self, uri, request, scopes, credentials, body, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -112,7 +112,8 @@ def create_authorization_response(self, request, scopes, credentials, allow): :param request: The current django.http.HttpRequest object :param scopes: A list of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri`, `response_type` + `client_id`, `state`, `redirect_uri` and `response_type` + :param body: Other body parameters not used in credentials dictionary :param allow: True if the user authorize the client, otherwise False """ try: @@ -124,10 +125,10 @@ def create_authorization_response(self, request, scopes, credentials, allow): credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) - uri = headers.get("Location", None) + uri=uri, scopes=scopes, credentials=credentials, body=body) + redirect_uri = headers.get("Location", None) - return uri, headers, body, status + return redirect_uri, headers, body, status except oauth2.FatalClientError as error: raise FatalClientError( @@ -166,6 +167,21 @@ def create_revocation_response(self, request): return uri, headers, body, status + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on a + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + headers, body, status = self.server.create_userinfo_response( + uri, http_method, body, headers + ) + uri = headers.get("Location", None) + + return uri, headers, body, status + def verify_request(self, request, scopes): """ A wrapper method that calls verify_request on `server_class` instance. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 515353d6f..e7fb860b3 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,6 +1,8 @@ import base64 import binascii +import hashlib import http.client +import json import logging from collections import OrderedDict from datetime import datetime, timedelta @@ -12,15 +14,21 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q -from django.utils import timezone +from django.http import HttpRequest +from django.urls import reverse +from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ +from jwcrypto import jwk, jwt +from jwcrypto.common import JWException +from jwcrypto.jwt import JWTExpired from oauthlib.oauth2 import RequestValidator +from oauthlib.oauth2.rfc6749 import utils from .exceptions import FatalClientError from .models import ( - AbstractApplication, get_access_token_model, - get_application_model, get_grant_model, get_refresh_token_model + AbstractApplication, get_access_token_model, get_application_model, + get_grant_model, get_id_token_model, get_refresh_token_model ) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -29,18 +37,23 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), - "password": (AbstractApplication.GRANT_PASSWORD, ), - "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), + "authorization_code": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_OPENID_HYBRID, + ), + "password": (AbstractApplication.GRANT_PASSWORD,), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, - ) + AbstractApplication.GRANT_OPENID_HYBRID, + ), } Application = get_application_model() AccessToken = get_access_token_model() +IDToken = get_id_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() @@ -93,12 +106,15 @@ def _authenticate_basic_auth(self, request): except UnicodeDecodeError: log.debug( "Failed basic auth: %r can't be decoded as unicode by %r", - auth_string, encoding + auth_string, + encoding, ) return False try: - client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) + client_id, client_secret = map( + unquote_plus, auth_string_decoded.split(":", 1) + ) except ValueError: log.debug("Failed basic auth, Invalid base64 encoding.") return False @@ -147,35 +163,54 @@ def _load_application(self, client_id, request): """ # we want to be sure that request has the client attribute! - assert hasattr(request, "client"), '"request" instance has no "client" attribute' + assert hasattr( + request, "client" + ), '"request" instance has no "client" attribute' try: - request.client = request.client or Application.objects.get(client_id=client_id) + request.client = request.client or Application.objects.get( + client_id=client_id + ) # Check that the application can be used (defaults to always True) if not request.client.is_usable(request): - log.debug("Failed body authentication: Application %r is disabled" % (client_id)) + log.debug( + "Failed body authentication: Application %r is disabled" + % (client_id) + ) return None return request.client except Application.DoesNotExist: - log.debug("Failed body authentication: Application %r does not exist" % (client_id)) + log.debug( + "Failed body authentication: Application %r does not exist" + % (client_id) + ) return None def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token is invalid."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token",), + ("error_description", _("The access token is invalid."),), + ] + ) elif access_token.is_expired(): - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token has expired."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token",), + ("error_description", _("The access token has expired."),), + ] + ) elif not access_token.allow_scopes(scopes): - error = OrderedDict([ - ("error", "insufficient_scope", ), - ("error_description", _("The access token is valid but does not have enough scope."), ), - ]) + error = OrderedDict( + [ + ("error", "insufficient_scope",), + ( + "error_description", + _("The access token is valid but does not have enough scope."), + ), + ] + ) else: log.warning("OAuth2 access token is invalid for an unknown reason.") error = OrderedDict([ @@ -241,11 +276,15 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug("Application %r has type %r" % (client_id, request.client.client_type)) + log.debug( + "Application %r has type %r" % (client_id, request.client.client_type) + ) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False - def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): + def confirm_redirect_uri( + self, client_id, code, redirect_uri, client, *args, **kwargs + ): """ Ensure the redirect_uri is listed in the Application instance redirect_uris field """ @@ -270,7 +309,7 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( - self, token, introspection_url, introspection_token, introspection_credentials + self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL @@ -298,11 +337,12 @@ def _get_token_from_authentication_server( try: response = requests.post( - introspection_url, - data={"token": token}, headers=headers + introspection_url, data={"token": token}, headers=headers ) except requests.exceptions.RequestException: - log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) + log.exception( + "Introspection: Failed POST to %r in token lookup", introspection_url + ) return None # Log an exception when response from auth server is not successful @@ -348,7 +388,8 @@ def _get_token_from_authentication_server( "application": None, "scope": scope, "expires": expires, - }) + }, + ) return access_token @@ -361,10 +402,14 @@ def validate_bearer_token(self, token, scopes, request): introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN - introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + introspection_credentials = ( + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + ) try: - access_token = AccessToken.objects.select_related("application", "user").get(token=token) + access_token = AccessToken.objects.select_related( + "application", "user" + ).get(token=token) except AccessToken.DoesNotExist: access_token = None @@ -375,7 +420,7 @@ def validate_bearer_token(self, token, scopes, request): token, introspection_url, introspection_token, - introspection_credentials + introspection_credentials, ) if access_token and access_token.is_valid(scopes): @@ -402,22 +447,38 @@ def validate_code(self, client_id, code, client, request, *args, **kwargs): except Grant.DoesNotExist: return False - def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): + def validate_grant_type( + self, client_id, grant_type, client, request, *args, **kwargs + ): """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ - assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration + assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) - def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): + def validate_response_type( + self, client_id, response_type, client, request, *args, **kwargs + ): """ We currently do not support the Authorization Endpoint Response Types registry as in rfc:`8.4`, so validate the response_type only if it matches "code" or "token" """ if response_type == "code": - return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) + return client.allows_grant_type( + AbstractApplication.GRANT_AUTHORIZATION_CODE + ) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "code id_token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) else: return False @@ -425,11 +486,15 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ Ensure required scopes are permitted (as specified in the settings file) """ - available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) + available_scopes = get_scopes_backend().get_available_scopes( + application=client, request=request + ) return set(scopes).issubset(set(available_scopes)) def get_default_scopes(self, client_id, request, *args, **kwargs): - default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) + default_scopes = get_scopes_backend().get_default_scopes( + application=request.client, request=request + ) return default_scopes def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): @@ -457,6 +522,24 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): self._create_authorization_code(request, code) + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + scopes = [] + fields = { + "code": code, + } + + if client_id: + fields["application__client_id"] = client_id + + if redirect_uri: + fields["redirect_uri"] = redirect_uri + + grant = Grant.objects.filter(**fields).values() + if grant.exists(): + grant_dict = dict(grant[0]) + scopes = utils.scope_to_list(grant_dict["scope"]) + return scopes + def rotate_refresh_token(self, request): """ Checks if rotate refresh token is enabled @@ -497,9 +580,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so - if not self.rotate_refresh_token(request) and \ - isinstance(refresh_token_instance, RefreshToken) and \ - refresh_token_instance.access_token: + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk @@ -546,14 +631,18 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - self._create_refresh_token(request, refresh_token_code, access_token) + self._create_refresh_token( + request, refresh_token_code, access_token + ) else: # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = RefreshToken.objects.filter( - access_token=previous_access_token - ).first().token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token) + .first() + .token + ) token["scope"] = previous_access_token.scope # No refresh token should be created, just access token @@ -561,11 +650,15 @@ def save_bearer_token(self, token, request, *args, **kwargs): self._create_access_token(expires, request, token) def _create_access_token(self, expires, request, token, source_refresh_token=None): + id_token = token.get("id_token", None) + if id_token: + id_token = IDToken.objects.get(token=id_token) return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], + id_token=id_token, application=request.client, source_refresh_token=source_refresh_token, ) @@ -590,7 +683,7 @@ def _create_refresh_token(self, request, refresh_token_code, access_token): user=request.user, token=refresh_token_code, application=request.client, - access_token=access_token + access_token=access_token, ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -643,9 +736,8 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs """ null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta( - seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS - ) + revoked__gt=timezone.now() + - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) ) rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( "access_token" @@ -659,3 +751,183 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt return rt.application == client + + @transaction.atomic + def _save_id_token(self, token, request, expires, *args, **kwargs): + + scopes = request.scope or " ".join(request.scopes) + + if request.grant_type == "client_credentials": + request.user = None + + id_token = IDToken.objects.create( + user=request.user, + scope=scopes, + expires=expires, + token=token.serialize(), + application=request.client, + ) + return id_token + + def get_jwt_bearer_token(self, token, token_handler, request): + return self.get_id_token(token, token_handler, request) + + def get_oidc_claims(self, token, token_handler, request): + # Required OIDC claims + claims = { + "sub": str(request.user.id), + } + + # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + claims.update(**self.get_additional_claims(request)) + + return claims + + def get_id_token_dictionary(self, token, token_handler, request): + # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 + # Save the id_token on database bound to code when the request come to + # Authorization Endpoint and return the same one when request come to + # Token Endpoint + + # TODO: Check if at this point this request parameters are alredy validated + claims = self.get_oidc_claims(token, token_handler, request) + + expiration_time = timezone.now() + timedelta( + seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS + ) + # Required ID Token claims + claims.update(**{ + "iss": self.get_oidc_issuer_endpoint(request), + "aud": request.client_id, + "exp": int(dateformat.format(expiration_time, "U")), + "iat": int(dateformat.format(datetime.utcnow(), "U")), + "auth_time": int(dateformat.format(request.user.last_login, "U")), + }) + + nonce = getattr(request, "nonce", None) + if nonce: + claims["nonce"] = nonce + + # TODO: create a function to check if we should add at_hash + # http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken + # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken + # if request.grant_type in 'authorization_code' and 'access_token' in token: + if ( + (request.grant_type == "authorization_code" and "access_token" in token) + or request.response_type == "code id_token token" + or (request.response_type == "id_token token" and "access_token" in token) + ): + acess_token = token["access_token"] + at_hash = self.generate_at_hash(acess_token) + claims["at_hash"] = at_hash + + # TODO: create a function to check if we should include c_hash + # http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken + if request.response_type in ("code id_token", "code id_token token"): + code = token["code"] + sha256 = hashlib.sha256(code.encode("ascii")) + bits256 = sha256.hexdigest()[:32] + c_hash = base64.urlsafe_b64encode(bits256.encode("ascii")) + claims["c_hash"] = c_hash.decode("utf8") + + return claims, expiration_time + + def get_oidc_issuer_endpoint(self, request): + if oauth2_settings.OIDC_ISS_ENDPOINT: + return oauth2_settings.OIDC_ISS_ENDPOINT + + # generate it based on known URL + django_request = HttpRequest() + django_request.META = request.headers + + abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) + base_url = abs_url[:-len("/.well-known/openid-configuration/")] + return base_url + + def generate_at_hash(self, access_token): + sha256 = hashlib.sha256(access_token.encode("ascii")) + bits128 = sha256.digest()[:16] + at_hash = base64.urlsafe_b64encode(bits128).decode("utf8").rstrip("=") + return at_hash + + def get_id_token(self, token, token_handler, request): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + + claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) + + jwt_token = jwt.JWT( + header=json.dumps({"alg": "RS256"}, default=str), + claims=json.dumps(claims, default=str), + ) + jwt_token.make_signed_token(key) + + id_token = self._save_id_token(jwt_token, request, expiration_time) + # this is needed by django rest framework + request.access_token = id_token + request.id_token = id_token + return jwt_token.serialize() + + def validate_jwt_bearer_token(self, token, scopes, request): + return self.validate_id_token(token, scopes, request) + + def validate_id_token(self, token, scopes, request): + """ + When users try to access resources, check that provided id_token is valid + """ + if not token: + return False + + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + + try: + jwt_token = jwt.JWT(key=key, jwt=token) + id_token = IDToken.objects.get(token=jwt_token.serialize()) + request.client = id_token.application + request.user = id_token.user + request.scopes = scopes + # this is needed by django rest framework + request.access_token = id_token + return True + except (JWException, JWTExpired): + # TODO: This is the base exception of all jwcrypto + return False + + return False + + def validate_user_match(self, id_token_hint, scopes, claims, request): + # TODO: Fix to validate when necessary acording + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 + # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section + return True + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + """ Extracts nonce from saved authorization code. + If present in the Authentication Request, Authorization + Servers MUST include a nonce Claim in the ID Token with the + Claim Value being the nonce value sent in the Authentication + Request. Authorization Servers SHOULD perform no other + processing on nonce values used. The nonce value is a + case-sensitive string. + Only code param should be sufficient to retrieve grant code from + any storage you are using. However, `client_id` and `redirect_uri` + have been validated and can be used also. + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: Unicode nonce + Method is used by: + - Authorization Token Grant Dispatcher + """ + # TODO: Fix this ;) + return "" + + def get_userinfo_claims(self, request): + """ + Generates and saves a new JWT for this request, and returns it as the + current user's claims. + + """ + return self.get_oidc_claims(None, None, request) + + def get_additional_claims(self, request): + return {} diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 858efdbe7..2038ce999 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -23,10 +23,19 @@ USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) -APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") -ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") +APPLICATION_MODEL = getattr( + settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application" +) +ACCESS_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken" +) +ID_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken" +) GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") -REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") +REFRESH_TOKEN_MODEL = getattr( + settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken" +) DEFAULTS = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", @@ -35,7 +44,7 @@ "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, - "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", + "OAUTH2_SERVER_CLASS": "oauthlib.openid.connect.core.endpoints.pre_configured.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, @@ -45,29 +54,46 @@ "WRITE_SCOPE": "write", "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, + "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, + "ID_TOKEN_MODEL": ID_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - + "OIDC_ISS_ENDPOINT": "", + "OIDC_USERINFO_ENDPOINT": "", + "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RESPONSE_TYPES_SUPPORTED": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], + "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED": ["RS256", "HS256"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ + "client_secret_post", + "client_secret_basic", + ], # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], - # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, - # Whether or not PKCE is required - "PKCE_REQUIRED": False + "PKCE_REQUIRED": False, } # List of settings that cannot be empty @@ -79,6 +105,11 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", + "OIDC_RSA_PRIVATE_KEY", + "OIDC_RESPONSE_TYPES_SUPPORTED", + "OIDC_SUBJECT_TYPES_SUPPORTED", + "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED", + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", ) # List of settings that may be in string import notation. @@ -117,7 +148,12 @@ def import_from_string(val, setting_name): module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) + msg = "Could not import %r for setting %r. %s: %s." % ( + val, + setting_name, + e.__class__.__name__, + e, + ) raise ImportError(msg) @@ -129,7 +165,9 @@ class OAuth2ProviderSettings(object): and return the class, rather than the string literal. """ - def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): + def __init__( + self, user_settings=None, defaults=None, import_strings=None, mandatory=None + ): self.user_settings = user_settings or {} self.defaults = defaults or {} self.import_strings = import_strings or () @@ -164,7 +202,9 @@ def __getattr__(self, attr): if scope in self._SCOPES: val.append(scope) else: - raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES") + raise ImproperlyConfigured( + "Defined DEFAULT_SCOPES not present in SCOPES" + ) self.validate_setting(attr, val) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4cf6d4c6d..f2f04d853 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -27,5 +27,12 @@ name="authorized-token-delete"), ] +oidc_urlpatterns = [ + re_path(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info"), + re_path(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), + re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") +] + -urlpatterns = base_urlpatterns + management_urlpatterns +urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 7636bd9c7..9f2ac4ff7 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,9 +1,13 @@ # flake8: noqa -from .base import AuthorizationView, TokenView, RevokeTokenView -from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ - ApplicationDelete, ApplicationUpdate +from .application import ( + ApplicationDelete, ApplicationDetail, ApplicationList, + ApplicationRegistration, ApplicationUpdate +) +from .base import AuthorizationView, RevokeTokenView, TokenView from .generic import ( - ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView, - ClientProtectedResourceView, ClientProtectedScopedResourceView) -from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView + ProtectedResourceView, ReadWriteScopedResourceView, + ScopedProtectedResourceView +) from .introspect import IntrospectTokenView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView +from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index c925493f5..b38c907ab 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -32,7 +32,7 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" + "authorization_grant_type", "redirect_uris", "algorithm", ) ) @@ -81,6 +81,6 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" + "authorization_grant_type", "redirect_uris", "algorithm", ) ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index b9b6ed7f9..eb825c307 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -86,6 +86,7 @@ class AuthorizationView(BaseAuthorizationView, FormView): * Authorization code * Implicit grant """ + template_name = "oauth2_provider/authorize.html" form_class = AllowForm @@ -101,11 +102,14 @@ def get_initial(self): initial_data = { "redirect_uri": self.oauth2_data.get("redirect_uri", None), "scope": " ".join(scopes), + "nonce": self.oauth2_data.get("nonce", None), "client_id": self.oauth2_data.get("client_id", None), "state": self.oauth2_data.get("state", None), "response_type": self.oauth2_data.get("response_type", None), "code_challenge": self.oauth2_data.get("code_challenge", None), - "code_challenge_method": self.oauth2_data.get("code_challenge_method", None), + "code_challenge_method": self.oauth2_data.get( + "code_challenge_method", None + ), } return initial_data @@ -116,18 +120,27 @@ def form_valid(self, form): "client_id": form.cleaned_data.get("client_id"), "redirect_uri": form.cleaned_data.get("redirect_uri"), "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None) + "state": form.cleaned_data.get("state", None), } if form.cleaned_data.get("code_challenge", False): credentials["code_challenge"] = form.cleaned_data.get("code_challenge") if form.cleaned_data.get("code_challenge_method", False): - credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method") + credentials["code_challenge_method"] = form.cleaned_data.get( + "code_challenge_method" + ) + + body = {"nonce": form.cleaned_data.get("nonce")} scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") try: uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=scopes, credentials=credentials, allow=allow + self.request.get_raw_uri(), + request=self.request, + scopes=scopes, + credentials=credentials, + body=body, + allow=allow, ) except OAuthToolkitError as error: return self.error_response(error, application) @@ -149,13 +162,21 @@ def get(self, request, *args, **kwargs): # at this point we know an Application instance with such client_id exists in the database # TODO: Cache this! - application = get_application_model().objects.get(client_id=credentials["client_id"]) + application = get_application_model().objects.get( + client_id=credentials["client_id"] + ) + + uri_query = urllib.parse.urlparse(self.request.get_raw_uri()).query + uri_query_params = dict( + urllib.parse.parse_qsl(uri_query, keep_blank_values=True, strict_parsing=True) + ) kwargs["application"] = application kwargs["client_id"] = credentials["client_id"] kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] + kwargs["nonce"] = uri_query_params.get("nonce", None) self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 @@ -164,7 +185,9 @@ def get(self, request, *args, **kwargs): # Check to see if the user has already granted access and return # a successful response depending on "approval_prompt" url parameter - require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + require_approval = request.GET.get( + "approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT + ) try: # If skip_authorization field is True, skip the authorization screen even @@ -173,26 +196,36 @@ def get(self, request, *args, **kwargs): # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + self.request.get_raw_uri(), + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, ) return self.redirect(uri, application) elif require_approval == "auto": - tokens = get_access_token_model().objects.filter( - user=request.user, - application=kwargs["application"], - expires__gt=timezone.now() - ).all() + tokens = ( + get_access_token_model() + .objects.filter( + user=request.user, + application=kwargs["application"], + expires__gt=timezone.now(), + ) + .all() + ) # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + self.request.get_raw_uri(), + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, ) - return self.redirect(uri, application, token) + return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) @@ -239,6 +272,7 @@ class TokenView(OAuthLibMixin, View): * Password * Client credentials """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS @@ -249,11 +283,8 @@ def post(self, request, *args, **kwargs): if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get( - token=access_token) - app_authorized.send( - sender=self, request=request, - token=token) + token = get_access_token_model().objects.get(token=access_token) + app_authorized.send(sender=self, request=request, token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): @@ -266,6 +297,7 @@ class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 7d4381179..460a1395d 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views import ClientProtectedScopedResourceView +from oauth2_provider.views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 851ec4cd5..986419ba4 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -97,7 +97,7 @@ def validate_authorization_request(self, request): core = self.get_oauthlib_core() return core.validate_authorization_request(request) - def create_authorization_response(self, request, scopes, credentials, allow): + def create_authorization_response(self, uri, request, scopes, credentials, allow, body=None): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -105,14 +105,15 @@ def create_authorization_response(self, request, scopes, credentials, allow): :param request: The current django.http.HttpRequest object :param scopes: A space-separated string of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri`, `response_type` + `client_id`, `state`, `redirect_uri` and `response_type` :param allow: True if the user authorize the client, otherwise False + :param body: Other body parameters not used in credentials dictionary """ # TODO: move this scopes conversion from and to string into a utils function scopes = scopes.split(" ") if scopes else [] core = self.get_oauthlib_core() - return core.create_authorization_response(request, scopes, credentials, allow) + return core.create_authorization_response(uri, request, scopes, credentials, body, allow) def create_token_response(self, request): """ @@ -133,6 +134,16 @@ def create_revocation_response(self, request): core = self.get_oauthlib_core() return core.create_revocation_response(request) + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on the + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_userinfo_response(request) + def verify_request(self, request): """ A wrapper method that calls verify_request on `server_class` instance. @@ -277,11 +288,13 @@ def dispatch(self, request, *args, **kwargs): if not valid: # Alternatively allow access tokens # check if the request is valid and the protected resource may be accessed - valid, r = self.verify_request(request) - if valid: - request.resource_owner = r.user - return super().dispatch(request, *args, **kwargs) - else: - return HttpResponseForbidden() + try: + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + except ValueError: + pass + return HttpResponseForbidden() else: return super().dispatch(request, *args, **kwargs) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py new file mode 100644 index 000000000..d7ffe4670 --- /dev/null +++ b/oauth2_provider/views/oidc.py @@ -0,0 +1,95 @@ +from __future__ import absolute_import, unicode_literals + +import json + +from django.http import HttpResponse, JsonResponse +from django.urls import reverse, reverse_lazy +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View +from jwcrypto import jwk + +from ..settings import oauth2_settings +from .mixins import OAuthLibMixin + + +class ConnectDiscoveryInfoView(View): + """ + View used to show oidc provider configuration information + """ + def get(self, request, *args, **kwargs): + issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + + if not issuer_url: + abs_url = request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) + issuer_url = abs_url[:-len("/.well-known/openid-configuration/")] + + authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) + token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) + userinfo_endpoint = ( + oauth2_settings.OIDC_USERINFO_ENDPOINT or + request.build_absolute_uri(reverse("oauth2_provider:user-info")) + ) + jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + else: + authorization_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")) + userinfo_endpoint = ( + oauth2_settings.OIDC_USERINFO_ENDPOINT or + "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:user-info")) + ) + jwks_uri = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")) + + data = { + "issuer": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_uri": jwks_uri, + "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, + "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, + "id_token_signing_alg_values_supported": + oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, + "token_endpoint_auth_methods_supported": + oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, + } + response = JsonResponse(data) + response["Access-Control-Allow-Origin"] = "*" + return response + + +class JwksInfoView(View): + """ + View used to show oidc json web key set document + """ + def get(self, request, *args, **kwargs): + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + data = { + "keys": [{ + "alg": "RS256", + "use": "sig", + "kid": key.thumbprint() + }] + } + data["keys"][0].update(json.loads(key.export_public())) + response = JsonResponse(data) + response["Access-Control-Allow-Origin"] = "*" + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class UserInfoView(OAuthLibMixin, View): + """ + View used to show Claims about the authenticated End-User + """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS + + def get(self, request, *args, **kwargs): + url, headers, body, status = self.create_userinfo_response(request) + response = HttpResponse(content=body or "", status=status) + + for k, v in headers.items(): + response[k] = v + return response diff --git a/setup.cfg b/setup.cfg index 3c4e0badc..fb060f88e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,6 +34,7 @@ install_requires = django >= 2.1 requests >= 2.13.0 oauthlib >= 3.1.0 + jwcrypto >= 0.4.2 [options.packages.find] exclude = tests diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index 60b17f2ae..eef6dbab5 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -53,6 +53,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('custom_field', models.CharField(max_length=255)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, @@ -71,6 +72,7 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), ], options={ 'abstract': False, @@ -83,7 +85,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -91,6 +93,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('allowed_schemes', models.TextField(blank=True)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, diff --git a/tests/settings.py b/tests/settings.py index 40eef5ebd..edd1ae679 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -130,3 +130,30 @@ }, } } + +OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT +j0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP +0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB +AoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77 ++IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju +YBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn +2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq +MH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el +fVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc +uEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67 +ZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT +qoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr +dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY +-----END RSA PRIVATE KEY-----""" + +OAUTH2_PROVIDER = { + "OIDC_ISS_ENDPOINT": "http://localhost", + "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", + "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, +} + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" +OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 6130876ce..64e112da3 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -50,6 +50,7 @@ def test_application_registration_user(self): "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "RS256", } response = self.client.post(reverse("oauth2_provider:register"), form_data) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index e98f5b041..e4eb8ae81 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -41,8 +41,12 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + self.test_user = UserModel.objects.create_user( + "test_user", "test@example.com", "123456" + ) + self.dev_user = UserModel.objects.create_user( + "dev_user", "dev@example.com", "123456" + ) oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] @@ -57,8 +61,13 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._SCOPES = ["read", "write", "openid"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect", + } def tearDown(self): self.application.delete() @@ -103,6 +112,25 @@ def test_skip_authorization_completely(self): }) self.assertEqual(response.status_code, 302) + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: code @@ -147,6 +175,32 @@ def test_pre_auth_valid_client(self): self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code @@ -176,10 +230,11 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") @@ -204,10 +259,11 @@ def test_pre_auth_approval_prompt_default(self): self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -224,10 +280,11 @@ def test_pre_auth_approval_prompt_default_override(self): oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -302,7 +359,32 @@ def test_code_post_auth_allow(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -323,7 +405,9 @@ def test_code_post_auth_deny(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -342,7 +426,9 @@ def test_code_post_auth_deny_no_state(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertNotIn("state", response["Location"]) @@ -362,7 +448,9 @@ def test_code_post_auth_bad_responsetype(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) @@ -381,7 +469,9 @@ def test_code_post_auth_forbidden_redirect_uri(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): @@ -399,7 +489,9 @@ def test_code_post_auth_malicious_redirect_uri(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): @@ -418,7 +510,9 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -440,7 +534,9 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -463,7 +559,9 @@ def test_code_post_auth_redirection_uri_with_querystring(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) @@ -486,7 +584,9 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): "allow": False, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -508,25 +608,29 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=form_data + ) self.assertEqual(response.status_code, 400) class TestAuthorizationCodeTokenView(BaseTest): - def get_auth(self): + def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", - "scope": "read write", + "scope": scope, "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() @@ -536,9 +640,13 @@ def generate_pkce_codes(self, algorithm, length=43): """ code_verifier = get_random_string(length) if algorithm == "S256": - code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ).decode().rstrip("=") + code_challenge = ( + base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ) + .decode() + .rstrip("=") + ) else: code_challenge = code_verifier return code_verifier, code_challenge @@ -559,7 +667,9 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): "code_challenge_method": code_challenge_method, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) oauth2_settings.PKCE_REQUIRED = False return query_dict["code"].pop() @@ -574,17 +684,23 @@ def test_basic_auth(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_refresh(self): """ @@ -596,11 +712,15 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -609,23 +729,29 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) # check refresh token cannot be used twice - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) @@ -641,11 +767,15 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -654,9 +784,11 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) token_request_data = { "grant_type": "refresh_token", @@ -664,7 +796,9 @@ def test_refresh_with_grace_period(self): "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -673,7 +807,9 @@ def test_refresh_with_grace_period(self): first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) @@ -693,11 +829,15 @@ def test_refresh_invalidates_old_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) rt = content["refresh_token"] @@ -708,7 +848,9 @@ def test_refresh_invalidates_old_tokens(self): "refresh_token": rt, "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(token=rt).first() @@ -725,11 +867,15 @@ def test_refresh_no_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -737,7 +883,9 @@ def test_refresh_no_scopes(self): "grant_type": "refresh_token", "refresh_token": content["refresh_token"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -753,11 +901,15 @@ def test_refresh_bad_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -766,7 +918,9 @@ def test_refresh_bad_scopes(self): "refresh_token": content["refresh_token"], "scope": "read write nuke", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): @@ -779,11 +933,15 @@ def test_refresh_fail_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -792,9 +950,13 @@ def test_refresh_fail_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): @@ -809,11 +971,15 @@ def test_refresh_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -822,18 +988,26 @@ def test_refresh_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) # try refreshing outside the refresh window, see #497 rt = RefreshToken.objects.get(token=content["refresh_token"]) self.assertIsNotNone(rt.revoked) - rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime + rt.revoked = timezone.now() - datetime.timedelta( + minutes=10 + ) # instead of mocking out datetime rt.save() - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 @@ -847,11 +1021,15 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -862,9 +1040,13 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): } oauth2_settings.ROTATE_REFRESH_TOKEN = False - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) oauth2_settings.ROTATE_REFRESH_TOKEN = True @@ -878,11 +1060,15 @@ def test_basic_auth_bad_authcode(self): token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): @@ -894,11 +1080,15 @@ def test_basic_auth_bad_granttype(self): token_request_data = { "grant_type": "UNKNOWN", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): @@ -907,18 +1097,27 @@ def test_basic_auth_grant_expired(self): """ self.client.login(username="test_user", password="123456") g = Grant( - application=self.application, user=self.test_user, code="BLAH", - expires=timezone.now(), redirect_uri="", scope="") + application=self.application, + user=self.test_user, + code="BLAH", + expires=timezone.now(), + redirect_uri="", + scope="", + ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): @@ -931,11 +1130,13 @@ def test_basic_auth_bad_secret(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): @@ -948,16 +1149,20 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format( + self.application.client_id, self.application.client_secret + ) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 401) def test_request_body_params(self): @@ -975,13 +1180,17 @@ def test_request_body_params(self): "client_secret": self.application.client_secret, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_public(self): """ @@ -997,16 +1206,52 @@ def test_public(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_public_pkce_S256_authorize_get(self): """ @@ -1082,16 +1327,20 @@ def test_public_pkce_S256(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain(self): @@ -1112,16 +1361,20 @@ def test_public_pkce_plain(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_invalid_algorithm(self): @@ -1224,10 +1477,12 @@ def test_public_pkce_S256_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1249,10 +1504,12 @@ def test_public_pkce_plain_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1273,10 +1530,12 @@ def test_public_pkce_S256_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1297,10 +1556,12 @@ def test_public_pkce_plain_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1319,14 +1580,19 @@ def test_malicious_redirect_uri(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", - "client_id": self.application.client_id + "client_id": self.application.client_id, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) def test_code_exchange_succeed_when_redirect_uri_match(self): """ @@ -1343,7 +1609,9 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1351,17 +1619,23 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar" + "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1378,7 +1652,9 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1386,17 +1662,26 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa" + "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) - def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): """ Tests code exchange succeed when redirect uri matches the one used for code request """ @@ -1413,7 +1698,9 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1421,17 +1708,72 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar" + "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code", + "allow": True, + } + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) def test_oob_as_html(self): """ @@ -1494,7 +1836,9 @@ def test_oob_as_json(self): "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) self.assertEqual(response.status_code, 200) self.assertRegex(response["Content-Type"], "^application/json") @@ -1511,13 +1855,17 @@ def test_oob_as_json(self): "client_secret": self.application.client_secret, } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data + ) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual( + content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS + ) class TestAuthorizationCodeProtectedResource(BaseTest): @@ -1533,7 +1881,9 @@ def test_resource_access_allowed(self): "response_type": "code", "allow": True, } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1541,11 +1891,15 @@ def test_resource_access_allowed(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] @@ -1560,6 +1914,63 @@ def test_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") + def test_id_token_resource_access_allowed(self): + self.client.login(username="test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + response = self.client.post( + reverse("oauth2_provider:authorize"), data=authcode_data + ) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header( + self.application.client_id, self.application.client_secret + ) + + response = self.client.post( + reverse("oauth2_provider:token"), data=token_request_data, **auth_headers + ) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", @@ -1573,7 +1984,6 @@ def test_resource_access_deny(self): class TestDefaultScopes(BaseTest): - def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py new file mode 100644 index 000000000..1f45aeeec --- /dev/null +++ b/tests/test_hybrid.py @@ -0,0 +1,1264 @@ +import base64 +import datetime +import json +from urllib.parse import parse_qs, urlencode, urlparse + +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors + +from oauth2_provider.models import ( + get_access_token_model, get_application_model, + get_grant_model, get_refresh_token_model +) +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views import ProtectedResourceView + +from .utils import get_basic_auth_header + + +Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() + + +# mocking a protected resource view +class ResourceView(ProtectedResourceView): + def get(self, request, *args, **kwargs): + return "This is a protected resource" + + +class BaseTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") + self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") + + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + + self.application = Application( + name="Hybrid Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.hy_dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_OPENID_HYBRID, + ) + self.application.save() + + oauth2_settings._SCOPES = ["read", "write", "openid"] + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect" + } + + def tearDown(self): + self.application.delete() + self.hy_test_user.delete() + self.hy_dev_user.delete() + + +class TestRegressionIssue315Hybrid(BaseTest): + """ + Test to avoid regression for the issue 315: request object + was being reassigned when getting AuthorizationView + """ + + def test_request_is_not_overwritten_code_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + +class TestHybridView(BaseTest): + def test_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_invalid_client(self): + """ + Test error for an invalid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": "fakeclientid", + "response_type": "code", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.context_data["url"], + "?error=invalid_request&error_description=Invalid+client_id+parameter+value." + ) + + def test_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "nonce": "nonce", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): + """ + Test response for a valid client_id with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_approval_prompt(self): + tok = AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + # user already authorized the application, but with different scopes: prompt them. + tok.scope = "read" + tok.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default(self): + oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" + self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + + AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default_override(self): + oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + + AccessToken.objects.create( + user=self.hy_test_user, token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write" + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_default_redirect(self): + """ + Test for default redirect uri if omitted from query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code id_token", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://localhost") + + def test_pre_auth_forbibben_redirect(self): + """ + Test error when passing a forbidden redirect_uri in query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + def test_pre_auth_wrong_response_type(self): + """ + Test error when passing a wrong response_type in query string + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "WRONG", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=unsupported_response_type", response["Location"]) + + def test_code_post_auth_allow_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_bad_responsetype(self): + """ + Test authorization code is given for an allowed request with a response_type not supported + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "UNKNOWN", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?error", response["Location"]) + + def test_code_post_auth_forbidden_redirect_uri(self): + """ + Test authorization code is given for an allowed request with a forbidden redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://forbidden.it", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_malicious_redirect_uri(self): + """ + Test validation of a malicious redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "/../", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_deny_custom_redirect_uri_scheme(self): + """ + Test error when resource owner deny access + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com?", response["Location"]) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_failing_redirection_uri_with_querystring(self): + """ + Test that in case of error the querystring of the redirection uri is preserved + + See https://github.com/evonove/django-oauth-toolkit/issues/238 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual( + "http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"] + ) + + def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): + """ + Tests that a redirection uri is matched using scheme + netloc + path + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com/a?foo=bar", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + +class TestHybridTokenView(BaseTest): + def get_auth(self, scope="read write"): + """ + Helper method to retrieve a valid authorization code + """ + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": scope, + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + return fragment_dict["code"].pop() + + def test_basic_auth(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_basic_auth_bad_authcode(self): + """ + Request an access token using a bad authorization code + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_bad_granttype(self): + """ + Request an access token using a bad grant_type string + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = { + "grant_type": "UNKNOWN", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_grant_expired(self): + """ + Request an access token using an expired grant token + """ + self.client.login(username="hy_test_user", password="123456") + g = Grant( + application=self.application, user=self.hy_test_user, code="BLAH", + expires=timezone.now(), redirect_uri="", scope="") + g.save() + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_bad_secret(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_basic_auth_wrong_auth_type(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + auth_string = base64.b64encode(user_pass.encode("utf-8")) + auth_headers = { + "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_request_body_params(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_malicious_redirect_uri(self): + """ + Request an access token using client_type: public and ensure redirect_uri is + properly validated. + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "/../", + "client_id": self.application.client_id + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=bar" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_code_exchange_fails_when_redirect_uri_does_not_match(self): + """ + Tests code exchange fails when redirect uri does not match the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=baraa" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +class TestHybridProtectedResource(BaseTest): + def test_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org" + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + def test_id_token_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + def test_resource_access_deny(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "faketoken", + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + + +class TestDefaultScopesHybrid(BaseTest): + + def test_pre_auth_default_scopes(self): + """ + Test response for a valid client_id with response_type: code using default scopes + """ + self.client.login(username="hy_test_user", password="123456") + oauth2_settings._DEFAULT_SCOPES = ["read"] + + query_string = urlencode({ + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "redirect_uri": "http://example.org", + }) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read") + self.assertEqual(form["client_id"].value(), self.application.client_id) + oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b51d0e1da..15ac7469d 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,8 +1,10 @@ +import json from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse +from jwcrypto import jwk, jwt from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings @@ -33,8 +35,14 @@ def setUp(self): authorization_grant_type=Application.GRANT_IMPLICIT, ) - oauth2_settings._SCOPES = ["read", "write"] + oauth2_settings._SCOPES = ["read", "write", "openid"] oauth2_settings._DEFAULT_SCOPES = ["read"] + oauth2_settings.SCOPES = { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect" + } + self.key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) def tearDown(self): self.application.delete() @@ -265,3 +273,191 @@ def test_resource_access_allowed(self): view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") + + +class TestOpenIDConnectImplicitFlow(BaseTest): + def test_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: id_token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely_missing_nonce(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_request", response["Location"]) + self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) + + def test_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_access_token_and_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index d844da5f4..0d98dad8b 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -65,7 +65,9 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: + with mock.patch( + "oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response" + ) as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7821148d5..1a0926988 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -287,6 +287,13 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r assert create_access_token_mock.call_count == 1 assert create_refresh_token_mock.call_count == 1 + def test_generate_at_hash(self): + # Values taken from spec, https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample + access_token = "jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y" + at_hash = self.validator.generate_at_hash(access_token) + + assert at_hash == "77QmUPtjPfzWtF2AnpK9RQ" + class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py new file mode 100644 index 000000000..71f41d7eb --- /dev/null +++ b/tests/test_oidc_views.py @@ -0,0 +1,77 @@ +from __future__ import unicode_literals + +from django.test import TestCase +from django.urls import reverse + +from oauth2_provider.settings import oauth2_settings + + +class TestConnectDiscoveryInfoView(TestCase): + def test_get_connect_discovery_info(self): + expected_response = { + "issuer": "http://localhost", + "authorization_endpoint": "http://localhost/o/authorize/", + "token_endpoint": "http://localhost/o/token/", + "userinfo_endpoint": "http://localhost/userinfo/", + "jwks_uri": "http://localhost/o/jwks/", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token" + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_without_issuer_url(self): + oauth2_settings.OIDC_ISS_ENDPOINT = None + oauth2_settings.OIDC_USERINFO_ENDPOINT = None + expected_response = { + "issuer": "http://testserver/o", + "authorization_endpoint": "http://testserver/o/authorize/", + "token_endpoint": "http://testserver/o/token/", + "userinfo_endpoint": "http://testserver/o/userinfo/", + "jwks_uri": "http://testserver/o/jwks/", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token" + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + oauth2_settings.OIDC_ISS_ENDPOINT = "http://localhost" + oauth2_settings.OIDC_USERINFO_ENDPOINT = "http://localhost/userinfo/" + + +class TestJwksInfoView(TestCase): + def test_get_jwks_info(self): + expected_response = { + "keys": [{ + "alg": "RS256", + "use": "sig", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "e": "AQAB", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" # noqa + }] + } + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response diff --git a/tests/urls.py b/tests/urls.py index 16dcf6ded..c7fa9a101 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,13 +1,11 @@ -from django.conf.urls import include, url from django.contrib import admin +from django.urls import include, re_path admin.autodiscover() urlpatterns = [ - url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + re_path(r"^admin/", admin.site.urls), ] - - -urlpatterns += [url(r"^admin/", admin.site.urls)] diff --git a/tox.ini b/tox.ini index c984f8b99..686bf366a 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,8 @@ envlist = django_find_project = false [testenv] -commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} +commands = + pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} -s setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} @@ -26,6 +27,7 @@ deps = djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.1.0 + jwcrypto coverage pytest pytest-cov @@ -42,6 +44,7 @@ commands = make html deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 + jwcrypto [testenv:py37-flake8] skip_install = True @@ -67,7 +70,9 @@ commands = [coverage:run] source = oauth2_provider -omit = */migrations/* +omit = + */migrations/* + oauth2_provider/settings.py [flake8] max-line-length = 110 From 8e75ad72a3762a303ea0193f20b0afe0b2523ed0 Mon Sep 17 00:00:00 2001 From: David Smith <smithdc@gmail.com> Date: Mon, 7 Sep 2020 21:56:50 +0100 Subject: [PATCH 334/722] Changed url() to path() and re_path() url() is deprecated in Django 3.1 --- tests/test_introspection_auth.py | 7 ++++--- tests/test_rest_framework.py | 25 +++++++++++++------------ 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index db37f6c72..6c06a1294 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -1,10 +1,11 @@ import calendar import datetime -from django.conf.urls import include, url +from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse from django.test import TestCase, override_settings +from django.urls import path from django.utils import timezone from oauthlib.common import Request @@ -64,8 +65,8 @@ def json(self): urlpatterns = [ - url(r"^oauth2/", include("oauth2_provider.urls")), - url(r"^oauth2-test-resource/$", ScopeResourceView.as_view()), + path("oauth2/", include("oauth2_provider.urls")), + path("oauth2-test-resource/", ScopeResourceView.as_view()), ] diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 21a6ccd71..d4fea56be 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,11 +1,12 @@ from datetime import timedelta -from django.conf.urls import include, url +from django.conf.urls import include from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse from django.test import TestCase from django.test.utils import override_settings +from django.urls import path, re_path from django.utils import timezone from rest_framework import permissions from rest_framework.authentication import BaseAuthentication @@ -109,17 +110,17 @@ class AuthenticationNoneOAuth2View(MockView): urlpatterns = [ - url(r"^oauth2/", include("oauth2_provider.urls")), - url(r"^oauth2-test/$", OAuth2View.as_view()), - url(r"^oauth2-scoped-test/$", ScopedView.as_view()), - url(r"^oauth2-scoped-missing-auth/$", TokenHasScopeViewWrongAuth.as_view()), - url(r"^oauth2-read-write-test/$", ReadWriteScopedView.as_view()), - url(r"^oauth2-resource-scoped-test/$", ResourceScopedView.as_view()), - url(r"^oauth2-authenticated-or-scoped-test/$", AuthenticatedOrScopedView.as_view()), - url(r"^oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), - url(r"^oauth2-method-scope-fail/$", MethodScopeAltViewBad.as_view()), - url(r"^oauth2-method-scope-missing-auth/$", MethodScopeAltViewWrongAuth.as_view()), - url(r"^oauth2-authentication-none/$", AuthenticationNoneOAuth2View.as_view()), + path("oauth2/", include("oauth2_provider.urls")), + path("oauth2-test/", OAuth2View.as_view()), + path("oauth2-scoped-test/", ScopedView.as_view()), + path("oauth2-scoped-missing-auth/", TokenHasScopeViewWrongAuth.as_view()), + path("oauth2-read-write-test/", ReadWriteScopedView.as_view()), + path("oauth2-resource-scoped-test/", ResourceScopedView.as_view()), + path("oauth2-authenticated-or-scoped-test/", AuthenticatedOrScopedView.as_view()), + re_path(r"oauth2-method-scope-test/.*$", MethodScopeAltView.as_view()), + path("oauth2-method-scope-fail/", MethodScopeAltViewBad.as_view()), + path("oauth2-method-scope-missing-auth/", MethodScopeAltViewWrongAuth.as_view()), + path("oauth2-authentication-none/", AuthenticationNoneOAuth2View.as_view()), ] From 4a83d4c17acae1ad2aee3ca1525ce1b7cdbfd801 Mon Sep 17 00:00:00 2001 From: David Smith <smithdc@gmail.com> Date: Tue, 8 Sep 2020 07:30:22 +0100 Subject: [PATCH 335/722] Passing `providing_args` into `Signal` is deprecated For documentation only -- left in code as a comment --- oauth2_provider/signals.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/signals.py b/oauth2_provider/signals.py index 060db8cd0..1640bda03 100644 --- a/oauth2_provider/signals.py +++ b/oauth2_provider/signals.py @@ -1,4 +1,4 @@ from django.dispatch import Signal -app_authorized = Signal(providing_args=["request", "token"]) +app_authorized = Signal() # providing_args=["request", "token"] From 88d22e8a92c8af34ab4d671960fdb0bf6520cbab Mon Sep 17 00:00:00 2001 From: David Smith <smithdc@gmail.com> Date: Mon, 7 Sep 2020 21:56:50 +0100 Subject: [PATCH 336/722] Passing None into middleware is deprecated --- AUTHORS | 1 + tests/test_auth_backends.py | 13 ++++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 671d31b54..611a0e62b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,3 +30,4 @@ Rodney Richardson Silvano Cerza Stéphane Raimbault Jun Zhou +David Smith \ No newline at end of file diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 530caa738..baf82169c 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -88,8 +88,11 @@ def setUp(self): super(TestOAuth2Middleware, self).setUp() self.anon_user = AnonymousUser() + def dummy_get_response(request): + return None + def test_middleware_wrong_headers(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) request = self.factory.get("/a-resource") self.assertIsNone(m.process_request(request)) auth_headers = { @@ -99,7 +102,7 @@ def test_middleware_wrong_headers(self): self.assertIsNone(m.process_request(request)) def test_middleware_user_is_set(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } @@ -110,7 +113,7 @@ def test_middleware_user_is_set(self): self.assertIsNone(m.process_request(request)) def test_middleware_success(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } @@ -119,7 +122,7 @@ def test_middleware_success(self): self.assertEqual(request.user, self.user) def test_middleware_response(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } @@ -129,7 +132,7 @@ def test_middleware_response(self): self.assertIs(response, processed) def test_middleware_response_header(self): - m = OAuth2TokenMiddleware() + m = OAuth2TokenMiddleware(self.dummy_get_response) auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } From 295c065a3e5a14b777f1e56c8e36adedae0af461 Mon Sep 17 00:00:00 2001 From: David Smith <39445562+smithdc1@users.noreply.github.com> Date: Tue, 15 Sep 2020 18:37:03 +0100 Subject: [PATCH 337/722] Removed inheritance from `object` (#863) No longer required with Python 3 --- oauth2_provider/backends.py | 2 +- oauth2_provider/generators.py | 2 +- oauth2_provider/oauth2_backends.py | 2 +- oauth2_provider/scopes.py | 2 +- oauth2_provider/settings.py | 2 +- oauth2_provider/views/mixins.py | 4 ++-- tests/test_mixins.py | 2 +- 7 files changed, 8 insertions(+), 8 deletions(-) diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index aa7e1ec2a..3f6fab9af 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -7,7 +7,7 @@ OAuthLibCore = get_oauthlib_core() -class OAuth2Backend(object): +class OAuth2Backend: """ Authenticate against an OAuth2 access token """ diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index a54808857..ab5d25a7a 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -4,7 +4,7 @@ from .settings import oauth2_settings -class BaseHashGenerator(object): +class BaseHashGenerator: """ All generators should extend this class overriding `.hash()` method. """ diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 0263f63ac..404add70e 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -9,7 +9,7 @@ from .settings import oauth2_settings -class OAuthLibCore(object): +class OAuthLibCore: """ Wrapper for oauth Server providing django-specific interfaces. diff --git a/oauth2_provider/scopes.py b/oauth2_provider/scopes.py index d30f43eb0..5fc1276ff 100644 --- a/oauth2_provider/scopes.py +++ b/oauth2_provider/scopes.py @@ -1,7 +1,7 @@ from .settings import oauth2_settings -class BaseScopes(object): +class BaseScopes: def get_all_scopes(self): """ Return a dict-like object with all the scopes available in the diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 2038ce999..d3d60801e 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -157,7 +157,7 @@ def import_from_string(val, setting_name): raise ImportError(msg) -class OAuth2ProviderSettings(object): +class OAuth2ProviderSettings: """ A settings object, that allows OAuth2 Provider settings to be accessed as properties. diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 986419ba4..0b7e02c7a 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -13,7 +13,7 @@ SAFE_HTTP_METHODS = ["GET", "HEAD", "OPTIONS"] -class OAuthLibMixin(object): +class OAuthLibMixin: """ This mixin decouples Django OAuth Toolkit from OAuthLib. @@ -195,7 +195,7 @@ def authenticate_client(self, request): return core.authenticate_client(request) -class ScopedResourceMixin(object): +class ScopedResourceMixin: """ Helper mixin that implements "scopes handling" behaviour """ diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 79988c9fc..b8aa9ac4d 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -58,7 +58,7 @@ class TestView(OAuthLibMixin, View): self.assertIsInstance(test_view.get_server(), Server) def test_custom_backend(self): - class AnotherOauthLibBackend(object): + class AnotherOauthLibBackend: pass class TestView(OAuthLibMixin, View): From 3bde632d5722f1f85ffcd8277504955321f00fff Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@crosswell.us> Date: Tue, 6 Oct 2020 15:07:37 -0400 Subject: [PATCH 338/722] Revert "Openid Connect Core support - Round 2 (#859)" (#877) This reverts commit 4655c030be15616ba6e0872253a2c15a897d9701. --- .gitignore | 2 +- oauth2_provider/admin.py | 9 +- oauth2_provider/forms.py | 1 - .../migrations/0002_auto_20190406_1805.py | 2 + .../migrations/0003_auto_20200902_2022.py | 48 - oauth2_provider/models.py | 114 -- oauth2_provider/oauth2_backends.py | 26 +- oauth2_provider/oauth2_validators.py | 376 +---- oauth2_provider/settings.py | 62 +- oauth2_provider/urls.py | 9 +- oauth2_provider/views/__init__.py | 16 +- oauth2_provider/views/application.py | 4 +- oauth2_provider/views/base.py | 74 +- oauth2_provider/views/introspect.py | 2 +- oauth2_provider/views/mixins.py | 31 +- oauth2_provider/views/oidc.py | 95 -- setup.cfg | 1 - tests/migrations/0001_initial.py | 7 +- tests/settings.py | 27 - tests/test_application_views.py | 1 - tests/test_authorization_code.py | 682 ++------- tests/test_hybrid.py | 1264 ----------------- tests/test_implicit.py | 198 +-- tests/test_oauth2_backends.py | 4 +- tests/test_oauth2_validators.py | 7 - tests/test_oidc_views.py | 77 - tests/urls.py | 8 +- tox.ini | 9 +- 28 files changed, 259 insertions(+), 2897 deletions(-) delete mode 100644 oauth2_provider/migrations/0003_auto_20200902_2022.py delete mode 100644 oauth2_provider/views/oidc.py delete mode 100644 tests/test_hybrid.py delete mode 100644 tests/test_oidc_views.py diff --git a/.gitignore b/.gitignore index c22ef00fa..af644d1e3 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ __pycache__ pip-log.txt # Unit test / coverage reports -.pytest_cache +.cache .coverage .tox .pytest_cache/ diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index a8d69e623..8b963d981 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -2,7 +2,7 @@ from .models import ( get_access_token_model, get_application_model, - get_grant_model, get_id_token_model, get_refresh_token_model + get_grant_model, get_refresh_token_model ) @@ -26,11 +26,6 @@ class AccessTokenAdmin(admin.ModelAdmin): raw_id_fields = ("user", "source_refresh_token") -class IDTokenAdmin(admin.ModelAdmin): - list_display = ("token", "user", "application", "expires") - raw_id_fields = ("user", ) - - class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") @@ -39,11 +34,9 @@ class RefreshTokenAdmin(admin.ModelAdmin): Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() -IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() admin.site.register(Application, ApplicationAdmin) admin.site.register(Grant, GrantAdmin) admin.site.register(AccessToken, AccessTokenAdmin) -admin.site.register(IDToken, IDTokenAdmin) admin.site.register(RefreshToken, RefreshTokenAdmin) diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 41129c449..2e465959a 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -5,7 +5,6 @@ class AllowForm(forms.Form): allow = forms.BooleanField(required=False) redirect_uri = forms.CharField(widget=forms.HiddenInput()) scope = forms.CharField(widget=forms.HiddenInput()) - nonce = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) diff --git a/oauth2_provider/migrations/0002_auto_20190406_1805.py b/oauth2_provider/migrations/0002_auto_20190406_1805.py index bcacc23ce..8ca177abf 100644 --- a/oauth2_provider/migrations/0002_auto_20190406_1805.py +++ b/oauth2_provider/migrations/0002_auto_20190406_1805.py @@ -1,3 +1,5 @@ +# Generated by Django 2.2 on 2019-04-06 18:05 + from django.db import migrations, models diff --git a/oauth2_provider/migrations/0003_auto_20200902_2022.py b/oauth2_provider/migrations/0003_auto_20200902_2022.py deleted file mode 100644 index 684949c9d..000000000 --- a/oauth2_provider/migrations/0003_auto_20200902_2022.py +++ /dev/null @@ -1,48 +0,0 @@ -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - -from oauth2_provider.settings import oauth2_settings - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('oauth2_provider', '0002_auto_20190406_1805'), - ] - - operations = [ - migrations.AddField( - model_name='application', - name='algorithm', - field=models.CharField(choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256', max_length=5), - ), - migrations.AlterField( - model_name='application', - name='authorization_grant_type', - field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), - ), - migrations.CreateModel( - name='IDToken', - fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.TextField(unique=True)), - ('expires', models.DateTimeField()), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), - ], - options={ - 'abstract': False, - 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', - }, - ), - migrations.AddField( - model_name='accesstoken', - name='id_token', - field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), - ), - ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 7135192db..5676bc0c5 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,4 +1,3 @@ -import json import logging from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -10,7 +9,6 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ -from jwcrypto import jwk, jwt from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend @@ -52,20 +50,11 @@ class AbstractApplication(models.Model): GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" - GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), - (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), - ) - - RS256_ALGORITHM = "RS256" - HS256_ALGORITHM = "HS256" - ALGORITHM_TYPES = ( - (RS256_ALGORITHM, _("RSA with SHA-2 256")), - (HS256_ALGORITHM, _("HMAC with SHA-2 256")), ) id = models.BigAutoField(primary_key=True) @@ -93,7 +82,6 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) - algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=RS256_ALGORITHM) class Meta: abstract = True @@ -294,10 +282,6 @@ class AbstractAccessToken(models.Model): related_name="refreshed_access_token" ) token = models.CharField(max_length=255, unique=True, ) - id_token = models.OneToOneField( - oauth2_settings.ID_TOKEN_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="access_token" - ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, ) @@ -431,99 +415,6 @@ class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" -class AbstractIDToken(models.Model): - """ - An IDToken instance represents the actual token to - access user's resources, as in :openid:`2`. - - Fields: - - * :attr:`user` The Django user representing resources' owner - * :attr:`token` ID token - * :attr:`application` Application instance - * :attr:`expires` Date and time of token expiration, in DateTime format - * :attr:`scope` Allowed scopes - """ - id = models.BigAutoField(primary_key=True) - user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="%(app_label)s_%(class)s" - ) - token = models.TextField(unique=True) - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, - ) - expires = models.DateTimeField() - scope = models.TextField(blank=True) - - created = models.DateTimeField(auto_now_add=True) - updated = models.DateTimeField(auto_now=True) - - def is_valid(self, scopes=None): - """ - Checks if the access token is valid. - - :param scopes: An iterable containing the scopes to check or None - """ - return not self.is_expired() and self.allow_scopes(scopes) - - def is_expired(self): - """ - Check token expiration with timezone awareness - """ - if not self.expires: - return True - - return timezone.now() >= self.expires - - def allow_scopes(self, scopes): - """ - Check if the token allows the provided scopes - - :param scopes: An iterable containing the scopes to check - """ - if not scopes: - return True - - provided_scopes = set(self.scope.split()) - resource_scopes = set(scopes) - - return resource_scopes.issubset(provided_scopes) - - def revoke(self): - """ - Convenience method to uniform tokens' interface, for now - simply remove this token from the database in order to revoke it. - """ - self.delete() - - @property - def scopes(self): - """ - Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) - """ - all_scopes = get_scopes_backend().get_all_scopes() - token_scopes = self.scope.split() - return {name: desc for name, desc in all_scopes.items() if name in token_scopes} - - @property - def claims(self): - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - jwt_token = jwt.JWT(key=key, jwt=self.token) - return json.loads(jwt_token.claims) - - def __str__(self): - return self.token - - class Meta: - abstract = True - - -class IDToken(AbstractIDToken): - class Meta(AbstractIDToken.Meta): - swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" - - def get_application_model(): """ Return the Application model that is active in this project. """ return apps.get_model(oauth2_settings.APPLICATION_MODEL) @@ -539,11 +430,6 @@ def get_access_token_model(): return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) -def get_id_token_model(): - """ Return the AccessToken model that is active in this project. """ - return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) - - def get_refresh_token_model(): """ Return the RefreshToken model that is active in this project. """ return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 404add70e..6d8e68a2c 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -104,7 +104,7 @@ def validate_authorization_request(self, request): except oauth2.OAuth2Error as error: raise OAuthToolkitError(error=error) - def create_authorization_response(self, uri, request, scopes, credentials, body, allow): + def create_authorization_response(self, request, scopes, credentials, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -112,8 +112,7 @@ def create_authorization_response(self, uri, request, scopes, credentials, body, :param request: The current django.http.HttpRequest object :param scopes: A list of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri` and `response_type` - :param body: Other body parameters not used in credentials dictionary + `client_id`, `state`, `redirect_uri`, `response_type` :param allow: True if the user authorize the client, otherwise False """ try: @@ -125,10 +124,10 @@ def create_authorization_response(self, uri, request, scopes, credentials, body, credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=uri, scopes=scopes, credentials=credentials, body=body) - redirect_uri = headers.get("Location", None) + uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) + uri = headers.get("Location", None) - return redirect_uri, headers, body, status + return uri, headers, body, status except oauth2.FatalClientError as error: raise FatalClientError( @@ -167,21 +166,6 @@ def create_revocation_response(self, request): return uri, headers, body, status - def create_userinfo_response(self, request): - """ - A wrapper method that calls create_userinfo_response on a - `server_class` instance. - - :param request: The current django.http.HttpRequest object - """ - uri, http_method, body, headers = self._extract_params(request) - headers, body, status = self.server.create_userinfo_response( - uri, http_method, body, headers - ) - uri = headers.get("Location", None) - - return uri, headers, body, status - def verify_request(self, request, scopes): """ A wrapper method that calls verify_request on `server_class` instance. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index e7fb860b3..515353d6f 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,8 +1,6 @@ import base64 import binascii -import hashlib import http.client -import json import logging from collections import OrderedDict from datetime import datetime, timedelta @@ -14,21 +12,15 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q -from django.http import HttpRequest -from django.urls import reverse -from django.utils import dateformat, timezone +from django.utils import timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ -from jwcrypto import jwk, jwt -from jwcrypto.common import JWException -from jwcrypto.jwt import JWTExpired from oauthlib.oauth2 import RequestValidator -from oauthlib.oauth2.rfc6749 import utils from .exceptions import FatalClientError from .models import ( - AbstractApplication, get_access_token_model, get_application_model, - get_grant_model, get_id_token_model, get_refresh_token_model + AbstractApplication, get_access_token_model, + get_application_model, get_grant_model, get_refresh_token_model ) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -37,23 +29,18 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": ( - AbstractApplication.GRANT_AUTHORIZATION_CODE, - AbstractApplication.GRANT_OPENID_HYBRID, - ), - "password": (AbstractApplication.GRANT_PASSWORD,), - "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), + "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), + "password": (AbstractApplication.GRANT_PASSWORD, ), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, - AbstractApplication.GRANT_OPENID_HYBRID, - ), + ) } Application = get_application_model() AccessToken = get_access_token_model() -IDToken = get_id_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() @@ -106,15 +93,12 @@ def _authenticate_basic_auth(self, request): except UnicodeDecodeError: log.debug( "Failed basic auth: %r can't be decoded as unicode by %r", - auth_string, - encoding, + auth_string, encoding ) return False try: - client_id, client_secret = map( - unquote_plus, auth_string_decoded.split(":", 1) - ) + client_id, client_secret = map(unquote_plus, auth_string_decoded.split(":", 1)) except ValueError: log.debug("Failed basic auth, Invalid base64 encoding.") return False @@ -163,54 +147,35 @@ def _load_application(self, client_id, request): """ # we want to be sure that request has the client attribute! - assert hasattr( - request, "client" - ), '"request" instance has no "client" attribute' + assert hasattr(request, "client"), '"request" instance has no "client" attribute' try: - request.client = request.client or Application.objects.get( - client_id=client_id - ) + request.client = request.client or Application.objects.get(client_id=client_id) # Check that the application can be used (defaults to always True) if not request.client.is_usable(request): - log.debug( - "Failed body authentication: Application %r is disabled" - % (client_id) - ) + log.debug("Failed body authentication: Application %r is disabled" % (client_id)) return None return request.client except Application.DoesNotExist: - log.debug( - "Failed body authentication: Application %r does not exist" - % (client_id) - ) + log.debug("Failed body authentication: Application %r does not exist" % (client_id)) return None def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: - error = OrderedDict( - [ - ("error", "invalid_token",), - ("error_description", _("The access token is invalid."),), - ] - ) + error = OrderedDict([ + ("error", "invalid_token", ), + ("error_description", _("The access token is invalid."), ), + ]) elif access_token.is_expired(): - error = OrderedDict( - [ - ("error", "invalid_token",), - ("error_description", _("The access token has expired."),), - ] - ) + error = OrderedDict([ + ("error", "invalid_token", ), + ("error_description", _("The access token has expired."), ), + ]) elif not access_token.allow_scopes(scopes): - error = OrderedDict( - [ - ("error", "insufficient_scope",), - ( - "error_description", - _("The access token is valid but does not have enough scope."), - ), - ] - ) + error = OrderedDict([ + ("error", "insufficient_scope", ), + ("error_description", _("The access token is valid but does not have enough scope."), ), + ]) else: log.warning("OAuth2 access token is invalid for an unknown reason.") error = OrderedDict([ @@ -276,15 +241,11 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug( - "Application %r has type %r" % (client_id, request.client.client_type) - ) + log.debug("Application %r has type %r" % (client_id, request.client.client_type)) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False - def confirm_redirect_uri( - self, client_id, code, redirect_uri, client, *args, **kwargs - ): + def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **kwargs): """ Ensure the redirect_uri is listed in the Application instance redirect_uris field """ @@ -309,7 +270,7 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( - self, token, introspection_url, introspection_token, introspection_credentials + self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL @@ -337,12 +298,11 @@ def _get_token_from_authentication_server( try: response = requests.post( - introspection_url, data={"token": token}, headers=headers + introspection_url, + data={"token": token}, headers=headers ) except requests.exceptions.RequestException: - log.exception( - "Introspection: Failed POST to %r in token lookup", introspection_url - ) + log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) return None # Log an exception when response from auth server is not successful @@ -388,8 +348,7 @@ def _get_token_from_authentication_server( "application": None, "scope": scope, "expires": expires, - }, - ) + }) return access_token @@ -402,14 +361,10 @@ def validate_bearer_token(self, token, scopes, request): introspection_url = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN - introspection_credentials = ( - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS - ) + introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS try: - access_token = AccessToken.objects.select_related( - "application", "user" - ).get(token=token) + access_token = AccessToken.objects.select_related("application", "user").get(token=token) except AccessToken.DoesNotExist: access_token = None @@ -420,7 +375,7 @@ def validate_bearer_token(self, token, scopes, request): token, introspection_url, introspection_token, - introspection_credentials, + introspection_credentials ) if access_token and access_token.is_valid(scopes): @@ -447,38 +402,22 @@ def validate_code(self, client_id, code, client, request, *args, **kwargs): except Grant.DoesNotExist: return False - def validate_grant_type( - self, client_id, grant_type, client, request, *args, **kwargs - ): + def validate_grant_type(self, client_id, grant_type, client, request, *args, **kwargs): """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ - assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration + assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) - def validate_response_type( - self, client_id, response_type, client, request, *args, **kwargs - ): + def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): """ We currently do not support the Authorization Endpoint Response Types registry as in rfc:`8.4`, so validate the response_type only if it matches "code" or "token" """ if response_type == "code": - return client.allows_grant_type( - AbstractApplication.GRANT_AUTHORIZATION_CODE - ) + return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) - elif response_type == "id_token": - return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) - elif response_type == "id_token token": - return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) - elif response_type == "code id_token": - return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) - elif response_type == "code token": - return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) - elif response_type == "code id_token token": - return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) else: return False @@ -486,15 +425,11 @@ def validate_scopes(self, client_id, scopes, client, request, *args, **kwargs): """ Ensure required scopes are permitted (as specified in the settings file) """ - available_scopes = get_scopes_backend().get_available_scopes( - application=client, request=request - ) + available_scopes = get_scopes_backend().get_available_scopes(application=client, request=request) return set(scopes).issubset(set(available_scopes)) def get_default_scopes(self, client_id, request, *args, **kwargs): - default_scopes = get_scopes_backend().get_default_scopes( - application=request.client, request=request - ) + default_scopes = get_scopes_backend().get_default_scopes(application=request.client, request=request) return default_scopes def validate_redirect_uri(self, client_id, redirect_uri, request, *args, **kwargs): @@ -522,24 +457,6 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): self._create_authorization_code(request, code) - def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): - scopes = [] - fields = { - "code": code, - } - - if client_id: - fields["application__client_id"] = client_id - - if redirect_uri: - fields["redirect_uri"] = redirect_uri - - grant = Grant.objects.filter(**fields).values() - if grant.exists(): - grant_dict = dict(grant[0]) - scopes = utils.scope_to_list(grant_dict["scope"]) - return scopes - def rotate_refresh_token(self, request): """ Checks if rotate refresh token is enabled @@ -580,11 +497,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so - if ( - not self.rotate_refresh_token(request) - and isinstance(refresh_token_instance, RefreshToken) - and refresh_token_instance.access_token - ): + if not self.rotate_refresh_token(request) and \ + isinstance(refresh_token_instance, RefreshToken) and \ + refresh_token_instance.access_token: access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk @@ -631,18 +546,14 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - self._create_refresh_token( - request, refresh_token_code, access_token - ) + self._create_refresh_token(request, refresh_token_code, access_token) else: # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = ( - RefreshToken.objects.filter(access_token=previous_access_token) - .first() - .token - ) + token["refresh_token"] = RefreshToken.objects.filter( + access_token=previous_access_token + ).first().token token["scope"] = previous_access_token.scope # No refresh token should be created, just access token @@ -650,15 +561,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): self._create_access_token(expires, request, token) def _create_access_token(self, expires, request, token, source_refresh_token=None): - id_token = token.get("id_token", None) - if id_token: - id_token = IDToken.objects.get(token=id_token) return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], - id_token=id_token, application=request.client, source_refresh_token=source_refresh_token, ) @@ -683,7 +590,7 @@ def _create_refresh_token(self, request, refresh_token_code, access_token): user=request.user, token=refresh_token_code, application=request.client, - access_token=access_token, + access_token=access_token ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -736,8 +643,9 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs """ null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) + revoked__gt=timezone.now() - timedelta( + seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS + ) ) rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( "access_token" @@ -751,183 +659,3 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt return rt.application == client - - @transaction.atomic - def _save_id_token(self, token, request, expires, *args, **kwargs): - - scopes = request.scope or " ".join(request.scopes) - - if request.grant_type == "client_credentials": - request.user = None - - id_token = IDToken.objects.create( - user=request.user, - scope=scopes, - expires=expires, - token=token.serialize(), - application=request.client, - ) - return id_token - - def get_jwt_bearer_token(self, token, token_handler, request): - return self.get_id_token(token, token_handler, request) - - def get_oidc_claims(self, token, token_handler, request): - # Required OIDC claims - claims = { - "sub": str(request.user.id), - } - - # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - claims.update(**self.get_additional_claims(request)) - - return claims - - def get_id_token_dictionary(self, token, token_handler, request): - # TODO: http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken2 - # Save the id_token on database bound to code when the request come to - # Authorization Endpoint and return the same one when request come to - # Token Endpoint - - # TODO: Check if at this point this request parameters are alredy validated - claims = self.get_oidc_claims(token, token_handler, request) - - expiration_time = timezone.now() + timedelta( - seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS - ) - # Required ID Token claims - claims.update(**{ - "iss": self.get_oidc_issuer_endpoint(request), - "aud": request.client_id, - "exp": int(dateformat.format(expiration_time, "U")), - "iat": int(dateformat.format(datetime.utcnow(), "U")), - "auth_time": int(dateformat.format(request.user.last_login, "U")), - }) - - nonce = getattr(request, "nonce", None) - if nonce: - claims["nonce"] = nonce - - # TODO: create a function to check if we should add at_hash - # http://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken - # http://openid.net/specs/openid-connect-core-1_0.html#ImplicitIDToken - # if request.grant_type in 'authorization_code' and 'access_token' in token: - if ( - (request.grant_type == "authorization_code" and "access_token" in token) - or request.response_type == "code id_token token" - or (request.response_type == "id_token token" and "access_token" in token) - ): - acess_token = token["access_token"] - at_hash = self.generate_at_hash(acess_token) - claims["at_hash"] = at_hash - - # TODO: create a function to check if we should include c_hash - # http://openid.net/specs/openid-connect-core-1_0.html#HybridIDToken - if request.response_type in ("code id_token", "code id_token token"): - code = token["code"] - sha256 = hashlib.sha256(code.encode("ascii")) - bits256 = sha256.hexdigest()[:32] - c_hash = base64.urlsafe_b64encode(bits256.encode("ascii")) - claims["c_hash"] = c_hash.decode("utf8") - - return claims, expiration_time - - def get_oidc_issuer_endpoint(self, request): - if oauth2_settings.OIDC_ISS_ENDPOINT: - return oauth2_settings.OIDC_ISS_ENDPOINT - - # generate it based on known URL - django_request = HttpRequest() - django_request.META = request.headers - - abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) - base_url = abs_url[:-len("/.well-known/openid-configuration/")] - return base_url - - def generate_at_hash(self, access_token): - sha256 = hashlib.sha256(access_token.encode("ascii")) - bits128 = sha256.digest()[:16] - at_hash = base64.urlsafe_b64encode(bits128).decode("utf8").rstrip("=") - return at_hash - - def get_id_token(self, token, token_handler, request): - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - - claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) - - jwt_token = jwt.JWT( - header=json.dumps({"alg": "RS256"}, default=str), - claims=json.dumps(claims, default=str), - ) - jwt_token.make_signed_token(key) - - id_token = self._save_id_token(jwt_token, request, expiration_time) - # this is needed by django rest framework - request.access_token = id_token - request.id_token = id_token - return jwt_token.serialize() - - def validate_jwt_bearer_token(self, token, scopes, request): - return self.validate_id_token(token, scopes, request) - - def validate_id_token(self, token, scopes, request): - """ - When users try to access resources, check that provided id_token is valid - """ - if not token: - return False - - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - - try: - jwt_token = jwt.JWT(key=key, jwt=token) - id_token = IDToken.objects.get(token=jwt_token.serialize()) - request.client = id_token.application - request.user = id_token.user - request.scopes = scopes - # this is needed by django rest framework - request.access_token = id_token - return True - except (JWException, JWTExpired): - # TODO: This is the base exception of all jwcrypto - return False - - return False - - def validate_user_match(self, id_token_hint, scopes, claims, request): - # TODO: Fix to validate when necessary acording - # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 - # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section - return True - - def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): - """ Extracts nonce from saved authorization code. - If present in the Authentication Request, Authorization - Servers MUST include a nonce Claim in the ID Token with the - Claim Value being the nonce value sent in the Authentication - Request. Authorization Servers SHOULD perform no other - processing on nonce values used. The nonce value is a - case-sensitive string. - Only code param should be sufficient to retrieve grant code from - any storage you are using. However, `client_id` and `redirect_uri` - have been validated and can be used also. - :param client_id: Unicode client identifier - :param code: Unicode authorization code grant - :param redirect_uri: Unicode absolute URI - :return: Unicode nonce - Method is used by: - - Authorization Token Grant Dispatcher - """ - # TODO: Fix this ;) - return "" - - def get_userinfo_claims(self, request): - """ - Generates and saves a new JWT for this request, and returns it as the - current user's claims. - - """ - return self.get_oidc_claims(None, None, request) - - def get_additional_claims(self, request): - return {} diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index d3d60801e..0135da8b7 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -23,19 +23,10 @@ USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) -APPLICATION_MODEL = getattr( - settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application" -) -ACCESS_TOKEN_MODEL = getattr( - settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken" -) -ID_TOKEN_MODEL = getattr( - settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken" -) +APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") +ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") -REFRESH_TOKEN_MODEL = getattr( - settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken" -) +REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") DEFAULTS = { "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", @@ -44,7 +35,7 @@ "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, - "OAUTH2_SERVER_CLASS": "oauthlib.openid.connect.core.endpoints.pre_configured.Server", + "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, @@ -54,46 +45,29 @@ "WRITE_SCOPE": "write", "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, - "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, - "ID_TOKEN_MODEL": ID_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - "OIDC_ISS_ENDPOINT": "", - "OIDC_USERINFO_ENDPOINT": "", - "OIDC_RSA_PRIVATE_KEY": "", - "OIDC_RESPONSE_TYPES_SUPPORTED": [ - "code", - "token", - "id_token", - "id_token token", - "code token", - "code id_token", - "code id_token token", - ], - "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], - "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED": ["RS256", "HS256"], - "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ - "client_secret_post", - "client_secret_basic", - ], + # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], + # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, + # Whether or not PKCE is required - "PKCE_REQUIRED": False, + "PKCE_REQUIRED": False } # List of settings that cannot be empty @@ -105,11 +79,6 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", - "OIDC_RSA_PRIVATE_KEY", - "OIDC_RESPONSE_TYPES_SUPPORTED", - "OIDC_SUBJECT_TYPES_SUPPORTED", - "OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED", - "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", ) # List of settings that may be in string import notation. @@ -148,12 +117,7 @@ def import_from_string(val, setting_name): module = importlib.import_module(module_path) return getattr(module, class_name) except ImportError as e: - msg = "Could not import %r for setting %r. %s: %s." % ( - val, - setting_name, - e.__class__.__name__, - e, - ) + msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) raise ImportError(msg) @@ -165,9 +129,7 @@ class OAuth2ProviderSettings: and return the class, rather than the string literal. """ - def __init__( - self, user_settings=None, defaults=None, import_strings=None, mandatory=None - ): + def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): self.user_settings = user_settings or {} self.defaults = defaults or {} self.import_strings = import_strings or () @@ -202,9 +164,7 @@ def __getattr__(self, attr): if scope in self._SCOPES: val.append(scope) else: - raise ImproperlyConfigured( - "Defined DEFAULT_SCOPES not present in SCOPES" - ) + raise ImproperlyConfigured("Defined DEFAULT_SCOPES not present in SCOPES") self.validate_setting(attr, val) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index f2f04d853..4cf6d4c6d 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -27,12 +27,5 @@ name="authorized-token-delete"), ] -oidc_urlpatterns = [ - re_path(r"^\.well-known/openid-configuration/$", views.ConnectDiscoveryInfoView.as_view(), - name="oidc-connect-discovery-info"), - re_path(r"^jwks/$", views.JwksInfoView.as_view(), name="jwks-info"), - re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info") -] - -urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns +urlpatterns = base_urlpatterns + management_urlpatterns diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 9f2ac4ff7..7636bd9c7 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,13 +1,9 @@ # flake8: noqa -from .application import ( - ApplicationDelete, ApplicationDetail, ApplicationList, - ApplicationRegistration, ApplicationUpdate -) -from .base import AuthorizationView, RevokeTokenView, TokenView +from .base import AuthorizationView, TokenView, RevokeTokenView +from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ + ApplicationDelete, ApplicationUpdate from .generic import ( - ProtectedResourceView, ReadWriteScopedResourceView, - ScopedProtectedResourceView -) + ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView, + ClientProtectedResourceView, ClientProtectedScopedResourceView) +from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView from .introspect import IntrospectTokenView -from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView -from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index b38c907ab..c925493f5 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -32,7 +32,7 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris", "algorithm", + "authorization_grant_type", "redirect_uris" ) ) @@ -81,6 +81,6 @@ def get_form_class(self): get_application_model(), fields=( "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris", "algorithm", + "authorization_grant_type", "redirect_uris" ) ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index eb825c307..b9b6ed7f9 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -86,7 +86,6 @@ class AuthorizationView(BaseAuthorizationView, FormView): * Authorization code * Implicit grant """ - template_name = "oauth2_provider/authorize.html" form_class = AllowForm @@ -102,14 +101,11 @@ def get_initial(self): initial_data = { "redirect_uri": self.oauth2_data.get("redirect_uri", None), "scope": " ".join(scopes), - "nonce": self.oauth2_data.get("nonce", None), "client_id": self.oauth2_data.get("client_id", None), "state": self.oauth2_data.get("state", None), "response_type": self.oauth2_data.get("response_type", None), "code_challenge": self.oauth2_data.get("code_challenge", None), - "code_challenge_method": self.oauth2_data.get( - "code_challenge_method", None - ), + "code_challenge_method": self.oauth2_data.get("code_challenge_method", None), } return initial_data @@ -120,27 +116,18 @@ def form_valid(self, form): "client_id": form.cleaned_data.get("client_id"), "redirect_uri": form.cleaned_data.get("redirect_uri"), "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None), + "state": form.cleaned_data.get("state", None) } if form.cleaned_data.get("code_challenge", False): credentials["code_challenge"] = form.cleaned_data.get("code_challenge") if form.cleaned_data.get("code_challenge_method", False): - credentials["code_challenge_method"] = form.cleaned_data.get( - "code_challenge_method" - ) - - body = {"nonce": form.cleaned_data.get("nonce")} + credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method") scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") try: uri, headers, body, status = self.create_authorization_response( - self.request.get_raw_uri(), - request=self.request, - scopes=scopes, - credentials=credentials, - body=body, - allow=allow, + request=self.request, scopes=scopes, credentials=credentials, allow=allow ) except OAuthToolkitError as error: return self.error_response(error, application) @@ -162,21 +149,13 @@ def get(self, request, *args, **kwargs): # at this point we know an Application instance with such client_id exists in the database # TODO: Cache this! - application = get_application_model().objects.get( - client_id=credentials["client_id"] - ) - - uri_query = urllib.parse.urlparse(self.request.get_raw_uri()).query - uri_query_params = dict( - urllib.parse.parse_qsl(uri_query, keep_blank_values=True, strict_parsing=True) - ) + application = get_application_model().objects.get(client_id=credentials["client_id"]) kwargs["application"] = application kwargs["client_id"] = credentials["client_id"] kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] - kwargs["nonce"] = uri_query_params.get("nonce", None) self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 @@ -185,9 +164,7 @@ def get(self, request, *args, **kwargs): # Check to see if the user has already granted access and return # a successful response depending on "approval_prompt" url parameter - require_approval = request.GET.get( - "approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT - ) + require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) try: # If skip_authorization field is True, skip the authorization screen even @@ -196,36 +173,26 @@ def get(self, request, *args, **kwargs): # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( - self.request.get_raw_uri(), - request=self.request, - scopes=" ".join(scopes), - credentials=credentials, - allow=True, + request=self.request, scopes=" ".join(scopes), + credentials=credentials, allow=True ) return self.redirect(uri, application) elif require_approval == "auto": - tokens = ( - get_access_token_model() - .objects.filter( - user=request.user, - application=kwargs["application"], - expires__gt=timezone.now(), - ) - .all() - ) + tokens = get_access_token_model().objects.filter( + user=request.user, + application=kwargs["application"], + expires__gt=timezone.now() + ).all() # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( - self.request.get_raw_uri(), - request=self.request, - scopes=" ".join(scopes), - credentials=credentials, - allow=True, + request=self.request, scopes=" ".join(scopes), + credentials=credentials, allow=True ) - return self.redirect(uri, application) + return self.redirect(uri, application, token) except OAuthToolkitError as error: return self.error_response(error, application) @@ -272,7 +239,6 @@ class TokenView(OAuthLibMixin, View): * Password * Client credentials """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS @@ -283,8 +249,11 @@ def post(self, request, *args, **kwargs): if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get(token=access_token) - app_authorized.send(sender=self, request=request, token=token) + token = get_access_token_model().objects.get( + token=access_token) + app_authorized.send( + sender=self, request=request, + token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): @@ -297,7 +266,6 @@ class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 460a1395d..7d4381179 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views.generic import ClientProtectedScopedResourceView +from oauth2_provider.views import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 0b7e02c7a..b5d0d4145 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -97,7 +97,7 @@ def validate_authorization_request(self, request): core = self.get_oauthlib_core() return core.validate_authorization_request(request) - def create_authorization_response(self, uri, request, scopes, credentials, allow, body=None): + def create_authorization_response(self, request, scopes, credentials, allow): """ A wrapper method that calls create_authorization_response on `server_class` instance. @@ -105,15 +105,14 @@ def create_authorization_response(self, uri, request, scopes, credentials, allow :param request: The current django.http.HttpRequest object :param scopes: A space-separated string of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri` and `response_type` + `client_id`, `state`, `redirect_uri`, `response_type` :param allow: True if the user authorize the client, otherwise False - :param body: Other body parameters not used in credentials dictionary """ # TODO: move this scopes conversion from and to string into a utils function scopes = scopes.split(" ") if scopes else [] core = self.get_oauthlib_core() - return core.create_authorization_response(uri, request, scopes, credentials, body, allow) + return core.create_authorization_response(request, scopes, credentials, allow) def create_token_response(self, request): """ @@ -134,16 +133,6 @@ def create_revocation_response(self, request): core = self.get_oauthlib_core() return core.create_revocation_response(request) - def create_userinfo_response(self, request): - """ - A wrapper method that calls create_userinfo_response on the - `server_class` instance. - - :param request: The current django.http.HttpRequest object - """ - core = self.get_oauthlib_core() - return core.create_userinfo_response(request) - def verify_request(self, request): """ A wrapper method that calls verify_request on `server_class` instance. @@ -288,13 +277,11 @@ def dispatch(self, request, *args, **kwargs): if not valid: # Alternatively allow access tokens # check if the request is valid and the protected resource may be accessed - try: - valid, r = self.verify_request(request) - if valid: - request.resource_owner = r.user - return super().dispatch(request, *args, **kwargs) - except ValueError: - pass - return HttpResponseForbidden() + valid, r = self.verify_request(request) + if valid: + request.resource_owner = r.user + return super().dispatch(request, *args, **kwargs) + else: + return HttpResponseForbidden() else: return super().dispatch(request, *args, **kwargs) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py deleted file mode 100644 index d7ffe4670..000000000 --- a/oauth2_provider/views/oidc.py +++ /dev/null @@ -1,95 +0,0 @@ -from __future__ import absolute_import, unicode_literals - -import json - -from django.http import HttpResponse, JsonResponse -from django.urls import reverse, reverse_lazy -from django.utils.decorators import method_decorator -from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View -from jwcrypto import jwk - -from ..settings import oauth2_settings -from .mixins import OAuthLibMixin - - -class ConnectDiscoveryInfoView(View): - """ - View used to show oidc provider configuration information - """ - def get(self, request, *args, **kwargs): - issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT - - if not issuer_url: - abs_url = request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) - issuer_url = abs_url[:-len("/.well-known/openid-configuration/")] - - authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) - token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) - userinfo_endpoint = ( - oauth2_settings.OIDC_USERINFO_ENDPOINT or - request.build_absolute_uri(reverse("oauth2_provider:user-info")) - ) - jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) - else: - authorization_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:authorize")) - token_endpoint = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:token")) - userinfo_endpoint = ( - oauth2_settings.OIDC_USERINFO_ENDPOINT or - "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:user-info")) - ) - jwks_uri = "{}{}".format(issuer_url, reverse_lazy("oauth2_provider:jwks-info")) - - data = { - "issuer": issuer_url, - "authorization_endpoint": authorization_endpoint, - "token_endpoint": token_endpoint, - "userinfo_endpoint": userinfo_endpoint, - "jwks_uri": jwks_uri, - "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, - "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, - "id_token_signing_alg_values_supported": - oauth2_settings.OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, - "token_endpoint_auth_methods_supported": - oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, - } - response = JsonResponse(data) - response["Access-Control-Allow-Origin"] = "*" - return response - - -class JwksInfoView(View): - """ - View used to show oidc json web key set document - """ - def get(self, request, *args, **kwargs): - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - data = { - "keys": [{ - "alg": "RS256", - "use": "sig", - "kid": key.thumbprint() - }] - } - data["keys"][0].update(json.loads(key.export_public())) - response = JsonResponse(data) - response["Access-Control-Allow-Origin"] = "*" - return response - - -@method_decorator(csrf_exempt, name="dispatch") -class UserInfoView(OAuthLibMixin, View): - """ - View used to show Claims about the authenticated End-User - """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - - def get(self, request, *args, **kwargs): - url, headers, body, status = self.create_userinfo_response(request) - response = HttpResponse(content=body or "", status=status) - - for k, v in headers.items(): - response[k] = v - return response diff --git a/setup.cfg b/setup.cfg index fb060f88e..3c4e0badc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -34,7 +34,6 @@ install_requires = django >= 2.1 requests >= 2.13.0 oauthlib >= 3.1.0 - jwcrypto >= 0.4.2 [options.packages.find] exclude = tests diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index eef6dbab5..60b17f2ae 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -45,7 +45,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -53,7 +53,6 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('custom_field', models.CharField(max_length=255)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, @@ -72,7 +71,6 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), - ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), ], options={ 'abstract': False, @@ -85,7 +83,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -93,7 +91,6 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('allowed_schemes', models.TextField(blank=True)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, diff --git a/tests/settings.py b/tests/settings.py index edd1ae679..40eef5ebd 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -130,30 +130,3 @@ }, } } - -OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- -MIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT -j0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP -0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB -AoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77 -+IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju -YBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn -2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq -MH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el -fVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc -uEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67 -ZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT -qoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr -dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY ------END RSA PRIVATE KEY-----""" - -OAUTH2_PROVIDER = { - "OIDC_ISS_ENDPOINT": "http://localhost", - "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", - "OIDC_RSA_PRIVATE_KEY": OIDC_RSA_PRIVATE_KEY, -} - -OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" -OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" -OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" -OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 64e112da3..6130876ce 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -50,7 +50,6 @@ def test_application_registration_user(self): "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, - "algorithm": "RS256", } response = self.client.post(reverse("oauth2_provider:register"), form_data) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index e4eb8ae81..e98f5b041 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -41,12 +41,8 @@ def get(self, request, *args, **kwargs): class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user( - "test_user", "test@example.com", "123456" - ) - self.dev_user = UserModel.objects.create_user( - "dev_user", "dev@example.com", "123456" - ) + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] @@ -61,13 +57,8 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write", "openid"] + oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect", - } def tearDown(self): self.application.delete() @@ -112,25 +103,6 @@ def test_skip_authorization_completely(self): }) self.assertEqual(response.status_code, 302) - def test_id_token_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_data = { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - - response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 302) - def test_pre_auth_invalid_client(self): """ Test error for an invalid client_id with response_type: code @@ -175,32 +147,6 @@ def test_pre_auth_valid_client(self): self.assertEqual(form["scope"].value(), "read write") self.assertEqual(form["client_id"].value(), self.application.client_id) - def test_id_token_pre_auth_valid_client(self): - """ - Test response for a valid client_id with response_type: code - """ - self.client.login(username="test_user", password="123456") - - query_data = { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - - response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://example.org") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "openid") - self.assertEqual(form["client_id"].value(), self.application.client_id) - def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): """ Test response for a valid client_id with response_type: code @@ -230,11 +176,10 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( - user=self.test_user, - token="1234567890", + user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write", + scope="read write" ) self.client.login(username="test_user", password="123456") @@ -259,11 +204,10 @@ def test_pre_auth_approval_prompt_default(self): self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( - user=self.test_user, - token="1234567890", + user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write", + scope="read write" ) self.client.login(username="test_user", password="123456") query_data = { @@ -280,11 +224,10 @@ def test_pre_auth_approval_prompt_default_override(self): oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( - user=self.test_user, - token="1234567890", + user=self.test_user, token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write", + scope="read write" ) self.client.login(username="test_user", password="123456") query_data = { @@ -359,32 +302,7 @@ def test_code_post_auth_allow(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org?", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - - def test_id_token_code_post_auth_allow(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - } - - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -405,9 +323,7 @@ def test_code_post_auth_deny(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -426,9 +342,7 @@ def test_code_post_auth_deny_no_state(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("error=access_denied", response["Location"]) self.assertNotIn("state", response["Location"]) @@ -448,9 +362,7 @@ def test_code_post_auth_bad_responsetype(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.org?error", response["Location"]) @@ -469,9 +381,7 @@ def test_code_post_auth_forbidden_redirect_uri(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_malicious_redirect_uri(self): @@ -489,9 +399,7 @@ def test_code_post_auth_malicious_redirect_uri(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) def test_code_post_auth_allow_custom_redirect_uri_scheme(self): @@ -510,9 +418,7 @@ def test_code_post_auth_allow_custom_redirect_uri_scheme(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("state=random_state_string", response["Location"]) @@ -534,9 +440,7 @@ def test_code_post_auth_deny_custom_redirect_uri_scheme(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("custom-scheme://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -559,9 +463,7 @@ def test_code_post_auth_redirection_uri_with_querystring(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?foo=bar", response["Location"]) self.assertIn("code=", response["Location"]) @@ -584,9 +486,7 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): "allow": False, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 302) self.assertIn("http://example.com?", response["Location"]) self.assertIn("error=access_denied", response["Location"]) @@ -608,29 +508,25 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=form_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) self.assertEqual(response.status_code, 400) class TestAuthorizationCodeTokenView(BaseTest): - def get_auth(self, scope="read write"): + def get_auth(self): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", - "scope": scope, + "scope": "read write", "redirect_uri": "http://example.org", "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) return query_dict["code"].pop() @@ -640,13 +536,9 @@ def generate_pkce_codes(self, algorithm, length=43): """ code_verifier = get_random_string(length) if algorithm == "S256": - code_challenge = ( - base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ) - .decode() - .rstrip("=") - ) + code_challenge = base64.urlsafe_b64encode( + hashlib.sha256(code_verifier.encode()).digest() + ).decode().rstrip("=") else: code_challenge = code_verifier return code_verifier, code_challenge @@ -667,9 +559,7 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): "code_challenge_method": code_challenge_method, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) oauth2_settings.PKCE_REQUIRED = False return query_dict["code"].pop() @@ -684,23 +574,17 @@ def test_basic_auth(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_refresh(self): """ @@ -712,15 +596,11 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -729,29 +609,23 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) # check refresh token cannot be used twice - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) content = json.loads(response.content.decode("utf-8")) self.assertTrue("invalid_grant" in content.values()) @@ -767,15 +641,11 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -784,11 +654,9 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) token_request_data = { "grant_type": "refresh_token", @@ -796,9 +664,7 @@ def test_refresh_with_grace_period(self): "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -807,9 +673,7 @@ def test_refresh_with_grace_period(self): first_refresh_token = content["refresh_token"] # check access token returns same data if used twice, see #497 - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertTrue("access_token" in content) @@ -829,15 +693,11 @@ def test_refresh_invalidates_old_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) rt = content["refresh_token"] @@ -848,9 +708,7 @@ def test_refresh_invalidates_old_tokens(self): "refresh_token": rt, "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) refresh_token = RefreshToken.objects.filter(token=rt).first() @@ -867,15 +725,11 @@ def test_refresh_no_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -883,9 +737,7 @@ def test_refresh_no_scopes(self): "grant_type": "refresh_token", "refresh_token": content["refresh_token"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) @@ -901,15 +753,11 @@ def test_refresh_bad_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -918,9 +766,7 @@ def test_refresh_bad_scopes(self): "refresh_token": content["refresh_token"], "scope": "read write nuke", } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_fail_repeating_requests(self): @@ -933,15 +779,11 @@ def test_refresh_fail_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -950,13 +792,9 @@ def test_refresh_fail_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_refresh_repeating_requests(self): @@ -971,15 +809,11 @@ def test_refresh_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -988,26 +822,18 @@ def test_refresh_repeating_requests(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) # try refreshing outside the refresh window, see #497 rt = RefreshToken.objects.get(token=content["refresh_token"]) self.assertIsNotNone(rt.revoked) - rt.revoked = timezone.now() - datetime.timedelta( - minutes=10 - ) # instead of mocking out datetime + rt.revoked = timezone.now() - datetime.timedelta(minutes=10) # instead of mocking out datetime rt.save() - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 @@ -1021,15 +847,11 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) self.assertTrue("refresh_token" in content) @@ -1040,13 +862,9 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): } oauth2_settings.ROTATE_REFRESH_TOKEN = False - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) oauth2_settings.ROTATE_REFRESH_TOKEN = True @@ -1060,15 +878,11 @@ def test_basic_auth_bad_authcode(self): token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_granttype(self): @@ -1080,15 +894,11 @@ def test_basic_auth_bad_granttype(self): token_request_data = { "grant_type": "UNKNOWN", "code": "BLAH", - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_grant_expired(self): @@ -1097,27 +907,18 @@ def test_basic_auth_grant_expired(self): """ self.client.login(username="test_user", password="123456") g = Grant( - application=self.application, - user=self.test_user, - code="BLAH", - expires=timezone.now(), - redirect_uri="", - scope="", - ) + application=self.application, user=self.test_user, code="BLAH", + expires=timezone.now(), redirect_uri="", scope="") g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) def test_basic_auth_bad_secret(self): @@ -1130,13 +931,11 @@ def test_basic_auth_bad_secret(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_basic_auth_wrong_auth_type(self): @@ -1149,20 +948,16 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - user_pass = "{0}:{1}".format( - self.application.client_id, self.application.client_secret - ) + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 401) def test_request_body_params(self): @@ -1180,17 +975,13 @@ def test_request_body_params(self): "client_secret": self.application.client_secret, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ @@ -1206,52 +997,16 @@ def test_public(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id, + "client_id": self.application.client_id } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - - def test_id_token_public(self): - """ - Request an access token using client_type: public - """ - self.client.login(username="test_user", password="123456") - - self.application.client_type = Application.CLIENT_PUBLIC - self.application.save() - authorization_code = self.get_auth(scope="openid") - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - "client_id": self.application.client_id, - "scope": "openid", - } - - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_S256_authorize_get(self): """ @@ -1327,20 +1082,16 @@ def test_public_pkce_S256(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier, + "code_verifier": code_verifier } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain(self): @@ -1361,20 +1112,16 @@ def test_public_pkce_plain(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier, + "code_verifier": code_verifier } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_invalid_algorithm(self): @@ -1477,12 +1224,10 @@ def test_public_pkce_S256_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid", + "code_verifier": "invalid" } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1504,12 +1249,10 @@ def test_public_pkce_plain_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid", + "code_verifier": "invalid" } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1530,12 +1273,10 @@ def test_public_pkce_S256_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id, + "client_id": self.application.client_id } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1556,12 +1297,10 @@ def test_public_pkce_plain_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id, + "client_id": self.application.client_id } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) oauth2_settings.PKCE_REQUIRED = False @@ -1580,19 +1319,14 @@ def test_malicious_redirect_uri(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", - "client_id": self.application.client_id, + "client_id": self.application.client_id } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual( - data["error_description"], - oauthlib_errors.MismatchingRedirectURIError.description, - ) + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) def test_code_exchange_succeed_when_redirect_uri_match(self): """ @@ -1609,9 +1343,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1619,23 +1351,17 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar", + "redirect_uri": "http://example.org?foo=bar" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1652,9 +1378,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1662,26 +1386,17 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa", + "redirect_uri": "http://example.org?foo=baraa" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual( - data["error_description"], - oauthlib_errors.MismatchingRedirectURIError.description, - ) + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) - def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( - self, - ): + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): """ Tests code exchange succeed when redirect uri matches the one used for code request """ @@ -1698,9 +1413,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1708,72 +1421,17 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar", + "redirect_uri": "http://example.com?bar=baz&foo=bar" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) - - def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( - self, - ): - """ - Tests code exchange succeed when redirect uri matches the one used for code request - """ - self.client.login(username="test_user", password="123456") - self.application.redirect_uris = "http://localhost http://example.com?foo=bar" - self.application.save() - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.com?bar=baz&foo=bar", - "response_type": "code", - "allow": True, - } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) - query_dict = parse_qs(urlparse(response["Location"]).query) - authorization_code = query_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar", - } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) - - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_oob_as_html(self): """ @@ -1836,9 +1494,7 @@ def test_oob_as_json(self): "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) self.assertEqual(response.status_code, 200) self.assertRegex(response["Content-Type"], "^application/json") @@ -1855,17 +1511,13 @@ def test_oob_as_json(self): "client_secret": self.application.client_secret, } - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 200) content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual( - content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS - ) + self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) class TestAuthorizationCodeProtectedResource(BaseTest): @@ -1881,54 +1533,7 @@ def test_resource_access_allowed(self): "response_type": "code", "allow": True, } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) - query_dict = parse_qs(urlparse(response["Location"]).query) - authorization_code = query_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) - - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") - - def test_id_token_resource_access_allowed(self): - self.client.login(username="test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - } - response = self.client.post( - reverse("oauth2_provider:authorize"), data=authcode_data - ) + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) authorization_code = query_dict["code"].pop() @@ -1936,18 +1541,13 @@ def test_id_token_resource_access_allowed(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org", + "redirect_uri": "http://example.org" } - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret - ) + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - response = self.client.post( - reverse("oauth2_provider:token"), data=token_request_data, **auth_headers - ) + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) access_token = content["access_token"] - id_token = content["id_token"] # use token to access the resource auth_headers = { @@ -1960,17 +1560,6 @@ def test_id_token_resource_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") - # use id_token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + id_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") - def test_resource_access_deny(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "faketoken", @@ -1984,6 +1573,7 @@ def test_resource_access_deny(self): class TestDefaultScopes(BaseTest): + def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py deleted file mode 100644 index 1f45aeeec..000000000 --- a/tests/test_hybrid.py +++ /dev/null @@ -1,1264 +0,0 @@ -import base64 -import datetime -import json -from urllib.parse import parse_qs, urlencode, urlparse - -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase -from django.urls import reverse -from django.utils import timezone -from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors - -from oauth2_provider.models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model -) -from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ProtectedResourceView - -from .utils import get_basic_auth_header - - -Application = get_application_model() -AccessToken = get_access_token_model() -Grant = get_grant_model() -RefreshToken = get_refresh_token_model() -UserModel = get_user_model() - - -# mocking a protected resource view -class ResourceView(ProtectedResourceView): - def get(self, request, *args, **kwargs): - return "This is a protected resource" - - -class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") - self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] - - self.application = Application( - name="Hybrid Test Application", - redirect_uris=( - "http://localhost http://example.com http://example.org custom-scheme://example.com" - ), - user=self.hy_dev_user, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_OPENID_HYBRID, - ) - self.application.save() - - oauth2_settings._SCOPES = ["read", "write", "openid"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect" - } - - def tearDown(self): - self.application.delete() - self.hy_test_user.delete() - self.hy_dev_user.delete() - - -class TestRegressionIssue315Hybrid(BaseTest): - """ - Test to avoid regression for the issue 315: request object - was being reassigned when getting AuthorizationView - """ - - def test_request_is_not_overwritten_code_token(self): - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - assert "request" not in response.context_data - - def test_request_is_not_overwritten_code_id_token(self): - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - assert "request" not in response.context_data - - def test_request_is_not_overwritten_code_id_token_token(self): - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token token", - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - assert "request" not in response.context_data - - -class TestHybridView(BaseTest): - def test_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="hy_test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_id_token_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="hy_test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_pre_auth_invalid_client(self): - """ - Test error for an invalid client_id with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": "fakeclientid", - "response_type": "code", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 400) - self.assertEqual( - response.context_data["url"], - "?error=invalid_request&error_description=Invalid+client_id+parameter+value." - ) - - def test_pre_auth_valid_client(self): - """ - Test response for a valid client_id with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://example.org") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "read write") - self.assertEqual(form["client_id"].value(), self.application.client_id) - - def test_id_token_pre_auth_valid_client(self): - """ - Test response for a valid client_id with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "nonce": "nonce", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://example.org") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "openid") - self.assertEqual(form["client_id"].value(), self.application.client_id) - - def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): - """ - Test response for a valid client_id with response_type: code - using a non-standard, but allowed, redirect_uri scheme. - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "custom-scheme://example.com", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "read write") - self.assertEqual(form["client_id"].value(), self.application.client_id) - - def test_pre_auth_approval_prompt(self): - tok = AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" - ) - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "approval_prompt": "auto", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - # user already authorized the application, but with different scopes: prompt them. - tok.scope = "read" - tok.save() - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_pre_auth_approval_prompt_default(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" - self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") - - AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" - ) - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - def test_pre_auth_approval_prompt_default_override(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" - - AccessToken.objects.create( - user=self.hy_test_user, token="1234567890", - application=self.application, - expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" - ) - self.client.login(username="hy_test_user", password="123456") - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - - def test_pre_auth_default_redirect(self): - """ - Test for default redirect uri if omitted from query string with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code id_token", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://localhost") - - def test_pre_auth_forbibben_redirect(self): - """ - Test error when passing a forbidden redirect_uri in query string with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code", - "redirect_uri": "http://forbidden.it", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 400) - - def test_pre_auth_wrong_response_type(self): - """ - Test error when passing a wrong response_type in query string - """ - self.client.login(username="hy_test_user", password="123456") - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "WRONG", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 302) - self.assertIn("error=unsupported_response_type", response["Location"]) - - def test_code_post_auth_allow_code_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "response_type": "code token", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_code_post_auth_allow_code_id_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "response_type": "code id_token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - - def test_code_post_auth_allow_code_id_token_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "response_type": "code id_token token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_id_token_code_post_auth_allow(self): - """ - Test authorization code is given for an allowed request with response_type: code - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "code id_token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - - def test_code_post_auth_deny(self): - """ - Test error when resource owner deny access - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": False, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("error=access_denied", response["Location"]) - - def test_code_post_auth_bad_responsetype(self): - """ - Test authorization code is given for an allowed request with a response_type not supported - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - "response_type": "UNKNOWN", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org?error", response["Location"]) - - def test_code_post_auth_forbidden_redirect_uri(self): - """ - Test authorization code is given for an allowed request with a forbidden redirect_uri - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://forbidden.it", - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 400) - - def test_code_post_auth_malicious_redirect_uri(self): - """ - Test validation of a malicious redirect_uri - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "/../", - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 400) - - def test_code_post_auth_allow_custom_redirect_uri_scheme_code_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - using a non-standard, but allowed, redirect_uri scheme. - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "custom-scheme://example.com", - "response_type": "code token", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("custom-scheme://example.com", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - using a non-standard, but allowed, redirect_uri scheme. - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "custom-scheme://example.com", - "response_type": "code id_token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("custom-scheme://example.com", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - - def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(self): - """ - Test authorization code is given for an allowed request with response_type: code - using a non-standard, but allowed, redirect_uri scheme. - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "custom-scheme://example.com", - "response_type": "code id_token token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("custom-scheme://example.com", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_code_post_auth_deny_custom_redirect_uri_scheme(self): - """ - Test error when resource owner deny access - using a non-standard, but allowed, redirect_uri scheme. - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "custom-scheme://example.com", - "response_type": "code", - "allow": False, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("custom-scheme://example.com?", response["Location"]) - self.assertIn("error=access_denied", response["Location"]) - - def test_code_post_auth_redirection_uri_with_querystring_code_token(self): - """ - Tests that a redirection uri with query string is allowed - and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.com?foo=bar", - "response_type": "code token", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.com?foo=bar", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): - """ - Tests that a redirection uri with query string is allowed - and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.com?foo=bar", - "response_type": "code id_token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.com?foo=bar", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - - def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(self): - """ - Tests that a redirection uri with query string is allowed - and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.com?foo=bar", - "response_type": "code id_token token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.com?foo=bar", response["Location"]) - self.assertIn("code=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("access_token=", response["Location"]) - - def test_code_post_auth_failing_redirection_uri_with_querystring(self): - """ - Test that in case of error the querystring of the redirection uri is preserved - - See https://github.com/evonove/django-oauth-toolkit/issues/238 - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.com?foo=bar", - "response_type": "code", - "allow": False, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertEqual( - "http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"] - ) - - def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): - """ - Tests that a redirection uri is matched using scheme + netloc + path - """ - self.client.login(username="hy_test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.com/a?foo=bar", - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 400) - - -class TestHybridTokenView(BaseTest): - def get_auth(self, scope="read write"): - """ - Helper method to retrieve a valid authorization code - """ - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": scope, - "redirect_uri": "http://example.org", - "response_type": "code id_token", - "allow": True, - "nonce": "nonce", - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - return fragment_dict["code"].pop() - - def test_basic_auth(self): - """ - Request an access token using basic authentication for client authentication - """ - self.client.login(username="hy_test_user", password="123456") - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_basic_auth_bad_authcode(self): - """ - Request an access token using a bad authorization code - """ - self.client.login(username="hy_test_user", password="123456") - - token_request_data = { - "grant_type": "authorization_code", - "code": "BLAH", - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 400) - - def test_basic_auth_bad_granttype(self): - """ - Request an access token using a bad grant_type string - """ - self.client.login(username="hy_test_user", password="123456") - - token_request_data = { - "grant_type": "UNKNOWN", - "code": "BLAH", - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 400) - - def test_basic_auth_grant_expired(self): - """ - Request an access token using an expired grant token - """ - self.client.login(username="hy_test_user", password="123456") - g = Grant( - application=self.application, user=self.hy_test_user, code="BLAH", - expires=timezone.now(), redirect_uri="", scope="") - g.save() - - token_request_data = { - "grant_type": "authorization_code", - "code": "BLAH", - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 400) - - def test_basic_auth_bad_secret(self): - """ - Request an access token using basic authentication for client authentication - """ - self.client.login(username="hy_test_user", password="123456") - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) - - def test_basic_auth_wrong_auth_type(self): - """ - Request an access token using basic authentication for client authentication - """ - self.client.login(username="hy_test_user", password="123456") - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org" - } - - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) - auth_string = base64.b64encode(user_pass.encode("utf-8")) - auth_headers = { - "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) - - def test_request_body_params(self): - """ - Request an access token using client_type: public - """ - self.client.login(username="hy_test_user", password="123456") - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - "client_id": self.application.client_id, - "client_secret": self.application.client_secret, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_public(self): - """ - Request an access token using client_type: public - """ - self.client.login(username="hy_test_user", password="123456") - - self.application.client_type = Application.CLIENT_PUBLIC - self.application.save() - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - "client_id": self.application.client_id - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_id_token_public(self): - """ - Request an access token using client_type: public - """ - self.client.login(username="hy_test_user", password="123456") - - self.application.client_type = Application.CLIENT_PUBLIC - self.application.save() - authorization_code = self.get_auth(scope="openid") - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - "client_id": self.application.client_id, - "scope": "openid", - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_malicious_redirect_uri(self): - """ - Request an access token using client_type: public and ensure redirect_uri is - properly validated. - """ - self.client.login(username="hy_test_user", password="123456") - - self.application.client_type = Application.CLIENT_PUBLIC - self.application.save() - authorization_code = self.get_auth() - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "/../", - "client_id": self.application.client_id - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 400) - data = response.json() - self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) - - def test_code_exchange_succeed_when_redirect_uri_match(self): - """ - Tests code exchange succeed when redirect uri matches the one used for code request - """ - self.client.login(username="hy_test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org?foo=bar", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = fragment_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_code_exchange_fails_when_redirect_uri_does_not_match(self): - """ - Tests code exchange fails when redirect uri does not match the one used for code request - """ - self.client.login(username="hy_test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org?foo=bar", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - query_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = query_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 400) - data = response.json() - self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) - - def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): - """ - Tests code exchange succeed when redirect uri matches the one used for code request - """ - self.client.login(username="hy_test_user", password="123456") - self.application.redirect_uris = "http://localhost http://example.com?foo=bar" - self.application.save() - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.com?bar=baz&foo=bar", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = fragment_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): - """ - Tests code exchange succeed when redirect uri matches the one used for code request - """ - self.client.login(username="hy_test_user", password="123456") - self.application.redirect_uris = "http://localhost http://example.com?foo=bar" - self.application.save() - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.com?bar=baz&foo=bar", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = fragment_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar", - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "openid") - self.assertIn("access_token", content) - self.assertIn("id_token", content) - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - -class TestHybridProtectedResource(BaseTest): - def test_resource_access_allowed(self): - self.client.login(username="hy_test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid read write", - "redirect_uri": "http://example.org", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = fragment_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org" - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.hy_test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") - - def test_id_token_resource_access_allowed(self): - self.client.login(username="hy_test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "code token", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - fragment_dict = parse_qs(urlparse(response["Location"]).fragment) - authorization_code = fragment_dict["code"].pop() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": "http://example.org", - } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - id_token = content["id_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.hy_test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") - - # use id_token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + id_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.hy_test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") - - def test_resource_access_deny(self): - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + "faketoken", - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.hy_test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response.status_code, 403) - - -class TestDefaultScopesHybrid(BaseTest): - - def test_pre_auth_default_scopes(self): - """ - Test response for a valid client_id with response_type: code using default scopes - """ - self.client.login(username="hy_test_user", password="123456") - oauth2_settings._DEFAULT_SCOPES = ["read"] - - query_string = urlencode({ - "client_id": self.application.client_id, - "response_type": "code token", - "state": "random_state_string", - "redirect_uri": "http://example.org", - }) - url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) - - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - - # check form is in context and form params are valid - self.assertIn("form", response.context) - - form = response.context["form"] - self.assertEqual(form["redirect_uri"].value(), "http://example.org") - self.assertEqual(form["state"].value(), "random_state_string") - self.assertEqual(form["scope"].value(), "read") - self.assertEqual(form["client_id"].value(), self.application.client_id) - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 15ac7469d..b51d0e1da 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,10 +1,8 @@ -import json from urllib.parse import parse_qs, urlparse from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse -from jwcrypto import jwk, jwt from oauth2_provider.models import get_application_model from oauth2_provider.settings import oauth2_settings @@ -35,14 +33,8 @@ def setUp(self): authorization_grant_type=Application.GRANT_IMPLICIT, ) - oauth2_settings._SCOPES = ["read", "write", "openid"] + oauth2_settings._SCOPES = ["read", "write"] oauth2_settings._DEFAULT_SCOPES = ["read"] - oauth2_settings.SCOPES = { - "read": "Reading scope", - "write": "Writing scope", - "openid": "OpenID connect" - } - self.key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) def tearDown(self): self.application.delete() @@ -273,191 +265,3 @@ def test_resource_access_allowed(self): view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") - - -class TestOpenIDConnectImplicitFlow(BaseTest): - def test_id_token_post_auth_allow(self): - """ - Test authorization code is given for an allowed request with response_type: id_token - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "nonce": "random_nonce_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "id_token", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org#", response["Location"]) - self.assertNotIn("access_token=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - - uri_query = urlparse(response["Location"]).fragment - uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) - id_token = uri_query_params["id_token"][0] - jwt_token = jwt.JWT(key=self.key, jwt=id_token) - claims = json.loads(jwt_token.claims) - self.assertIn("nonce", claims) - self.assertNotIn("at_hash", claims) - - def test_id_token_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_data = { - "client_id": self.application.client_id, - "response_type": "id_token", - "state": "random_state_string", - "nonce": "random_nonce_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - - response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org#", response["Location"]) - self.assertNotIn("access_token=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - - uri_query = urlparse(response["Location"]).fragment - uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) - id_token = uri_query_params["id_token"][0] - jwt_token = jwt.JWT(key=self.key, jwt=id_token) - claims = json.loads(jwt_token.claims) - self.assertIn("nonce", claims) - self.assertNotIn("at_hash", claims) - - def test_id_token_skip_authorization_completely_missing_nonce(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_data = { - "client_id": self.application.client_id, - "response_type": "id_token", - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - - response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 302) - self.assertIn("error=invalid_request", response["Location"]) - self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) - - def test_id_token_post_auth_deny(self): - """ - Test error when resource owner deny access - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "nonce": "random_nonce_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "id_token", - "allow": False, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("error=access_denied", response["Location"]) - - def test_access_token_and_id_token_post_auth_allow(self): - """ - Test authorization code is given for an allowed request with response_type: token - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "nonce": "random_nonce_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "id_token token", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org#", response["Location"]) - self.assertIn("access_token=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - - uri_query = urlparse(response["Location"]).fragment - uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) - id_token = uri_query_params["id_token"][0] - jwt_token = jwt.JWT(key=self.key, jwt=id_token) - claims = json.loads(jwt_token.claims) - self.assertIn("nonce", claims) - self.assertIn("at_hash", claims) - - def test_access_token_and_id_token_skip_authorization_completely(self): - """ - If application.skip_authorization = True, should skip the authorization page. - """ - self.client.login(username="test_user", password="123456") - self.application.skip_authorization = True - self.application.save() - - query_data = { - "client_id": self.application.client_id, - "response_type": "id_token token", - "state": "random_state_string", - "nonce": "random_nonce_string", - "scope": "openid", - "redirect_uri": "http://example.org", - } - - response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 302) - self.assertIn("http://example.org#", response["Location"]) - self.assertIn("access_token=", response["Location"]) - self.assertIn("id_token=", response["Location"]) - self.assertIn("state=random_state_string", response["Location"]) - - uri_query = urlparse(response["Location"]).fragment - uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) - id_token = uri_query_params["id_token"][0] - jwt_token = jwt.JWT(key=self.key, jwt=id_token) - claims = json.loads(jwt_token.claims) - self.assertIn("nonce", claims) - self.assertIn("at_hash", claims) - - def test_access_token_and_id_token_post_auth_deny(self): - """ - Test error when resource owner deny access - """ - self.client.login(username="test_user", password="123456") - - form_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", - "response_type": "id_token token", - "allow": False, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) - self.assertEqual(response.status_code, 302) - self.assertIn("error=access_denied", response["Location"]) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 0d98dad8b..d844da5f4 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -65,9 +65,7 @@ def test_create_token_response_gets_extra_credentials(self): payload = "grant_type=password&username=john&password=123456" request = self.factory.post("/o/token/", payload, content_type="application/x-www-form-urlencoded") - with mock.patch( - "oauthlib.openid.connect.core.endpoints.pre_configured.Server.create_token_response" - ) as create_token_response: + with mock.patch("oauthlib.oauth2.Server.create_token_response") as create_token_response: mocked = mock.MagicMock() create_token_response.return_value = mocked, mocked, mocked core = self.MyOAuthLibCore() diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 1a0926988..7821148d5 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -287,13 +287,6 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r assert create_access_token_mock.call_count == 1 assert create_refresh_token_mock.call_count == 1 - def test_generate_at_hash(self): - # Values taken from spec, https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample - access_token = "jHkWEdUXMU1BwAsC4vtUsZwnNvTIxEl0z9K3vx5KF0Y" - at_hash = self.validator.generate_at_hash(access_token) - - assert at_hash == "77QmUPtjPfzWtF2AnpK9RQ" - class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py deleted file mode 100644 index 71f41d7eb..000000000 --- a/tests/test_oidc_views.py +++ /dev/null @@ -1,77 +0,0 @@ -from __future__ import unicode_literals - -from django.test import TestCase -from django.urls import reverse - -from oauth2_provider.settings import oauth2_settings - - -class TestConnectDiscoveryInfoView(TestCase): - def test_get_connect_discovery_info(self): - expected_response = { - "issuer": "http://localhost", - "authorization_endpoint": "http://localhost/o/authorize/", - "token_endpoint": "http://localhost/o/token/", - "userinfo_endpoint": "http://localhost/userinfo/", - "jwks_uri": "http://localhost/o/jwks/", - "response_types_supported": [ - "code", - "token", - "id_token", - "id_token token", - "code token", - "code id_token", - "code id_token token" - ], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256", "HS256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] - } - response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) - self.assertEqual(response.status_code, 200) - assert response.json() == expected_response - - def test_get_connect_discovery_info_without_issuer_url(self): - oauth2_settings.OIDC_ISS_ENDPOINT = None - oauth2_settings.OIDC_USERINFO_ENDPOINT = None - expected_response = { - "issuer": "http://testserver/o", - "authorization_endpoint": "http://testserver/o/authorize/", - "token_endpoint": "http://testserver/o/token/", - "userinfo_endpoint": "http://testserver/o/userinfo/", - "jwks_uri": "http://testserver/o/jwks/", - "response_types_supported": [ - "code", - "token", - "id_token", - "id_token token", - "code token", - "code id_token", - "code id_token token" - ], - "subject_types_supported": ["public"], - "id_token_signing_alg_values_supported": ["RS256", "HS256"], - "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"] - } - response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) - self.assertEqual(response.status_code, 200) - assert response.json() == expected_response - oauth2_settings.OIDC_ISS_ENDPOINT = "http://localhost" - oauth2_settings.OIDC_USERINFO_ENDPOINT = "http://localhost/userinfo/" - - -class TestJwksInfoView(TestCase): - def test_get_jwks_info(self): - expected_response = { - "keys": [{ - "alg": "RS256", - "use": "sig", - "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", - "e": "AQAB", - "kty": "RSA", - "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8" # noqa - }] - } - response = self.client.get(reverse("oauth2_provider:jwks-info")) - self.assertEqual(response.status_code, 200) - assert response.json() == expected_response diff --git a/tests/urls.py b/tests/urls.py index c7fa9a101..16dcf6ded 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,11 +1,13 @@ +from django.conf.urls import include, url from django.contrib import admin -from django.urls import include, re_path admin.autodiscover() urlpatterns = [ - re_path(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), - re_path(r"^admin/", admin.site.urls), + url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), ] + + +urlpatterns += [url(r"^admin/", admin.site.urls)] diff --git a/tox.ini b/tox.ini index 686bf366a..c984f8b99 100644 --- a/tox.ini +++ b/tox.ini @@ -14,8 +14,7 @@ envlist = django_find_project = false [testenv] -commands = - pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} -s +commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} setenv = DJANGO_SETTINGS_MODULE = tests.settings PYTHONPATH = {toxinidir} @@ -27,7 +26,6 @@ deps = djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.1.0 - jwcrypto coverage pytest pytest-cov @@ -44,7 +42,6 @@ commands = make html deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 - jwcrypto [testenv:py37-flake8] skip_install = True @@ -70,9 +67,7 @@ commands = [coverage:run] source = oauth2_provider -omit = - */migrations/* - oauth2_provider/settings.py +omit = */migrations/* [flake8] max-line-length = 110 From 1b2d73da1e3d904142bcf6b4eb773c00a280d980 Mon Sep 17 00:00:00 2001 From: David Smith <smithdc@gmail.com> Date: Sat, 10 Oct 2020 06:28:53 +0100 Subject: [PATCH 339/722] Updated url() to path() url() is deprecated in Django 3.1. Path is available in all supported versions of Django. --- tests/urls.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/urls.py b/tests/urls.py index 16dcf6ded..f4b22a4d4 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,13 +1,13 @@ -from django.conf.urls import include, url from django.contrib import admin +from django.urls import include, path admin.autodiscover() urlpatterns = [ - url(r"^o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), ] -urlpatterns += [url(r"^admin/", admin.site.urls)] +urlpatterns += [path("admin/", admin.site.urls)] From 342a63488fee02c86b1b3e5f399ce00a4f6765d5 Mon Sep 17 00:00:00 2001 From: Mattia Procopio <promat85@gmail.com> Date: Fri, 16 Oct 2020 19:07:55 +0200 Subject: [PATCH 340/722] Update changelog for 1.3.3 release (#889) --- CHANGELOG.md | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f32d2eb3e..2f48ba0c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,14 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [unreleased] +## [unreleased] + +## [1.3.3] 2020-10-16 -### added +### Added * added `select_related` in intospect view for better query performance +* #831 Authorization token creation now can receive an expire date +* #831 Added a method to override Grant creation +* #825 Bump oauthlib to 3.1.0 to introduce PKCE ### Fixed * #847: Fix inappropriate message when response from authentication server is not OK. +### Changed +* few smaller improvements to remove older django version compatibility #830, #861, #862, #863 + ## [1.3.2] 2020-03-24 ### Fixed From 02a872c20d641e6731d3df2095c30a620eb3d8a9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 20 Oct 2020 01:39:33 -0400 Subject: [PATCH 341/722] release 1.3.3 (#890) --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3c4e0badc..696e45ff7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.3.2 +version = 1.3.3 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From 0a62a9767f31b42aff00ebbe62ff31ffed8fcad2 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 20 Oct 2020 10:45:46 -0400 Subject: [PATCH 342/722] improve contributing docs (#891) --- docs/contributing.rst | 72 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 021895e38..5d36149b0 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -2,6 +2,13 @@ Contributing ============ +.. image:: https://jazzband.co/static/img/jazzband.svg + :target: https://jazzband.co/ + :alt: Jazzband + +This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_. + + Setup ===== @@ -70,7 +77,7 @@ When you begin your PR, you'll be asked to provide the following: JazzBand security team `<security@jazzband.co>`. Do not file an issue on the tracker or submit a PR until directed to do so.) -* Make sure your name is in `AUTHORS`. +* Make sure your name is in `AUTHORS`. We want to give credit to all contrbutors! If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. @@ -106,6 +113,29 @@ How to get your pull request accepted We really want your code, so please follow these simple guidelines to make the process as smooth as possible. +The Checklist +------------- + +A checklist template is automatically added to your PR when you create it. Make sure you've done all the +applicable steps and check them off to indicate you have done so. This is +what you'll see when creating your PR: + + Fixes # + + ## Description of the Change + + ## Checklist + + - [ ] PR only contains one change (considered splitting up PR) + - [ ] unit-test added + - [ ] documentation updated + - [ ] `CHANGELOG.md` updated (only for user relevant changes) + - [ ] author name in `AUTHORS` + +Any PRs that are missing checklist items will not be merged and may be reverted if they are merged by +mistake. + + Run the tests! -------------- @@ -142,5 +172,45 @@ Try reading our code and grasp the overall philosophy regarding method and varia the sake of readability, keep in mind that *simple is better than complex*. If you feel the code is not straightforward, add a comment. If you think a function is not trivial, add a docstrings. +To see if your code formatting will pass muster use: `tox -e py37-flake8` + The contents of this page are heavily based on the docs from `django-admin2 <https://github.com/twoscoops/django-admin2>`_ + +Maintainer Checklist +==================== +The following notes are to remind the project maintainers and leads of the steps required to +review and merge PRs and to publish a new release. + +Reviewing and Merging PRs +------------------------ + +- Make sure the PR description includes the `pull request template + <https://github.com/jazzband/django-oauth-toolkit/blob/master/.github/pull_request_template.md>`_ +- Confirm that all required checklist items from the PR template are both indicated as done in the + PR description and are actually done. +- Perform a careful review and ask for any needed changes. +- Make sure any PRs only ever improve code coverage percentage. +- All PRs should be be reviewed by one individual (not the submitter) and merged by another. + +PRs that are incorrectly merged may (reluctantly) be reverted by the Project Leads. + + +Publishing a Release +-------------------- + +Only Project Leads can publish a release to pypi.org and rtfd.io. This checklist is a reminder +of steps. + +- When planning a new release, create a `milestone + <https://github.com/jazzband/django-oauth-toolkit/milestones>`_ + and assign issues, PRs, etc. to that milestone. +- Review all commits since the last release and confirm that they are properly + documented in the CHANGELOG. (Unfortunately, this has not always been the case + so you may be stuck documenting things that should have been documented as part of their PRs.) +- Make a final PR for the release that updates: + + - CHANGELOG to show the release date. + - setup.cfg to set `version = ...` + +- Once the final PR is committed push the new release to pypi and rtfd.io. From 6f08e3bfa5e87fed0025ccea1f6befee5dc90bb2 Mon Sep 17 00:00:00 2001 From: David Smith <39445562+smithdc1@users.noreply.github.com> Date: Fri, 23 Oct 2020 07:42:10 +0100 Subject: [PATCH 343/722] Make calls to super() more python3 (#881) --- tests/models.py | 2 +- tests/test_application_views.py | 4 ++-- tests/test_auth_backends.py | 2 +- tests/test_client_credential.py | 2 +- tests/test_decorators.py | 2 +- tests/test_mixins.py | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/models.py b/tests/models.py index 7ca0c57c5..ad3575844 100644 --- a/tests/models.py +++ b/tests/models.py @@ -13,7 +13,7 @@ class BaseTestApplication(AbstractApplication): def get_allowed_schemes(self): if self.allowed_schemes: return self.allowed_schemes.split() - return super(BaseTestApplication, self).get_allowed_schemes() + return super().get_allowed_schemes() class SampleApplication(AbstractApplication): diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 6130876ce..8f281611b 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -70,7 +70,7 @@ def _create_application(self, name, user): return app def setUp(self): - super(TestApplicationViews, self).setUp() + super().setUp() self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) @@ -79,7 +79,7 @@ def setUp(self): self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) def tearDown(self): - super(TestApplicationViews, self).tearDown() + super().tearDown() get_application_model().objects.all().delete() def test_application_list(self): diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index baf82169c..1e1cbb544 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -85,7 +85,7 @@ def test_get_user(self): class TestOAuth2Middleware(BaseTest): def setUp(self): - super(TestOAuth2Middleware, self).setUp() + super().setUp() self.anon_user = AnonymousUser() def dummy_get_response(request): diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 09401cf0e..0f3756358 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -105,7 +105,7 @@ class TestExtendedRequest(BaseTest): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(TestExtendedRequest, cls).setUpClass() + super().setUpClass() def test_extended_request(self): class TestView(OAuthLibMixin, View): diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 0732b2920..80d2ae1a2 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -18,7 +18,7 @@ class TestProtectedResourceDecorator(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(TestProtectedResourceDecorator, cls).setUpClass() + super().setUpClass() def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") diff --git a/tests/test_mixins.py b/tests/test_mixins.py index b8aa9ac4d..5a4531596 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -14,7 +14,7 @@ class BaseTest(TestCase): @classmethod def setUpClass(cls): cls.request_factory = RequestFactory() - super(BaseTest, cls).setUpClass() + super().setUpClass() class TestOAuthLibMixin(BaseTest): From a3c085e60da809dfd3ec1e16e049899a6c8f6922 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=81ukasz=20Skar=C5=BCy=C5=84ski?= <skarzynski_lukasz@protonmail.com> Date: Thu, 12 Nov 2020 10:52:32 +0100 Subject: [PATCH 344/722] pass PKCE fields to AuthorizationView form (#896) * add tests for issue of PKCE authorization code GET request * pass PKCE fields to AuthorizationView form Pass code_challenge and code_challenge_method from query string to AuthorizationView form in get(). Without this, it was impossible to use authorization code grant flow with GET, because code_challenge and code_challenge_method data were never passed to form, so they weren't in form.cleaned_data, which causes creating Grant with always empty code_challenge and code_challenge_method. This issue was quite hard bug to discover because there are already few tests for authorization code flow pkce, however, they weren't checking form rendering in GET request, but only response.status_code, I have added asserts for these 2 values, please look at the changes in test_public_pkce_plain_authorize_get and test_public_pkce_S256_authorize_get tests in test_authorization_code.py. --- AUTHORS | 3 ++- oauth2_provider/views/base.py | 4 ++++ tests/test_authorization_code.py | 10 ++++++---- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 611a0e62b..ef1708d5c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,4 +30,5 @@ Rodney Richardson Silvano Cerza Stéphane Raimbault Jun Zhou -David Smith \ No newline at end of file +David Smith +Łukasz Skarżyński diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index b9b6ed7f9..f9a28cfaa 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -156,6 +156,10 @@ def get(self, request, *args, **kwargs): kwargs["redirect_uri"] = credentials["redirect_uri"] kwargs["response_type"] = credentials["response_type"] kwargs["state"] = credentials["state"] + if "code_challenge" in credentials: + kwargs["code_challenge"] = credentials["code_challenge"] + if "code_challenge_method" in credentials: + kwargs["code_challenge_method"] = credentials["code_challenge_method"] self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index e98f5b041..a80a54490 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1012,7 +1012,7 @@ def test_public_pkce_S256_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successfull - for the S256 algorithm + for the S256 algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") @@ -1033,14 +1033,15 @@ def test_public_pkce_S256_authorize_get(self): } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 200) + self.assertContains(response, 'value="S256"', count=1, status_code=200) + self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_authorize_get(self): """ Request an access token using client_type: public and PKCE enabled. Tests if the authorize get is successfull - for the plain algorithm + for the plain algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") @@ -1061,7 +1062,8 @@ def test_public_pkce_plain_authorize_get(self): } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) - self.assertEqual(response.status_code, 200) + self.assertContains(response, 'value="plain"', count=1, status_code=200) + self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256(self): From 6dc1ca20b78569fdfdaa66bb53558e0ed818257c Mon Sep 17 00:00:00 2001 From: Timm Simpkins <poduck@gmail.com> Date: Fri, 13 Nov 2020 09:01:28 -0500 Subject: [PATCH 345/722] Fixed some grammar and spelling mistakes in the docs. (#895) --- docs/getting_started.rst | 26 +++++++++++++------------- docs/index.rst | 2 +- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index c5b5ec51c..427195ae9 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -82,7 +82,7 @@ That’ll create a directory :file:`users`, which is laid out like this:: If you’re starting a new project, it’s highly recommended to set up a custom user model, even if the default `User`_ model is sufficient for you. This model behaves identically to the default user model, but you’ll be able to customize it in the future if the need arises. -- `Django documentation`_ -Edit :file:`users/models.py` adding the code bellow: +Edit :file:`users/models.py` adding the code below: .. code-block:: python @@ -105,7 +105,7 @@ Change :file:`iam/settings.py` to add ``users`` application to ``INSTALLED_APPS` 'users', ] -Configure ``users.User`` to be the model used for the ``auth`` application adding ``AUTH_USER_MODEL`` to :file:`iam/settings.py`: +Configure ``users.User`` to be the model used for the ``auth`` application by adding ``AUTH_USER_MODEL`` to :file:`iam/settings.py`: .. code-block:: python @@ -152,7 +152,7 @@ The ``migrate`` output:: Django OAuth Toolkit -------------------- -Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 capabilities to your Django projects. +Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Install Django OAuth Toolkit:: @@ -231,12 +231,12 @@ We will start by given a try to the grant types listed below: * Authorization code * Client credential -This two grant types cover the most initially used uses cases. +These two grant types cover the most initially used use cases. Authorization Code ------------------ -The Authorization Code flow is best used in web and mobile apps. This is the flow used for third party integration, the user authorize your partner to access its products in your APIs. +The Authorization Code flow is best used in web and mobile apps. This is the flow used for third party integration, the user authorizes your partner to access its products in your APIs. Start the development server:: @@ -256,7 +256,7 @@ Export ``Client id`` and ``Client secret`` values as environment variable: export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO -To start the Authorization code flow got to this `URL`_ with is the same as show bellow:: +To start the Authorization code flow go to this `URL`_ which is the same as shown below:: http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback @@ -273,13 +273,13 @@ Go ahead and authorize the ``web-app`` .. image:: _images/application-authorize-web-app.png :alt: Authorization code authorize web-app -Remenber we used ``http://127.0.0.1:8000/noexist/callback`` as ``redirect_uri`` you will get a **Page not found (404)** but it worked if you get a url like:: +Remember we used ``http://127.0.0.1:8000/noexist/callback`` as ``redirect_uri`` you will get a **Page not found (404)** but it worked if you get a url like:: http://127.0.0.1:8000/noexist/callback?code=uVqLxiHDKIirldDZQfSnDsmYW1Abj2 -This is the OAuth2 provider trying to give you a ``code`` in this case ``uVqLxiHDKIirldDZQfSnDsmYW1Abj2``. +This is the OAuth2 provider trying to give you a ``code``. in this case ``uVqLxiHDKIirldDZQfSnDsmYW1Abj2``. -Export it as environment variable: +Export it as an environment variable: .. code-block:: sh @@ -326,7 +326,7 @@ The Client Credential grant is suitable for machine-to-machine authentication. Y Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. -Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. +Fill the form as show in the screenshot below, and before saving take note of ``Client id`` and ``Client secret`` we will use it in a minute. .. image:: _images/application-register-client-credential.png :alt: Client credential application registration @@ -352,7 +352,7 @@ We need to encode ``client_id`` and ``client_secret`` as HTTP base authenticatio b'YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg==' >>> -Export the credential as environment variable +Export the credential as an environment variable .. code-block:: sh @@ -362,7 +362,7 @@ To start the Client Credential flow you call ``/token/`` endpoint direct:: curl -X POST -H "Authorization: Basic ${CREDENTIAL}" -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "grant_type=client_credentials" -To be more easy to visualize:: +To be easier to visualize:: curl -X POST \ -H "Authorization: Basic ${CREDENTIAL}" \ @@ -371,7 +371,7 @@ To be more easy to visualize:: "http://127.0.0.1:8000/o/token/" \ -d "grant_type=client_credentials" -The OAuth2 provider will return the follow response: +The OAuth2 provider will return the following response: .. code-block:: javascript diff --git a/docs/index.rst b/docs/index.rst index 635837832..51696a6f4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to Django OAuth Toolkit Documentation ============================================= -Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 +Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib <https://github.com/idan/oauthlib>`_, so that everything is `rfc-compliant <http://tools.ietf.org/html/rfc6749>`_. From 29bed25680ba863524e467129b21ce6f45631499 Mon Sep 17 00:00:00 2001 From: David Smith <39445562+smithdc1@users.noreply.github.com> Date: Sat, 14 Nov 2020 17:16:13 +0000 Subject: [PATCH 346/722] Added Python 3.9 to test matrix for djangomaster (#884) * Added Python 3.9 to test matrix for djangomaster * Added Python3.9 support for Django 2.2 and 3.0 --- .travis.yml | 8 ++++++++ CHANGELOG.md | 3 +++ setup.cfg | 1 + tox.ini | 2 ++ 4 files changed, 14 insertions(+) diff --git a/.travis.yml b/.travis.yml index 2aef56d6f..65284a65f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -14,6 +14,7 @@ matrix: - env: TOXENV=py36-djangomaster - env: TOXENV=py37-djangomaster - env: TOXENV=py38-djangomaster + - env: TOXENV=py39-djangomaster include: - python: 3.7 @@ -21,6 +22,13 @@ matrix: - python: 3.7 env: TOXENV=py37-docs + - python: 3.9 + env: TOXENV=py39-djangomaster + - python: 3.9 + env: TOXENV=py39-django30 + - python: 3.9 + env: TOXENV=py39-django22 + - python: 3.8 env: TOXENV=py38-django30 - python: 3.8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f48ba0c9..3fce3f882 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* #884 Added support for Python 3.9 + ## [1.3.3] 2020-10-16 ### Added diff --git a/setup.cfg b/setup.cfg index 696e45ff7..6c2012991 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,7 @@ classifiers = Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 Topic :: Internet :: WWW/HTTP [options] diff --git a/tox.ini b/tox.ini index c984f8b99..d3218b19f 100644 --- a/tox.ini +++ b/tox.ini @@ -6,6 +6,8 @@ envlist = py37-django{30,22,21}, py36-django{22,21}, py35-django{22,21}, + py39-django{22,30} + py39-djangomaster, py38-djangomaster, py37-djangomaster, py36-djangomaster, From afd651c8f1e160b608af807acf7c60029d465b32 Mon Sep 17 00:00:00 2001 From: David Smith <smithdc@gmail.com> Date: Sat, 14 Nov 2020 19:57:07 +0000 Subject: [PATCH 347/722] Updated supported Django versions added Support for 3.1 Removed support for 2.1 --- .travis.yml | 16 ++++++++-------- CHANGELOG.md | 1 + docs/index.rst | 2 +- setup.cfg | 4 ++-- tox.ini | 12 ++++++------ 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/.travis.yml b/.travis.yml index 65284a65f..1505d8cf3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -29,33 +29,33 @@ matrix: - python: 3.9 env: TOXENV=py39-django22 + - python: 3.8 + env: TOXENV=py38-django31 - python: 3.8 env: TOXENV=py38-django30 - python: 3.8 env: TOXENV=py38-django22 - - python: 3.8 - env: TOXENV=py38-django21 - python: 3.8 env: TOXENV=py38-djangomaster + - python: 3.7 + env: TOXENV=py37-django31 - python: 3.7 env: TOXENV=py37-django30 - python: 3.7 env: TOXENV=py37-django22 - - python: 3.7 - env: TOXENV=py37-django21 - python: 3.7 env: TOXENV=py37-djangomaster - python: 3.6 - env: TOXENV=py36-django22 + env: TOXENV=py36-django31 + - python: 3.6 + env: TOXENV=py36-django30 - python: 3.6 - env: TOXENV=py36-django21 + env: TOXENV=py36-django22 - python: 3.5 env: TOXENV=py35-django22 - - python: 3.5 - env: TOXENV=py35-django21 install: - pip install coveralls tox tox-travis diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fce3f882..b10ebfe30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #831 Authorization token creation now can receive an expire date * #831 Added a method to override Grant creation * #825 Bump oauthlib to 3.1.0 to introduce PKCE +* Support for Django 3.1 ### Fixed * #847: Fix inappropriate message when response from authentication server is not OK. diff --git a/docs/index.rst b/docs/index.rst index 51696a6f4..75ed1afcf 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,7 +22,7 @@ Requirements ------------ * Python 3.5+ -* Django 2.1+ +* Django 2.2+ * oauthlib 3.1+ Index diff --git a/setup.cfg b/setup.cfg index 6c2012991..df6db19d4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,9 +13,9 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 2.1 Framework :: Django :: 2.2 Framework :: Django :: 3.0 + Framework :: Django :: 3.1 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent @@ -32,7 +32,7 @@ packages = find: include_package_data = True zip_safe = False install_requires = - django >= 2.1 + django >= 2.2 requests >= 2.13.0 oauthlib >= 3.1.0 diff --git a/tox.ini b/tox.ini index d3218b19f..ab677a738 100644 --- a/tox.ini +++ b/tox.ini @@ -2,11 +2,11 @@ envlist = py37-flake8, py37-docs, - py38-django{30,22,21}, - py37-django{30,22,21}, - py36-django{22,21}, - py35-django{22,21}, - py39-django{22,30} + py39-django{31,30,22}, + py38-django{31,30,22}, + py37-django{31,30,22}, + py36-django{31,30,22}, + py35-django{22}, py39-djangomaster, py38-djangomaster, py37-djangomaster, @@ -22,9 +22,9 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - django21: Django>=2.1,<2.2 django22: Django>=2.2,<3 django30: Django>=3.0,<3.1 + django31: Django>=3.1,<3.2 djangomaster: https://github.com/django/django/archive/master.tar.gz djangorestframework oauthlib>=3.1.0 From c2f379d103624b8eb0474524b6a656ea5870d0b9 Mon Sep 17 00:00:00 2001 From: David Smith <39445562+smithdc1@users.noreply.github.com> Date: Sat, 14 Nov 2020 19:59:36 +0000 Subject: [PATCH 348/722] Removed universal wheels (py2) --- setup.cfg | 3 --- 1 file changed, 3 deletions(-) diff --git a/setup.cfg b/setup.cfg index df6db19d4..98ef302b8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,3 @@ install_requires = [options.packages.find] exclude = tests - -[bdist_wheel] -universal = 1 From 5cb5398905e5ed6bf071e3aca14b9e05aafc4026 Mon Sep 17 00:00:00 2001 From: Tom Evans <tevans.uk@googlemail.com> Date: Tue, 17 Nov 2020 08:42:03 +0000 Subject: [PATCH 349/722] Use black for formatting the code (#887) * Add black, isort and pre-commit hooks * Add black configuration * Run black as part of flake8 testenv in tox * Add editorconfig to ensure indent style in tox.ini/setup.cfg * Add pre-commit hooks to check flake8, black, isort and common errors * Update isort configuration to be black-compatible * Update contributing documentation * Add myself to AUTHORS * Skip migrations in black/isort/pre-commit * Run black over the source tree This is the result of running `black .` over the repository. By-hand improvements of the blackened code will be in follow up commits, to make it easier to reapply this commit to future updates, if necessary - IE to remove this commit and re-run black over a fresh tree, rather than trying to merge new changes in to this commit. * Hand tweak some of black's autoformatting Some minor hand tweaks: oauth2_provider/contrib/rest_framework/authentication.py oauth2_provider/oauth2_validators.py Construct OrderedDict in a clearer, still black compliant way (one line per dict entry) tests/test_token_revocation.py Remove empty method docstrings * Apply isort over codebase Co-authored-by: Tom Evans <tevans@mintel.com> --- .editorconfig | 15 ++ .pre-commit-config.yaml | 27 ++ AUTHORS | 1 + docs/conf.py | 151 ++++++----- docs/contributing.rst | 28 ++ oauth2_provider/admin.py | 9 +- .../contrib/rest_framework/__init__.py | 7 +- .../contrib/rest_framework/authentication.py | 14 +- .../contrib/rest_framework/permissions.py | 43 +-- oauth2_provider/decorators.py | 7 +- oauth2_provider/exceptions.py | 2 + oauth2_provider/generators.py | 1 + oauth2_provider/http.py | 5 +- .../management/commands/createapplication.py | 13 +- oauth2_provider/models.py | 112 ++++---- oauth2_provider/oauth2_backends.py | 30 +-- oauth2_provider/oauth2_validators.py | 126 ++++----- oauth2_provider/settings.py | 5 +- oauth2_provider/urls.py | 7 +- oauth2_provider/validators.py | 11 +- oauth2_provider/views/__init__.py | 21 +- oauth2_provider/views/application.py | 30 ++- oauth2_provider/views/base.py | 54 ++-- oauth2_provider/views/generic.py | 16 +- oauth2_provider/views/introspect.py | 23 +- oauth2_provider/views/mixins.py | 21 +- oauth2_provider/views/token.py | 6 +- pyproject.toml | 10 + tests/models.py | 20 +- tests/settings.py | 25 +- tests/test_application_views.py | 6 +- tests/test_auth_backends.py | 8 +- tests/test_authorization_code.py | 141 +++++----- tests/test_client_credential.py | 6 +- tests/test_commands.py | 1 - tests/test_decorators.py | 2 +- tests/test_generator.py | 7 +- tests/test_introspection_auth.py | 42 +-- tests/test_introspection_view.py | 251 ++++++++++-------- tests/test_mixins.py | 8 +- tests/test_models.py | 55 ++-- tests/test_oauth2_backends.py | 26 +- tests/test_oauth2_validators.py | 135 ++++++---- tests/test_rest_framework.py | 16 +- tests/test_scopes.py | 20 +- tests/test_token_revocation.py | 58 ++-- tests/test_token_view.py | 48 ++-- tox.ini | 36 ++- 48 files changed, 957 insertions(+), 749 deletions(-) create mode 100644 .editorconfig create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..2ca598bbd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 4 +insert_final_newline = true +trim_trailing_whitespace = true + +[{Makefile,tox.ini,setup.cfg}] +indent_style = tab + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..323a7fcff --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,27 @@ +repos: + - repo: https://github.com/ambv/black + rev: 20.8b1 + hooks: + - id: black + exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v3.2.0 + hooks: + - id: check-ast + - id: trailing-whitespace + - id: check-merge-conflict + - id: check-json + - id: check-xml + - id: check-yaml + - id: mixed-line-ending + args: ['--fix=lf'] + - repo: https://github.com/PyCQA/isort + rev: 5.6.3 + hooks: + - id: isort + exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.4 + hooks: + - id: flake8 + exclude: ^(oauth2_provider/migrations/|tests/migrations/) diff --git a/AUTHORS b/AUTHORS index ef1708d5c..4f9cd850b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,3 +32,4 @@ Stéphane Raimbault Jun Zhou David Smith Łukasz Skarżyński +Tom Evans diff --git a/docs/conf.py b/docs/conf.py index 628fb4bed..fefcff4dc 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -32,27 +32,33 @@ # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', 'sphinx.ext.todo', 'sphinx.ext.coverage', 'rfc', 'm2r',] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.coverage", + "rfc", + "m2r", +] # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' +master_doc = "index" # General information about the project. -project = u'Django OAuth Toolkit' -copyright = u'2013, Evonove' +project = "Django OAuth Toolkit" +copyright = "2013, Evonove" # The version info for the project you're documenting, acts as replacement for @@ -66,181 +72,176 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +exclude_patterns = ["_build"] # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). -#add_module_names = True +# add_module_names = True # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # If true, keep warnings as "system message" paragraphs in the built documents. -#keep_warnings = False +# keep_warnings = False # http://www.sphinx-doc.org/en/1.5.1/ext/intersphinx.html -extensions.append('sphinx.ext.intersphinx') -intersphinx_mapping = {'python3': ('https://docs.python.org/3.6', None), - 'django': ('http://django.readthedocs.org/en/latest/', None)} - +extensions.append("sphinx.ext.intersphinx") +intersphinx_mapping = { + "python3": ("https://docs.python.org/3.6", None), + "django": ("http://django.readthedocs.org/en/latest/", None), +} # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -#html_theme = 'classic' +# html_theme = 'classic' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. -#html_theme_options = {} +# html_theme_options = {} # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # "<project> v<release> documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -#html_static_path = ['_static'] +# html_static_path = ['_static'] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a <link> tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'DjangoOAuthToolkitdoc' +htmlhelp_basename = "DjangoOAuthToolkitdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'DjangoOAuthToolkit.tex', u'Django OAuth Toolkit Documentation', - u'Evonove', 'manual'), + ("index", "DjangoOAuthToolkit.tex", "Django OAuth Toolkit Documentation", "Evonove", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'djangooauthtoolkit', u'Django OAuth Toolkit Documentation', - [u'Evonove'], 1) -] +man_pages = [("index", "djangooauthtoolkit", "Django OAuth Toolkit Documentation", ["Evonove"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -249,19 +250,25 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'DjangoOAuthToolkit', u'Django OAuth Toolkit Documentation', - u'Evonove', 'DjangoOAuthToolkit', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "DjangoOAuthToolkit", + "Django OAuth Toolkit Documentation", + "Evonove", + "DjangoOAuthToolkit", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # If true, do not generate a @detailmenu in the "Top" node's menu. -#texinfo_no_detailmenu = False +# texinfo_no_detailmenu = False diff --git a/docs/contributing.rst b/docs/contributing.rst index 5d36149b0..39ed1a427 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -24,6 +24,34 @@ You can find the list of bugs, enhancements and feature requests on the `issue tracker <https://github.com/jazzband/django-oauth-toolkit/issues>`_. If you want to fix an issue, pick up one and add a comment stating you're working on it. +Code Style +========== + +The project uses `flake8 <https://flake8.pycqa.org/en/latest/>`_ for linting, +`black <https://black.readthedocs.io/en/stable/>`_ for formatting the code, +`isort <https://pycqa.github.io/isort/>`_ for formatting and sorting imports, +and `pre-commit <https://pre-commit.com/>`_ for checking/fixing commits for +correctness before they are made. + +You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will +take care of installing ``flake8``, ``black`` and ``isort``. + +After cloning your repository, go into it and run:: + + pre-commit install + +to install the hooks. On the next commit that you make, ``pre-commit`` will +download and install the necessary hooks (a one off task). If anything in the +commit would fail the hooks, the commit will be abandoned. For ``black`` and +``isort``, any necessary changes will be made automatically, but not staged. +Review the changes, and then re-stage and commit again. + +Using ``pre-commit`` ensures that code that would fail in QA does not make it +into a commit in the first place, and will save you time in the long run. You +can also (largely) stop worrying about code style, although you should always +check how the code looks after ``black`` has formatted it, and think if there +is a better way to structure the code so that it is more readable. + Pull requests ============= diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 8b963d981..a2ec8501a 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,9 +1,6 @@ from django.contrib import admin -from .models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model -) +from .models import get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model class ApplicationAdmin(admin.ModelAdmin): @@ -13,12 +10,12 @@ class ApplicationAdmin(admin.ModelAdmin): "client_type": admin.HORIZONTAL, "authorization_grant_type": admin.VERTICAL, } - raw_id_fields = ("user", ) + raw_id_fields = ("user",) class GrantAdmin(admin.ModelAdmin): list_display = ("code", "application", "user", "expires") - raw_id_fields = ("user", ) + raw_id_fields = ("user",) class AccessTokenAdmin(admin.ModelAdmin): diff --git a/oauth2_provider/contrib/rest_framework/__init__.py b/oauth2_provider/contrib/rest_framework/__init__.py index a004c1872..b54f42220 100644 --- a/oauth2_provider/contrib/rest_framework/__init__.py +++ b/oauth2_provider/contrib/rest_framework/__init__.py @@ -1,6 +1,9 @@ # flake8: noqa from .authentication import OAuth2Authentication from .permissions import ( - TokenHasScope, TokenHasReadWriteScope, TokenMatchesOASRequirements, - TokenHasResourceScope, IsAuthenticatedOrTokenHasScope + IsAuthenticatedOrTokenHasScope, + TokenHasReadWriteScope, + TokenHasResourceScope, + TokenHasScope, + TokenMatchesOASRequirements, ) diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 228361967..53087f756 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -9,16 +9,14 @@ class OAuth2Authentication(BaseAuthentication): """ OAuth 2 authentication backend using `django-oauth-toolkit` """ + www_authenticate_realm = "api" def _dict_to_string(self, my_dict): """ Return a string of comma-separated key-value pairs (e.g. k="v",k2="v2"). """ - return ",".join([ - '{k}="{v}"'.format(k=k, v=v) - for k, v in my_dict.items() - ]) + return ",".join(['{k}="{v}"'.format(k=k, v=v) for k, v in my_dict.items()]) def authenticate(self, request): """ @@ -36,9 +34,11 @@ def authenticate_header(self, request): """ Bearer is the only finalized type currently """ - www_authenticate_attributes = OrderedDict([ - ("realm", self.www_authenticate_realm,), - ]) + www_authenticate_attributes = OrderedDict( + [ + ("realm", self.www_authenticate_realm), + ] + ) oauth2_error = getattr(request, "oauth2_error", {}) www_authenticate_attributes.update(oauth2_error) return "Bearer {attributes}".format( diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 7ba1c5c71..1050bf751 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -2,9 +2,7 @@ from django.core.exceptions import ImproperlyConfigured from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import ( - SAFE_METHODS, BasePermission, IsAuthenticated -) +from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated from ...settings import oauth2_settings from .authentication import OAuth2Authentication @@ -33,10 +31,10 @@ def has_permission(self, request, view): # Provide information about required scope? include_required_scope = ( - oauth2_settings.ERROR_RESPONSE_WITH_SCOPES and - required_scopes and - not token.is_expired() and - not token.allow_scopes(required_scopes) + oauth2_settings.ERROR_RESPONSE_WITH_SCOPES + and required_scopes + and not token.is_expired() + and not token.allow_scopes(required_scopes) ) if include_required_scope: @@ -47,9 +45,11 @@ def has_permission(self, request, view): return False - assert False, ("TokenHasScope requires the" - "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " - "class to be used.") + assert False, ( + "TokenHasScope requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) def get_scopes(self, request, view): try: @@ -96,9 +96,7 @@ def get_scopes(self, request, view): else: scope_type = oauth2_settings.WRITE_SCOPE - required_scopes = [ - "{}:{}".format(scope, scope_type) for scope in view_scopes - ] + required_scopes = ["{}:{}".format(scope, scope_type) for scope in view_scopes] return required_scopes @@ -113,6 +111,7 @@ class IsAuthenticatedOrTokenHasScope(BasePermission): the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ + def has_permission(self, request, view): is_authenticated = IsAuthenticated().has_permission(request, view) oauth2authenticated = False @@ -155,8 +154,11 @@ def has_permission(self, request, view): m = request.method.upper() if m in required_alternate_scopes: - log.debug("Required scopes alternatives to access resource: {0}" - .format(required_alternate_scopes[m])) + log.debug( + "Required scopes alternatives to access resource: {0}".format( + required_alternate_scopes[m] + ) + ) for alt in required_alternate_scopes[m]: if token.is_valid(alt): return True @@ -165,9 +167,11 @@ def has_permission(self, request, view): log.warning("no scope alternates defined for method {0}".format(m)) return False - assert False, ("TokenMatchesOASRequirements requires the" - "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " - "class to be used.") + assert False, ( + "TokenMatchesOASRequirements requires the" + "`oauth2_provider.rest_framework.OAuth2Authentication` authentication " + "class to be used." + ) def get_required_alternate_scopes(self, request, view): try: @@ -175,4 +179,5 @@ def get_required_alternate_scopes(self, request, view): except AttributeError: raise ImproperlyConfigured( "TokenMatchesOASRequirements requires the view to" - " define the required_alternate_scopes attribute") + " define the required_alternate_scopes attribute" + ) diff --git a/oauth2_provider/decorators.py b/oauth2_provider/decorators.py index d4b7085aa..0ab26ddb4 100644 --- a/oauth2_provider/decorators.py +++ b/oauth2_provider/decorators.py @@ -33,7 +33,9 @@ def _validate(request, *args, **kwargs): request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() + return _validate + return decorator @@ -62,8 +64,7 @@ def _validate(request, *args, **kwargs): if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "rw_protected_resource decorator requires following scopes {0}" - " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format( - read_write_scopes) + " to be in OAUTH2_PROVIDER['SCOPES'] list in settings".format(read_write_scopes) ) # Check if method is safe @@ -80,5 +81,7 @@ def _validate(request, *args, **kwargs): request.resource_owner = oauthlib_req.user return view_func(request, *args, **kwargs) return HttpResponseForbidden() + return _validate + return decorator diff --git a/oauth2_provider/exceptions.py b/oauth2_provider/exceptions.py index 215515500..c4208488d 100644 --- a/oauth2_provider/exceptions.py +++ b/oauth2_provider/exceptions.py @@ -2,6 +2,7 @@ class OAuthToolkitError(Exception): """ Base class for exceptions """ + def __init__(self, error=None, redirect_uri=None, *args, **kwargs): super().__init__(*args, **kwargs) self.oauthlib_error = error @@ -14,4 +15,5 @@ class FatalClientError(OAuthToolkitError): """ Class for critical errors """ + pass diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index ab5d25a7a..f72bc6e7a 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -8,6 +8,7 @@ class BaseHashGenerator: """ All generators should extend this class overriding `.hash()` method. """ + def hash(self): raise NotImplementedError() diff --git a/oauth2_provider/http.py b/oauth2_provider/http.py index 980cb7bd4..274ed81af 100644 --- a/oauth2_provider/http.py +++ b/oauth2_provider/http.py @@ -11,6 +11,7 @@ class OAuth2ResponseRedirect(HttpResponse): Works like django.http.HttpResponseRedirect but we customize it to give us more flexibility on allowed scheme validation. """ + status_code = 302 def __init__(self, redirect_to, allowed_schemes, *args, **kwargs): @@ -28,6 +29,4 @@ def validate_redirect(self, redirect_to): if not parsed.scheme: raise DisallowedRedirect("OAuth2 redirects require a URI scheme.") if parsed.scheme not in self.allowed_schemes: - raise DisallowedRedirect( - "Redirect to scheme {!r} is not permitted".format(parsed.scheme) - ) + raise DisallowedRedirect("Redirect to scheme {!r} is not permitted".format(parsed.scheme)) diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 95cb2d865..92c4ae46b 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -72,15 +72,10 @@ def handle(self, *args, **options): try: new_application.full_clean() except ValidationError as exc: - errors = "\n ".join(["- " + err_key + ": " + str(err_value) for err_key, - err_value in exc.message_dict.items()]) - self.stdout.write( - self.style.ERROR( - "Please correct the following errors:\n %s" % errors - ) + errors = "\n ".join( + ["- " + err_key + ": " + str(err_value) for err_key, err_value in exc.message_dict.items()] ) + self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) else: new_application.save() - self.stdout.write( - self.style.SUCCESS("New application created successfully") - ) + self.stdout.write(self.style.SUCCESS("New application created successfully")) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 5676bc0c5..77542d35f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -39,6 +39,7 @@ class AbstractApplication(models.Model): the registration process as described in :rfc:`2.2` * :attr:`name` Friendly name for the Application """ + CLIENT_CONFIDENTIAL = "confidential" CLIENT_PUBLIC = "public" CLIENT_TYPES = ( @@ -58,22 +59,21 @@ class AbstractApplication(models.Model): ) id = models.BigAutoField(primary_key=True) - client_id = models.CharField( - max_length=100, unique=True, default=generate_client_id, db_index=True - ) + client_id = models.CharField(max_length=100, unique=True, default=generate_client_id, db_index=True) user = models.ForeignKey( settings.AUTH_USER_MODEL, related_name="%(app_label)s_%(class)s", - null=True, blank=True, on_delete=models.CASCADE + null=True, + blank=True, + on_delete=models.CASCADE, ) redirect_uris = models.TextField( - blank=True, help_text=_("Allowed URIs list, space separated"), + blank=True, + help_text=_("Allowed URIs list, space separated"), ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) - authorization_grant_type = models.CharField( - max_length=32, choices=GRANT_TYPES - ) + authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) client_secret = models.CharField( max_length=255, blank=True, default=generate_client_secret, db_index=True ) @@ -115,9 +115,11 @@ def redirect_uri_allowed(self, uri): for allowed_uri in self.redirect_uris.split(): parsed_allowed_uri = urlparse(allowed_uri) - if (parsed_allowed_uri.scheme == parsed_uri.scheme and - parsed_allowed_uri.netloc == parsed_uri.netloc and - parsed_allowed_uri.path == parsed_uri.path): + if ( + parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.netloc == parsed_uri.netloc + and parsed_allowed_uri.path == parsed_uri.path + ): aqs_set = set(parse_qsl(parsed_allowed_uri.query)) @@ -143,14 +145,14 @@ def clean(self): validator(uri) scheme = urlparse(uri).scheme if scheme not in allowed_schemes: - raise ValidationError(_( - "Unauthorized redirect scheme: {scheme}" - ).format(scheme=scheme)) + raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) elif self.authorization_grant_type in grant_types: - raise ValidationError(_( - "redirect_uris cannot be empty with grant_type {grant_type}" - ).format(grant_type=self.authorization_grant_type)) + raise ValidationError( + _("redirect_uris cannot be empty with grant_type {grant_type}").format( + grant_type=self.authorization_grant_type + ) + ) def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) @@ -206,22 +208,17 @@ class AbstractGrant(models.Model): * :attr:`code_challenge` PKCE code challenge * :attr:`code_challenge_method` PKCE code challenge transform algorithm """ + CODE_CHALLENGE_PLAIN = "plain" CODE_CHALLENGE_S256 = "S256" - CODE_CHALLENGE_METHODS = ( - (CODE_CHALLENGE_PLAIN, "plain"), - (CODE_CHALLENGE_S256, "S256") - ) + CODE_CHALLENGE_METHODS = ((CODE_CHALLENGE_PLAIN, "plain"), (CODE_CHALLENGE_S256, "S256")) id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) code = models.CharField(max_length=255, unique=True) # code comes from oauthlib - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE - ) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) expires = models.DateTimeField() redirect_uri = models.CharField(max_length=255) scope = models.TextField(blank=True) @@ -231,7 +228,8 @@ class AbstractGrant(models.Model): code_challenge = models.CharField(max_length=128, blank=True, default="") code_challenge_method = models.CharField( - max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS) + max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS + ) def is_expired(self): """ @@ -271,19 +269,32 @@ class AbstractAccessToken(models.Model): * :attr:`expires` Date and time of token expiration, in DateTime format * :attr:`scope` Allowed scopes """ + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, blank=True, null=True, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", ) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField - oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="refreshed_access_token" + oauth2_settings.REFRESH_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refreshed_access_token", + ) + token = models.CharField( + max_length=255, + unique=True, ) - token = models.CharField(max_length=255, unique=True, ) application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, blank=True, null=True, + oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, ) expires = models.DateTimeField() scope = models.TextField(blank=True) @@ -364,17 +375,19 @@ class AbstractRefreshToken(models.Model): bounded to * :attr:`revoked` Timestamp of when this refresh token was revoked """ + id = models.BigAutoField(primary_key=True) user = models.ForeignKey( - settings.AUTH_USER_MODEL, on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s" + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="%(app_label)s_%(class)s" ) token = models.CharField(max_length=255) - application = models.ForeignKey( - oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) + application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) access_token = models.OneToOneField( - oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="refresh_token" + oauth2_settings.ACCESS_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="refresh_token", ) created = models.DateTimeField(auto_now_add=True) @@ -388,9 +401,11 @@ def revoke(self): access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() with transaction.atomic(): - self = refresh_token_model.objects.filter( - pk=self.pk, revoked__isnull=True - ).select_for_update().first() + self = ( + refresh_token_model.objects.filter(pk=self.pk, revoked__isnull=True) + .select_for_update() + .first() + ) if not self: return @@ -407,7 +422,10 @@ def __str__(self): class Meta: abstract = True - unique_together = ("token", "revoked",) + unique_together = ( + "token", + "revoked", + ) class RefreshToken(AbstractRefreshToken): @@ -466,13 +484,9 @@ def clear_expired(): revoked.delete() expired.delete() else: - logger.info("refresh_expire_at is %s. No refresh tokens deleted.", - refresh_expire_at) + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) - access_tokens = access_token_model.objects.filter( - refresh_token__isnull=True, - expires__lt=now - ) + access_tokens = access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now) grants = grant_model.objects.filter(expires__lt=now) logger.info("%s Expired access tokens to be deleted", access_tokens.count()) diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 6d8e68a2c..34b1c62cd 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -24,9 +24,7 @@ def __init__(self, server=None): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() server_kwargs = oauth2_settings.server_kwargs - self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS( - validator, **server_kwargs - ) + self.server = server or oauth2_settings.OAUTH2_SERVER_CLASS(validator, **server_kwargs) def _get_escaped_full_path(self, request): """ @@ -96,7 +94,8 @@ def validate_authorization_request(self, request): try: uri, http_method, body, headers = self._extract_params(request) scopes, credentials = self.server.validate_authorization_request( - uri, http_method=http_method, body=body, headers=headers) + uri, http_method=http_method, body=body, headers=headers + ) return scopes, credentials except oauth2.FatalClientError as error: @@ -117,24 +116,22 @@ def create_authorization_response(self, request, scopes, credentials, allow): """ try: if not allow: - raise oauth2.AccessDeniedError( - state=credentials.get("state", None)) + raise oauth2.AccessDeniedError(state=credentials.get("state", None)) # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS credentials["user"] = request.user headers, body, status = self.server.create_authorization_response( - uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials) + uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials + ) uri = headers.get("Location", None) return uri, headers, body, status except oauth2.FatalClientError as error: - raise FatalClientError( - error=error, redirect_uri=credentials["redirect_uri"]) + raise FatalClientError(error=error, redirect_uri=credentials["redirect_uri"]) except oauth2.OAuth2Error as error: - raise OAuthToolkitError( - error=error, redirect_uri=credentials["redirect_uri"]) + raise OAuthToolkitError(error=error, redirect_uri=credentials["redirect_uri"]) def create_token_response(self, request): """ @@ -145,8 +142,9 @@ def create_token_response(self, request): uri, http_method, body, headers = self._extract_params(request) extra_credentials = self._get_extra_credentials(request) - headers, body, status = self.server.create_token_response(uri, http_method, body, - headers, extra_credentials) + headers, body, status = self.server.create_token_response( + uri, http_method, body, headers, extra_credentials + ) uri = headers.get("Location", None) return uri, headers, body, status @@ -160,8 +158,7 @@ def create_revocation_response(self, request): """ uri, http_method, body, headers = self._extract_params(request) - headers, body, status = self.server.create_revocation_response( - uri, http_method, body, headers) + headers, body, status = self.server.create_revocation_response(uri, http_method, body, headers) uri = headers.get("Location", None) return uri, headers, body, status @@ -175,8 +172,7 @@ def verify_request(self, request, scopes): """ uri, http_method, body, headers = self._extract_params(request) - valid, r = self.server.verify_request( - uri, http_method, body, headers, scopes=scopes) + valid, r = self.server.verify_request(uri, http_method, body, headers, scopes=scopes) return valid, r def authenticate_client(self, request): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 515353d6f..de707bb21 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -19,8 +19,11 @@ from .exceptions import FatalClientError from .models import ( - AbstractApplication, get_access_token_model, - get_application_model, get_grant_model, get_refresh_token_model + AbstractApplication, + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from .scopes import get_scopes_backend from .settings import oauth2_settings @@ -29,14 +32,14 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE, ), - "password": (AbstractApplication.GRANT_PASSWORD, ), - "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS, ), + "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE,), + "password": (AbstractApplication.GRANT_PASSWORD,), + "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, - ) + ), } Application = get_application_model() @@ -91,10 +94,7 @@ def _authenticate_basic_auth(self, request): try: auth_string_decoded = b64_decoded.decode(encoding) except UnicodeDecodeError: - log.debug( - "Failed basic auth: %r can't be decoded as unicode by %r", - auth_string, encoding - ) + log.debug("Failed basic auth: %r can't be decoded as unicode by %r", auth_string, encoding) return False try: @@ -162,25 +162,33 @@ def _load_application(self, client_id, request): def _set_oauth2_error_on_request(self, request, access_token, scopes): if access_token is None: - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token is invalid."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token is invalid.")), + ] + ) elif access_token.is_expired(): - error = OrderedDict([ - ("error", "invalid_token", ), - ("error_description", _("The access token has expired."), ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ("error_description", _("The access token has expired.")), + ] + ) elif not access_token.allow_scopes(scopes): - error = OrderedDict([ - ("error", "insufficient_scope", ), - ("error_description", _("The access token is valid but does not have enough scope."), ), - ]) + error = OrderedDict( + [ + ("error", "insufficient_scope"), + ("error_description", _("The access token is valid but does not have enough scope.")), + ] + ) else: log.warning("OAuth2 access token is invalid for an unknown reason.") - error = OrderedDict([ - ("error", "invalid_token", ), - ]) + error = OrderedDict( + [ + ("error", "invalid_token"), + ] + ) request.oauth2_error = error return request @@ -270,7 +278,7 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri def _get_token_from_authentication_server( - self, token, introspection_url, introspection_token, introspection_credentials + self, token, introspection_url, introspection_token, introspection_credentials ): """Use external introspection endpoint to "crack open" the token. :param introspection_url: introspection endpoint URL @@ -297,20 +305,18 @@ def _get_token_from_authentication_server( headers = {"Authorization": "Basic {}".format(basic_auth.decode("utf-8"))} try: - response = requests.post( - introspection_url, - data={"token": token}, headers=headers - ) + response = requests.post(introspection_url, data={"token": token}, headers=headers) except requests.exceptions.RequestException: log.exception("Introspection: Failed POST to %r in token lookup", introspection_url) return None # Log an exception when response from auth server is not successful if response.status_code != http.client.OK: - log.exception("Introspection: Failed to get a valid response " - "from authentication server. Status code: {}, " - "Reason: {}.".format(response.status_code, - response.reason)) + log.exception( + "Introspection: Failed to get a valid response " + "from authentication server. Status code: {}, " + "Reason: {}.".format(response.status_code, response.reason) + ) return None try: @@ -348,7 +354,8 @@ def _get_token_from_authentication_server( "application": None, "scope": scope, "expires": expires, - }) + }, + ) return access_token @@ -372,10 +379,7 @@ def validate_bearer_token(self, token, scopes, request): if not access_token or not access_token.is_valid(scopes): if introspection_url and (introspection_token or introspection_credentials): access_token = self._get_token_from_authentication_server( - token, - introspection_url, - introspection_token, - introspection_credentials + token, introspection_url, introspection_token, introspection_credentials ) if access_token and access_token.is_valid(scopes): @@ -406,7 +410,7 @@ def validate_grant_type(self, client_id, grant_type, client, request, *args, **k """ Validate both grant_type is a valid string and grant_type is allowed for current workflow """ - assert(grant_type in GRANT_TYPE_MAPPING) # mapping misconfiguration + assert grant_type in GRANT_TYPE_MAPPING # mapping misconfiguration return request.client.allows_grant_type(*GRANT_TYPE_MAPPING[grant_type]) def validate_response_type(self, client_id, response_type, client, request, *args, **kwargs): @@ -477,9 +481,12 @@ def save_bearer_token(self, token, request, *args, **kwargs): # expires_in is passed to Server on initialization # custom server class can have logic to override this - expires = timezone.now() + timedelta(seconds=token.get( - "expires_in", oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, - )) + expires = timezone.now() + timedelta( + seconds=token.get( + "expires_in", + oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS, + ) + ) if request.grant_type == "client_credentials": request.user = None @@ -497,9 +504,11 @@ def save_bearer_token(self, token, request, *args, **kwargs): refresh_token_instance = getattr(request, "refresh_token_instance", None) # If we are to reuse tokens, and we can: do so - if not self.rotate_refresh_token(request) and \ - isinstance(refresh_token_instance, RefreshToken) and \ - refresh_token_instance.access_token: + if ( + not self.rotate_refresh_token(request) + and isinstance(refresh_token_instance, RefreshToken) + and refresh_token_instance.access_token + ): access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk @@ -551,9 +560,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): # make sure that the token data we're returning matches # the existing token token["access_token"] = previous_access_token.token - token["refresh_token"] = RefreshToken.objects.filter( - access_token=previous_access_token - ).first().token + token["refresh_token"] = ( + RefreshToken.objects.filter(access_token=previous_access_token).first().token + ) token["scope"] = previous_access_token.scope # No refresh token should be created, just access token @@ -582,15 +591,12 @@ def _create_authorization_code(self, request, code, expires=None): redirect_uri=request.redirect_uri, scope=" ".join(request.scopes), code_challenge=request.code_challenge or "", - code_challenge_method=request.code_challenge_method or "" + code_challenge_method=request.code_challenge_method or "", ) def _create_refresh_token(self, request, refresh_token_code, access_token): return RefreshToken.objects.create( - user=request.user, - token=refresh_token_code, - application=request.client, - access_token=access_token + user=request.user, token=refresh_token_code, application=request.client, access_token=access_token ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -643,13 +649,13 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs """ null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta( - seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS - ) + revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) + ) + rt = ( + RefreshToken.objects.filter(null_or_recent, token=refresh_token) + .select_related("access_token") + .first() ) - rt = RefreshToken.objects.filter(null_or_recent, token=refresh_token).select_related( - "access_token" - ).first() if not rt: return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 0135da8b7..42c08b676 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -55,19 +55,16 @@ "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], - # Resource Server with Token Introspection "RESOURCE_SERVER_INTROSPECTION_URL": None, "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, - # Whether or not PKCE is required - "PKCE_REQUIRED": False + "PKCE_REQUIRED": False, } # List of settings that cannot be empty diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4cf6d4c6d..c7ae526f0 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -23,8 +23,11 @@ re_path(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), # Token management views re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path(r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", views.AuthorizedTokenDeleteView.as_view(), - name="authorized-token-delete"), + re_path( + r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", + views.AuthorizedTokenDeleteView.as_view(), + name="authorized-token-delete", + ), ] diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index f3f82102c..6c8fa3839 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -10,12 +10,9 @@ class URIValidator(URLValidator): scheme_re = r"^(?:[a-z][a-z0-9\.\-\+]*)://" dotless_domain_re = r"(?!-)[A-Z\d-]{1,63}(?<!-)" - host_re = "|".join(( - r"(?:" + URLValidator.host_re, - URLValidator.ipv4_re, - URLValidator.ipv6_re, - dotless_domain_re + ")" - )) + host_re = "|".join( + (r"(?:" + URLValidator.host_re, URLValidator.ipv4_re, URLValidator.ipv6_re, dotless_domain_re + ")") + ) port_re = r"(?::\d{2,5})?" path_re = r"(?:[/?#][^\s]*)?" regex = re.compile(scheme_re + host_re + port_re + path_re, re.IGNORECASE) @@ -39,9 +36,11 @@ def __call__(self, value): # This is required in order to move validation of the scheme from # URLValidator (the base class of URIValidator), to OAuth2Application.clean(). + class WildcardSet(set): """ A set that always returns True on `in`. """ + def __contains__(self, item): return True diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 7636bd9c7..6d5d74c67 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -1,9 +1,18 @@ # flake8: noqa -from .base import AuthorizationView, TokenView, RevokeTokenView -from .application import ApplicationRegistration, ApplicationDetail, ApplicationList, \ - ApplicationDelete, ApplicationUpdate +from .base import AuthorizationView, TokenView, RevokeTokenView # isort:skip +from .application import ( + ApplicationDelete, + ApplicationDetail, + ApplicationList, + ApplicationRegistration, + ApplicationUpdate, +) from .generic import ( - ProtectedResourceView, ScopedProtectedResourceView, ReadWriteScopedResourceView, - ClientProtectedResourceView, ClientProtectedScopedResourceView) -from .token import AuthorizedTokensListView, AuthorizedTokenDeleteView + ClientProtectedResourceView, + ClientProtectedScopedResourceView, + ProtectedResourceView, + ReadWriteScopedResourceView, + ScopedProtectedResourceView, +) from .introspect import IntrospectTokenView +from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index c925493f5..186097ae4 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -1,9 +1,7 @@ from django.contrib.auth.mixins import LoginRequiredMixin from django.forms.models import modelform_factory from django.urls import reverse_lazy -from django.views.generic import ( - CreateView, DeleteView, DetailView, ListView, UpdateView -) +from django.views.generic import CreateView, DeleteView, DetailView, ListView, UpdateView from ..models import get_application_model @@ -12,6 +10,7 @@ class ApplicationOwnerIsUserMixin(LoginRequiredMixin): """ This mixin is used to provide an Application queryset filtered by the current request.user. """ + fields = "__all__" def get_queryset(self): @@ -22,6 +21,7 @@ class ApplicationRegistration(LoginRequiredMixin, CreateView): """ View used to register a new Application for the request.user """ + template_name = "oauth2_provider/application_registration_form.html" def get_form_class(self): @@ -31,9 +31,13 @@ def get_form_class(self): return modelform_factory( get_application_model(), fields=( - "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" - ) + "name", + "client_id", + "client_secret", + "client_type", + "authorization_grant_type", + "redirect_uris", + ), ) def form_valid(self, form): @@ -45,6 +49,7 @@ class ApplicationDetail(ApplicationOwnerIsUserMixin, DetailView): """ Detail view for an application instance owned by the request.user """ + context_object_name = "application" template_name = "oauth2_provider/application_detail.html" @@ -53,6 +58,7 @@ class ApplicationList(ApplicationOwnerIsUserMixin, ListView): """ List view for all the applications owned by the request.user """ + context_object_name = "applications" template_name = "oauth2_provider/application_list.html" @@ -61,6 +67,7 @@ class ApplicationDelete(ApplicationOwnerIsUserMixin, DeleteView): """ View used to delete an application owned by the request.user """ + context_object_name = "application" success_url = reverse_lazy("oauth2_provider:list") template_name = "oauth2_provider/application_confirm_delete.html" @@ -70,6 +77,7 @@ class ApplicationUpdate(ApplicationOwnerIsUserMixin, UpdateView): """ View used to update an application owned by the request.user """ + context_object_name = "application" template_name = "oauth2_provider/application_form.html" @@ -80,7 +88,11 @@ def get_form_class(self): return modelform_factory( get_application_model(), fields=( - "name", "client_id", "client_secret", "client_type", - "authorization_grant_type", "redirect_uris" - ) + "name", + "client_id", + "client_secret", + "client_type", + "authorization_grant_type", + "redirect_uris", + ), ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index f9a28cfaa..104413787 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -86,6 +86,7 @@ class AuthorizationView(BaseAuthorizationView, FormView): * Authorization code * Implicit grant """ + template_name = "oauth2_provider/authorize.html" form_class = AllowForm @@ -116,7 +117,7 @@ def form_valid(self, form): "client_id": form.cleaned_data.get("client_id"), "redirect_uri": form.cleaned_data.get("redirect_uri"), "response_type": form.cleaned_data.get("response_type", None), - "state": form.cleaned_data.get("state", None) + "state": form.cleaned_data.get("state", None), } if form.cleaned_data.get("code_challenge", False): credentials["code_challenge"] = form.cleaned_data.get("code_challenge") @@ -177,24 +178,24 @@ def get(self, request, *args, **kwargs): # are already approved. if application.skip_authorization: uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True ) return self.redirect(uri, application) elif require_approval == "auto": - tokens = get_access_token_model().objects.filter( - user=request.user, - application=kwargs["application"], - expires__gt=timezone.now() - ).all() + tokens = ( + get_access_token_model() + .objects.filter( + user=request.user, application=kwargs["application"], expires__gt=timezone.now() + ) + .all() + ) # check past authorizations regarded the same scopes as the current one for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), - credentials=credentials, allow=True + request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True ) return self.redirect(uri, application, token) @@ -214,23 +215,23 @@ def redirect(self, redirect_to, application, token=None): if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): response = { - "access_token": code, - "token_uri": redirect_to, - "client_id": application.client_id, - "client_secret": application.client_secret, - "revoke_uri": reverse("oauth2_provider:revoke-token"), - } + "access_token": code, + "token_uri": redirect_to, + "client_id": application.client_id, + "client_secret": application.client_secret, + "revoke_uri": reverse("oauth2_provider:revoke-token"), + } return JsonResponse(response) else: return render( - request=self.request, - template_name="oauth2_provider/authorized-oob.html", - context={ - "code": code, - }, - ) + request=self.request, + template_name="oauth2_provider/authorized-oob.html", + context={ + "code": code, + }, + ) @method_decorator(csrf_exempt, name="dispatch") @@ -243,6 +244,7 @@ class TokenView(OAuthLibMixin, View): * Password * Client credentials """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS @@ -253,11 +255,8 @@ def post(self, request, *args, **kwargs): if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get( - token=access_token) - app_authorized.send( - sender=self, request=request, - token=token) + token = get_access_token_model().objects.get(token=access_token) + app_authorized.send(sender=self, request=request, token=token) response = HttpResponse(content=body, status=status) for k, v in headers.items(): @@ -270,6 +269,7 @@ class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens """ + server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index 5c0c760e5..10e84d59f 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -2,15 +2,17 @@ from ..settings import oauth2_settings from .mixins import ( - ClientProtectedResourceMixin, OAuthLibMixin, ProtectedResourceMixin, - ReadWriteScopedResourceMixin, ScopedResourceMixin + ClientProtectedResourceMixin, + OAuthLibMixin, + ProtectedResourceMixin, + ReadWriteScopedResourceMixin, + ScopedResourceMixin, ) class InitializationMixin(OAuthLibMixin): - """Initializer for OauthLibMixin - """ + """Initializer for OauthLibMixin""" server_class = oauth2_settings.OAUTH2_SERVER_CLASS validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS @@ -21,6 +23,7 @@ class ProtectedResourceView(ProtectedResourceMixin, InitializationMixin, View): """ Generic view protecting resources by providing OAuth2 authentication out of the box """ + pass @@ -29,6 +32,7 @@ class ScopedProtectedResourceView(ScopedResourceMixin, ProtectedResourceView): Generic view protecting resources by providing OAuth2 authentication and Scopes handling out of the box """ + pass @@ -37,6 +41,7 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc Generic view protecting resources with OAuth2 authentication and read/write scopes. GET, HEAD, OPTIONS http methods require "read" scope. Otherwise "write" scope is required. """ + pass @@ -51,7 +56,6 @@ class ClientProtectedResourceView(ClientProtectedResourceMixin, InitializationMi class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): - """Impose scope restrictions if client protection fallsback to access token. - """ + """Impose scope restrictions if client protection fallsback to access token.""" pass diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 7d4381179..d29605097 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -19,19 +19,18 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): To access this view the request must pass a OAuth2 Bearer Token which is allowed to access the scope `introspection`. """ + required_scopes = ["introspection"] @staticmethod def get_token_response(token_value=None): try: - token = get_access_token_model().objects.select_related( - "user", "application" - ).get(token=token_value) + token = ( + get_access_token_model().objects.select_related("user", "application").get(token=token_value) + ) except ObjectDoesNotExist: return HttpResponse( - content=json.dumps({"active": False}), - status=401, - content_type="application/json" + content=json.dumps({"active": False}), status=401, content_type="application/json" ) else: if token.is_valid(): @@ -46,9 +45,15 @@ def get_token_response(token_value=None): data["username"] = token.user.get_username() return HttpResponse(content=json.dumps(data), status=200, content_type="application/json") else: - return HttpResponse(content=json.dumps({ - "active": False, - }), status=200, content_type="application/json") + return HttpResponse( + content=json.dumps( + { + "active": False, + } + ), + status=200, + content_type="application/json", + ) def get(self, request, *args, **kwargs): """ diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index b5d0d4145..0a0c66ea9 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -26,6 +26,7 @@ class OAuthLibMixin: * oauthlib_backend_class """ + server_class = None validator_class = None oauthlib_backend_class = None @@ -38,7 +39,8 @@ def get_server_class(cls): if cls.server_class is None: raise ImproperlyConfigured( "OAuthLibMixin requires either a definition of 'server_class'" - " or an implementation of 'get_server_class()'") + " or an implementation of 'get_server_class()'" + ) else: return cls.server_class @@ -50,7 +52,8 @@ def get_validator_class(cls): if cls.validator_class is None: raise ImproperlyConfigured( "OAuthLibMixin requires either a definition of 'validator_class'" - " or an implementation of 'get_validator_class()'") + " or an implementation of 'get_validator_class()'" + ) else: return cls.validator_class @@ -62,7 +65,8 @@ def get_oauthlib_backend_class(cls): if cls.oauthlib_backend_class is None: raise ImproperlyConfigured( "OAuthLibMixin requires either a definition of 'oauthlib_backend_class'" - " or an implementation of 'get_oauthlib_backend_class()'") + " or an implementation of 'get_oauthlib_backend_class()'" + ) else: return cls.oauthlib_backend_class @@ -188,6 +192,7 @@ class ScopedResourceMixin: """ Helper mixin that implements "scopes handling" behaviour """ + required_scopes = None def get_scopes(self, *args, **kwargs): @@ -199,7 +204,8 @@ def get_scopes(self, *args, **kwargs): if self.required_scopes is None: raise ImproperlyConfigured( "ProtectedResourceMixin requires either a definition of 'required_scopes'" - " or an implementation of 'get_scopes()'") + " or an implementation of 'get_scopes()'" + ) else: return self.required_scopes @@ -228,19 +234,18 @@ class ReadWriteScopedResourceMixin(ScopedResourceMixin, OAuthLibMixin): """ Helper mixin that implements "read and write scopes" behavior """ + required_scopes = [] read_write_scope = None def __new__(cls, *args, **kwargs): provided_scopes = get_scopes_backend().get_all_scopes() - read_write_scopes = [oauth2_settings.READ_SCOPE, - oauth2_settings.WRITE_SCOPE] + read_write_scopes = [oauth2_settings.READ_SCOPE, oauth2_settings.WRITE_SCOPE] if not set(read_write_scopes).issubset(set(provided_scopes)): raise ImproperlyConfigured( "ReadWriteScopedResourceMixin requires following scopes {}" - ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format( - read_write_scopes) + ' to be in OAUTH2_PROVIDER["SCOPES"] list in settings'.format(read_write_scopes) ) return super().__new__(cls, *args, **kwargs) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 399953fcd..53fcf3544 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -9,6 +9,7 @@ class AuthorizedTokensListView(LoginRequiredMixin, ListView): """ Show a page where the current logged-in user can see his tokens so they can revoke them """ + context_object_name = "authorized_tokens" template_name = "oauth2_provider/authorized-tokens.html" model = get_access_token_model() @@ -17,15 +18,14 @@ def get_queryset(self): """ Show only user"s tokens """ - return super().get_queryset().select_related("application").filter( - user=self.request.user - ) + return super().get_queryset().select_related("application").filter(user=self.request.user) class AuthorizedTokenDeleteView(LoginRequiredMixin, DeleteView): """ View for revoking a specific token """ + template_name = "oauth2_provider/authorized-token-delete.html" success_url = reverse_lazy("oauth2_provider:authorized-token-list") model = get_access_token_model() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..b0dda8314 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,10 @@ +[tool.black] +line-length = 110 +target-version = ['py35'] +exclude = ''' +^/( + oauth2_provider/migrations/ + | tests/migrations/ + | .tox +) +''' diff --git a/tests/models.py b/tests/models.py index ad3575844..32f9a1b7c 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,8 +1,10 @@ from django.db import models from oauth2_provider.models import ( - AbstractAccessToken, AbstractApplication, - AbstractGrant, AbstractRefreshToken + AbstractAccessToken, + AbstractApplication, + AbstractGrant, + AbstractRefreshToken, ) from oauth2_provider.settings import oauth2_settings @@ -24,16 +26,22 @@ class SampleAccessToken(AbstractAccessToken): custom_field = models.CharField(max_length=255) source_refresh_token = models.OneToOneField( # unique=True implied by the OneToOneField - oauth2_settings.REFRESH_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="s_refreshed_access_token" + oauth2_settings.REFRESH_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="s_refreshed_access_token", ) class SampleRefreshToken(AbstractRefreshToken): custom_field = models.CharField(max_length=255) access_token = models.OneToOneField( - oauth2_settings.ACCESS_TOKEN_MODEL, on_delete=models.SET_NULL, blank=True, null=True, - related_name="s_refresh_token" + oauth2_settings.ACCESS_TOKEN_MODEL, + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="s_refresh_token", ) diff --git a/tests/settings.py b/tests/settings.py index 40eef5ebd..536762c43 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -80,7 +80,6 @@ "django.contrib.staticfiles", "django.contrib.admin", "django.contrib.messages", - "oauth2_provider", "tests", ) @@ -89,29 +88,17 @@ "version": 1, "disable_existing_loggers": False, "formatters": { - "verbose": { - "format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s" - }, - "simple": { - "format": "%(levelname)s %(message)s" - }, - }, - "filters": { - "require_debug_false": { - "()": "django.utils.log.RequireDebugFalse" - } + "verbose": {"format": "%(levelname)s %(asctime)s %(module)s %(process)d %(thread)d %(message)s"}, + "simple": {"format": "%(levelname)s %(message)s"}, }, + "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}}, "handlers": { "mail_admins": { "level": "ERROR", "filters": ["require_debug_false"], - "class": "django.utils.log.AdminEmailHandler" - }, - "console": { - "level": "DEBUG", - "class": "logging.StreamHandler", - "formatter": "simple" + "class": "django.utils.log.AdminEmailHandler", }, + "console": {"level": "DEBUG", "class": "logging.StreamHandler", "formatter": "simple"}, "null": { "level": "DEBUG", "class": "logging.NullHandler", @@ -128,5 +115,5 @@ "level": "DEBUG", "propagate": True, }, - } + }, } diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 8f281611b..0e476054a 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -24,7 +24,6 @@ def tearDown(self): class TestApplicationRegistrationView(BaseTest): - def test_get_form_class(self): """ Tests that the form class returned by the "get_form_class" method is @@ -62,10 +61,11 @@ def test_application_registration_user(self): class TestApplicationViews(BaseTest): def _create_application(self, name, user): app = Application.objects.create( - name=name, redirect_uris="http://example.com", + name=name, + redirect_uris="http://example.com", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - user=user + user=user, ) return app diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 1e1cbb544..ddf64d167 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -19,17 +19,17 @@ class BaseTest(TestCase): """ Base class for cases in this module """ + def setUp(self): self.user = UserModel.objects.create_user("user", "test@example.com", "123456") self.app = ApplicationModel.objects.create( name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, - user=self.user + user=self.user, ) self.token = AccessTokenModel.objects.create( - user=self.user, token="tokstr", application=self.app, - expires=now() + timedelta(days=365) + user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) ) self.factory = RequestFactory() @@ -40,7 +40,6 @@ def tearDown(self): class TestOAuth2Backend(BaseTest): - def test_authenticate(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "tokstr", @@ -83,7 +82,6 @@ def test_get_user(self): } ) class TestOAuth2Middleware(BaseTest): - def setUp(self): super().setUp() self.anon_user = AnonymousUser() diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index a80a54490..44c474380 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -13,8 +13,10 @@ from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( - get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView @@ -74,13 +76,16 @@ class TestRegressionIssue315(BaseTest): def test_request_is_not_overwritten(self): self.client.login(username="test_user", password="123456") - response = self.client.get(reverse("oauth2_provider:authorize"), { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + response = self.client.get( + reverse("oauth2_provider:authorize"), + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }, + ) self.assertEqual(response.status_code, 200) assert "request" not in response.context_data @@ -94,13 +99,16 @@ def test_skip_authorization_completely(self): self.application.skip_authorization = True self.application.save() - response = self.client.get(reverse("oauth2_provider:authorize"), { - "client_id": self.application.client_id, - "response_type": "code", - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "http://example.org", - }) + response = self.client.get( + reverse("oauth2_provider:authorize"), + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + }, + ) self.assertEqual(response.status_code, 302) def test_pre_auth_invalid_client(self): @@ -118,7 +126,7 @@ def test_pre_auth_invalid_client(self): self.assertEqual(response.status_code, 400) self.assertEqual( response.context_data["url"], - "?error=invalid_request&error_description=Invalid+client_id+parameter+value." + "?error=invalid_request&error_description=Invalid+client_id+parameter+value.", ) def test_pre_auth_valid_client(self): @@ -176,10 +184,11 @@ def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): def test_pre_auth_approval_prompt(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") @@ -204,10 +213,11 @@ def test_pre_auth_approval_prompt_default(self): self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -224,10 +234,11 @@ def test_pre_auth_approval_prompt_default_override(self): oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="test_user", password="123456") query_data = { @@ -536,9 +547,9 @@ def generate_pkce_codes(self, algorithm, length=43): """ code_verifier = get_random_string(length) if algorithm == "S256": - code_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ).decode().rstrip("=") + code_challenge = ( + base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()).decode().rstrip("=") + ) else: code_challenge = code_verifier return code_verifier, code_challenge @@ -574,7 +585,7 @@ def test_basic_auth(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -596,7 +607,7 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -609,7 +620,7 @@ def test_refresh(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) @@ -641,7 +652,7 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -654,7 +665,7 @@ def test_refresh_with_grace_period(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) @@ -693,7 +704,7 @@ def test_refresh_invalidates_old_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -725,7 +736,7 @@ def test_refresh_no_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -753,7 +764,7 @@ def test_refresh_bad_scopes(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -779,7 +790,7 @@ def test_refresh_fail_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -809,7 +820,7 @@ def test_refresh_repeating_requests(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -847,7 +858,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -878,7 +889,7 @@ def test_basic_auth_bad_authcode(self): token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -891,11 +902,7 @@ def test_basic_auth_bad_granttype(self): """ self.client.login(username="test_user", password="123456") - token_request_data = { - "grant_type": "UNKNOWN", - "code": "BLAH", - "redirect_uri": "http://example.org" - } + token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) @@ -907,14 +914,19 @@ def test_basic_auth_grant_expired(self): """ self.client.login(username="test_user", password="123456") g = Grant( - application=self.application, user=self.test_user, code="BLAH", - expires=timezone.now(), redirect_uri="", scope="") + application=self.application, + user=self.test_user, + code="BLAH", + expires=timezone.now(), + redirect_uri="", + scope="", + ) g.save() token_request_data = { "grant_type": "authorization_code", "code": "BLAH", - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -931,7 +943,7 @@ def test_basic_auth_bad_secret(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") @@ -948,7 +960,7 @@ def test_basic_auth_wrong_auth_type(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) @@ -997,7 +1009,7 @@ def test_public(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1029,7 +1041,7 @@ def test_public_pkce_S256_authorize_get(self): "response_type": "code", "allow": True, "code_challenge": code_challenge, - "code_challenge_method": "S256" + "code_challenge_method": "S256", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) @@ -1058,7 +1070,7 @@ def test_public_pkce_plain_authorize_get(self): "response_type": "code", "allow": True, "code_challenge": code_challenge, - "code_challenge_method": "plain" + "code_challenge_method": "plain", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) @@ -1084,7 +1096,7 @@ def test_public_pkce_S256(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1114,7 +1126,7 @@ def test_public_pkce_plain(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": code_verifier + "code_verifier": code_verifier, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1174,7 +1186,7 @@ def test_public_pkce_missing_code_challenge(self): "redirect_uri": "http://example.org", "response_type": "code", "allow": True, - "code_challenge_method": "S256" + "code_challenge_method": "S256", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) @@ -1201,7 +1213,7 @@ def test_public_pkce_missing_code_challenge_method(self): "redirect_uri": "http://example.org", "response_type": "code", "allow": True, - "code_challenge": code_challenge + "code_challenge": code_challenge, } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) @@ -1226,7 +1238,7 @@ def test_public_pkce_S256_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1251,7 +1263,7 @@ def test_public_pkce_plain_invalid_code_verifier(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "code_verifier": "invalid" + "code_verifier": "invalid", } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1275,7 +1287,7 @@ def test_public_pkce_S256_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1299,7 +1311,7 @@ def test_public_pkce_plain_missing_code_verifier(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "http://example.org", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1321,7 +1333,7 @@ def test_malicious_redirect_uri(self): "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": "/../", - "client_id": self.application.client_id + "client_id": self.application.client_id, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1353,7 +1365,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=bar" + "redirect_uri": "http://example.org?foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1388,7 +1400,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org?foo=baraa" + "redirect_uri": "http://example.org?foo=baraa", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1423,7 +1435,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.com?bar=baz&foo=bar" + "redirect_uri": "http://example.com?bar=baz&foo=bar", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1543,7 +1555,7 @@ def test_resource_access_allowed(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -1575,7 +1587,6 @@ def test_resource_access_deny(self): class TestDefaultScopes(BaseTest): - def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 0f3756358..966eb826b 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -158,11 +158,7 @@ def test_client_resource_password_based(self): authorization_grant_type=Application.GRANT_PASSWORD, ) - token_request_data = { - "grant_type": "password", - "username": "test_user", - "password": "123456" - } + token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} auth_headers = get_basic_auth_header( quote_plus(self.application.client_id), quote_plus(self.application.client_secret) ) diff --git a/tests/test_commands.py b/tests/test_commands.py index 274eccec5..ff5deba4e 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -12,7 +12,6 @@ class CreateApplicationTest(TestCase): - def test_command_creates_application(self): output = StringIO() self.assertEqual(Application.objects.count(), 0) diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 80d2ae1a2..22ce48e76 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -34,7 +34,7 @@ def setUp(self): scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application + application=self.application, ) oauth2_settings._SCOPES = ["read", "write"] diff --git a/tests/test_generator.py b/tests/test_generator.py index 211713b07..670ac9ea1 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,8 +1,11 @@ from django.test import TestCase from oauth2_provider.generators import ( - BaseHashGenerator, ClientIdGenerator, ClientSecretGenerator, - generate_client_id, generate_client_secret + BaseHashGenerator, + ClientIdGenerator, + ClientSecretGenerator, + generate_client_id, + generate_client_secret, ) from oauth2_provider.settings import oauth2_settings diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 6c06a1294..5fc12b6b1 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -42,6 +42,7 @@ def mocked_requests_post(url, data, *args, **kwargs): """ Mock the response from the authentication server """ + class MockResponse: def __init__(self, json_data, status_code): self.json_data = json_data @@ -51,17 +52,23 @@ def json(self): return self.json_data if "token" in data and data["token"] and data["token"] != "12345678900": - return MockResponse({ - "active": True, - "scope": "read write dolphin", - "client_id": "client_id_{}".format(data["token"]), - "username": "{}_user".format(data["token"]), - "exp": int(calendar.timegm(exp.timetuple())), - }, 200) + return MockResponse( + { + "active": True, + "scope": "read write dolphin", + "client_id": "client_id_{}".format(data["token"]), + "username": "{}_user".format(data["token"]), + "exp": int(calendar.timegm(exp.timetuple())), + }, + 200, + ) - return MockResponse({ - "active": False, - }, 200) + return MockResponse( + { + "active": False, + }, + 200, + ) urlpatterns = [ @@ -75,6 +82,7 @@ class TestTokenIntrospectionAuth(TestCase): """ Tests for Authorization through token introspection """ + def setUp(self): self.validator = OAuth2Validator() self.request = mock.MagicMock(wraps=Request) @@ -91,17 +99,19 @@ def setUp(self): ) self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678900", + user=self.resource_server_user, + token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="introspection" + scope="introspection", ) self.invalid_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678901", + user=self.resource_server_user, + token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), - scope="read write dolphin" + scope="read write dolphin", ) oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] @@ -128,7 +138,7 @@ def test_get_token_from_authentication_server_not_existing_token(self, mock_get) self.resource_server_token.token, oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsNone(token) @@ -141,7 +151,7 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): "foo", oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsInstance(token, AccessToken) self.assertEqual(token.user.username, "foo_user") diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 20196606e..5b3fc58f8 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -23,10 +23,8 @@ class TestTokenIntrospectionViews(TestCase): """ def setUp(self): - self.resource_server_user = UserModel.objects.create_user( - "resource_server", "test@example.com") - self.test_user = UserModel.objects.create_user( - "bar_user", "dev@example.com") + self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") + self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") self.application = Application.objects.create( name="Test Application", @@ -37,38 +35,43 @@ def setUp(self): ) self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, token="12345678900", + user=self.resource_server_user, + token="12345678900", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="introspection" + scope="introspection", ) self.valid_token = AccessToken.objects.create( - user=self.test_user, token="12345678901", + user=self.test_user, + token="12345678901", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) self.invalid_token = AccessToken.objects.create( - user=self.test_user, token="12345678902", + user=self.test_user, + token="12345678902", application=self.application, expires=timezone.now() + datetime.timedelta(days=-1), - scope="read write dolphin" + scope="read write dolphin", ) self.token_without_user = AccessToken.objects.create( - user=None, token="12345678903", + user=None, + token="12345678903", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) self.token_without_app = AccessToken.objects.create( - user=self.test_user, token="12345678904", + user=self.test_user, + token="12345678904", application=None, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write dolphin" + scope="read write dolphin", ) oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] @@ -97,20 +100,22 @@ def test_view_get_valid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_get_valid_token_without_user(self): """ @@ -121,19 +126,21 @@ def test_view_get_valid_token_without_user(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.token_without_user.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.token_without_user.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.token_without_user.scope, - "client_id": self.token_without_user.application.client_id, - "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.token_without_user.scope, + "client_id": self.token_without_user.application.client_id, + "exp": int(calendar.timegm(self.token_without_user.expires.timetuple())), + }, + ) def test_view_get_valid_token_without_app(self): """ @@ -144,19 +151,21 @@ def test_view_get_valid_token_without_app(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.token_without_app.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.token_without_app.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.token_without_app.scope, - "username": self.token_without_app.user.get_username(), - "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.token_without_app.scope, + "username": self.token_without_app.user.get_username(), + "exp": int(calendar.timegm(self.token_without_app.expires.timetuple())), + }, + ) def test_view_get_invalid_token(self): """ @@ -167,16 +176,18 @@ def test_view_get_invalid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": self.invalid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_get_notexisting_token(self): """ @@ -187,16 +198,18 @@ def test_view_get_notexisting_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.get( - reverse("oauth2_provider:introspect"), - {"token": "kaudawelsch"}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers + ) self.assertEqual(response.status_code, 401) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_post_valid_token(self): """ @@ -207,20 +220,22 @@ def test_view_post_valid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_post_invalid_token(self): """ @@ -231,16 +246,18 @@ def test_view_post_invalid_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.invalid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.invalid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_post_notexisting_token(self): """ @@ -251,75 +268,83 @@ def test_view_post_notexisting_token(self): "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, } response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": "kaudawelsch"}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers + ) self.assertEqual(response.status_code, 401) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": False, - }) + self.assertDictEqual( + content, + { + "active": False, + }, + ) def test_view_post_valid_client_creds_basic_auth(self): - """Test HTTP basic auth working - """ - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret) + """Test HTTP basic auth working""" + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_post_invalid_client_creds_basic_auth(self): - """Must fail for invalid client credentials - """ + """Must fail for invalid client credentials""" auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret + "_so_wrong") + self.application.client_id, self.application.client_secret + "_so_wrong" + ) response = self.client.post( - reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token}, - **auth_headers) + reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers + ) self.assertEqual(response.status_code, 403) def test_view_post_valid_client_creds_plaintext(self): - """Test introspecting with credentials in request body - """ + """Test introspecting with credentials in request body""" response = self.client.post( reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token, - "client_id": self.application.client_id, - "client_secret": self.application.client_secret}) + { + "token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + }, + ) self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) - self.assertDictEqual(content, { - "active": True, - "scope": self.valid_token.scope, - "client_id": self.valid_token.application.client_id, - "username": self.valid_token.user.get_username(), - "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), - }) + self.assertDictEqual( + content, + { + "active": True, + "scope": self.valid_token.scope, + "client_id": self.valid_token.application.client_id, + "username": self.valid_token.user.get_username(), + "exp": int(calendar.timegm(self.valid_token.expires.timetuple())), + }, + ) def test_view_post_invalid_client_creds_plaintext(self): - """Must fail for invalid creds in request body. - """ + """Must fail for invalid creds in request body.""" response = self.client.post( reverse("oauth2_provider:introspect"), - {"token": self.valid_token.token, - "client_id": self.application.client_id, - "client_secret": self.application.client_secret + "_so_wrong"}) + { + "token": self.valid_token.token, + "client_id": self.application.client_id, + "client_secret": self.application.client_secret + "_so_wrong", + }, + ) self.assertEqual(response.status_code, 403) def test_select_related_in_view_for_less_db_queries(self): diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 5a4531596..793a5b4b4 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -5,9 +5,7 @@ from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.views.mixins import ( - OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin -) +from oauth2_provider.views.mixins import OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin class BaseTest(TestCase): @@ -70,9 +68,7 @@ class TestView(OAuthLibMixin, View): request.user = "fake" test_view = TestView() - self.assertEqual( - test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend - ) + self.assertEqual(test_view.get_oauthlib_backend_class(), AnotherOauthLibBackend) class TestScopedResourceMixin(BaseTest): diff --git a/tests/test_models.py b/tests/test_models.py index 95e8eb414..c8e06a308 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,8 +6,11 @@ from django.utils import timezone from oauth2_provider.models import ( - clear_expired, get_access_token_model, get_application_model, - get_grant_model, get_refresh_token_model + clear_expired, + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, ) from oauth2_provider.settings import oauth2_settings @@ -20,7 +23,6 @@ class TestModels(TestCase): - def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -34,13 +36,7 @@ def test_allow_scopes(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app - ) + access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) self.assertTrue(access_token.allow_scopes(["read", "write"])) self.assertTrue(access_token.allow_scopes(["write", "read"])) @@ -93,21 +89,9 @@ def test_scopes_property(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - access_token = AccessToken( - user=self.user, - scope="read write", - expires=0, - token="", - application=app - ) + access_token = AccessToken(user=self.user, scope="read write", expires=0, token="", application=app) - access_token2 = AccessToken( - user=self.user, - scope="write", - expires=0, - token="", - application=app - ) + access_token2 = AccessToken(user=self.user, scope="write", expires=0, token="", application=app) self.assertEqual(access_token.scopes, {"read": "Reading scope", "write": "Writing scope"}) self.assertEqual(access_token2.scopes, {"write": "Writing scope"}) @@ -117,10 +101,9 @@ def test_scopes_property(self): OAUTH2_PROVIDER_APPLICATION_MODEL="tests.SampleApplication", OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL="tests.SampleAccessToken", OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", - OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant" + OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) class TestCustomModels(TestCase): - def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -132,7 +115,8 @@ def test_custom_application_model(self): See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) """ related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:application", related_object_names) @@ -163,7 +147,8 @@ def test_custom_access_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:access_token", related_object_names) @@ -194,7 +179,8 @@ def test_custom_refresh_token_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:refresh_token", related_object_names) @@ -225,7 +211,8 @@ def test_custom_grant_model(self): """ # Django internals caches the related objects. related_object_names = [ - f.name for f in UserModel._meta.get_fields() + f.name + for f in UserModel._meta.get_fields() if (f.one_to_many or f.one_to_one) and f.auto_created and not f.concrete ] self.assertNotIn("oauth2_provider:grant", related_object_names) @@ -251,7 +238,6 @@ def test_custom_grant_model_not_installed(self): class TestGrantModel(TestCase): - def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -263,7 +249,6 @@ def test_expires_can_be_none(self): class TestAccessTokenModel(TestCase): - def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") @@ -289,14 +274,12 @@ def test_expires_can_be_none(self): class TestRefreshTokenModel(TestCase): - def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) class TestClearExpired(TestCase): - def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") # Insert two tokens on database. @@ -315,7 +298,7 @@ def setUp(self): user=self.user, created=timezone.now(), updated=timezone.now(), - ) + ) AccessToken.objects.create( token="666", expires=timezone.now(), @@ -324,7 +307,7 @@ def setUp(self): user=self.user, created=timezone.now(), updated=timezone.now(), - ) + ) def test_clear_expired_tokens(self): oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index d844da5f4..f318ccde1 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -13,7 +13,6 @@ class TestOAuthLibCoreBackend(TestCase): - def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() @@ -33,11 +32,13 @@ def test_form_urlencoded_extract_params(self): self.assertIn("password=123456", body) def test_application_json_extract_params(self): - payload = json.dumps({ - "grant_type": "password", - "username": "john", - "password": "123456", - }) + payload = json.dumps( + { + "grant_type": "password", + "username": "john", + "password": "123456", + } + ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) @@ -51,6 +52,7 @@ class TestCustomOAuthLibCoreBackend(TestCase): Tests that the public API behaves as expected when we override the OAuthLibCoreBackend core methods. """ + class MyOAuthLibCore(OAuthLibCore): def _get_extra_credentials(self, request): return 1 @@ -79,11 +81,13 @@ def setUp(self): self.oauthlib_core = JSONOAuthLibCore() def test_application_json_extract_params(self): - payload = json.dumps({ - "grant_type": "password", - "username": "john", - "password": "123456", - }) + payload = json.dumps( + { + "grant_type": "password", + "username": "john", + "password": "123456", + } + ) request = self.factory.post("/o/token/", payload, content_type="application/json") uri, http_method, body, headers = self.oauthlib_core._extract_params(request) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7821148d5..21b0fcfa2 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -7,9 +7,7 @@ from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_refresh_token_model -) +from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator @@ -46,8 +44,12 @@ def setUp(self): self.request.grant_type = "not client" self.validator = OAuth2Validator() self.application = Application.objects.create( - client_id="client_id", client_secret="client_secret", user=self.user, - client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD) + client_id="client_id", + client_secret="client_secret", + user=self.user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, + ) self.request.client = self.application def tearDown(self): @@ -163,13 +165,10 @@ def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(sel token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { @@ -196,13 +195,10 @@ def test_save_bearer_token__checks_to_rotate_tokens(self): token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token token = { @@ -234,13 +230,10 @@ def test_save_bearer_token__with_new_token_equal_to_existing_token__revokes_old_ token="123", user=self.user, expires=timezone.now() + datetime.timedelta(seconds=60), - application=self.application + application=self.application, ) refresh_token = RefreshToken.objects.create( - access_token=access_token, - token="abc", - user=self.user, - application=self.application + access_token=access_token, token="abc", user=self.user, application=self.application ) self.request.refresh_token_instance = refresh_token @@ -318,7 +311,9 @@ class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): def setUp(self): self.user = UserModel.objects.create_user( - "user", "test@example.com", "123456", + "user", + "test@example.com", + "123456", ) self.request = mock.MagicMock(wraps=Request) self.request.user = self.user @@ -340,13 +335,20 @@ def test_validate_bearer_token_does_not_add_error_when_no_token_is_provided(self def test_validate_bearer_token_adds_error_to_the_request_when_an_invalid_token_is_provided(self): access_token = mock.MagicMock(token="some_invalid_token") - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - "error_description": "The access token is invalid.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + "error_description": "The access token is invalid.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_is_provided(self): access_token = AccessToken.objects.create( @@ -355,13 +357,20 @@ def test_validate_bearer_token_adds_error_to_the_request_when_an_expired_token_i expires=timezone.now() - datetime.timedelta(seconds=1), application=self.application, ) - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - "error_description": "The access token has expired.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + "error_description": "The access token has expired.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_insufficient_scope(self): access_token = AccessToken.objects.create( @@ -370,13 +379,20 @@ def test_validate_bearer_token_adds_error_to_the_request_when_a_valid_token_has_ expires=timezone.now() + datetime.timedelta(seconds=1), application=self.application, ) - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, ["some_extra_scope"], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "insufficient_scope", - "error_description": "The access token is valid but does not have enough scope.", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + ["some_extra_scope"], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "insufficient_scope", + "error_description": "The access token is valid but does not have enough scope.", + }, + ) def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_token_is_provided(self): access_token = AccessToken.objects.create( @@ -386,12 +402,19 @@ def test_validate_bearer_token_adds_error_to_the_request_when_a_invalid_custom_t application=self.application, ) with always_invalid_token(): - self.assertFalse(self.validator.validate_bearer_token( - access_token.token, [], self.request, - )) - self.assertDictEqual(self.request.oauth2_error, { - "error": "invalid_token", - }) + self.assertFalse( + self.validator.validate_bearer_token( + access_token.token, + [], + self.request, + ) + ) + self.assertDictEqual( + self.request.oauth2_error, + { + "error": "invalid_token", + }, + ) class TestOAuth2ValidatorErrorResourceToken(TestCase): @@ -408,10 +431,12 @@ def setUp(self): def test_response_when_auth_server_response_return_404(self): with self.assertLogs(logger="oauth2_provider") as mock_log: self.validator._get_token_from_authentication_server( - self.token, self.introspection_url, - self.introspection_token, None) - self.assertIn("ERROR:oauth2_provider:Introspection: Failed to " - "get a valid response from authentication server. " - "Status code: 404, Reason: " - "Not Found.\nNoneType: None", - mock_log.output) + self.token, self.introspection_url, self.introspection_token, None + ) + self.assertIn( + "ERROR:oauth2_provider:Introspection: Failed to " + "get a valid response from authentication server. " + "Status code: 404, Reason: " + "Not Found.\nNoneType: None", + mock_log.output, + ) diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index d4fea56be..f23891dca 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -14,9 +14,12 @@ from rest_framework.views import APIView from oauth2_provider.contrib.rest_framework import ( - IsAuthenticatedOrTokenHasScope, OAuth2Authentication, - TokenHasReadWriteScope, TokenHasResourceScope, - TokenHasScope, TokenMatchesOASRequirements + IsAuthenticatedOrTokenHasScope, + OAuth2Authentication, + TokenHasReadWriteScope, + TokenHasResourceScope, + TokenHasScope, + TokenMatchesOASRequirements, ) from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.settings import oauth2_settings @@ -85,7 +88,10 @@ class MethodScopeAltViewBad(OAuth2View): class MissingAuthentication(BaseAuthentication): def authenticate(self, request): - return ("junk", "junk",) + return ( + "junk", + "junk", + ) class BrokenOAuth2View(MockView): @@ -145,7 +151,7 @@ def setUp(self): scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application + application=self.application, ) def tearDown(self): diff --git a/tests/test_scopes.py b/tests/test_scopes.py index f744d673f..d2efa5856 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -6,13 +6,9 @@ from django.test import RequestFactory, TestCase from django.urls import reverse -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_grant_model -) +from oauth2_provider.models import get_access_token_model, get_application_model, get_grant_model from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views import ( - ReadWriteScopedResourceView, ScopedProtectedResourceView -) +from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView from .utils import get_basic_auth_header @@ -117,7 +113,7 @@ def test_scopes_save_in_access_token(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -153,7 +149,7 @@ def test_scopes_protection_valid(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -195,7 +191,7 @@ def test_scopes_protection_fail(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -237,7 +233,7 @@ def test_multi_scope_fail(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -279,7 +275,7 @@ def test_multi_scope_valid(self): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) @@ -320,7 +316,7 @@ def get_access_token(self, scopes): token_request_data = { "grant_type": "authorization_code", "code": authorization_code, - "redirect_uri": "http://example.org" + "redirect_uri": "http://example.org", } auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 0368ef283..5274ee13e 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -5,9 +5,7 @@ from django.urls import reverse from django.utils import timezone -from oauth2_provider.models import ( - get_access_token_model, get_application_model, get_refresh_token_model -) +from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model from oauth2_provider.settings import oauth2_settings @@ -41,14 +39,12 @@ def tearDown(self): class TestRevocationView(BaseTest): def test_revoke_access_token(self): - """ - - """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { @@ -73,9 +69,11 @@ def test_revoke_access_token_public(self): public_app.save() tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", application=public_app, + user=self.test_user, + token="1234567890", + application=public_app, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { @@ -88,21 +86,19 @@ def test_revoke_access_token_public(self): self.assertEqual(response.status_code, 200) def test_revoke_access_token_with_hint(self): - """ - - """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "access_token" + "token_type_hint": "access_token", } url = reverse("oauth2_provider:revoke-token") @@ -112,10 +108,11 @@ def test_revoke_access_token_with_hint(self): def test_revoke_access_token_with_invalid_hint(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) # invalid hint should have no effect @@ -123,7 +120,7 @@ def test_revoke_access_token_with_invalid_hint(self): "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "bad_hint" + "token_type_hint": "bad_hint", } url = reverse("oauth2_provider:revoke-token") @@ -133,14 +130,14 @@ def test_revoke_access_token_with_invalid_hint(self): def test_revoke_refresh_token(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) rtok = RefreshToken.objects.create( - user=self.test_user, token="999999999", - application=self.application, access_token=tok + user=self.test_user, token="999999999", application=self.application, access_token=tok ) data = { @@ -158,14 +155,14 @@ def test_revoke_refresh_token(self): def test_revoke_refresh_token_with_revoked_access_token(self): tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) rtok = RefreshToken.objects.create( - user=self.test_user, token="999999999", - application=self.application, access_token=tok + user=self.test_user, token="999999999", application=self.application, access_token=tok ) for token in (tok.token, rtok.token): data = { @@ -191,17 +188,18 @@ def test_revoke_token_with_wrong_hint(self): .. _`Section 4.1.2`: http://tools.ietf.org/html/draft-ietf-oauth-revocation-11#section-4.1.2 """ tok = AccessToken.objects.create( - user=self.test_user, token="1234567890", + user=self.test_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) data = { "client_id": self.application.client_id, "client_secret": self.application.client_secret, "token": tok.token, - "token_type_hint": "refresh_token" + "token_type_hint": "refresh_token", } url = reverse("oauth2_provider:revoke-token") diff --git a/tests/test_token_view.py b/tests/test_token_view.py index fc3044cbb..784ea3b84 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -17,6 +17,7 @@ class TestAuthorizedTokenViews(TestCase): """ TestCase superclass for Authorized Token Views" Test Cases """ + def setUp(self): self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") @@ -38,6 +39,7 @@ class TestAuthorizedTokenListView(TestAuthorizedTokenViews): """ Tests for the Authorized Token ListView """ + def test_list_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. @@ -62,10 +64,11 @@ def test_list_view_one_token(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.bar_user, token="1234567890", + user=self.bar_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -80,16 +83,18 @@ def test_list_view_two_tokens(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.bar_user, token="1234567890", + user=self.bar_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) AccessToken.objects.create( - user=self.bar_user, token="0123456789", + user=self.bar_user, + token="0123456789", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -102,10 +107,11 @@ def test_list_view_shows_correct_user_token(self): """ self.client.login(username="bar_user", password="123456") AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) response = self.client.get(reverse("oauth2_provider:authorized-token-list")) @@ -117,15 +123,17 @@ class TestAuthorizedTokenDeleteView(TestAuthorizedTokenViews): """ Tests for the Authorized Token DeleteView """ + def test_delete_view_authorization_required(self): """ Test that the view redirects to login page if user is not logged-in. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) url = reverse("oauth2_provider:authorized-token-delete", kwargs={"pk": self.token.pk}) @@ -138,10 +146,11 @@ def test_delete_view_works(self): Test that a GET on this view returns 200 if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="foo_user", password="123456") @@ -154,10 +163,11 @@ def test_delete_view_token_belongs_to_user(self): Test that a 404 is returned when trying to GET this view with someone else"s tokens. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="bar_user", password="123456") @@ -170,10 +180,11 @@ def test_delete_view_post_actually_deletes(self): Test that a POST on this view works if the token belongs to the logged-in user. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="foo_user", password="123456") @@ -187,10 +198,11 @@ def test_delete_view_only_deletes_user_own_token(self): Test that a 404 is returned when trying to POST on this view with someone else"s tokens. """ self.token = AccessToken.objects.create( - user=self.foo_user, token="1234567890", + user=self.foo_user, + token="1234567890", application=self.application, expires=timezone.now() + datetime.timedelta(days=1), - scope="read write" + scope="read write", ) self.client.login(username="bar_user", password="123456") diff --git a/tox.ini b/tox.ini index ab677a738..284a5bcc9 100644 --- a/tox.ini +++ b/tox.ini @@ -35,15 +35,18 @@ deps = pytest-xdist py27: mock requests +passenv = + PYTEST_ADDOPTS [testenv:py37-docs] basepython = python changedir = docs whitelist_externals = make commands = make html -deps = sphinx<3 - oauthlib>=3.1.0 - m2r>=0.2.1 +deps = + sphinx<3 + oauthlib>=3.1.0 + m2r>=0.2.1 [testenv:py37-flake8] skip_install = True @@ -53,18 +56,19 @@ deps = flake8 flake8-isort flake8-quotes + flake8-black [testenv:install] deps = - twine - setuptools>=39.0 - wheel + twine + setuptools>=39.0 + wheel whitelist_externals= - rm + rm commands = - rm -rf dist - python setup.py sdist bdist_wheel - twine upload dist/* + rm -rf dist + python setup.py sdist bdist_wheel + twine upload dist/* [coverage:run] @@ -76,12 +80,16 @@ max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ application-import-names = oauth2_provider inline-quotes = double +extend-ignore = E203, W503 [isort] -balanced_wrapping = True default_section = THIRDPARTY known_first_party = oauth2_provider -line_length = 80 +line_length = 110 lines_after_imports = 2 -multi_line_output = 5 -skip = oauth2_provider/migrations/, .tox/ +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +skip = oauth2_provider/migrations/, .tox/, tests/migrations/ From 86e78b93a86f37c6731c23bfd21f340c91954136 Mon Sep 17 00:00:00 2001 From: Vaskevich Aleksander <foltfrend@list.ru> Date: Wed, 16 Dec 2020 12:01:07 +0300 Subject: [PATCH 350/722] #898 Added the ability to customize classes for django admin (#904) --- AUTHORS | 1 + CHANGELOG.md | 4 +- docs/settings.rst | 24 ++++++++++ oauth2_provider/admin.py | 42 +++++++++++------ oauth2_provider/models.py | 24 ++++++++++ oauth2_provider/settings.py | 63 +++++++++++++++++++------- tests/admin.py | 17 +++++++ tests/test_settings.py | 90 +++++++++++++++++++++++++++++++++++++ 8 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 tests/admin.py create mode 100644 tests/test_settings.py diff --git a/AUTHORS b/AUTHORS index 4f9cd850b..7e03b37ed 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Contributors Abhishek Patel Alessandro De Angelis +Aleksander Vaskevich Alan Crosswell Anvesh Agarwal Asif Saif Uddin diff --git a/CHANGELOG.md b/CHANGELOG.md index b10ebfe30..1cb02280a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -<!-- +<!-- ## [unreleased] ### Added ### Changed @@ -16,6 +16,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +* #898 Added the ability to customize classes for django admin + ### Added * #884 Added support for Python 3.9 diff --git a/docs/settings.rst b/docs/settings.rst index eb7324672..911edb255 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -97,6 +97,30 @@ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). +APPLICATION_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your application admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.ApplicationAdmin``). + +ACCESS_TOKEN_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your access token admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.AccessTokenAdmin``). + +GRANT_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your grant admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.GrantAdmin``). + +REFRESH_TOKEN_ADMIN_CLASS +~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your refresh token admin class. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.admin.RefreshTokenAdmin``). + OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index a2ec8501a..ed835cd16 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,6 +1,15 @@ from django.contrib import admin -from .models import get_access_token_model, get_application_model, get_grant_model, get_refresh_token_model +from oauth2_provider.models import ( + get_access_token_admin_class, + get_access_token_model, + get_application_admin_class, + get_application_model, + get_grant_admin_class, + get_grant_model, + get_refresh_token_admin_class, + get_refresh_token_model, +) class ApplicationAdmin(admin.ModelAdmin): @@ -13,27 +22,32 @@ class ApplicationAdmin(admin.ModelAdmin): raw_id_fields = ("user",) -class GrantAdmin(admin.ModelAdmin): - list_display = ("code", "application", "user", "expires") - raw_id_fields = ("user",) - - class AccessTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application", "expires") raw_id_fields = ("user", "source_refresh_token") +class GrantAdmin(admin.ModelAdmin): + list_display = ("code", "application", "user", "expires") + raw_id_fields = ("user",) + + class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") -Application = get_application_model() -Grant = get_grant_model() -AccessToken = get_access_token_model() -RefreshToken = get_refresh_token_model() +application_model = get_application_model() +access_token_model = get_access_token_model() +grant_model = get_grant_model() +refresh_token_model = get_refresh_token_model() + +application_admin_class = get_application_admin_class() +access_token_admin_class = get_access_token_admin_class() +grant_admin_class = get_grant_admin_class() +refresh_token_admin_class = get_refresh_token_admin_class() -admin.site.register(Application, ApplicationAdmin) -admin.site.register(Grant, GrantAdmin) -admin.site.register(AccessToken, AccessTokenAdmin) -admin.site.register(RefreshToken, RefreshTokenAdmin) +admin.site.register(application_model, application_admin_class) +admin.site.register(access_token_model, access_token_admin_class) +admin.site.register(grant_model, grant_admin_class) +admin.site.register(refresh_token_model, refresh_token_admin_class) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 77542d35f..23c16bdf6 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -453,6 +453,30 @@ def get_refresh_token_model(): return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) +def get_application_admin_class(): + """ Return the Application admin class that is active in this project. """ + application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS + return application_admin_class + + +def get_access_token_admin_class(): + """ Return the AccessToken admin class that is active in this project. """ + access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS + return access_token_admin_class + + +def get_grant_admin_class(): + """ Return the Grant admin class that is active in this project. """ + grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS + return grant_admin_class + + +def get_refresh_token_admin_class(): + """ Return the RefreshToken admin class that is active in this project. """ + refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS + return refresh_token_admin_class + + def clear_expired(): now = timezone.now() refresh_expire_at = None diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 42c08b676..5d81a05ef 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -15,10 +15,11 @@ OAuth2 Provider settings, checking for user settings first, then falling back to the defaults. """ -import importlib from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.test.signals import setting_changed +from django.utils.module_loading import import_string USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) @@ -53,6 +54,10 @@ "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, + "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", + "ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin", + "GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin", + "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], # Special settings that will be evaluated at runtime @@ -88,6 +93,10 @@ "OAUTH2_VALIDATOR_CLASS", "OAUTH2_BACKEND_CLASS", "SCOPES_BACKEND_CLASS", + "APPLICATION_ADMIN_CLASS", + "ACCESS_TOKEN_ADMIN_CLASS", + "GRANT_ADMIN_CLASS", + "REFRESH_TOKEN_ADMIN_CLASS", ) @@ -96,12 +105,13 @@ def perform_import(val, setting_name): If the given setting is a string import notation, then perform the necessary import or imports. """ - if isinstance(val, (list, tuple)): - return [import_from_string(item, setting_name) for item in val] - elif "." in val: + if val is None: + return None + elif isinstance(val, str): return import_from_string(val, setting_name) - else: - raise ImproperlyConfigured("Bad value for %r: %r" % (setting_name, val)) + elif isinstance(val, (list, tuple)): + return [import_from_string(item, setting_name) for item in val] + return val def import_from_string(val, setting_name): @@ -109,10 +119,7 @@ def import_from_string(val, setting_name): Attempt to import a class from a string representation. """ try: - parts = val.split(".") - module_path, class_name = ".".join(parts[:-1]), parts[-1] - module = importlib.import_module(module_path) - return getattr(module, class_name) + return import_string(val) except ImportError as e: msg = "Could not import %r for setting %r. %s: %s." % (val, setting_name, e.__class__.__name__, e) raise ImportError(msg) @@ -127,14 +134,21 @@ class OAuth2ProviderSettings: """ def __init__(self, user_settings=None, defaults=None, import_strings=None, mandatory=None): - self.user_settings = user_settings or {} - self.defaults = defaults or {} - self.import_strings = import_strings or () + self._user_settings = user_settings or {} + self.defaults = defaults or DEFAULTS + self.import_strings = import_strings or IMPORT_STRINGS self.mandatory = mandatory or () + self._cached_attrs = set() + + @property + def user_settings(self): + if not hasattr(self, "_user_settings"): + self._user_settings = getattr(settings, "OAUTH2_PROVIDER", {}) + return self._user_settings def __getattr__(self, attr): - if attr not in self.defaults.keys(): - raise AttributeError("Invalid OAuth2Provider setting: %r" % (attr)) + if attr not in self.defaults: + raise AttributeError("Invalid OAuth2Provider setting: %s" % attr) try: # Check if present in user settings @@ -166,12 +180,13 @@ def __getattr__(self, attr): self.validate_setting(attr, val) # Cache the result + self._cached_attrs.add(attr) setattr(self, attr, val) return val def validate_setting(self, attr, val): if not val and attr in self.mandatory: - raise AttributeError("OAuth2Provider setting: %r is mandatory" % (attr)) + raise AttributeError("OAuth2Provider setting: %s is mandatory" % attr) @property def server_kwargs(self): @@ -199,5 +214,21 @@ def server_kwargs(self): kwargs.update(self.EXTRA_SERVER_KWARGS) return kwargs + def reload(self): + for attr in self._cached_attrs: + delattr(self, attr) + self._cached_attrs.clear() + if hasattr(self, "_user_settings"): + delattr(self, "_user_settings") + oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) + + +def reload_oauth2_settings(*args, **kwargs): + setting = kwargs["setting"] + if setting == "OAUTH2_PROVIDER": + oauth2_settings.reload() + + +setting_changed.connect(reload_oauth2_settings) diff --git a/tests/admin.py b/tests/admin.py new file mode 100644 index 000000000..557434250 --- /dev/null +++ b/tests/admin.py @@ -0,0 +1,17 @@ +from django.contrib import admin + + +class CustomApplicationAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomAccessTokenAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomGrantAdmin(admin.ModelAdmin): + list_display = ("id",) + + +class CustomRefreshTokenAdmin(admin.ModelAdmin): + list_display = ("id",) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 000000000..379d12c2e --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,90 @@ +from django.test import TestCase +from django.test.utils import override_settings + +from oauth2_provider.admin import ( + get_access_token_admin_class, + get_application_admin_class, + get_grant_admin_class, + get_refresh_token_admin_class, +) +from oauth2_provider.settings import OAuth2ProviderSettings, oauth2_settings +from tests.admin import ( + CustomAccessTokenAdmin, + CustomApplicationAdmin, + CustomGrantAdmin, + CustomRefreshTokenAdmin, +) + + +class TestAdminClass(TestCase): + def test_import_error_message_maintained(self): + """ + Make sure import errors are captured and raised sensibly. + """ + settings = OAuth2ProviderSettings({"CLIENT_ID_GENERATOR_CLASS": "invalid_module.InvalidClassName"}) + with self.assertRaises(ImportError): + settings.CLIENT_ID_GENERATOR_CLASS + + def test_get_application_admin_class(self): + """ + Test for getting class for application admin. + """ + application_admin_class = get_application_admin_class() + default_application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS + assert application_admin_class == default_application_admin_class + + def test_get_access_token_admin_class(self): + """ + Test for getting class for access token admin. + """ + access_token_admin_class = get_access_token_admin_class() + default_access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS + assert access_token_admin_class == default_access_token_admin_class + + def test_get_grant_admin_class(self): + """ + Test for getting class for grant admin. + """ + grant_admin_class = get_grant_admin_class() + default_grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS + assert grant_admin_class, default_grant_admin_class + + def test_get_refresh_token_admin_class(self): + """ + Test for getting class for refresh token admin. + """ + refresh_token_admin_class = get_refresh_token_admin_class() + default_refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS + assert refresh_token_admin_class == default_refresh_token_admin_class + + @override_settings(OAUTH2_PROVIDER={"APPLICATION_ADMIN_CLASS": "tests.admin.CustomApplicationAdmin"}) + def test_get_custom_application_admin_class(self): + """ + Test for getting custom class for application admin. + """ + application_admin_class = get_application_admin_class() + assert application_admin_class == CustomApplicationAdmin + + @override_settings(OAUTH2_PROVIDER={"ACCESS_TOKEN_ADMIN_CLASS": "tests.admin.CustomAccessTokenAdmin"}) + def test_get_custom_access_token_admin_class(self): + """ + Test for getting custom class for access token admin. + """ + access_token_admin_class = get_access_token_admin_class() + assert access_token_admin_class == CustomAccessTokenAdmin + + @override_settings(OAUTH2_PROVIDER={"GRANT_ADMIN_CLASS": "tests.admin.CustomGrantAdmin"}) + def test_get_custom_grant_admin_class(self): + """ + Test for getting custom class for grant admin. + """ + grant_admin_class = get_grant_admin_class() + assert grant_admin_class == CustomGrantAdmin + + @override_settings(OAUTH2_PROVIDER={"REFRESH_TOKEN_ADMIN_CLASS": "tests.admin.CustomRefreshTokenAdmin"}) + def test_get_custom_refresh_token_admin_class(self): + """ + Test for getting custom class for refresh token admin. + """ + refresh_token_admin_class = get_refresh_token_admin_class() + assert refresh_token_admin_class == CustomRefreshTokenAdmin From f344b06d330ce2eec644f02879d399ce34f8450f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Tvrd=C3=ADk?= <pawel.tvrdik@gmail.com> Date: Fri, 18 Dec 2020 11:20:05 +0100 Subject: [PATCH 351/722] Disable 255 chars length limit for redirect uri (#902) (#903) * Disable 255 chars length limit for redirect uri (#902) RFC 7230 recommends to design system to be capable to work with URI at least to 8000 chars long. This commit allows handle redirect_uri that is over 255 chars. --- AUTHORS | 1 + CHANGELOG.md | 1 + .../migrations/0003_auto_20201211_1314.py | 18 ++++++ oauth2_provider/models.py | 2 +- tests/test_models.py | 55 +++++++++++++++---- 5 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 oauth2_provider/migrations/0003_auto_20201211_1314.py diff --git a/AUTHORS b/AUTHORS index 7e03b37ed..a1789bf2a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -26,6 +26,7 @@ Jens Timmerman Jerome Leclanche Jim Graham Paul Oswald +Pavel Tvrdík pySilver Rodney Richardson Silvano Cerza diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cb02280a..353776c5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] * #898 Added the ability to customize classes for django admin +* #903 Disable `redirect_uri` field length limit for `AbstractGrant` ### Added * #884 Added support for Python 3.9 diff --git a/oauth2_provider/migrations/0003_auto_20201211_1314.py b/oauth2_provider/migrations/0003_auto_20201211_1314.py new file mode 100644 index 000000000..2787d51a3 --- /dev/null +++ b/oauth2_provider/migrations/0003_auto_20201211_1314.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.4 on 2020-12-11 13:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0002_auto_20190406_1805'), + ] + + operations = [ + migrations.AlterField( + model_name='grant', + name='redirect_uri', + field=models.TextField(), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 23c16bdf6..fba246e38 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -220,7 +220,7 @@ class AbstractGrant(models.Model): code = models.CharField(max_length=255, unique=True) # code comes from oauthlib application = models.ForeignKey(oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE) expires = models.DateTimeField() - redirect_uri = models.CharField(max_length=255) + redirect_uri = models.TextField() scope = models.TextField(blank=True) created = models.DateTimeField(auto_now_add=True) diff --git a/tests/test_models.py b/tests/test_models.py index c8e06a308..afcd6b419 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -22,10 +22,15 @@ UserModel = get_user_model() -class TestModels(TestCase): +class BaseTestModels(TestCase): def setUp(self): self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + def tearDown(self): + self.user.delete() + + +class TestModels(BaseTestModels): def test_allow_scopes(self): self.client.login(username="test_user", password="123456") app = Application.objects.create( @@ -103,10 +108,7 @@ def test_scopes_property(self): OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) -class TestCustomModels(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - +class TestCustomModels(BaseTestModels): def test_custom_application_model(self): """ If a custom application model is installed, it should be present in @@ -237,7 +239,21 @@ def test_custom_grant_model_not_installed(self): oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" -class TestGrantModel(TestCase): +class TestGrantModel(BaseTestModels): + def setUp(self): + super().setUp() + self.application = Application.objects.create( + name="Test Application", + redirect_uris="", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + def tearDown(self): + self.application.delete() + super().tearDown() + def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -247,11 +263,26 @@ def test_expires_can_be_none(self): self.assertIsNone(grant.expires) self.assertTrue(grant.is_expired()) + def test_redirect_uri_can_be_longer_than_255_chars(self): + long_redirect_uri = "http://example.com/{}".format("authorized/" * 25) + self.assertTrue(len(long_redirect_uri) > 255) + grant = Grant.objects.create( + user=self.user, + code="test_code", + application=self.application, + expires=timezone.now(), + redirect_uri=long_redirect_uri, + scope="", + ) + grant.refresh_from_db() + + # It would be necessary to run test using another DB engine than sqlite + # that transform varchar(255) into text data type. + # https://sqlite.org/datatype3.html#affinity_name_examples + self.assertEqual(grant.redirect_uri, long_redirect_uri) -class TestAccessTokenModel(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") +class TestAccessTokenModel(BaseTestModels): def test_str(self): access_token = AccessToken(token="test_token") self.assertEqual("%s" % access_token, access_token.token) @@ -273,15 +304,15 @@ def test_expires_can_be_none(self): self.assertTrue(access_token.is_expired()) -class TestRefreshTokenModel(TestCase): +class TestRefreshTokenModel(BaseTestModels): def test_str(self): refresh_token = RefreshToken(token="test_token") self.assertEqual("%s" % refresh_token, refresh_token.token) -class TestClearExpired(TestCase): +class TestClearExpired(BaseTestModels): def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + super().setUp() # Insert two tokens on database. app = Application.objects.create( name="test_app", From 6af081c8053dc9712cb4822f5c876d18269b7851 Mon Sep 17 00:00:00 2001 From: Mattia Procopio <promat85@gmail.com> Date: Mon, 21 Dec 2020 13:51:38 +0100 Subject: [PATCH 352/722] Migrate to GitHub Actions (#907) * WIP - GH actions * drop pypy * Fix tox.ini * Try to fix coverage * Add 3.5 (for django 2.2) to GH actions * Minor doc updates. * Rename some tox config. * Minor typo. * Add release workflow. * Remove tarball from download URL. * Ignore errors on Django masters. * Minor fix. Co-authored-by: Jannis Leidel <jannis@leidel.info> --- .github/workflows/release.yml | 40 +++++++++++++ .github/workflows/test.yml | 49 ++++++++++++++++ .travis.yml | 67 ---------------------- README.rst | 13 ++--- docs/contributing.rst | 4 +- setup.cfg | 1 - tox.ini | 103 ++++++++++++++++++---------------- 7 files changed, 152 insertions(+), 125 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/test.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..25051eaff --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: Release + +on: + push: + tags: + - '*' + +jobs: + build: + if: github.repository == 'jazzband/django-oauth-toolkit' + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install -U pip + python -m pip install -U setuptools twine wheel + + - name: Build package + run: | + python setup.py --version + python setup.py sdist --format=gztar bdist_wheel + twine check dist/* + + - name: Upload packages to Jazzband + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') + uses: pypa/gh-action-pypi-publish@master + with: + user: jazzband + password: ${{ secrets.JAZZBAND_RELEASE_KEY }} + repository_url: https://jazzband.co/projects/django-oauth-toolkit/upload diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..e659cf70d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: Test + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + max-parallel: 5 + matrix: + python-version: ['3.5' ,'3.6', '3.7', '3.8', '3.9'] + + steps: + - uses: actions/checkout@v2 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + + + - name: Get pip cache dir + id: pip-cache + run: | + echo "::set-output name=dir::$(pip cache dir)" + + - name: Cache + uses: actions/cache@v2 + with: + path: ${{ steps.pip-cache.outputs.dir }} + key: + ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} + restore-keys: | + ${{ matrix.python-version }}-v1- + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade tox tox-gh-actions + + - name: Tox tests + run: | + tox -v + + - name: Upload coverage + uses: codecov/codecov-action@v1 + with: + name: Python ${{ matrix.python-version }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 1505d8cf3..000000000 --- a/.travis.yml +++ /dev/null @@ -1,67 +0,0 @@ -# https://travis-ci.org/jazzband/django-oauth-toolkit -dist: bionic - -language: python - -cache: - directories: - - $HOME/.cache/pip - - $TRAVIS_BUILD_DIR/.tox - -# Make sure to coordinate changes to envlist in tox.ini. -matrix: - allow_failures: - - env: TOXENV=py36-djangomaster - - env: TOXENV=py37-djangomaster - - env: TOXENV=py38-djangomaster - - env: TOXENV=py39-djangomaster - - include: - - python: 3.7 - env: TOXENV=py37-flake8 - - python: 3.7 - env: TOXENV=py37-docs - - - python: 3.9 - env: TOXENV=py39-djangomaster - - python: 3.9 - env: TOXENV=py39-django30 - - python: 3.9 - env: TOXENV=py39-django22 - - - python: 3.8 - env: TOXENV=py38-django31 - - python: 3.8 - env: TOXENV=py38-django30 - - python: 3.8 - env: TOXENV=py38-django22 - - python: 3.8 - env: TOXENV=py38-djangomaster - - - python: 3.7 - env: TOXENV=py37-django31 - - python: 3.7 - env: TOXENV=py37-django30 - - python: 3.7 - env: TOXENV=py37-django22 - - python: 3.7 - env: TOXENV=py37-djangomaster - - - python: 3.6 - env: TOXENV=py36-django31 - - python: 3.6 - env: TOXENV=py36-django30 - - python: 3.6 - env: TOXENV=py36-django22 - - - python: 3.5 - env: TOXENV=py35-django22 - -install: - - pip install coveralls tox tox-travis - -script: - - tox - -after_script: - - coveralls diff --git a/README.rst b/README.rst index 1a5adcd06..b90d7b2e3 100644 --- a/README.rst +++ b/README.rst @@ -10,14 +10,13 @@ Django OAuth Toolkit .. image:: https://badge.fury.io/py/django-oauth-toolkit.png :target: http://badge.fury.io/py/django-oauth-toolkit -.. image:: https://travis-ci.org/jazzband/django-oauth-toolkit.png - :alt: Build Status - :target: https://travis-ci.org/jazzband/django-oauth-toolkit - -.. image:: https://coveralls.io/repos/github/jazzband/django-oauth-toolkit/badge.svg?branch=master - :alt: Coverage Status - :target: https://coveralls.io/github/jazzband/django-oauth-toolkit?branch=master +.. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg + :target: https://github.com/jazzband/django-oauth-toolkit/actions + :alt: GitHub Actions +.. image:: https://codecov.io/gh/jazzband/django-oauth-toolkit/branch/master/graph/badge.svg + :target: https://codecov.io/gh/jazzband/django-oauth-toolkit + :alt: Coverage If you are facing one or more of the following: * Your Django app exposes a web API you want to protect with OAuth2 authentication, diff --git a/docs/contributing.rst b/docs/contributing.rst index 39ed1a427..45f0c3765 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -84,7 +84,7 @@ When you begin your PR, you'll be asked to provide the following: * Any new or changed code requires that a unit test be added or updated. Make sure your tests check for correct error behavior as well as normal expected behavior. Strive for 100% code coverage of any new code you contribute! Improving unit tests is always a welcome contribution. - If your change reduces coverage, you'll be warned by `coveralls <https://coveralls.io/>`_. + If your change reduces coverage, you'll be warned by `Codecov <https://codecov.io/>`_. * Update the documentation (in `docs/`) to describe the new or changed functionality. @@ -190,7 +190,7 @@ You can check your coverage locally with the `coverage <https://pypi.org/project Open mycoverage/index.html in your browser and you can see a coverage summary and coverage details for each file. -There's no need to wait for coveralls to complain after you submit your PR. +There's no need to wait for Codecov to complain after you submit your PR. Code conventions matter ----------------------- diff --git a/setup.cfg b/setup.cfg index 98ef302b8..1a6de586c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -7,7 +7,6 @@ long_description_content_type = text/x-rst author = Federico Frenguelli, Massimiliano Pippi author_email = synasius@gmail.com url = https://github.com/jazzband/django-oauth-toolkit -download_url = https://github.com/jazzband/django-oauth-toolkit/tarball/master keywords = django, oauth, oauth2, oauthlib classifiers = Development Status :: 5 - Production/Stable diff --git a/tox.ini b/tox.ini index 284a5bcc9..cb0129cda 100644 --- a/tox.ini +++ b/tox.ini @@ -1,74 +1,81 @@ [tox] envlist = - py37-flake8, - py37-docs, - py39-django{31,30,22}, - py38-django{31,30,22}, - py37-django{31,30,22}, - py36-django{31,30,22}, - py35-django{22}, - py39-djangomaster, - py38-djangomaster, - py37-djangomaster, - py36-djangomaster, + flake8, + docs, + py{36,37,38,39}-dj{31,30,22}, + py35-dj{22}, + py{36,37,38,39}-djmaster, + +[gh-actions] +python = + 3.5: py35 + 3.6: py36 + 3.7: py37 + 3.8: py38, docs + 3.9: py39 [pytest] django_find_project = false [testenv] -commands = pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} +commands = + pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} + coverage report + coverage xml setenv = - DJANGO_SETTINGS_MODULE = tests.settings - PYTHONPATH = {toxinidir} - PYTHONWARNINGS = all + DJANGO_SETTINGS_MODULE = tests.settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all deps = - django22: Django>=2.2,<3 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 - djangomaster: https://github.com/django/django/archive/master.tar.gz - djangorestframework - oauthlib>=3.1.0 - coverage - pytest - pytest-cov - pytest-django - pytest-xdist - py27: mock - requests + dj22: Django>=2.2,<3 + dj30: Django>=3.0,<3.1 + dj31: Django>=3.1,<3.2 + djmaster: https://github.com/django/django/archive/master.tar.gz + djangorestframework + oauthlib>=3.1.0 + coverage + pytest + pytest-cov + pytest-django + pytest-xdist + requests passenv = PYTEST_ADDOPTS -[testenv:py37-docs] -basepython = python +[testenv:py{36,37,38,39}-djmaster] +ignore_errors = true +ignore_outcome = true + +[testenv:docs] +basepython = python3.8 changedir = docs whitelist_externals = make commands = make html deps = - sphinx<3 - oauthlib>=3.1.0 - m2r>=0.2.1 + sphinx<3 + oauthlib>=3.1.0 + m2r>=0.2.1 -[testenv:py37-flake8] +[testenv:flake8] +basepython = python3.8 skip_install = True -commands = - flake8 {toxinidir} +commands = flake8 {toxinidir} deps = - flake8 - flake8-isort - flake8-quotes - flake8-black + flake8 + flake8-isort + flake8-quotes + flake8-black [testenv:install] deps = - twine - setuptools>=39.0 - wheel -whitelist_externals= - rm + twine + setuptools>=39.0 + wheel +whitelist_externals = rm commands = - rm -rf dist - python setup.py sdist bdist_wheel - twine upload dist/* + rm -rf dist + python setup.py sdist bdist_wheel + twine upload dist/* [coverage:run] From 30100591bdfd2213986c217c6faa0c1da80bc79e Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Mon, 21 Dec 2020 18:37:18 +0100 Subject: [PATCH 353/722] Use SVG for all badges. --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b90d7b2e3..a1754b399 100644 --- a/README.rst +++ b/README.rst @@ -7,7 +7,7 @@ Django OAuth Toolkit *OAuth2 goodies for the Djangonauts!* -.. image:: https://badge.fury.io/py/django-oauth-toolkit.png +.. image:: https://badge.fury.io/py/django-oauth-toolkit.svg :target: http://badge.fury.io/py/django-oauth-toolkit .. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg From 331b49d3312f533bbc41590247199e956b439acf Mon Sep 17 00:00:00 2001 From: Dylan Giesler <44983548+dag18e@users.noreply.github.com> Date: Sun, 7 Feb 2021 10:46:30 -0600 Subject: [PATCH 354/722] Fix select for update (#906) * removed limit on select for update (due to an Oracle 12c limitation) * added my name to authors * Use filter and wrap in a list * documentation --- AUTHORS | 2 ++ CHANGELOG.md | 3 +++ oauth2_provider/models.py | 14 ++++++++------ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/AUTHORS b/AUTHORS index a1789bf2a..bfd2db97d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -35,3 +35,5 @@ Jun Zhou David Smith Łukasz Skarżyński Tom Evans +Dylan Giesler +Spencer Carroll diff --git a/CHANGELOG.md b/CHANGELOG.md index 353776c5b..a8bb0f7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #884 Added support for Python 3.9 +### Fixed +* made token revocation not apply a limit to the `select_for_update` statement #866 + ## [1.3.3] 2020-10-16 ### Added diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index fba246e38..e1644e541 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -401,13 +401,15 @@ def revoke(self): access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() with transaction.atomic(): - self = ( - refresh_token_model.objects.filter(pk=self.pk, revoked__isnull=True) - .select_for_update() - .first() - ) - if not self: + try: + token = refresh_token_model.objects.select_for_update().filter( + pk=self.pk, revoked__isnull=True + ) + except refresh_token_model.DoesNotExist: + return + if not token: return + self = list(token)[0] try: access_token_model.objects.get(id=self.access_token_id).revoke() From ca90e8f6305618c312aad70cdd0e36b1a129eb1d Mon Sep 17 00:00:00 2001 From: Tom Evans <tevans@mintel.com> Date: Sun, 7 Feb 2021 17:01:23 +0000 Subject: [PATCH 355/722] Add live docs rebuilding using sphinx-autobuild (#916) Add a new tox target, `livedocs`, that uses sphinx-autobuild to monitor the docs and auto rebuild the HTML docs when they change. It also starts a webserver on 127.0.0.1:8000 using livereload to serve the docs, which will automatically reload the pages in your browser as they are changed. Add docs on updating the docs. Add sphinx-rtd-theme so that the docs generated in development match the style that they are displayed in on readthedocs. Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/Makefile | 3 +++ docs/conf.py | 9 +++++---- docs/contributing.rst | 26 ++++++++++++++++++++++++++ tox.ini | 8 ++++++-- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 8e9b89e43..c0af4d98f 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -175,3 +175,6 @@ pseudoxml: $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml @echo @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." + +livehtml: + sphinx-autobuild "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py index fefcff4dc..c32af9c03 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -42,6 +42,7 @@ "sphinx.ext.coverage", "rfc", "m2r", + "sphinx_rtd_theme", ] # Add any paths that contain templates here, relative to this directory. @@ -120,7 +121,7 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -# html_theme = 'classic' +html_theme = "sphinx_rtd_theme" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the @@ -200,11 +201,11 @@ latex_elements = { # The paper size ('letterpaper' or 'a4paper'). - #'papersize': 'letterpaper', + # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). - #'pointsize': '10pt', + # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. - #'preamble': '', + # 'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples diff --git a/docs/contributing.rst b/docs/contributing.rst index 45f0c3765..7d90f684c 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -52,6 +52,32 @@ can also (largely) stop worrying about code style, although you should always check how the code looks after ``black`` has formatted it, and think if there is a better way to structure the code so that it is more readable. +Documentation +============= + +You can edit the documentation by editing files in ``docs/``. This project +uses sphinx to turn ``ReStructuredText`` into the HTML docs you are reading. + +In order to build the docs in to HTML, you can run:: + + tox -e docs + +This will build the docs, and place the result in ``docs/_build/html``. +Alternatively, you can run:: + + tox -e livedocs + +This will run ``sphinx`` in a live reload mode, so any changes that you make to +the ``RST`` files will be automatically detected and the HTML files rebuilt. +It will also run a simple HTTP server available at `<http://localhost:8000/>`_ +serving the HTML files, and auto-reload the page when changes are made. + +This allows you to edit the docs and see your changes instantly reflected in +the browser. + +* `ReStructuredText primer + <https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_ + Pull requests ============= diff --git a/tox.ini b/tox.ini index cb0129cda..2ee0f6fb8 100644 --- a/tox.ini +++ b/tox.ini @@ -46,15 +46,19 @@ passenv = ignore_errors = true ignore_outcome = true -[testenv:docs] +[testenv:{docs,livedocs}] basepython = python3.8 changedir = docs whitelist_externals = make -commands = make html +commands = + docs: make html + livedocs: make livehtml deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 + sphinx-rtd-theme + livedocs: sphinx-autobuild [testenv:flake8] basepython = python3.8 From b9083eb43c44229da5b132053a1a7dd30c31635f Mon Sep 17 00:00:00 2001 From: Jonathan Steffan <damaestro@users.noreply.github.com> Date: Sun, 7 Feb 2021 10:11:57 -0700 Subject: [PATCH 356/722] Update docs to inform that the default ACCESS_TOKEN_EXPIRE_SECONDS is 36000. (#917) Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/settings.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 911edb255..be06e83ca 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -31,7 +31,7 @@ ACCESS_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients -can cache the token for a reasonable amount of time. +can cache the token for a reasonable amount of time. (default: 36000) ACCESS_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~ @@ -142,7 +142,7 @@ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. NOTE: This value is completely ignored when validating refresh tokens. If you don't change the validator code and don't run cleartokens all refresh -tokens will last until revoked or the end of time. +tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From e1760df2f301bc3b1e25d20e5e4336741aa44ee9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 8 Feb 2021 09:00:34 -0500 Subject: [PATCH 357/722] Add pt-PT translation (#920) * document how to contribute translations --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/contributing.rst | 18 ++ .../locale/pt/LC_MESSAGES/django.po | 167 ++++++++++++++++++ .../application_confirm_delete.html | 2 +- .../oauth2_provider/application_form.html | 2 +- .../oauth2_provider/application_list.html | 3 +- .../templates/oauth2_provider/authorize.html | 4 +- .../authorized-token-delete.html | 2 +- .../oauth2_provider/authorized-tokens.html | 2 +- 10 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 oauth2_provider/locale/pt/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index bfd2db97d..fca094db4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,6 +29,7 @@ Paul Oswald Pavel Tvrdík pySilver Rodney Richardson +Sandro Rodrigues Silvano Cerza Stéphane Raimbault Jun Zhou diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bb0f7f8..560ad3303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #898 Added the ability to customize classes for django admin * #903 Disable `redirect_uri` field length limit for `AbstractGrant` +* #690 Added pt-PT translations to HTML templates. This enables adding additional translations. ### Added * #884 Added support for Python 3.9 diff --git a/docs/contributing.rst b/docs/contributing.rst index 7d90f684c..c336d0422 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -78,6 +78,24 @@ the browser. * `ReStructuredText primer <https://www.sphinx-doc.org/en/master/usage/restructuredtext/basics.html>`_ +Translations +============ + +You can contribute international language translations using +`django-admin makemessages <https://docs.djangoproject.com/en/dev/ref/django-admin/#makemessages>`_. + +For example, to add Deutsch:: + + cd oauth2_provider + django-admin makemessages --locale de + +Then edit ``locale/de/LC_MESSAGES/django.po`` to add your translations. + +When deploying your app, don't forget to compile the messages with:: + + django-admin compilemessages + + Pull requests ============= diff --git a/oauth2_provider/locale/pt/LC_MESSAGES/django.po b/oauth2_provider/locale/pt/LC_MESSAGES/django.po new file mode 100644 index 000000000..0f111d991 --- /dev/null +++ b/oauth2_provider/locale/pt/LC_MESSAGES/django.po @@ -0,0 +1,167 @@ +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2019-01-25 11:45+0000\n" +"PO-Revision-Date: 2019-01-25 11:45+0000\n" +"Last-Translator: Sandro Rodrigues <srtabs@gmail.com>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: pt-PT\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: docs/_build/html/_sources/templates.rst.txt:94 +#: oauth2_provider/templates/oauth2_provider/authorize.html:8 +#: oauth2_provider/templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autorizar" + +#: docs/_build/html/_sources/templates.rst.txt:103 +#: oauth2_provider/templates/oauth2_provider/authorize.html:17 +msgid "Application requires following permissions" +msgstr "A aplicação requer as seguintes permissões" + +#: oauth2_provider/models.py:41 +msgid "Confidential" +msgstr "Confidencial" + +#: oauth2_provider/models.py:42 +msgid "Public" +msgstr "Público" + +#: oauth2_provider/models.py:50 +msgid "Authorization code" +msgstr "Código de autorização" + +#: oauth2_provider/models.py:51 +msgid "Implicit" +msgstr "Implícito" + +#: oauth2_provider/models.py:52 +msgid "Resource owner password-based" +msgstr "Palavra-passe do proprietário de dados" + +#: oauth2_provider/models.py:53 +msgid "Client credentials" +msgstr "Credenciais do cliente" + +#: oauth2_provider/models.py:67 +msgid "Allowed URIs list, space separated" +msgstr "Lista de URIs permitidos, separados por espaço" + +#: oauth2_provider/models.py:143 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Esquema de redirecionamento não autorizado: {scheme}" + +#: oauth2_provider/models.py:148 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris não pode estar vazio com o grant_type {grant_type}" + +#: oauth2_provider/oauth2_validators.py:166 +msgid "The access token is invalid." +msgstr "O token de acesso é inválido." + +#: oauth2_provider/oauth2_validators.py:171 +msgid "The access token has expired." +msgstr "O token de acesso expirou." + +#: oauth2_provider/oauth2_validators.py:176 +msgid "The access token is valid but does not have enough scope." +msgstr "O token de acesso é válido, mas não tem permissões suficientes." + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Tem a certeza que pretende apagar a aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 +#: oauth2_provider/templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Cancelar" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 +#: oauth2_provider/templates/oauth2_provider/application_detail.html:38 +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Apagar" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ID do Cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Segredo do cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Tipo de cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Tipo de concessão de autorização" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "URI's de redirecionamento" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:36 +#: oauth2_provider/templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Voltar" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Editar" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Editar aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Guardar" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "As tuas aplicações" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nova Aplicação" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Sem aplicações definidas" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Clica aqui" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "se pretender registar uma nova" + +#: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Registar nova aplicação" + +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Tem a certeza que pretende apagar o token?" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokens" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "revogar" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "De momento, não tem tokens autorizados." diff --git a/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html index 35b961a0b..4716dc5b7 100644 --- a/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html +++ b/oauth2_provider/templates/oauth2_provider/application_confirm_delete.html @@ -10,7 +10,7 @@ <h3 class="block-center-heading">{% trans "Are you sure to delete the applicatio <div class="control-group"> <div class="controls"> <a class="btn btn-large" href="{% url "oauth2_provider:list" %}">{% trans "Cancel" %}</a> - <input type="submit" class="btn btn-large btn-danger" name="allow" value="{% trans "Delete" %}"/> + <input type="submit" class="btn btn-large btn-danger" name="allow" value="{% trans 'Delete' %}"/> </div> </div> </form> diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html index 43926e134..dd8a644e8 100644 --- a/oauth2_provider/templates/oauth2_provider/application_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -34,7 +34,7 @@ <h3 class="block-center-heading"> <a class="btn" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}"> {% trans "Go Back" %} </a> - <button type="submit" class="btn btn-primary">Save</button> + <button type="submit" class="btn btn-primary">{% trans "Save" %}</button> </div> </div> </form> diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index 34b299a6c..b8e4f3af4 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -11,8 +11,9 @@ <h3 class="block-center-heading">{% trans "Your applications" %}</h3> {% endfor %} </ul> - <a class="btn btn-success" href="{% url "oauth2_provider:register" %}">New Application</a> + <a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a> {% else %} + <p>{% trans "No applications defined" %}. <a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}</p> {% endif %} </div> diff --git a/oauth2_provider/templates/oauth2_provider/authorize.html b/oauth2_provider/templates/oauth2_provider/authorize.html index 6e6a2a93e..b75efb96d 100644 --- a/oauth2_provider/templates/oauth2_provider/authorize.html +++ b/oauth2_provider/templates/oauth2_provider/authorize.html @@ -26,8 +26,8 @@ <h3 class="block-center-heading">{% trans "Authorize" %} {{ application.name }}? <div class="control-group"> <div class="controls"> - <input type="submit" class="btn btn-large" value="Cancel"/> - <input type="submit" class="btn btn-large btn-primary" name="allow" value="Authorize"/> + <input type="submit" class="btn btn-large" value="{% trans 'Cancel' %}"/> + <input type="submit" class="btn btn-large btn-primary" name="allow" value="{% trans 'Authorize' %}"/> </div> </div> </form> diff --git a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html index e08233a70..02a6ff402 100644 --- a/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html +++ b/oauth2_provider/templates/oauth2_provider/authorized-token-delete.html @@ -4,6 +4,6 @@ {% block content %} <form action="" method="post">{% csrf_token %} <p>{% trans "Are you sure you want to delete this token?" %}</p> - <input type="submit" value="{% trans "Delete" %}" /> + <input type="submit" value="{% trans 'Delete' %}" /> </form> {% endblock %} diff --git a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html index 2c6a028a8..0f2732503 100644 --- a/oauth2_provider/templates/oauth2_provider/authorized-tokens.html +++ b/oauth2_provider/templates/oauth2_provider/authorized-tokens.html @@ -8,7 +8,7 @@ <h1>{% trans "Tokens" %}</h1> {% for authorized_token in authorized_tokens %} <li> {{ authorized_token.application }} - (<a href="{% url 'oauth2_provider:authorized-token-delete' authorized_token.pk %}">revoke</a>) + (<a href="{% url 'oauth2_provider:authorized-token-delete' authorized_token.pk %}">{% trans "revoke" %}</a>) </li> <ul> {% for scope_name, scope_description in authorized_token.scopes.items %} From 37640eeb8b1fda1b9e42d228d35bdbd7a59620b4 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 8 Feb 2021 10:43:14 -0500 Subject: [PATCH 358/722] Release 1.4.0 (#921) --- AUTHORS | 1 + CHANGELOG.md | 14 ++++++++++---- setup.cfg | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index fca094db4..e2f34c43d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -25,6 +25,7 @@ Hiroki Kiyohara Jens Timmerman Jerome Leclanche Jim Graham +Jonathan Steffan Paul Oswald Pavel Tvrdík pySilver diff --git a/CHANGELOG.md b/CHANGELOG.md index 560ad3303..8627d4ae2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,15 +16,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] -* #898 Added the ability to customize classes for django admin -* #903 Disable `redirect_uri` field length limit for `AbstractGrant` -* #690 Added pt-PT translations to HTML templates. This enables adding additional translations. +## [1.4.0] 2021-02-08 ### Added +* #917 Documentation improvement for Access Token expiration. +* #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `locahost:8000` + to display Sphinx documentation with live updates as you edit. +* #891 (for DOT contributors) Added [details](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) + on how best to contribute to this project. * #884 Added support for Python 3.9 +* #898 Added the ability to customize classes for django admin +* #690 Added pt-PT translations to HTML templates. This enables adding additional translations. ### Fixed -* made token revocation not apply a limit to the `select_for_update` statement #866 +* #906 Made token revocation not apply a limit to the `select_for_update` statement (impacts Oracle 12c database). +* #903 Disable `redirect_uri` field length limit for `AbstractGrant` ## [1.3.3] 2020-10-16 diff --git a/setup.cfg b/setup.cfg index 1a6de586c..fc5774a83 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.3.3 +version = 1.4.0 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From 41aa49e6d7fad0a392b1557d1ef6d40ccc444f7e Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Tue, 16 Feb 2021 18:58:57 +0800 Subject: [PATCH 359/722] new style middleware --- AUTHORS | 1 + CHANGELOG.md | 5 +++++ oauth2_provider/middleware.py | 9 +++++---- tests/test_auth_backends.py | 26 ++++++++++++++------------ tox.ini | 4 ++-- 5 files changed, 27 insertions(+), 18 deletions(-) diff --git a/AUTHORS b/AUTHORS index e2f34c43d..ce10613c2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -39,3 +39,4 @@ David Smith Tom Evans Dylan Giesler Spencer Carroll +Dulmandakh Sukhbaatar diff --git a/CHANGELOG.md b/CHANGELOG.md index 8627d4ae2..954403a24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +## [1.4.1] + +### Changed +* #925 OAuth2TokenMiddleware converted to new style middleware, and no longer extends MiddlewareMixin. + ## [1.4.0] 2021-02-08 ### Added diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index b94cb719f..45dc2aca1 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,9 +1,8 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers -from django.utils.deprecation import MiddlewareMixin -class OAuth2TokenMiddleware(MiddlewareMixin): +class OAuth2TokenMiddleware: """ Middleware for OAuth2 user authentication @@ -22,8 +21,10 @@ class OAuth2TokenMiddleware(MiddlewareMixin): It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a reverse proxy can create proper cache keys. """ + def __init__(self, get_response): + self.get_response = get_response - def process_request(self, request): + def __call__(self, request): # do something only if request contains a Bearer token if request.META.get("HTTP_AUTHORIZATION", "").startswith("Bearer"): if not hasattr(request, "user") or request.user.is_anonymous: @@ -31,6 +32,6 @@ def process_request(self, request): if user: request.user = request._cached_user = user - def process_response(self, request, response): + response = self.get_response(request) patch_vary_headers(response, ("Authorization",)) return response diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index ddf64d167..151fc30d2 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -86,18 +86,20 @@ def setUp(self): super().setUp() self.anon_user = AnonymousUser() - def dummy_get_response(request): - return None + def dummy_get_response(self, request): + return HttpResponse() def test_middleware_wrong_headers(self): m = OAuth2TokenMiddleware(self.dummy_get_response) request = self.factory.get("/a-resource") - self.assertIsNone(m.process_request(request)) + m(request) + self.assertFalse(hasattr(request, "user")) auth_headers = { "HTTP_AUTHORIZATION": "Beerer " + "badstring", # a Beer token for you! } request = self.factory.get("/a-resource", **auth_headers) - self.assertIsNone(m.process_request(request)) + m(request) + self.assertFalse(hasattr(request, "user")) def test_middleware_user_is_set(self): m = OAuth2TokenMiddleware(self.dummy_get_response) @@ -106,9 +108,11 @@ def test_middleware_user_is_set(self): } request = self.factory.get("/a-resource", **auth_headers) request.user = self.user - self.assertIsNone(m.process_request(request)) + m(request) + self.assertIs(request.user, self.user) request.user = self.anon_user - self.assertIsNone(m.process_request(request)) + m(request) + self.assertEqual(request.user.pk, self.user.pk) def test_middleware_success(self): m = OAuth2TokenMiddleware(self.dummy_get_response) @@ -116,7 +120,7 @@ def test_middleware_success(self): "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - m.process_request(request) + m(request) self.assertEqual(request.user, self.user) def test_middleware_response(self): @@ -125,9 +129,8 @@ def test_middleware_response(self): "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - response = HttpResponse() - processed = m.process_response(request, response) - self.assertIs(response, processed) + response = m(request) + self.assertIsInstance(response, HttpResponse) def test_middleware_response_header(self): m = OAuth2TokenMiddleware(self.dummy_get_response) @@ -135,7 +138,6 @@ def test_middleware_response_header(self): "HTTP_AUTHORIZATION": "Bearer " + "tokstr", } request = self.factory.get("/a-resource", **auth_headers) - response = HttpResponse() - m.process_response(request, response) + response = m(request) self.assertIn("Vary", response) self.assertIn("Authorization", response["Vary"]) diff --git a/tox.ini b/tox.ini index 2ee0f6fb8..a0335626a 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = docs, py{36,37,38,39}-dj{31,30,22}, py35-dj{22}, - py{36,37,38,39}-djmaster, + py{38,39}-djmaster, [gh-actions] python = @@ -42,7 +42,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{36,37,38,39}-djmaster] +[testenv:py{38,39}-djmaster] ignore_errors = true ignore_outcome = true From e3207d8c555cd017637eba6949ae00c1b0f0e90d Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Tue, 9 Mar 2021 19:19:57 +0100 Subject: [PATCH 360/722] Rename Django's dev branch to main. (#932) More information: https://groups.google.com/g/django-developers/c/tctDuKUGosc/ Refs: https://github.com/django/django/pull/14048 --- tox.ini | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tox.ini b/tox.ini index a0335626a..857049cf5 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,7 @@ envlist = docs, py{36,37,38,39}-dj{31,30,22}, py35-dj{22}, - py{38,39}-djmaster, + py{38,39}-djmain, [gh-actions] python = @@ -30,7 +30,7 @@ deps = dj22: Django>=2.2,<3 dj30: Django>=3.0,<3.1 dj31: Django>=3.1,<3.2 - djmaster: https://github.com/django/django/archive/master.tar.gz + djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 coverage @@ -42,7 +42,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{38,39}-djmaster] +[testenv:py{38,39}-djmain] ignore_errors = true ignore_outcome = true From ffff6b6083079ab6267118c41c79687f9b50b043 Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Tue, 9 Mar 2021 18:42:47 +0000 Subject: [PATCH 361/722] Remove italics from "Models" section header (#927) Single quotes are italics in reST and I see no need for them in this particular heading. Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/models.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/models.rst b/docs/models.rst index 8fcbdc5c7..1e2657ce7 100644 --- a/docs/models.rst +++ b/docs/models.rst @@ -1,5 +1,5 @@ -`Models` -======== +Models +====== .. automodule:: oauth2_provider.models :members: From 1ada5afbfb22dbba471cf56c3489aaf230548792 Mon Sep 17 00:00:00 2001 From: Tom Evans <tevans.uk@googlemail.com> Date: Tue, 9 Mar 2021 19:39:03 +0000 Subject: [PATCH 362/722] Run lint in Github Actions (#933) Run lint in Github Actions to avoid PRs being merged that have accidentally included linting violations. Fix a file that has a linting violation. --- oauth2_provider/middleware.py | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 45dc2aca1..17ba6c35f 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -21,6 +21,7 @@ class OAuth2TokenMiddleware: It also adds "Authorization" to the "Vary" header, so that django's cache middleware or a reverse proxy can create proper cache keys. """ + def __init__(self, get_response): self.get_response = get_response diff --git a/tox.ini b/tox.ini index 857049cf5..f9ca24806 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ python = 3.5: py35 3.6: py36 3.7: py37 - 3.8: py38, docs + 3.8: py38, docs, flake8 3.9: py39 [pytest] From 4c6f059355f078f33caf09d06dfec8ffa8390cb1 Mon Sep 17 00:00:00 2001 From: 2O4 <35725720+2O4@users.noreply.github.com> Date: Tue, 9 Mar 2021 21:18:13 +0100 Subject: [PATCH 363/722] Fix wrong import (#930) * Fix wrong import fix wrong import from `url` (unused in example) to `path` * Update tutorial_02.rst Co-authored-by: Rust Saiargaliev <rustem.saiargaliev@thermondo.de> --- docs/tutorial/tutorial_02.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index 7beb606ce..b05877d7a 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -34,7 +34,7 @@ URL this view will respond to: .. code-block:: python - from django.conf.urls import url, include + from django.urls import path, include import oauth2_provider.views as oauth2_views from django.conf import settings from .views import ApiEndpoint From e09e7d411ff002a15ddccd6fd78098345f3b82ea Mon Sep 17 00:00:00 2001 From: willbeaufoy <will@willbeaufoy.net> Date: Wed, 10 Mar 2021 00:08:07 +0000 Subject: [PATCH 364/722] Fix weird grammar in messages on authorize screen (#935) * Fix weird grammar in messages on authorize screen The existing message to users was 'Application requires following permissions' which sounds like bad english to me. I've changed them to 'Application requires the following permissions'. * Add myself to authors Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + docs/templates.rst | 2 +- oauth2_provider/locale/pt/LC_MESSAGES/django.po | 2 +- oauth2_provider/templates/oauth2_provider/authorize.html | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index ce10613c2..67a49a541 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,3 +40,4 @@ Tom Evans Dylan Giesler Spencer Carroll Dulmandakh Sukhbaatar +Will Beaufoy diff --git a/docs/templates.rst b/docs/templates.rst index 4b7e1033a..4f6320bf7 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -100,7 +100,7 @@ Example (this is the default page you may find on ``templates/oauth2_provider/au {% endif %} {% endfor %} - <p>{% trans "Application requires following permissions" %}</p> + <p>{% trans "Application requires the following permissions" %}</p> <ul> {% for scope in scopes_descriptions %} <li>{{ scope }}</li> diff --git a/oauth2_provider/locale/pt/LC_MESSAGES/django.po b/oauth2_provider/locale/pt/LC_MESSAGES/django.po index 0f111d991..63c47083d 100644 --- a/oauth2_provider/locale/pt/LC_MESSAGES/django.po +++ b/oauth2_provider/locale/pt/LC_MESSAGES/django.po @@ -21,7 +21,7 @@ msgstr "Autorizar" #: docs/_build/html/_sources/templates.rst.txt:103 #: oauth2_provider/templates/oauth2_provider/authorize.html:17 -msgid "Application requires following permissions" +msgid "Application requires the following permissions" msgstr "A aplicação requer as seguintes permissões" #: oauth2_provider/models.py:41 diff --git a/oauth2_provider/templates/oauth2_provider/authorize.html b/oauth2_provider/templates/oauth2_provider/authorize.html index b75efb96d..96c4ca8cd 100644 --- a/oauth2_provider/templates/oauth2_provider/authorize.html +++ b/oauth2_provider/templates/oauth2_provider/authorize.html @@ -14,7 +14,7 @@ <h3 class="block-center-heading">{% trans "Authorize" %} {{ application.name }}? {% endif %} {% endfor %} - <p>{% trans "Application requires following permissions" %}</p> + <p>{% trans "Application requires the following permissions" %}</p> <ul> {% for scope in scopes_descriptions %} <li>{{ scope }}</li> @@ -37,4 +37,4 @@ <h2>Error: {{ error.error }}</h2> <p>{{ error.description }}</p> {% endif %} </div> -{% endblock %} \ No newline at end of file +{% endblock %} From 062408ae5e776f2a1d652c81e0bc4d6c82dc462a Mon Sep 17 00:00:00 2001 From: Rust Saiargaliev <rustem.saiargaliev@thermondo.de> Date: Wed, 10 Mar 2021 01:25:01 +0100 Subject: [PATCH 365/722] GitHub Actions: smart run strategy (#937) * GHA: run CI on PRs and pushes to master only Previously, every pull request was running actions twice, since two signas were fired - push commit in the PR and new PR itself. * Update CHANGELOG * Review comments Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .github/workflows/test.yml | 6 +++++- AUTHORS | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e659cf70d..c542ba13b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,6 +1,10 @@ name: Test -on: [push, pull_request] +on: + push: + branches: + - master + pull_request: jobs: build: diff --git a/AUTHORS b/AUTHORS index 67a49a541..e71c6389f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,4 +40,5 @@ Tom Evans Dylan Giesler Spencer Carroll Dulmandakh Sukhbaatar +Rustem Saiargaliev Will Beaufoy From d6d1f990d3512144096c38a638678c645fca58ac Mon Sep 17 00:00:00 2001 From: Rust Saiargaliev <rustem.saiargaliev@thermondo.de> Date: Wed, 10 Mar 2021 12:39:14 +0100 Subject: [PATCH 366/722] Revert "GitHub Actions: smart run strategy (#937)" (#939) This reverts commit 062408ae5e776f2a1d652c81e0bc4d6c82dc462a. --- .github/workflows/test.yml | 6 +----- AUTHORS | 1 - 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c542ba13b..e659cf70d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,10 +1,6 @@ name: Test -on: - push: - branches: - - master - pull_request: +on: [push, pull_request] jobs: build: diff --git a/AUTHORS b/AUTHORS index e71c6389f..67a49a541 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,5 +40,4 @@ Tom Evans Dylan Giesler Spencer Carroll Dulmandakh Sukhbaatar -Rustem Saiargaliev Will Beaufoy From 2555156171350b7bfbc901e3be543028d576dc6f Mon Sep 17 00:00:00 2001 From: Rust Saiargaliev <rustem.saiargaliev@thermondo.de> Date: Wed, 10 Mar 2021 15:21:55 +0100 Subject: [PATCH 367/722] Drop retired Python 3.5 (#936) * Drop retired Python 3.5 Python 3.5 did reach end-of-life in September 2020: https://www.python.org/downloads/release/python-3510/ We can safely remove it from CI to reduce test running time. * Add my name to AUTHORS Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .github/workflows/test.yml | 2 +- AUTHORS | 1 + CHANGELOG.md | 3 +++ README.rst | 2 +- docs/index.rst | 2 +- pyproject.toml | 2 +- setup.cfg | 1 - tox.ini | 2 -- 8 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e659cf70d..7d257b465 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.5' ,'3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8', '3.9'] steps: - uses: actions/checkout@v2 diff --git a/AUTHORS b/AUTHORS index 67a49a541..ae3d17dec 100644 --- a/AUTHORS +++ b/AUTHORS @@ -41,3 +41,4 @@ Dylan Giesler Spencer Carroll Dulmandakh Sukhbaatar Will Beaufoy +Rustem Saiargaliev diff --git a/CHANGELOG.md b/CHANGELOG.md index 954403a24..adb4eb628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Removed +* Remove support for Python 3.5 + ## [1.4.1] ### Changed diff --git a/README.rst b/README.rst index a1754b399..c96cb28be 100644 --- a/README.rst +++ b/README.rst @@ -41,7 +41,7 @@ Please report any security issues to the JazzBand security team at <security@jaz Requirements ------------ -* Python 3.5+ +* Python 3.6+ * Django 2.1+ * oauthlib 3.1+ diff --git a/docs/index.rst b/docs/index.rst index 75ed1afcf..f4add1bdd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,7 +21,7 @@ If you need support please send a message to the `Django OAuth Toolkit Google Gr Requirements ------------ -* Python 3.5+ +* Python 3.6+ * Django 2.2+ * oauthlib 3.1+ diff --git a/pyproject.toml b/pyproject.toml index b0dda8314..a4b95794e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.black] line-length = 110 -target-version = ['py35'] +target-version = ['py38'] exclude = ''' ^/( oauth2_provider/migrations/ diff --git a/setup.cfg b/setup.cfg index fc5774a83..ce656309c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,7 +19,6 @@ classifiers = License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.5 Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 diff --git a/tox.ini b/tox.ini index f9ca24806..8d0611633 100644 --- a/tox.ini +++ b/tox.ini @@ -3,12 +3,10 @@ envlist = flake8, docs, py{36,37,38,39}-dj{31,30,22}, - py35-dj{22}, py{38,39}-djmain, [gh-actions] python = - 3.5: py35 3.6: py36 3.7: py37 3.8: py38, docs, flake8 From c0a9ac9db99b26303381e05b69895aa63659c73d Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 12 Mar 2021 04:20:14 -0500 Subject: [PATCH 368/722] 1.4.1 release (#940) --- CHANGELOG.md | 6 +++--- oauth2_provider/models.py | 7 +------ setup.cfg | 2 +- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb4eb628..01e45bb33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,14 +16,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] -### Removed -* Remove support for Python 3.5 - ## [1.4.1] ### Changed * #925 OAuth2TokenMiddleware converted to new style middleware, and no longer extends MiddlewareMixin. +### Removed +* #936 Remove support for Python 3.5 + ## [1.4.0] 2021-02-08 ### Added diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e1644e541..835fe24b2 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -401,12 +401,7 @@ def revoke(self): access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() with transaction.atomic(): - try: - token = refresh_token_model.objects.select_for_update().filter( - pk=self.pk, revoked__isnull=True - ) - except refresh_token_model.DoesNotExist: - return + token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True) if not token: return self = list(token)[0] diff --git a/setup.cfg b/setup.cfg index ce656309c..22e81675e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.4.0 +version = 1.4.1 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From b56987e604d37737e50634e04c3a4559d695f6cb Mon Sep 17 00:00:00 2001 From: Tom Evans <tevans.uk@googlemail.com> Date: Wed, 17 Mar 2021 22:08:50 +0000 Subject: [PATCH 369/722] OpenID Connect support (#915) * Openid Connect Core support - Round 3 * Add OpenID connect hybrid grant type * Add OpenID connect algorithm type to Application model * Add OpenID connect id token model * Add nonce Authorization as required by OpenID connect Implicit Flow * Add body to create_authorization_response to pass nonce and future OpenID parameters to oauthlib.common.Request * Add OpenID connect ID token creation and validation methods and scopes * Add OpenID connect response types * Add OpenID connect authorization code flow test * Add OpenID connect implicit flow tests * Add validate_user_match method to OAuth2Validator * Add RSA_PRIVATE_KEY setting with blank value * Update tox * Add get_jwt_bearer_token to OAuth2Validator * Add validate_jwt_bearer_token to OAuth2Validator * Change OAuth2Validator.validate_id_token default return value to False to avoid validation security breach * Change to use .encode to avoid py2.7 tox test error * Add OpenID connect hybrid flow tests * Change to use .encode to avoid py2.7 tox test error * Add RSA_PRIVATE_KEY to the list of settings that cannot be empt * Add support for oidc connect discovery * Use double quotes for strings * Rename migrations to avoid name and order conflict * Remove commando to install OAuthLib from master and removed jwcrypto duplication * Remove python 2 compatible code * Change errors access_denied/unauthorized_client/consent_required/login_required to be 400 as changed in oauthlib/pull/623 * Change iss claim value to come from settings * Change to use openid connect code server class * Change test to include missing state * Add id_token relation to AbstractAccessToken * Add claims property to AbstractIDToken * Change OAuth2Validator._create_access_token to save id_token to access_token * Add userinfo endpoint * Update migrations and remove oauthlib duplication * Remove old generated migrations * Add new migrations * Fix tests * Add nonce to hybrid tests * Add missing new attributes to test migration * Rebase fixing conflicts and tests * Remove auto generate message * Fix flake8 issues * Fix test doc deps * Add project settings to be ignored in coverage * Tweak migrations to support non-overidden models * OIDC_USERINFO_ENDPOINT is not mandatory * refresh_token grant should be support for OpenID hybrid * Fix the user info view, and remove hard dependency on DRF * Use proper URL generation for OIDC endpoints * Support rich ID tokens and userinfo claims Extend the validator and override get_additional_claims based on your own user model. * Bug fix for at_hash generation See https://openid.net/specs/openid-connect-core-1_0.html#id_token-tokenExample to prove algorithm * OIDC_ISS_ENDPOINT is an optional setting * Support OIDC urls from issuer url if provided * Test for generated OIDC urls * Flake * Rebase on master and migrate url function to re_path * Handle invalid token format exceptions as invalid tokens * Merge migrations and sort imports isort for flake8 lint check Co-authored-by: Dave Burkholder <dave@thinkwelldesigns.com> Co-authored-by: Wiliam Souza <wiliamsouza83@gmail.com> Co-authored-by: Allisson Azevedo <allisson@gmail.com> Co-authored-by: fvlima <frederico.vieira@gmail.com> Co-authored-by: Shaun Stanworth <shaun.stanworth@googlemail.com> * Make IDToken admin class swappable * Make OIDC support optional Make OIDC support optional by not requiring OIDC_RSA_PRIVATE_KEY to be set in the settings, and using the standard oauthlib.oauth2.Server class when an OIDC private key is not configured. Add a test fixture wrapping oauth2_settings. This allows individual tests / test suites to override oauth2 settings and have them reset at the end of the test. This avoids configuration leaking from one test to another, and allows us to test multiple different configurations in one test run. When using the oauth2_settings fixture, allow configuration for the test case to be loaded from a pytest marker called oauth2_settings. Split out OIDC specific tests requiring specific OIDC configuration into separate TestCase. Adjust the OAuthLibMixin to fallback to using the server, validator and core classes specified in oauth2_settings when not hardcoded in to the class. These classes can still be specified as hard-coded attributes in sub-classes, but it's no longer required if you just want what is configured in oauth2_settings, so remove all attributes that are just pointing at the configuration anyway. Add a setting ALWAYS_RELOAD_OAUTHLIB_CORE, which causes OAuthLibMixin to reload the OAuthLibCore object on each request. This is only intended to be used during testing, to allow the views to recognise changes in configuration. Show missing coverage lines in the coverage report. Fixes: #873 * Add tests for OIDC userinfo view * Add test for creating the OIDC issuer url for JWTs * Add ID token generation and validation tests * Add tests for OAuth2ProviderSettings * Add tests for IDToken model methods * Remove unnecessary __future__ declarations * Enable OIDC only views only when using OIDC Add a mixin for OIDC only views that checks that OIDC is correctly configured before allowing the request to continue. If OIDC is not enabled, raise an ImproperlyConfigured exception if the site is in DEBUG mode, otherwise log a warning and return a 404 response. * Remove mistakenly committed comment * Add OIDC documentation * Add OIDC support change to CHANGELOG.md * Support nonce and claims in OIDC auth * Add nonce and claims fields to Grant model. * Complete support for nonce in the OIDC auth code flow, and work around an oauthlib bug in the OIDC hybrid flow that caused a nonce presented in the authentication endpoint to be omitted from the ID token. * Switch to using `RequestValidator.finalize_id_token` instead of `RequestValidator.get_id_token`. This allows us to use the oauthlib stock implementation of `get_id_token`, which creates `at_hash` and `c_hash` when appropriate. * Support `claims` in the authentication endpoint to specify what claims are desired in the ID token, as per section 5.5. Interpreting the claims parameter is up to implementers. * Add documentation on using scopes and claims as authentication endpoint parameters to influence what claims to add to the tokens. * Add tests for `nonce` and `claims` handling. * Fix py3.5 tests getting scopes in unexpected order Not entirely sure how my previous changes caused that, but convert them to sets and compare the sets of scopes. * Simplify and optimise get_authorization_code_scopes `code` is sufficient for retrieving an auth code's scopes, and we don't need to do two DB queries where one will suffice. * Serve JWKs using well-known URL JWKs are commonly served from `.well-known/jwks.json`. This isn't a standard, but it is in common usage so it makes sense to conform. * Allow POST for OIDC UserInfo endpoint * Refactor how OIDC issuer url is generated Split out how to generate the OIDC issuer (when not specified in settings) out to a util function. Add tests Dont use reverse_lazy when the next thing we do with the url is to format it in to a string - no time for it to be lazy! * Support HS256 for OIDC * Add full support for HS256 signed OIDC keys. * Add a new default signing algorithm, NO_ALGORITHM. * Add docs on why you shouldn't choose HS256, and how to do it anyway. * Add docs on how to enable an app for OIDC. * Remove OIDC_ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED and generate it depending on whether you have added an RSA key. * Add validation for your application signing algorithm so its not possible to accidentally setup something insecure. * Add `Application.jwk_key` property to load the correct key for an application. * Peek at a supplied id_token to determine the application it belongs to so that we can load the correct key to verify the signature. * Add an OIDC_ENABLED setting, as we can now enable OIDC without adding OIDC_RSA_PRIVATE_KEY. * Update and add new tests. * fix: json.loads on python 3.5 requires a str * Remove changes to create_authorization_response When using OIDC, the additional parameters nonce and claims are correctly extracted by oauthlib, we don't need to manually parse them out and pass them as a phony body parameter. Similarly, the django request is passed down to the final wrapper layer that calls create_authorization_response in oauthlib, we can defer calling `request.get_raw_uri()` until that point and drop passing `uri` down two function calls. This reverts create_authorization_response back to basically how it was pre-OIDC changes. * Reduce changes compared to master There were a few other places where stylistic changes have been made that are not a part of the changes. Revert them to make the patch less different compared to master. * Use simpler import name for oauthlib OIDC server * Fix another reference to oauthlib.openid.Server * Add indirect six dependency from jwcrpyto jwcrypto has a direct dependency on six, but does not list it yet in a release. Previously, cryptography also depended on six, so this was unnoticed. * Django master now supports only python 3.8+ * Store jti instead of the ID token contents Add a jti parameter to each ID token generated. After verifying a received ID token JWT, extract the jti claim to verify that the ID token exists in our database and is not expired. We don't require the contents of the JWT to verify that an ID token hasn't expired or been revoked, just the jti claim, so don't bother to store or index the ID token contents. Because we only would look at this when presented with an ID token JWT, all the claim contents are available in this JWT. Add missing scope check when verifying an ID token, add tests to verify this. Add functions _load_id_token and _load_access_token to OAuth2Validator, analagous to _save_id_token and _create_access_token. These can be overridden in sub-classes to customise loading behaviour, if these models have been swapped. * Code review changes * Don't swallow ValueError in ClientProtectedResourceMixin * Use OAuthLibCore._get_escaped_full_path to encode URI before passing to oauthlib. * You cannot create an ID Token using client credentials flow, so remove dead code handling this eventuality from OAuth2Validator._save_id_token * Remove TODO comment about saving IDTokens, it isn't necessary. * Generate the correct issuer URL from oauthlib OAuthlib will use OAuth2Validator.get_oidc_issuer_endpoint to generate the issuer endpoint; we create a phony django request to use django's mechanisms for generating the fully qualified URL. However, when SSL is enableld, not enough information is passed through to determine that the protocol is HTTPS. Adjust OAuthLibCore.extract_headers() to inject a custom header when the django request is secure. Use this extra header when generating the issuer URL to determine whether to set the protocol to "https", and use a custom django.http.HttpRequest subclass to allow us to set this. Adjust OAuthLibCore.create_authorization_response() to pass through headers to oauthlib to allow this header to be received. * Update OIDC docs * Krl/oidc round three fixes (#1) Add kid to id_token header, conditional on the algorithm used * Fix linting from PR * Handle invalid tokens in userinfo endpoint * Update a comment Co-authored-by: Dave Burkholder <dave@thinkwelldesigns.com> Co-authored-by: Wiliam Souza <wiliamsouza83@gmail.com> Co-authored-by: Allisson Azevedo <allisson@gmail.com> Co-authored-by: fvlima <frederico.vieira@gmail.com> Co-authored-by: Shaun Stanworth <shaun.stanworth@googlemail.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> Co-authored-by: Kristian Rune Larsen <67627991+kristianrunelarsen@users.noreply.github.com> --- .editorconfig | 2 +- .gitignore | 1 + CHANGELOG.md | 3 + docs/index.rst | 1 + docs/oidc.rst | 308 ++++ docs/settings.rst | 65 +- oauth2_provider/admin.py | 10 + oauth2_provider/forms.py | 2 + .../migrations/0004_auto_20200902_2022.py | 60 + oauth2_provider/models.py | 159 ++ oauth2_provider/oauth2_backends.py | 27 +- oauth2_provider/oauth2_validators.py | 233 ++- oauth2_provider/settings.py | 70 +- oauth2_provider/urls.py | 12 +- oauth2_provider/views/__init__.py | 1 + oauth2_provider/views/application.py | 2 + oauth2_provider/views/base.py | 28 +- oauth2_provider/views/generic.py | 14 +- oauth2_provider/views/introspect.py | 2 +- oauth2_provider/views/mixins.py | 63 +- oauth2_provider/views/oidc.py | 97 ++ setup.cfg | 4 + tests/admin.py | 4 + tests/conftest.py | 156 ++ tests/migrations/0001_initial.py | 9 +- tests/presets.py | 45 + tests/settings.py | 21 + tests/test_application_views.py | 9 +- tests/test_authorization_code.py | 354 +++- tests/test_client_credential.py | 8 +- tests/test_decorators.py | 3 - tests/test_generator.py | 23 +- tests/test_hybrid.py | 1431 +++++++++++++++++ tests/test_implicit.py | 207 ++- tests/test_introspection_auth.py | 27 +- tests/test_introspection_view.py | 10 +- tests/test_mixins.py | 84 +- tests/test_models.py | 143 +- tests/test_oauth2_backends.py | 8 +- tests/test_oauth2_validators.py | 80 + tests/test_oidc_views.py | 139 ++ tests/test_password.py | 10 +- tests/test_rest_framework.py | 17 +- tests/test_scopes.py | 32 +- tests/test_scopes_backend.py | 4 +- tests/test_settings.py | 83 +- tests/test_token_revocation.py | 3 - tests/test_validators.py | 5 +- tests/urls.py | 4 +- tests/utils.py | 17 + tox.ini | 17 +- 51 files changed, 3860 insertions(+), 257 deletions(-) create mode 100644 docs/oidc.rst create mode 100644 oauth2_provider/migrations/0004_auto_20200902_2022.py create mode 100644 oauth2_provider/views/oidc.py create mode 100644 tests/conftest.py create mode 100644 tests/presets.py create mode 100644 tests/test_hybrid.py create mode 100644 tests/test_oidc_views.py diff --git a/.editorconfig b/.editorconfig index 2ca598bbd..5a7ffef02 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true -[{Makefile,tox.ini,setup.cfg}] +[{Makefile,setup.cfg}] indent_style = tab [*.{yml,yaml}] diff --git a/.gitignore b/.gitignore index af644d1e3..3643335d4 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ pip-log.txt # Unit test / coverage reports .cache +.pytest_cache .coverage .tox .pytest_cache/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 01e45bb33..58f279398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* #915 Add optional OpenID Connect support. + ## [1.4.1] ### Changed diff --git a/docs/index.rst b/docs/index.rst index f4add1bdd..4f83249f0 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Index views/details models advanced_topics + oidc signals settings resource_server diff --git a/docs/oidc.rst b/docs/oidc.rst new file mode 100644 index 000000000..29c9406bd --- /dev/null +++ b/docs/oidc.rst @@ -0,0 +1,308 @@ +OpenID Connect +++++++++++++++ + +OpenID Connect support +====================== + +``django-oauth-toolkit`` supports OpenID Connect (OIDC), which standardizes +authentication flows and provides a plug and play integration with other +systems. OIDC is built on top of OAuth 2.0 to provide: + +* Generating ID tokens as part of the login process. These are JWT that + describe the user, and can be used to authenticate them to your application. +* Metadata based auto-configuration for providers +* A user info endpoint, which applications can query to get more information + about a user. + +Enabling OIDC doesn't affect your existing OAuth 2.0 flows, these will +continue to work alongside OIDC. + +We support: + +* OpenID Connect Authorization Code Flow +* OpenID Connect Implicit Flow +* OpenID Connect Hybrid Flow + + +Configuration +============= + +OIDC is not enabled by default because it requires additional configuration +that must be provided. ``django-oauth-toolkit`` supports two different +algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a +public key and a private key), and ``HS256``, which uses a symmetric key. + +It is preferrable to use ``RS256``, because this produces a token that can be +verified by anyone using the public key (which is made available and +discoverable by OIDC service auto-discovery, included with +``django-oauth-toolkit``). ``HS256`` on the other hand uses the +``client_secret`` in order to verify keys. This is simpler to implement, but +makes it harder to safely verify tokens. + +Using ``HS256`` also means that you cannot use the Implicit or Hybrid flows, +or verify the tokens in public clients, because you cannot disclose the +``client_secret`` to a public client. If you are using a public client, you +must use ``RS256``. + + +Creating RSA private key +~~~~~~~~~~~~~~~~~~~~~~~~ + +To use ``RS256`` requires an RSA private key, which is used for signing JWT. You +can generate this using the `openssl`_ tool:: + + openssl genrsa -out oidc.key 4096 + +This will generate a 4096-bit RSA key, which will be sufficient for our needs. + +.. _openssl: https://www.openssl.org + +.. warning:: + The contents of this key *must* be kept a secret. Don't put it in your + settings and commit it to version control! + + If the key is ever accidentally disclosed, an attacker could use it to + forge JWT tokens that verify as issued by your OAuth provider, which is + very bad! + + If it is ever disclosed, you should immediately replace the key. + + Safe ways to handle it would be: + + * Store it in a secure system like `Hashicorp Vault`_, and inject it in to + your environment when running your server. + * Store it in a secure file on your server, and use your initialization + scripts to inject it in to your environment. + +.. _Hashicorp Vault: https://www.hashicorp.com/products/vault + +Now we need to add this key to our settings and allow the ``openid`` scope to +be used. Assuming we have set an environment variable called +``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: + + import os.environ + + OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, + "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"), + "SCOPES": { + "openid": "OpenID Connect scope", + # ... any other scopes that you use + }, + # ... any other settings you want + } + +If you are adding OIDC support to an existing OAuth 2.0 provider site, and you +are currently using a custom class for ``OAUTH2_SERVER_CLASS``, you must +change this class to derive from ``oauthlib.openid.Server`` instead of +``oauthlib.oauth2.Server``. + +With ``RSA`` key-pairs, the public key can be generated from the private key, +so there is no need to add a setting for the public key. + +Using ``HS256`` keys +~~~~~~~~~~~~~~~~~~~~ + +If you would prefer to use just ``HS256`` keys, you don't need to create any +additional keys, ``django-oauth-toolkit`` will just use the application's +``client_secret`` to sign the JWT token. + +In this case, you just need to enable OIDC and add ``openid`` to your list of +scopes in your ``settings.py``:: + + OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, + "SCOPES": { + "openid": "OpenID Connect scope", + # ... any other scopes that you use + }, + # ... any other settings you want + } + +.. info:: + If you want to enable ``RS256`` at a later date, you can do so - just add + the private key as described above. + +Setting up OIDC enabled clients +=============================== + +Setting up an OIDC client in ``django-oauth-toolkit`` is simple - in fact, all +existing OAuth 2.0 Authorization Code Flow and Implicit Flow applications that +are already configured can be easily updated to use OIDC by setting the +appropriate algorithm for them to use. + +You can also switch existing apps to use OIDC Hybrid Flow by changing their +Authorization Grant Type and selecting a signing algorithm to use. + +You can read about the pros and cons of the different flows in `this excellent +article`_ from Robert Broeckelmann. + +.. _this excellent article: https://medium.com/@robert.broeckelmann/when-to-use-which-oauth2-grants-and-oidc-flows-ec6a5c00d864 + +OIDC Authorization Code Flow +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To create an OIDC Authorization Code Flow client, create an ``Application`` +with the grant type ``Authorization code`` and select your desired signing +algorithm. + +When making an authorization request, be sure to include ``openid`` as a +scope. When the code is exchanged for the access token, the response will +also contain an ID token JWT. + +If the ``openid`` scope is not requested, authorization requests will be +treated as standard OAuth 2.0 Authorization Code Grant requests. + +With ``PKCE`` enabled, even public clients can use this flow, and it is the most +secure and recommended flow. + +OIDC Implicit Flow +~~~~~~~~~~~~~~~~~~ + +OIDC Implicit Flow is very similar to OAuth 2.0 Implicit Grant, except that +the client can request a ``response_type`` of ``id_token`` or ``id_token +token``. Requesting just ``token`` is also possible, but it would make it not +an OIDC flow and would fall back to being the same as OAuth 2.0 Implicit +Grant. + +To setup an OIDC Implicit Flow client, simply create an ``Application`` with +the a grant type of ``Implicit`` and select your desired signing algorithm, +and configure the client to request the ``openid`` scope and an OIDC +``response_type`` (``id_token`` or ``id_token token``). + + +OIDC Hybrid Flow +~~~~~~~~~~~~~~~~ + +OIDC Hybrid Flow is a mixture of the previous two flows. It allows the ID +token and an access token to be returned to the frontend, whilst also +allowing the backend to retrieve the ID token and an access token (not +necessarily the same access token) on the backend. + +To setup an OIDC Hybrid Flow application, create an ``Application`` with a +grant type of ``OpenID connect hybrid`` and select your desired signing +algorithm. + + +Customizing the OIDC responses +============================== + +This basic configuration will give you a basic working OIDC setup, but your +ID tokens will have very few claims in them, and the ``UserInfo`` service will +just return the same claims as the ID token. + +To configure all of these things we need to customize the +``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in +our project, eg ``my_project/oauth_validator.py``:: + + from oauth2_provider.oauth2_validators import OAuth2Validator + + + class CustomOAuth2Validator(OAuth2Validator): + pass + + +and then configure our site to use this in our ``settings.py``:: + + OAUTH2_PROVIDER = { + "OAUTH2_VALIDATOR_CLASS": "my_project.oauth_validators.CustomOAuth2Validator", + # ... other settings + } + +Now we can customize the tokens and the responses that are produced by adding +methods to our custom validator. + + +Adding claims to the ID token +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default the ID token will just have a ``sub`` claim (in addition to the +required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), +and the ``sub`` claim will use the primary key of the user as the value. +You'll probably want to customize this and add additional claims or change +what is sent for the ``sub`` claim. To do so, you will need to add a method to +our custom validator:: + + class CustomOAuth2Validator(OAuth2Validator): + + def get_additional_claims(self, request): + return { + "sub": request.user.email, + "first_name": request.user.first_name, + "last_name": request.user.last_name, + } + +.. note:: + This ``request`` object is not a ``django.http.Request`` object, but an + ``oauthlib.common.Request`` object. This has a number of attributes that + you can use to decide what claims to put in to the ID token: + + * ``request.scopes`` - a list of the scopes requested by the client when + making an authorization request. + * ``request.claims`` - a dictionary of the requested claims, using the + `OIDC claims requesting system`_. These must be requested by the client + when making an authorization request. + * ``request.user`` - the django user object. + +.. _OIDC claims requesting system: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter + +What claims you decide to put in to the token is up to you to determine based +upon what the scopes and / or claims means to your provider. + + +Adding information to the ``UserInfo`` service +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``UserInfo`` service is supplied as part of the OIDC service, and is used +to retrieve more information about the user than was supplied in the ID token +when the user logged in to the OIDC client application. It is optional to use +the service. The service is accessed by making a request to the +``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token +retrieved at login as a ``Bearer`` token. + +Again, to modify the content delivered, we need to add a function to our +custom validator. The default implementation adds the claims from the ID +token, so you will probably want to re-use that:: + + class CustomOAuth2Validator(OAuth2Validator): + + def get_userinfo_claims(self, request): + claims = super().get_userinfo_claims() + claims["color_scheme"] = get_color_scheme(request.user) + return claims + + +OIDC Views +========== + +Enabling OIDC support adds three views to ``django-oauth-toolkit``. When OIDC +is not enabled, these views will log that OIDC support is not enabled, and +return a ``404`` response, or if ``DEBUG`` is enabled, raise an +``ImproperlyConfigured`` exception. + +In the docs below, it assumes that you have mounted the +``django-oauth-toolkit`` at ``/o/``. If you have mounted it elsewhere, adjust +the URLs accordingly. + + +ConnectDiscoveryInfoView +~~~~~~~~~~~~~~~~~~~~~~~~ + +Available at ``/o/.well-known/openid-configuration/``, this view provides auto +discovery information to OIDC clients, telling them the JWT issuer to use, the +location of the JWKs to verify JWTs with, the token and userinfo endpoints to +query, and other details. + + +JwksInfoView +~~~~~~~~~~~~ + +Available at ``/o/.well-known/jwks.json``, this view provides details of the key used to sign +the JWTs generated for ID tokens, so that clients are able to verify them. + + +UserInfoView +~~~~~~~~~~~~ + +Available at ``/o/userinfo/``, this view provides extra user details. You can +customize the details included in the response as described above. diff --git a/docs/settings.rst b/docs/settings.rst index be06e83ca..afca76e01 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -124,7 +124,9 @@ Overwrite this value if you wrote your own implementation (subclass of OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) -used in the ``OAuthLibMixin`` that implements OAuth2 grant types. +used in the ``OAuthLibMixin`` that implements OAuth2 grant types. It defaults +to ``oauthlib.oauth2.Server``, except when OIDC support is enabled, when the +default is ``oauthlib.openid.Server``. OAUTH2_VALIDATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~ @@ -247,3 +249,64 @@ PKCE_REQUIRED Default: ``False`` Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool. + + +OIDC_RSA_PRIVATE_KEY +~~~~~~~~~~~~~~~~~~~~ +Default: ``""`` + +The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. + + +OIDC_USERINFO_ENDPOINT +~~~~~~~~~~~~~~~~~~~~~~ +Default: ``""`` + +The url of the userinfo endpoint. Used to advertise the location of the +endpoint in the OIDC discovery metadata. Changing this does not change the URL +that ``django-oauth-toolkit`` adds for the userinfo endpoint, so if you change +this you must also provide the service at that endpoint. + +If unset, the default location is used, eg if ``django-oauth-toolkit`` is +mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``. + +OIDC_ISS_ENDPOINT +~~~~~~~~~~~~~~~~~ +Default: ``""`` + +The URL of the issuer that is used in the ID token JWT and advertised in the +OIDC discovery metadata. Clients use this location to retrieve the OIDC +discovery metadata from ``OIDC_ISS_ENDPOINT`` + +``/.well-known/openid-configuration/``. + +If unset, the default location is used, eg if ``django-oauth-toolkit`` is +mounted at ``/o``, it will be ``<server-address>/o``. + +OIDC_RESPONSE_TYPES_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default:: + + [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ] + + +The response types that are advertised to be supported by this server. + +OIDC_SUBJECT_TYPES_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``["public"]`` + +The subject types that are advertised to be supported by this server. + +OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``["client_secret_post", "client_secret_basic"]`` + +The authentication methods that are advertised to be supported by this server. diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index ed835cd16..79bcf7702 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -7,6 +7,8 @@ get_application_model, get_grant_admin_class, get_grant_model, + get_id_token_admin_class, + get_id_token_model, get_refresh_token_admin_class, get_refresh_token_model, ) @@ -32,6 +34,11 @@ class GrantAdmin(admin.ModelAdmin): raw_id_fields = ("user",) +class IDTokenAdmin(admin.ModelAdmin): + list_display = ("jti", "user", "application", "expires") + raw_id_fields = ("user",) + + class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") @@ -40,14 +47,17 @@ class RefreshTokenAdmin(admin.ModelAdmin): application_model = get_application_model() access_token_model = get_access_token_model() grant_model = get_grant_model() +id_token_model = get_id_token_model() refresh_token_model = get_refresh_token_model() application_admin_class = get_application_admin_class() access_token_admin_class = get_access_token_admin_class() grant_admin_class = get_grant_admin_class() +id_token_admin_class = get_id_token_admin_class() refresh_token_admin_class = get_refresh_token_admin_class() admin.site.register(application_model, application_admin_class) admin.site.register(access_token_model, access_token_admin_class) admin.site.register(grant_model, grant_admin_class) +admin.site.register(id_token_model, id_token_admin_class) admin.site.register(refresh_token_model, refresh_token_admin_class) diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 2e465959a..876213626 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -5,8 +5,10 @@ class AllowForm(forms.Form): allow = forms.BooleanField(required=False) redirect_uri = forms.CharField(widget=forms.HiddenInput()) scope = forms.CharField(widget=forms.HiddenInput()) + nonce = forms.CharField(required=False, widget=forms.HiddenInput()) client_id = forms.CharField(widget=forms.HiddenInput()) state = forms.CharField(required=False, widget=forms.HiddenInput()) response_type = forms.CharField(widget=forms.HiddenInput()) code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) + claims = forms.CharField(required=False, widget=forms.HiddenInput()) diff --git a/oauth2_provider/migrations/0004_auto_20200902_2022.py b/oauth2_provider/migrations/0004_auto_20200902_2022.py new file mode 100644 index 000000000..81dd20d04 --- /dev/null +++ b/oauth2_provider/migrations/0004_auto_20200902_2022.py @@ -0,0 +1,60 @@ +import uuid + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0003_auto_20201211_1314'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='algorithm', + field=models.CharField(blank=True, choices=[("", "No OIDC support"), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + migrations.AlterField( + model_name='application', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), + ), + migrations.CreateModel( + name='IDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ("jti", models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID")), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=oauth2_settings.APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='oauth2_provider_idtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + 'swappable': 'OAUTH2_PROVIDER_ID_TOKEN_MODEL', + }, + ), + migrations.AddField( + model_name='accesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=oauth2_settings.ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name="grant", + name="nonce", + field=models.CharField(blank=True, max_length=255, default=""), + ), + migrations.AddField( + model_name="grant", + name="claims", + field=models.TextField(blank=True), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 835fe24b2..a21cb868b 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,4 +1,5 @@ import logging +import uuid from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -9,6 +10,8 @@ from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ +from jwcrypto import jwk +from jwcrypto.common import base64url_encode from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend @@ -51,11 +54,22 @@ class AbstractApplication(models.Model): GRANT_IMPLICIT = "implicit" GRANT_PASSWORD = "password" GRANT_CLIENT_CREDENTIALS = "client-credentials" + GRANT_OPENID_HYBRID = "openid-hybrid" GRANT_TYPES = ( (GRANT_AUTHORIZATION_CODE, _("Authorization code")), (GRANT_IMPLICIT, _("Implicit")), (GRANT_PASSWORD, _("Resource owner password-based")), (GRANT_CLIENT_CREDENTIALS, _("Client credentials")), + (GRANT_OPENID_HYBRID, _("OpenID connect hybrid")), + ) + + NO_ALGORITHM = "" + RS256_ALGORITHM = "RS256" + HS256_ALGORITHM = "HS256" + ALGORITHM_TYPES = ( + (NO_ALGORITHM, _("No OIDC support")), + (RS256_ALGORITHM, _("RSA with SHA-2 256")), + (HS256_ALGORITHM, _("HMAC with SHA-2 256")), ) id = models.BigAutoField(primary_key=True) @@ -82,6 +96,7 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) + algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=NO_ALGORITHM, blank=True) class Meta: abstract = True @@ -134,6 +149,11 @@ def clean(self): grant_types = ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_IMPLICIT, + AbstractApplication.GRANT_OPENID_HYBRID, + ) + hs_forbidden_grant_types = ( + AbstractApplication.GRANT_IMPLICIT, + AbstractApplication.GRANT_OPENID_HYBRID, ) redirect_uris = self.redirect_uris.strip().split() @@ -153,6 +173,18 @@ def clean(self): grant_type=self.authorization_grant_type ) ) + if self.algorithm == AbstractApplication.RS256_ALGORITHM: + if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: + raise ValidationError(_("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm")) + + if self.algorithm == AbstractApplication.HS256_ALGORITHM: + if any( + ( + self.authorization_grant_type in hs_forbidden_grant_types, + self.client_type == Application.CLIENT_PUBLIC, + ) + ): + raise ValidationError(_("You cannot use HS256 with public grants or clients")) def get_absolute_url(self): return reverse("oauth2_provider:detail", args=[str(self.id)]) @@ -175,6 +207,16 @@ def is_usable(self, request): """ return True + @property + def jwk_key(self): + if self.algorithm == AbstractApplication.RS256_ALGORITHM: + if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: + raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm") + return jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + elif self.algorithm == AbstractApplication.HS256_ALGORITHM: + return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret)) + raise ImproperlyConfigured("This application does not support signed tokens") + class ApplicationManager(models.Manager): def get_by_natural_key(self, client_id): @@ -231,6 +273,9 @@ class AbstractGrant(models.Model): max_length=10, blank=True, default="", choices=CODE_CHALLENGE_METHODS ) + nonce = models.CharField(max_length=255, blank=True, default="") + claims = models.TextField(blank=True) + def is_expired(self): """ Check token expiration with timezone awareness @@ -290,6 +335,13 @@ class AbstractAccessToken(models.Model): max_length=255, unique=True, ) + id_token = models.OneToOneField( + oauth2_settings.ID_TOKEN_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="access_token", + ) application = models.ForeignKey( oauth2_settings.APPLICATION_MODEL, on_delete=models.CASCADE, @@ -430,6 +482,102 @@ class Meta(AbstractRefreshToken.Meta): swappable = "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL" +class AbstractIDToken(models.Model): + """ + An IDToken instance represents the actual token to + access user's resources, as in :openid:`2`. + + Fields: + + * :attr:`user` The Django user representing resources' owner + * :attr:`jti` ID token JWT Token ID, to identify an individual token + * :attr:`application` Application instance + * :attr:`expires` Date and time of token expiration, in DateTime format + * :attr:`scope` Allowed scopes + * :attr:`created` Date and time of token creation, in DateTime format + * :attr:`updated` Date and time of token update, in DateTime format + """ + + id = models.BigAutoField(primary_key=True) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="%(app_label)s_%(class)s", + ) + jti = models.UUIDField(unique=True, default=uuid.uuid4, editable=False, verbose_name="JWT Token ID") + application = models.ForeignKey( + oauth2_settings.APPLICATION_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + ) + expires = models.DateTimeField() + scope = models.TextField(blank=True) + + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def is_valid(self, scopes=None): + """ + Checks if the access token is valid. + + :param scopes: An iterable containing the scopes to check or None + """ + return not self.is_expired() and self.allow_scopes(scopes) + + def is_expired(self): + """ + Check token expiration with timezone awareness + """ + if not self.expires: + return True + + return timezone.now() >= self.expires + + def allow_scopes(self, scopes): + """ + Check if the token allows the provided scopes + + :param scopes: An iterable containing the scopes to check + """ + if not scopes: + return True + + provided_scopes = set(self.scope.split()) + resource_scopes = set(scopes) + + return resource_scopes.issubset(provided_scopes) + + def revoke(self): + """ + Convenience method to uniform tokens' interface, for now + simply remove this token from the database in order to revoke it. + """ + self.delete() + + @property + def scopes(self): + """ + Returns a dictionary of allowed scope names (as keys) with their descriptions (as values) + """ + all_scopes = get_scopes_backend().get_all_scopes() + token_scopes = self.scope.split() + return {name: desc for name, desc in all_scopes.items() if name in token_scopes} + + def __str__(self): + return "JTI: {self.jti} User: {self.user_id}".format(self=self) + + class Meta: + abstract = True + + +class IDToken(AbstractIDToken): + class Meta(AbstractIDToken.Meta): + swappable = "OAUTH2_PROVIDER_ID_TOKEN_MODEL" + + def get_application_model(): """ Return the Application model that is active in this project. """ return apps.get_model(oauth2_settings.APPLICATION_MODEL) @@ -445,6 +593,11 @@ def get_access_token_model(): return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) +def get_id_token_model(): + """ Return the AccessToken model that is active in this project. """ + return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) + + def get_refresh_token_model(): """ Return the RefreshToken model that is active in this project. """ return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) @@ -468,6 +621,12 @@ def get_grant_admin_class(): return grant_admin_class +def get_id_token_admin_class(): + """ Return the IDToken admin class that is active in this project. """ + id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS + return id_token_admin_class + + def get_refresh_token_admin_class(): """ Return the RefreshToken admin class that is active in this project. """ refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 34b1c62cd..dbebd3a8e 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -4,6 +4,7 @@ from oauthlib import oauth2 from oauthlib.common import Request as OauthlibRequest from oauthlib.common import quote, urlencode, urlencoded +from oauthlib.oauth2 import OAuth2Error from .exceptions import FatalClientError, OAuthToolkitError from .settings import oauth2_settings @@ -74,6 +75,10 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + if request.is_secure(): + headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] = "1" + elif "X_DJANGO_OAUTH_TOOLKIT_SECURE" in headers: + del headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] return headers @@ -120,9 +125,14 @@ def create_authorization_response(self, request, scopes, credentials, allow): # add current user to credentials. this will be used by OAUTH2_VALIDATOR_CLASS credentials["user"] = request.user + request_uri, http_method, _, request_headers = self._extract_params(request) headers, body, status = self.server.create_authorization_response( - uri=credentials["redirect_uri"], scopes=scopes, credentials=credentials + uri=request_uri, + http_method=http_method, + headers=request_headers, + scopes=scopes, + credentials=credentials, ) uri = headers.get("Location", None) @@ -163,6 +173,21 @@ def create_revocation_response(self, request): return uri, headers, body, status + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on a + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + uri, http_method, body, headers = self._extract_params(request) + try: + headers, body, status = self.server.create_userinfo_response(uri, http_method, body, headers) + uri = headers.get("Location", None) + return uri, headers, body, status + except OAuth2Error as exc: + return None, exc.headers, exc.json, exc.status_code + def verify_request(self, request, scopes): """ A wrapper method that calls verify_request on `server_class` instance. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index de707bb21..f91c06011 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,7 +1,9 @@ import base64 import binascii import http.client +import json import logging +import uuid from collections import OrderedDict from datetime import datetime, timedelta from urllib.parse import unquote_plus @@ -12,10 +14,14 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q -from django.utils import timezone +from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ -from oauthlib.oauth2 import RequestValidator +from jwcrypto import jws, jwt +from jwcrypto.common import JWException +from jwcrypto.jwt import JWTExpired +from oauthlib.oauth2.rfc6749 import utils +from oauthlib.openid import RequestValidator from .exceptions import FatalClientError from .models import ( @@ -23,6 +29,7 @@ get_access_token_model, get_application_model, get_grant_model, + get_id_token_model, get_refresh_token_model, ) from .scopes import get_scopes_backend @@ -32,18 +39,23 @@ log = logging.getLogger("oauth2_provider") GRANT_TYPE_MAPPING = { - "authorization_code": (AbstractApplication.GRANT_AUTHORIZATION_CODE,), + "authorization_code": ( + AbstractApplication.GRANT_AUTHORIZATION_CODE, + AbstractApplication.GRANT_OPENID_HYBRID, + ), "password": (AbstractApplication.GRANT_PASSWORD,), "client_credentials": (AbstractApplication.GRANT_CLIENT_CREDENTIALS,), "refresh_token": ( AbstractApplication.GRANT_AUTHORIZATION_CODE, AbstractApplication.GRANT_PASSWORD, AbstractApplication.GRANT_CLIENT_CREDENTIALS, + AbstractApplication.GRANT_OPENID_HYBRID, ), } Application = get_application_model() AccessToken = get_access_token_model() +IDToken = get_id_token_model() Grant = get_grant_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() @@ -370,10 +382,7 @@ def validate_bearer_token(self, token, scopes, request): introspection_token = oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN introspection_credentials = oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS - try: - access_token = AccessToken.objects.select_related("application", "user").get(token=token) - except AccessToken.DoesNotExist: - access_token = None + access_token = self._load_access_token(token) # if there is no token or it's invalid then introspect the token if there's an external OAuth server if not access_token or not access_token.is_valid(scopes): @@ -394,12 +403,19 @@ def validate_bearer_token(self, token, scopes, request): self._set_oauth2_error_on_request(request, access_token, scopes) return False + def _load_access_token(self, token): + return AccessToken.objects.select_related("application", "user").filter(token=token).first() + def validate_code(self, client_id, code, client, request, *args, **kwargs): try: grant = Grant.objects.get(code=code, application=client) if not grant.is_expired(): request.scopes = grant.scope.split(" ") request.user = grant.user + if grant.nonce: + request.nonce = grant.nonce + if grant.claims: + request.claims = json.loads(grant.claims) return True return False @@ -422,6 +438,16 @@ def validate_response_type(self, client_id, response_type, client, request, *arg return client.allows_grant_type(AbstractApplication.GRANT_AUTHORIZATION_CODE) elif response_type == "token": return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_IMPLICIT) + elif response_type == "code id_token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) + elif response_type == "code id_token token": + return client.allows_grant_type(AbstractApplication.GRANT_OPENID_HYBRID) else: return False @@ -461,6 +487,12 @@ def get_code_challenge_method(self, code, request): def save_authorization_code(self, client_id, code, request, *args, **kwargs): self._create_authorization_code(request, code) + def get_authorization_code_scopes(self, client_id, code, redirect_uri, request): + scopes = Grant.objects.filter(code=code).values_list("scope", flat=True).first() + if scopes: + return utils.scope_to_list(scopes) + return [] + def rotate_refresh_token(self, request): """ Checks if rotate refresh token is enabled @@ -570,11 +602,15 @@ def save_bearer_token(self, token, request, *args, **kwargs): self._create_access_token(expires, request, token) def _create_access_token(self, expires, request, token, source_refresh_token=None): + id_token = token.get("id_token", None) + if id_token: + id_token = self._load_id_token(id_token) return AccessToken.objects.create( user=request.user, scope=token["scope"], expires=expires, token=token["access_token"], + id_token=id_token, application=request.client, source_refresh_token=source_refresh_token, ) @@ -582,7 +618,6 @@ def _create_access_token(self, expires, request, token, source_refresh_token=Non def _create_authorization_code(self, request, code, expires=None): if not expires: expires = timezone.now() + timedelta(seconds=oauth2_settings.AUTHORIZATION_CODE_EXPIRE_SECONDS) - return Grant.objects.create( application=request.client, user=request.user, @@ -592,6 +627,8 @@ def _create_authorization_code(self, request, code, expires=None): scope=" ".join(request.scopes), code_challenge=request.code_challenge or "", code_challenge_method=request.code_challenge_method or "", + nonce=request.nonce or "", + claims=json.dumps(request.claims or {}), ) def _create_refresh_token(self, request, refresh_token_code, access_token): @@ -665,3 +702,183 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt return rt.application == client + + @transaction.atomic + def _save_id_token(self, jti, request, expires, *args, **kwargs): + scopes = request.scope or " ".join(request.scopes) + + id_token = IDToken.objects.create( + user=request.user, + scope=scopes, + expires=expires, + jti=jti, + application=request.client, + ) + return id_token + + def get_jwt_bearer_token(self, token, token_handler, request): + return self.get_id_token(token, token_handler, request) + + def get_oidc_claims(self, token, token_handler, request): + # Required OIDC claims + claims = { + "sub": str(request.user.id), + } + + # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + claims.update(**self.get_additional_claims(request)) + + return claims + + def get_id_token_dictionary(self, token, token_handler, request): + """ + Get the claims to put in the ID Token. + + These claims are in addition to the claims automatically added by + ``oauthlib`` - aud, iat, nonce, at_hash, c_hash. + + This function adds in iss, exp and auth_time, plus any claims added from + calling ``get_oidc_claims()`` + """ + claims = self.get_oidc_claims(token, token_handler, request) + + expiration_time = timezone.now() + timedelta(seconds=oauth2_settings.ID_TOKEN_EXPIRE_SECONDS) + # Required ID Token claims + claims.update( + **{ + "iss": self.get_oidc_issuer_endpoint(request), + "exp": int(dateformat.format(expiration_time, "U")), + "auth_time": int(dateformat.format(request.user.last_login, "U")), + "jti": str(uuid.uuid4()), + } + ) + + return claims, expiration_time + + def get_oidc_issuer_endpoint(self, request): + return oauth2_settings.oidc_issuer(request) + + def finalize_id_token(self, id_token, token, token_handler, request): + claims, expiration_time = self.get_id_token_dictionary(token, token_handler, request) + id_token.update(**claims) + # Workaround for oauthlib bug #746 + # https://github.com/oauthlib/oauthlib/issues/746 + if "nonce" not in id_token and request.nonce: + id_token["nonce"] = request.nonce + + header = { + "typ": "JWT", + "alg": request.client.algorithm, + } + # RS256 consumers expect a kid in the header for verifying the token + if request.client.algorithm == AbstractApplication.RS256_ALGORITHM: + header["kid"] = request.client.jwk_key.thumbprint() + + jwt_token = jwt.JWT( + header=json.dumps(header, default=str), + claims=json.dumps(id_token, default=str), + ) + jwt_token.make_signed_token(request.client.jwk_key) + id_token = self._save_id_token(id_token["jti"], request, expiration_time) + # this is needed by django rest framework + request.access_token = id_token + request.id_token = id_token + return jwt_token.serialize() + + def validate_jwt_bearer_token(self, token, scopes, request): + return self.validate_id_token(token, scopes, request) + + def validate_id_token(self, token, scopes, request): + """ + When users try to access resources, check that provided id_token is valid + """ + if not token: + return False + + id_token = self._load_id_token(token) + if not id_token: + return False + + if not id_token.allow_scopes(scopes): + return False + + request.client = id_token.application + request.user = id_token.user + request.scopes = scopes + # this is needed by django rest framework + request.access_token = id_token + return True + + def _load_id_token(self, token): + key = self._get_key_for_token(token) + if not key: + return None + try: + jwt_token = jwt.JWT(key=key, jwt=token) + claims = json.loads(jwt_token.claims) + return IDToken.objects.get(jti=claims["jti"]) + except (JWException, JWTExpired, IDToken.DoesNotExist): + return None + + def _get_key_for_token(self, token): + """ + Peek at the unvalidated token to discover who it was issued for + and then use that to load that application and its key. + """ + unverified_token = jws.JWS() + unverified_token.deserialize(token) + claims = json.loads(unverified_token.objects["payload"].decode("utf-8")) + if "aud" not in claims: + return None + application = self._get_client_by_audience(claims["aud"]) + if application: + return application.jwk_key + + def _get_client_by_audience(self, audience): + """ + Load a client by the aud claim in a JWT. + aud may be multi-valued, if your provider makes it so. + This function is separate to allow further customization. + """ + if isinstance(audience, str): + audience = [audience] + return Application.objects.filter(client_id__in=audience).first() + + def validate_user_match(self, id_token_hint, scopes, claims, request): + # TODO: Fix to validate when necessary acording + # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 + # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section + return True + + def get_authorization_code_nonce(self, client_id, code, redirect_uri, request): + """Extracts nonce from saved authorization code. + If present in the Authentication Request, Authorization + Servers MUST include a nonce Claim in the ID Token with the + Claim Value being the nonce value sent in the Authentication + Request. Authorization Servers SHOULD perform no other + processing on nonce values used. The nonce value is a + case-sensitive string. + Only code param should be sufficient to retrieve grant code from + any storage you are using. However, `client_id` and `redirect_uri` + have been validated and can be used also. + :param client_id: Unicode client identifier + :param code: Unicode authorization code grant + :param redirect_uri: Unicode absolute URI + :return: Unicode nonce + Method is used by: + - Authorization Token Grant Dispatcher + """ + nonce = Grant.objects.filter(code=code).values_list("nonce", flat=True).first() + if nonce: + return nonce + + def get_userinfo_claims(self, request): + """ + Generates and saves a new JWT for this request, and returns it as the + current user's claims. + + """ + return self.get_oidc_claims(None, None, request) + + def get_additional_claims(self, request): + return {} diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 5d81a05ef..b862fca7a 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -18,14 +18,18 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.http import HttpRequest from django.test.signals import setting_changed +from django.urls import reverse from django.utils.module_loading import import_string +from oauthlib.common import Request USER_SETTINGS = getattr(settings, "OAUTH2_PROVIDER", None) APPLICATION_MODEL = getattr(settings, "OAUTH2_PROVIDER_APPLICATION_MODEL", "oauth2_provider.Application") ACCESS_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL", "oauth2_provider.AccessToken") +ID_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_ID_TOKEN_MODEL", "oauth2_provider.IDToken") GRANT_MODEL = getattr(settings, "OAUTH2_PROVIDER_GRANT_MODEL", "oauth2_provider.Grant") REFRESH_TOKEN_MODEL = getattr(settings, "OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL", "oauth2_provider.RefreshToken") @@ -37,6 +41,7 @@ "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, "OAUTH2_SERVER_CLASS": "oauthlib.oauth2.Server", + "OIDC_SERVER_CLASS": "oauthlib.openid.Server", "OAUTH2_VALIDATOR_CLASS": "oauth2_provider.oauth2_validators.OAuth2Validator", "OAUTH2_BACKEND_CLASS": "oauth2_provider.oauth2_backends.OAuthLibCore", "SCOPES": {"read": "Reading scope", "write": "Writing scope"}, @@ -46,20 +51,41 @@ "WRITE_SCOPE": "write", "AUTHORIZATION_CODE_EXPIRE_SECONDS": 60, "ACCESS_TOKEN_EXPIRE_SECONDS": 36000, + "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, "ACCESS_TOKEN_MODEL": ACCESS_TOKEN_MODEL, + "ID_TOKEN_MODEL": ID_TOKEN_MODEL, "GRANT_MODEL": GRANT_MODEL, "REFRESH_TOKEN_MODEL": REFRESH_TOKEN_MODEL, "APPLICATION_ADMIN_CLASS": "oauth2_provider.admin.ApplicationAdmin", "ACCESS_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.AccessTokenAdmin", "GRANT_ADMIN_CLASS": "oauth2_provider.admin.GrantAdmin", + "ID_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.IDTokenAdmin", "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], + "OIDC_ENABLED": False, + "OIDC_ISS_ENDPOINT": "", + "OIDC_USERINFO_ENDPOINT": "", + "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RESPONSE_TYPES_SUPPORTED": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "OIDC_SUBJECT_TYPES_SUPPORTED": ["public"], + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED": [ + "client_secret_post", + "client_secret_basic", + ], # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], @@ -70,6 +96,9 @@ "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, # Whether or not PKCE is required "PKCE_REQUIRED": False, + # Whether to re-create OAuthlibCore on every request. + # Should only be required in testing. + "ALWAYS_RELOAD_OAUTHLIB_CORE": False, } # List of settings that cannot be empty @@ -81,6 +110,9 @@ "OAUTH2_BACKEND_CLASS", "SCOPES", "ALLOWED_REDIRECT_URI_SCHEMES", + "OIDC_RESPONSE_TYPES_SUPPORTED", + "OIDC_SUBJECT_TYPES_SUPPORTED", + "OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED", ) # List of settings that may be in string import notation. @@ -96,6 +128,7 @@ "APPLICATION_ADMIN_CLASS", "ACCESS_TOKEN_ADMIN_CLASS", "GRANT_ADMIN_CLASS", + "ID_TOKEN_ADMIN_CLASS", "REFRESH_TOKEN_ADMIN_CLASS", ) @@ -125,6 +158,13 @@ def import_from_string(val, setting_name): raise ImportError(msg) +class _PhonyHttpRequest(HttpRequest): + _scheme = "http" + + def _get_scheme(self): + return self._scheme + + class OAuth2ProviderSettings: """ A settings object, that allows OAuth2 Provider settings to be accessed as properties. @@ -149,13 +189,17 @@ def user_settings(self): def __getattr__(self, attr): if attr not in self.defaults: raise AttributeError("Invalid OAuth2Provider setting: %s" % attr) - try: # Check if present in user settings val = self.user_settings[attr] except KeyError: # Fall back to defaults - val = self.defaults[attr] + # Special case OAUTH2_SERVER_CLASS - if not specified, and OIDC is + # enabled, use the OIDC_SERVER_CLASS setting instead + if attr == "OAUTH2_SERVER_CLASS" and self.OIDC_ENABLED: + val = self.defaults["OIDC_SERVER_CLASS"] + else: + val = self.defaults[attr] # Coerce import strings into classes if val and attr in self.import_strings: @@ -221,6 +265,28 @@ def reload(self): if hasattr(self, "_user_settings"): delattr(self, "_user_settings") + def oidc_issuer(self, request): + """ + Helper function to get the OIDC issuer URL, either from the settings + or constructing it from the passed request. + + If only an oauthlib request is available, a dummy django request is + built from that and used to generate the URL. + """ + if self.OIDC_ISS_ENDPOINT: + return self.OIDC_ISS_ENDPOINT + if isinstance(request, HttpRequest): + django_request = request + elif isinstance(request, Request): + django_request = _PhonyHttpRequest() + django_request.META = request.headers + if request.headers.get("X_DJANGO_OAUTH_TOOLKIT_SECURE", False): + django_request._scheme = "https" + else: + raise TypeError("request must be a django or oauthlib request: got %r" % request) + abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) + return abs_url[: -len("/.well-known/openid-configuration/")] + oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index c7ae526f0..508f97c96 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -30,5 +30,15 @@ ), ] +oidc_urlpatterns = [ + re_path( + r"^\.well-known/openid-configuration/$", + views.ConnectDiscoveryInfoView.as_view(), + name="oidc-connect-discovery-info", + ), + re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), + re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), +] + -urlpatterns = base_urlpatterns + management_urlpatterns +urlpatterns = base_urlpatterns + management_urlpatterns + oidc_urlpatterns diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 6d5d74c67..0720c1aa2 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -15,4 +15,5 @@ ScopedProtectedResourceView, ) from .introspect import IntrospectTokenView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 186097ae4..e9a21a99f 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -37,6 +37,7 @@ def get_form_class(self): "client_type", "authorization_grant_type", "redirect_uris", + "algorithm", ), ) @@ -94,5 +95,6 @@ def get_form_class(self): "client_type", "authorization_grant_type", "redirect_uris", + "algorithm", ), ) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 104413787..e46a49d10 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -90,10 +90,6 @@ class AuthorizationView(BaseAuthorizationView, FormView): template_name = "oauth2_provider/authorize.html" form_class = AllowForm - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - skip_authorization_completely = False def get_initial(self): @@ -102,11 +98,13 @@ def get_initial(self): initial_data = { "redirect_uri": self.oauth2_data.get("redirect_uri", None), "scope": " ".join(scopes), + "nonce": self.oauth2_data.get("nonce", None), "client_id": self.oauth2_data.get("client_id", None), "state": self.oauth2_data.get("state", None), "response_type": self.oauth2_data.get("response_type", None), "code_challenge": self.oauth2_data.get("code_challenge", None), "code_challenge_method": self.oauth2_data.get("code_challenge_method", None), + "claims": self.oauth2_data.get("claims", None), } return initial_data @@ -123,6 +121,11 @@ def form_valid(self, form): credentials["code_challenge"] = form.cleaned_data.get("code_challenge") if form.cleaned_data.get("code_challenge_method", False): credentials["code_challenge_method"] = form.cleaned_data.get("code_challenge_method") + if form.cleaned_data.get("nonce", False): + credentials["nonce"] = form.cleaned_data.get("nonce") + if form.cleaned_data.get("claims", False): + credentials["claims"] = form.cleaned_data.get("claims") + scopes = form.cleaned_data.get("scope") allow = form.cleaned_data.get("allow") @@ -161,6 +164,10 @@ def get(self, request, *args, **kwargs): kwargs["code_challenge"] = credentials["code_challenge"] if "code_challenge_method" in credentials: kwargs["code_challenge_method"] = credentials["code_challenge_method"] + if "nonce" in credentials: + kwargs["nonce"] = credentials["nonce"] + if "claims" in credentials: + kwargs["claims"] = json.dumps(credentials["claims"]) self.oauth2_data = kwargs # following two loc are here only because of https://code.djangoproject.com/ticket/17795 @@ -195,7 +202,10 @@ def get(self, request, *args, **kwargs): for token in tokens: if token.allow_scopes(scopes): uri, headers, body, status = self.create_authorization_response( - request=self.request, scopes=" ".join(scopes), credentials=credentials, allow=True + request=self.request, + scopes=" ".join(scopes), + credentials=credentials, + allow=True, ) return self.redirect(uri, application, token) @@ -245,10 +255,6 @@ class TokenView(OAuthLibMixin, View): * Client credentials """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - @method_decorator(sensitive_post_parameters("password")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) @@ -270,10 +276,6 @@ class RevokeTokenView(OAuthLibMixin, View): Implements an endpoint to revoke access or refresh tokens """ - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - def post(self, request, *args, **kwargs): url, headers, body, status = self.create_revocation_response(request) response = HttpResponse(content=body or "", status=status) diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index 10e84d59f..da675eac4 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -1,6 +1,5 @@ from django.views.generic import View -from ..settings import oauth2_settings from .mixins import ( ClientProtectedResourceMixin, OAuthLibMixin, @@ -10,16 +9,7 @@ ) -class InitializationMixin(OAuthLibMixin): - - """Initializer for OauthLibMixin""" - - server_class = oauth2_settings.OAUTH2_SERVER_CLASS - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - oauthlib_backend_class = oauth2_settings.OAUTH2_BACKEND_CLASS - - -class ProtectedResourceView(ProtectedResourceMixin, InitializationMixin, View): +class ProtectedResourceView(ProtectedResourceMixin, OAuthLibMixin, View): """ Generic view protecting resources by providing OAuth2 authentication out of the box """ @@ -45,7 +35,7 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc pass -class ClientProtectedResourceView(ClientProtectedResourceMixin, InitializationMixin, View): +class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): """View for protecting a resource with client-credentials method. This involves allowing access tokens, Basic Auth and plain credentials in request body. diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index d29605097..afb8ac627 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt from oauth2_provider.models import get_access_token_model -from oauth2_provider.views import ClientProtectedScopedResourceView +from oauth2_provider.views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 0a0c66ea9..477d24e24 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -1,7 +1,8 @@ import logging +from django.conf import settings from django.core.exceptions import ImproperlyConfigured -from django.http import HttpResponseForbidden +from django.http import HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError from ..scopes import get_scopes_backend @@ -25,6 +26,9 @@ class OAuthLibMixin: * validator_class * oauthlib_backend_class + If these class variables are not set, it will fall back to using the classes + specified in oauth2_settings (OAUTH2_SERVER_CLASS, OAUTH2_VALIDATOR_CLASS + and OAUTH2_BACKEND_CLASS). """ server_class = None @@ -37,10 +41,7 @@ def get_server_class(cls): Return the OAuthlib server class to use """ if cls.server_class is None: - raise ImproperlyConfigured( - "OAuthLibMixin requires either a definition of 'server_class'" - " or an implementation of 'get_server_class()'" - ) + return oauth2_settings.OAUTH2_SERVER_CLASS else: return cls.server_class @@ -50,10 +51,7 @@ def get_validator_class(cls): Return the RequestValidator implementation class to use """ if cls.validator_class is None: - raise ImproperlyConfigured( - "OAuthLibMixin requires either a definition of 'validator_class'" - " or an implementation of 'get_validator_class()'" - ) + return oauth2_settings.OAUTH2_VALIDATOR_CLASS else: return cls.validator_class @@ -63,10 +61,7 @@ def get_oauthlib_backend_class(cls): Return the OAuthLibCore implementation class to use """ if cls.oauthlib_backend_class is None: - raise ImproperlyConfigured( - "OAuthLibMixin requires either a definition of 'oauthlib_backend_class'" - " or an implementation of 'get_oauthlib_backend_class()'" - ) + return oauth2_settings.OAUTH2_BACKEND_CLASS else: return cls.oauthlib_backend_class @@ -85,8 +80,9 @@ def get_server(cls): def get_oauthlib_core(cls): """ Cache and return `OAuthlibCore` instance so it will be created only on first request + unless ALWAYS_RELOAD_OAUTHLIB_CORE is True. """ - if not hasattr(cls, "_oauthlib_core"): + if not hasattr(cls, "_oauthlib_core") or oauth2_settings.ALWAYS_RELOAD_OAUTHLIB_CORE: server = cls.get_server() core_class = cls.get_oauthlib_backend_class() cls._oauthlib_core = core_class(server) @@ -109,7 +105,7 @@ def create_authorization_response(self, request, scopes, credentials, allow): :param request: The current django.http.HttpRequest object :param scopes: A space-separated string of provided scopes :param credentials: Authorization credentials dictionary containing - `client_id`, `state`, `redirect_uri`, `response_type` + `client_id`, `state`, `redirect_uri` and `response_type` :param allow: True if the user authorize the client, otherwise False """ # TODO: move this scopes conversion from and to string into a utils function @@ -137,6 +133,16 @@ def create_revocation_response(self, request): core = self.get_oauthlib_core() return core.create_revocation_response(request) + def create_userinfo_response(self, request): + """ + A wrapper method that calls create_userinfo_response on the + `server_class` instance. + + :param request: The current django.http.HttpRequest object + """ + core = self.get_oauthlib_core() + return core.create_userinfo_response(request) + def verify_request(self, request): """ A wrapper method that calls verify_request on `server_class` instance. @@ -286,7 +292,30 @@ def dispatch(self, request, *args, **kwargs): if valid: request.resource_owner = r.user return super().dispatch(request, *args, **kwargs) - else: - return HttpResponseForbidden() + return HttpResponseForbidden() else: return super().dispatch(request, *args, **kwargs) + + +class OIDCOnlyMixin: + """ + Mixin for views that should only be accessible when OIDC is enabled. + + If OIDC is not enabled: + + * if DEBUG is True, raises an ImproperlyConfigured exception explaining why + * otherwise, returns a 404 response, logging the same warning + """ + + debug_error_message = ( + "django-oauth-toolkit OIDC views are not enabled unless you " + "have configured OIDC_ENABLED in the settings" + ) + + def dispatch(self, *args, **kwargs): + if not oauth2_settings.OIDC_ENABLED: + if settings.DEBUG: + raise ImproperlyConfigured(self.debug_error_message) + log.warning(self.debug_error_message) + return HttpResponseNotFound() + return super().dispatch(*args, **kwargs) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py new file mode 100644 index 000000000..ac3a2a172 --- /dev/null +++ b/oauth2_provider/views/oidc.py @@ -0,0 +1,97 @@ +import json + +from django.http import HttpResponse, JsonResponse +from django.urls import reverse +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt +from django.views.generic import View +from jwcrypto import jwk + +from ..models import get_application_model +from ..settings import oauth2_settings +from .mixins import OAuthLibMixin, OIDCOnlyMixin + + +Application = get_application_model() + + +class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): + """ + View used to show oidc provider configuration information + """ + + def get(self, request, *args, **kwargs): + issuer_url = oauth2_settings.OIDC_ISS_ENDPOINT + + if not issuer_url: + issuer_url = oauth2_settings.oidc_issuer(request) + authorization_endpoint = request.build_absolute_uri(reverse("oauth2_provider:authorize")) + token_endpoint = request.build_absolute_uri(reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or request.build_absolute_uri( + reverse("oauth2_provider:user-info") + ) + jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + else: + authorization_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:token")) + userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( + issuer_url, reverse("oauth2_provider:user-info") + ) + jwks_uri = "{}{}".format(issuer_url, reverse("oauth2_provider:jwks-info")) + signing_algorithms = [Application.HS256_ALGORITHM] + if oauth2_settings.OIDC_RSA_PRIVATE_KEY: + signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + data = { + "issuer": issuer_url, + "authorization_endpoint": authorization_endpoint, + "token_endpoint": token_endpoint, + "userinfo_endpoint": userinfo_endpoint, + "jwks_uri": jwks_uri, + "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, + "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, + "id_token_signing_alg_values_supported": signing_algorithms, + "token_endpoint_auth_methods_supported": ( + oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED + ), + } + response = JsonResponse(data) + response["Access-Control-Allow-Origin"] = "*" + return response + + +class JwksInfoView(OIDCOnlyMixin, View): + """ + View used to show oidc json web key set document + """ + + def get(self, request, *args, **kwargs): + keys = [] + if oauth2_settings.OIDC_RSA_PRIVATE_KEY: + key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} + data.update(json.loads(key.export_public())) + keys.append(data) + response = JsonResponse({"keys": keys}) + response["Access-Control-Allow-Origin"] = "*" + return response + + +@method_decorator(csrf_exempt, name="dispatch") +class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View): + """ + View used to show Claims about the authenticated End-User + """ + + def get(self, request, *args, **kwargs): + return self._create_userinfo_response(request) + + def post(self, request, *args, **kwargs): + return self._create_userinfo_response(request) + + def _create_userinfo_response(self, request): + url, headers, body, status = self.create_userinfo_response(request) + response = HttpResponse(content=body or "", status=status) + + for k, v in headers.items(): + response[k] = v + return response diff --git a/setup.cfg b/setup.cfg index 22e81675e..03d614a7f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,10 +29,14 @@ classifiers = packages = find: include_package_data = True zip_safe = False +# jwcrypto has a direct dependency on six, but does not list it yet in a release +# Previously, cryptography also depended on six, so this was unnoticed install_requires = django >= 2.2 requests >= 2.13.0 oauthlib >= 3.1.0 + jwcrypto >= 0.8.0 + six [options.packages.find] exclude = tests diff --git a/tests/admin.py b/tests/admin.py index 557434250..f071769ee 100644 --- a/tests/admin.py +++ b/tests/admin.py @@ -13,5 +13,9 @@ class CustomGrantAdmin(admin.ModelAdmin): list_display = ("id",) +class CustomIDTokenAdmin(admin.ModelAdmin): + list_display = ("id",) + + class CustomRefreshTokenAdmin(admin.ModelAdmin): list_display = ("id",) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..a3274aa33 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,156 @@ +from types import SimpleNamespace +from urllib.parse import parse_qs, urlparse + +import pytest +from django.conf import settings as test_settings +from django.contrib.auth import get_user_model +from django.urls import reverse +from jwcrypto import jwk + +from oauth2_provider.models import get_application_model +from oauth2_provider.settings import oauth2_settings as _oauth2_settings + +from . import presets + + +Application = get_application_model() +UserModel = get_user_model() + + +class OAuthSettingsWrapper: + """ + A wrapper around oauth2_settings to ensure that when an overridden value is + set, it also records it in _cached_attrs, so that the settings can be reset. + """ + + def __init__(self, settings, user_settings): + self.settings = settings + if not user_settings: + user_settings = {} + self.update(user_settings) + + def update(self, user_settings): + self.settings.OAUTH2_PROVIDER = user_settings + _oauth2_settings.reload() + # Reload OAuthlibCore for every view request during tests + self.ALWAYS_RELOAD_OAUTHLIB_CORE = True + + def __setattr__(self, attr, value): + if attr == "settings": + super().__setattr__(attr, value) + else: + setattr(_oauth2_settings, attr, value) + _oauth2_settings._cached_attrs.add(attr) + + def __delattr__(self, attr): + delattr(_oauth2_settings, attr) + if attr in _oauth2_settings._cached_attrs: + _oauth2_settings._cached_attrs.remove(attr) + + def __getattr__(self, attr): + return getattr(_oauth2_settings, attr) + + def finalize(self): + self.settings.finalize() + _oauth2_settings.reload() + + +@pytest.fixture +def oauth2_settings(request, settings): + """ + A fixture that provides a simple way to override OAUTH2_PROVIDER settings. + + It can be used two ways - either setting things on the fly, or by reading + configuration data from the pytest marker oauth2_settings. + + If used on a standard pytest function, you can use argument dependency + injection to get the wrapper. If used on a unittest.TestCase, the wrapper + is made available on the class instance, as `oauth2_settings`. + + Anything overridden will be restored at the end of the test case, ensuring + that there is no configuration leakage between test cases. + """ + marker = request.node.get_closest_marker("oauth2_settings") + user_settings = {} + if marker is not None: + user_settings = marker.args[0] + wrapper = OAuthSettingsWrapper(settings, user_settings) + if request.instance is not None: + request.instance.oauth2_settings = wrapper + yield wrapper + wrapper.finalize() + + +@pytest.fixture(scope="session") +def oidc_key_(): + return jwk.JWK.from_pem(test_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + + +@pytest.fixture +def oidc_key(request, oidc_key_): + if request.instance is not None: + request.instance.key = oidc_key_ + return oidc_key_ + + +@pytest.fixture +def application(): + return Application.objects.create( + name="Test Application", + redirect_uris="http://example.org", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + algorithm=Application.RS256_ALGORITHM, + ) + + +@pytest.fixture +def hybrid_application(application): + application.authorization_grant_type = application.GRANT_OPENID_HYBRID + application.save() + return application + + +@pytest.fixture +def test_user(): + return UserModel.objects.create_user("test_user", "test@example.com", "123456") + + +@pytest.fixture +def oidc_tokens(oauth2_settings, application, test_user, client): + oauth2_settings.update(presets.OIDC_SETTINGS_RW) + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] + client.logout() + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": application.client_id, + "client_secret": application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + return SimpleNamespace( + user=test_user, + application=application, + access_token=token_data["access_token"], + id_token=token_data["id_token"], + oauth2_settings=oauth2_settings, + ) diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index 60b17f2ae..8903a5a96 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -33,6 +33,8 @@ class Migration(migrations.Migration): ('custom_field', models.CharField(max_length=255)), ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)), + ("nonce", models.CharField(blank=True, max_length=255, default="")), + ("claims", models.TextField(blank=True)), ], options={ 'abstract': False, @@ -45,7 +47,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -53,6 +55,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('custom_field', models.CharField(max_length=255)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, @@ -71,6 +74,7 @@ class Migration(migrations.Migration): ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), + ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), ], options={ 'abstract': False, @@ -83,7 +87,7 @@ class Migration(migrations.Migration): ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials')], max_length=32)), + ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), ('name', models.CharField(blank=True, max_length=255)), ('skip_authorization', models.BooleanField(default=False)), @@ -91,6 +95,7 @@ class Migration(migrations.Migration): ('updated', models.DateTimeField(auto_now=True)), ('allowed_schemes', models.TextField(blank=True)), ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), + ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), ], options={ 'abstract': False, diff --git a/tests/presets.py b/tests/presets.py new file mode 100644 index 000000000..da1577bf4 --- /dev/null +++ b/tests/presets.py @@ -0,0 +1,45 @@ +from copy import deepcopy + +from django.conf import settings + + +# A set of OAUTH2_PROVIDER settings dicts that can be used in tests + +DEFAULT_SCOPES_RW = {"DEFAULT_SCOPES": ["read", "write"]} +DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} +OIDC_SETTINGS_RW = { + "OIDC_ENABLED": True, + "OIDC_ISS_ENDPOINT": "http://localhost", + "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", + "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, + "SCOPES": { + "read": "Reading scope", + "write": "Writing scope", + "openid": "OpenID connect", + }, + "DEFAULT_SCOPES": ["read", "write"], +} +OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] +OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) +del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] +REST_FRAMEWORK_SCOPES = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "scope1": "Scope 1", + "scope2": "Scope 2", + "resource1": "Resource 1", + }, +} +INTROSPECTION_SETTINGS = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "introspection": "Introspection scope", + "dolphin": "eek eek eek scope", + }, + "RESOURCE_SERVER_INTROSPECTION_URL": "http://example.org/introspection", + "READ_SCOPE": "read", + "WRITE_SCOPE": "write", +} diff --git a/tests/settings.py b/tests/settings.py index 536762c43..1d295982e 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -117,3 +117,24 @@ }, }, } + +OIDC_RSA_PRIVATE_KEY = """-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQCbCYh5h2NmQuBqVO6G+/CO+cHm9VBzsb0MeA6bbQfDnbhstVOT +j0hcnZJzDjYc6ajBZZf6gxVP9xrdm9Uh599VI3X5PFXLbMHrmzTAMzCGIyg+/fnP +0gocYxmCX2+XKyj/Zvt1pUX8VAN2AhrJSfxNDKUHERTVEV9bRBJg4F0C3wIDAQAB +AoGAP+i4nNw+Ec/8oWh8YSFm4xE6qKG0NdTtSMAOyWwy+KTB+vHuT1QPsLn1vj77 ++IQrX/moogg6F1oV9YdA3vat3U7rwt1sBGsRrLhA+Spp9WEQtglguNo4+QfVo2ju +YBa2rG+h75qjiA3xnU//F3rvwnAsOWv0NUVdVeguyR+u6okCQQDBUmgWeH2WHmUn +2nLNCz+9wj28rqhfOr9Ptem2gqk+ywJmuIr4Y5S1OdavOr2UZxOcEwncJ/MLVYQq +MH+x4V5HAkEAzU2GMR5OdVLcxfVTjzuIC76paoHVWnLibd1cdANpPmE6SM+pf5el +fVSwuH9Fmlizu8GiPCxbJUoXB/J1tGEKqQJBALhClEU+qOzpoZ6/voYi/6kdN3zc +uEy0EN6n09AKb8gS9QH1STgAqh+ltjMkeMe3C2DKYK5/QU9/Pc58lWl1FkcCQG67 +ZamQgxjcvJ85FvymS1aqW45KwNysIlzHjFo2jMlMf7dN6kobbPMQftDENLJvLWIT +qoFyGycdsxZiPAIyZSECQQCZFn3Dl6hnJxWZH8Fsa9hj79kZ/WVkIXGmtdgt0fNr +dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY +-----END RSA PRIVATE KEY-----""" + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" +OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" +OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 0e476054a..42eb17fd0 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -1,9 +1,9 @@ +import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views.application import ApplicationRegistration from .models import SampleApplication @@ -23,21 +23,19 @@ def tearDown(self): self.bar_user.delete() +@pytest.mark.usefixtures("oauth2_settings") class TestApplicationRegistrationView(BaseTest): + @pytest.mark.oauth2_settings({"APPLICATION_MODEL": "tests.SampleApplication"}) def test_get_form_class(self): """ Tests that the form class returned by the "get_form_class" method is bound to custom application model defined in the "OAUTH2_PROVIDER_APPLICATION_MODEL" setting. """ - # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "tests.SampleApplication" # Create a registration view and tests that the model form is bound # to the custom Application model application_form_class = ApplicationRegistration().get_form_class() self.assertEqual(SampleApplication, application_form_class._meta.model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" def test_application_registration_user(self): self.client.login(username="foo_user", password="123456") @@ -49,6 +47,7 @@ def test_application_registration_user(self): "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", } response = self.client.post(reverse("oauth2_provider:register"), form_data) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 44c474380..ea1bee86d 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -5,11 +5,13 @@ import re from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string +from jwcrypto import jwt from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors from oauth2_provider.models import ( @@ -18,9 +20,9 @@ get_grant_model, get_refresh_token_model, ) -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView +from . import presets from .utils import get_basic_auth_header @@ -40,13 +42,14 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application.objects.create( name="Test Application", @@ -59,9 +62,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() @@ -90,6 +90,7 @@ def test_request_is_not_overwritten(self): assert "request" not in response.context_data +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeView(BaseTest): def test_skip_authorization_completely(self): """ @@ -210,7 +211,7 @@ def test_pre_auth_approval_prompt(self): self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default(self): - self.assertEqual(oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") AccessToken.objects.create( user=self.test_user, @@ -231,7 +232,7 @@ def test_pre_auth_approval_prompt_default(self): self.assertEqual(response.status_code, 200) def test_pre_auth_approval_prompt_default_override(self): - oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" AccessToken.objects.create( user=self.test_user, @@ -523,15 +524,84 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): self.assertEqual(response.status_code, 400) -class TestAuthorizationCodeTokenView(BaseTest): - def get_auth(self): +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeView(BaseTest): + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + + +class BaseAuthorizationCodeTokenView(BaseTest): + def get_auth(self, scope="read write"): """ Helper method to retrieve a valid authorization code """ authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", - "scope": "read write", + "scope": scope, "redirect_uri": "http://example.org", "response_type": "code", "allow": True, @@ -558,7 +628,7 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", @@ -572,9 +642,11 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) query_dict = parse_qs(urlparse(response["Location"]).query) - oauth2_settings.PKCE_REQUIRED = False return query_dict["code"].pop() + +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class TestAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): def test_basic_auth(self): """ Request an access token using basic authentication for client authentication @@ -595,7 +667,7 @@ def test_basic_auth(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_refresh(self): """ @@ -645,7 +717,7 @@ def test_refresh_with_grace_period(self): """ Request an access token using a refresh token """ - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() @@ -692,7 +764,6 @@ def test_refresh_with_grace_period(self): # refresh token should be the same as well self.assertTrue("refresh_token" in content) self.assertEqual(content["refresh_token"], first_refresh_token) - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_invalidates_old_tokens(self): """ @@ -813,7 +884,7 @@ def test_refresh_repeating_requests(self): Trying to refresh an access token with the same refresh token more than once succeeds in the grace period and fails outside """ - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 self.client.login(username="test_user", password="123456") authorization_code = self.get_auth() @@ -846,7 +917,6 @@ def test_refresh_repeating_requests(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) - oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 0 def test_refresh_repeating_requests_non_rotating_tokens(self): """ @@ -871,15 +941,13 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "refresh_token": content["refresh_token"], "scope": content["scope"], } - oauth2_settings.ROTATE_REFRESH_TOKEN = False + self.oauth2_settings.ROTATE_REFRESH_TOKEN = False response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - oauth2_settings.ROTATE_REFRESH_TOKEN = True - def test_basic_auth_bad_authcode(self): """ Request an access token using a bad authorization code @@ -993,7 +1061,7 @@ def test_request_body_params(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public(self): """ @@ -1018,7 +1086,7 @@ def test_public(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_S256_authorize_get(self): """ @@ -1031,7 +1099,7 @@ def test_public_pkce_S256_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1047,7 +1115,6 @@ def test_public_pkce_S256_authorize_get(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertContains(response, 'value="S256"', count=1, status_code=200) self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_authorize_get(self): """ @@ -1060,7 +1127,7 @@ def test_public_pkce_plain_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1076,7 +1143,6 @@ def test_public_pkce_plain_authorize_get(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertContains(response, 'value="plain"', count=1, status_code=200) self.assertContains(response, 'value="{0}"'.format(code_challenge), count=1, status_code=200) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256(self): """ @@ -1089,7 +1155,7 @@ def test_public_pkce_S256(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1105,8 +1171,7 @@ def test_public_pkce_S256(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - oauth2_settings.PKCE_REQUIRED = False + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_plain(self): """ @@ -1119,7 +1184,7 @@ def test_public_pkce_plain(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1135,8 +1200,7 @@ def test_public_pkce_plain(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - oauth2_settings.PKCE_REQUIRED = False + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_public_pkce_invalid_algorithm(self): """ @@ -1148,7 +1212,7 @@ def test_public_pkce_invalid_algorithm(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1164,7 +1228,6 @@ def test_public_pkce_invalid_algorithm(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_missing_code_challenge(self): """ @@ -1177,7 +1240,7 @@ def test_public_pkce_missing_code_challenge(self): self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1192,7 +1255,6 @@ def test_public_pkce_missing_code_challenge(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_missing_code_challenge_method(self): """ @@ -1204,7 +1266,7 @@ def test_public_pkce_missing_code_challenge_method(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1218,7 +1280,6 @@ def test_public_pkce_missing_code_challenge_method(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 200) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256_invalid_code_verifier(self): """ @@ -1231,7 +1292,7 @@ def test_public_pkce_S256_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1243,7 +1304,6 @@ def test_public_pkce_S256_invalid_code_verifier(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_invalid_code_verifier(self): """ @@ -1256,7 +1316,7 @@ def test_public_pkce_plain_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1268,7 +1328,6 @@ def test_public_pkce_plain_invalid_code_verifier(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_S256_missing_code_verifier(self): """ @@ -1281,7 +1340,7 @@ def test_public_pkce_S256_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1292,7 +1351,6 @@ def test_public_pkce_S256_missing_code_verifier(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_public_pkce_plain_missing_code_verifier(self): """ @@ -1305,7 +1363,7 @@ def test_public_pkce_plain_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - oauth2_settings.PKCE_REQUIRED = True + self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1316,7 +1374,6 @@ def test_public_pkce_plain_missing_code_verifier(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) self.assertEqual(response.status_code, 400) - oauth2_settings.PKCE_REQUIRED = False def test_malicious_redirect_uri(self): """ @@ -1340,7 +1397,10 @@ def test_malicious_redirect_uri(self): self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) def test_code_exchange_succeed_when_redirect_uri_match(self): """ @@ -1375,7 +1435,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_code_exchange_fails_when_redirect_uri_does_not_match(self): """ @@ -1408,9 +1468,14 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): self.assertEqual(response.status_code, 400) data = response.json() self.assertEqual(data["error"], "invalid_request") - self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + self.assertEqual( + data["error_description"], + oauthlib_errors.MismatchingRedirectURIError.description, + ) - def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): """ Tests code exchange succeed when redirect uri matches the one used for code request """ @@ -1445,7 +1510,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_oob_as_html(self): """ @@ -1491,7 +1556,7 @@ def test_oob_as_html(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_oob_as_json(self): """ @@ -1531,9 +1596,130 @@ def test_oob_as_json(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="test_user", password="123456") + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params( + self, + ): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeHSAlgorithm(BaseAuthorizationCodeTokenView): + def setUp(self): + super().setUp() + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + self.application.algorithm = Application.HS256_ALGORITHM + self.application.save() + + def test_id_token(self): + """ + Request an access token using an HS256 application + """ + self.client.login(username="test_user", password="123456") + + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = response.json() + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + # Check decoding JWT using HS256 + key = self.application.jwk_key + assert key.key_type == "oct" + jwt_token = jwt.JWT(key=key, jwt=content["id_token"]) + claims = json.loads(jwt_token.claims) + assert claims["sub"] == "1" + + +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class TestAuthorizationCodeProtectedResource(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") @@ -1586,13 +1772,72 @@ def test_resource_access_deny(self): self.assertEqual(response.status_code, 403) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOIDCAuthorizationCodeProtectedResource(BaseTest): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + + def test_id_token_resource_access_allowed(self): + self.client.login(username="test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestDefaultScopes(BaseTest): def test_pre_auth_default_scopes(self): """ Test response for a valid client_id with response_type: code using default scopes """ self.client.login(username="test_user", password="123456") - oauth2_settings._DEFAULT_SCOPES = ["read"] query_data = { "client_id": self.application.client_id, @@ -1612,4 +1857,3 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form["state"].value(), "random_state_string") self.assertEqual(form["scope"].value(), "read") self.assertEqual(form["client_id"].value(), self.application.client_id) - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 966eb826b..8b9aa3bc2 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,7 @@ import json from urllib.parse import quote_plus +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse @@ -10,10 +11,10 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from oauth2_provider.views.mixins import OAuthLibMixin +from . import presets from .utils import get_basic_auth_header @@ -28,6 +29,8 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -41,9 +44,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index 22ce48e76..ce17a891a 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -6,7 +6,6 @@ from oauth2_provider.decorators import protected_resource, rw_protected_resource from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings Application = get_application_model() @@ -37,8 +36,6 @@ def setUp(self): application=self.application, ) - oauth2_settings._SCOPES = ["read", "write"] - def test_access_denied(self): @protected_resource() def view(request, *args, **kwargs): diff --git a/tests/test_generator.py b/tests/test_generator.py index 670ac9ea1..cc7928017 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,13 +1,7 @@ +import pytest from django.test import TestCase -from oauth2_provider.generators import ( - BaseHashGenerator, - ClientIdGenerator, - ClientSecretGenerator, - generate_client_id, - generate_client_secret, -) -from oauth2_provider.settings import oauth2_settings +from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret class MockHashGenerator(BaseHashGenerator): @@ -15,23 +9,20 @@ def hash(self): return 42 +@pytest.mark.usefixtures("oauth2_settings") class TestGenerators(TestCase): - def tearDown(self): - oauth2_settings.CLIENT_ID_GENERATOR_CLASS = ClientIdGenerator - oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = ClientSecretGenerator - def test_generate_client_id(self): - g = oauth2_settings.CLIENT_ID_GENERATOR_CLASS() + g = self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 40) - oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator + self.oauth2_settings.CLIENT_ID_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_id(), 42) def test_generate_secret_id(self): - g = oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() + g = self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS() self.assertEqual(len(g.hash()), 128) - oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator + self.oauth2_settings.CLIENT_SECRET_GENERATOR_CLASS = MockHashGenerator self.assertEqual(generate_client_secret(), 42) def test_basegen_misuse(self): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py new file mode 100644 index 000000000..d198988f6 --- /dev/null +++ b/tests/test_hybrid.py @@ -0,0 +1,1431 @@ +import base64 +import datetime +import json +from urllib.parse import parse_qs, urlencode, urlparse + +import pytest +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse +from django.utils import timezone +from jwcrypto import jwt +from oauthlib.oauth2.rfc6749 import errors as oauthlib_errors + +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) +from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.views import ProtectedResourceView, ScopedProtectedResourceView + +from . import presets +from .utils import get_basic_auth_header, spy_on + + +Application = get_application_model() +AccessToken = get_access_token_model() +Grant = get_grant_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() + + +# mocking a protected resource view +class ResourceView(ProtectedResourceView): + def get(self, request, *args, **kwargs): + return "This is a protected resource" + + +class ScopedResourceView(ScopedProtectedResourceView): + required_scopes = ["read"] + + def get(self, request, *args, **kwargs): + return "This is a protected resource" + + +@pytest.mark.usefixtures("oauth2_settings") +class BaseTest(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") + self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") + + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + + self.application = Application( + name="Hybrid Test Application", + redirect_uris=( + "http://localhost http://example.com http://example.org custom-scheme://example.com" + ), + user=self.hy_dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_OPENID_HYBRID, + algorithm=Application.RS256_ALGORITHM, + ) + self.application.save() + + def tearDown(self): + self.application.delete() + self.hy_test_user.delete() + self.hy_dev_user.delete() + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestRegressionIssue315Hybrid(BaseTest): + """ + Test to avoid regression for the issue 315: request object + was being reassigned when getting AuthorizationView + """ + + def test_request_is_not_overwritten_code_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + def test_request_is_not_overwritten_code_id_token_token(self): + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token token", + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + assert "request" not in response.context_data + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestHybridView(BaseTest): + def test_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="hy_test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_invalid_client(self): + """ + Test error for an invalid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": "fakeclientid", + "response_type": "code", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.context_data["url"], + "?error=invalid_request&error_description=Invalid+client_id+parameter+value.", + ) + + def test_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_id_token_pre_auth_valid_client(self): + """ + Test response for a valid client_id with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "nonce": "nonce", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "openid") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_valid_client_custom_redirect_uri_scheme(self): + """ + Test response for a valid client_id with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "custom-scheme://example.com") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read write") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + def test_pre_auth_approval_prompt(self): + tok = AccessToken.objects.create( + user=self.hy_test_user, + token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write", + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "approval_prompt": "auto", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + # user already authorized the application, but with different scopes: prompt them. + tok.scope = "read" + tok.save() + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default(self): + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "force" + self.assertEqual(self.oauth2_settings.REQUEST_APPROVAL_PROMPT, "force") + + AccessToken.objects.create( + user=self.hy_test_user, + token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write", + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + def test_pre_auth_approval_prompt_default_override(self): + self.oauth2_settings.REQUEST_APPROVAL_PROMPT = "auto" + + AccessToken.objects.create( + user=self.hy_test_user, + token="1234567890", + application=self.application, + expires=timezone.now() + datetime.timedelta(days=1), + scope="read write", + ) + self.client.login(username="hy_test_user", password="123456") + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_pre_auth_default_redirect(self): + """ + Test for default redirect uri if omitted from query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code id_token", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://localhost") + + def test_pre_auth_forbibben_redirect(self): + """ + Test error when passing a forbidden redirect_uri in query string with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code", + "redirect_uri": "http://forbidden.it", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 400) + + def test_pre_auth_wrong_response_type(self): + """ + Test error when passing a wrong response_type in query string + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "WRONG", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + self.assertIn("error=unsupported_response_type", response["Location"]) + + def test_code_post_auth_allow_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_id_token_code_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: code + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_bad_responsetype(self): + """ + Test authorization code is given for an allowed request with a response_type not supported + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "response_type": "UNKNOWN", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org?error", response["Location"]) + + def test_code_post_auth_forbidden_redirect_uri(self): + """ + Test authorization code is given for an allowed request with a forbidden redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://forbidden.it", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_malicious_redirect_uri(self): + """ + Test validation of a malicious redirect_uri + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "/../", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_allow_custom_redirect_uri_scheme_code_id_token_token(self): + """ + Test authorization code is given for an allowed request with response_type: code + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_deny_custom_redirect_uri_scheme(self): + """ + Test error when resource owner deny access + using a non-standard, but allowed, redirect_uri scheme. + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "custom-scheme://example.com", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("custom-scheme://example.com?", response["Location"]) + self.assertIn("error=access_denied", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + + def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(self): + """ + Tests that a redirection uri with query string is allowed + and query string is retained on redirection. + See http://tools.ietf.org/html/rfc6749#section-3.1.2 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code id_token token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.com?foo=bar", response["Location"]) + self.assertIn("code=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("access_token=", response["Location"]) + + def test_code_post_auth_failing_redirection_uri_with_querystring(self): + """ + Test that in case of error the querystring of the redirection uri is preserved + + See https://github.com/evonove/django-oauth-toolkit/issues/238 + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com?foo=bar", + "response_type": "code", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertEqual( + "http://example.com?foo=bar&error=access_denied&state=random_state_string", response["Location"] + ) + + def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): + """ + Tests that a redirection uri is matched using scheme + netloc + path + """ + self.client.login(username="hy_test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.com/a?foo=bar", + "response_type": "code", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 400) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestHybridTokenView(BaseTest): + def get_auth(self, scope="read write"): + """ + Helper method to retrieve a valid authorization code + """ + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": scope, + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "allow": True, + "nonce": "nonce", + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + return fragment_dict["code"].pop() + + def test_basic_auth(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_basic_auth_bad_authcode(self): + """ + Request an access token using a bad authorization code + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_bad_granttype(self): + """ + Request an access token using a bad grant_type string + """ + self.client.login(username="hy_test_user", password="123456") + + token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_grant_expired(self): + """ + Request an access token using an expired grant token + """ + self.client.login(username="hy_test_user", password="123456") + g = Grant( + application=self.application, + user=self.hy_test_user, + code="BLAH", + expires=timezone.now(), + redirect_uri="", + scope="", + ) + g.save() + + token_request_data = { + "grant_type": "authorization_code", + "code": "BLAH", + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + def test_basic_auth_bad_secret(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, "BOOM!") + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_basic_auth_wrong_auth_type(self): + """ + Request an access token using basic authentication for client authentication + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + + user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + auth_string = base64.b64encode(user_pass.encode("utf-8")) + auth_headers = { + "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + + def test_request_body_params(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "client_secret": self.application.client_secret, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "read write") + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_public(self): + """ + Request an access token using client_type: public + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth(scope="openid") + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client_id": self.application.client_id, + "scope": "openid", + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_malicious_redirect_uri(self): + """ + Request an access token using client_type: public and ensure redirect_uri is + properly validated. + """ + self.client.login(username="hy_test_user", password="123456") + + self.application.client_type = Application.CLIENT_PUBLIC + self.application.save() + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "/../", + "client_id": self.application.client_id, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_code_exchange_fails_when_redirect_uri_does_not_match(self): + """ + Tests code exchange fails when redirect uri does not match the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org?foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = query_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org?foo=baraa", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data["error"], "invalid_request") + self.assertEqual(data["error_description"], oauthlib_errors.MismatchingRedirectURIError.description) + + def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid read write") + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_params(self): + """ + Tests code exchange succeed when redirect uri matches the one used for code request + """ + self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost http://example.com?foo=bar" + self.application.save() + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.com?bar=baz&foo=bar", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.com?bar=baz&foo=bar", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["token_type"], "Bearer") + self.assertEqual(content["scope"], "openid") + self.assertIn("access_token", content) + self.assertIn("id_token", content) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestHybridProtectedResource(BaseTest): + def test_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid read write", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + def test_id_token_resource_access_allowed(self): + self.client.login(username="hy_test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code token", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + fragment_dict = parse_qs(urlparse(response["Location"]).fragment) + authorization_code = fragment_dict["code"].pop() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + access_token = content["access_token"] + id_token = content["id_token"] + + # use token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + access_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # use id_token to access the resource + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + id_token, + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response, "This is a protected resource") + + # If the resource requires more scopes than we requested, we should get an error + view = ScopedResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + + def test_resource_access_deny(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "faketoken", + } + request = self.factory.get("/fake-resource", **auth_headers) + request.user = self.hy_test_user + + view = ResourceView.as_view() + response = view(request) + self.assertEqual(response.status_code, 403) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RO) +class TestDefaultScopesHybrid(BaseTest): + def test_pre_auth_default_scopes(self): + """ + Test response for a valid client_id with response_type: code using default scopes + """ + self.client.login(username="hy_test_user", password="123456") + + query_string = urlencode( + { + "client_id": self.application.client_id, + "response_type": "code token", + "state": "random_state_string", + "redirect_uri": "http://example.org", + } + ) + url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + # check form is in context and form params are valid + self.assertIn("form", response.context) + + form = response.context["form"] + self.assertEqual(form["redirect_uri"].value(), "http://example.org") + self.assertEqual(form["state"].value(), "random_state_string") + self.assertEqual(form["scope"].value(), "read") + self.assertEqual(form["client_id"].value(), self.application.client_id) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_application, client, oidc_key): + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": hybrid_application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "nonce": "random_nonce_string", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) + assert "code" in auth_data + assert "id_token" in auth_data + # Decode the id token - is the nonce correct + jwt_token = jwt.JWT(key=oidc_key, jwt=auth_data["id_token"][0]) + claims = json.loads(jwt_token.claims) + assert "nonce" in claims + assert claims["nonce"] == "random_nonce_string" + code = auth_data["code"][0] + client.logout() + # Get the token response using the code + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": hybrid_application.client_id, + "client_secret": hybrid_application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + assert "id_token" in token_data + # The nonce should be present in this id token also + jwt_token = jwt.JWT(key=oidc_key, jwt=token_data["id_token"]) + claims = json.loads(jwt_token.claims) + assert "nonce" in claims + assert claims["nonce"] == "random_nonce_string" + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_claims_passed_to_code_generation( + oauth2_settings, test_user, hybrid_application, client, mocker, oidc_key +): + # Add a spy on to OAuth2Validator.finalize_id_token + mocker.patch.object( + OAuth2Validator, + "finalize_id_token", + spy_on(OAuth2Validator.finalize_id_token), + ) + claims = {"id_token": {"email": {"essential": True}}} + client.force_login(test_user) + auth_form_rsp = client.get( + reverse("oauth2_provider:authorize"), + data={ + "client_id": hybrid_application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "code id_token", + "nonce": "random_nonce_string", + "claims": json.dumps(claims), + }, + ) + # Check that claims has made it in to the form to be submitted + assert auth_form_rsp.status_code == 200 + form_initial_data = auth_form_rsp.context_data["form"].initial + assert "claims" in form_initial_data + assert json.loads(form_initial_data["claims"]) == claims + # Filter out not specified values + form_data = {key: value for key, value in form_initial_data.items() if value is not None} + # Now submitting the form (with allow=True) should persist requested claims + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={"allow": True, **form_data}, + ) + assert auth_rsp.status_code == 302 + auth_data = parse_qs(urlparse(auth_rsp["Location"]).fragment) + assert "code" in auth_data + assert "id_token" in auth_data + assert OAuth2Validator.finalize_id_token.spy.call_count == 1 + oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] + assert oauthlib_request.claims == claims + assert Grant.objects.get().claims == json.dumps(claims) + OAuth2Validator.finalize_id_token.spy.reset_mock() + + # Get the token response using the code + client.logout() + code = auth_data["code"][0] + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": hybrid_application.client_id, + "client_secret": hybrid_application.client_secret, + "scope": "openid", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + assert "id_token" in token_data + assert OAuth2Validator.finalize_id_token.spy.call_count == 1 + oauthlib_request = OAuth2Validator.finalize_id_token.spy.call_args[0][4] + assert oauthlib_request.claims == claims diff --git a/tests/test_implicit.py b/tests/test_implicit.py index b51d0e1da..a5863401c 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -1,13 +1,17 @@ +import json from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse +from jwcrypto import jwt from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView +from . import presets + Application = get_application_model() UserModel = get_user_model() @@ -19,6 +23,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -33,15 +38,13 @@ def setUp(self): authorization_grant_type=Application.GRANT_IMPLICIT, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read"] - def tearDown(self): self.application.delete() self.test_user.delete() self.dev_user.delete() +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitAuthorizationCodeView(BaseTest): def test_pre_auth_valid_client_default_scopes(self): """ @@ -237,6 +240,7 @@ def test_implicit_fails_when_redirect_uri_path_is_invalid(self): self.assertEqual(response.status_code, 400) +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitTokenView(BaseTest): def test_resource_access_allowed(self): self.client.login(username="test_user", password="123456") @@ -265,3 +269,198 @@ def test_resource_access_allowed(self): view = ResourceView.as_view() response = view(request) self.assertEqual(response, "This is a protected resource") + + +@pytest.mark.usefixtures("oidc_key") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestOpenIDConnectImplicitFlow(BaseTest): + def setUp(self): + super().setUp() + self.application.algorithm = Application.RS256_ALGORITHM + self.application.save() + + def test_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: id_token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertNotIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertNotIn("at_hash", claims) + + def test_id_token_skip_authorization_completely_missing_nonce(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=invalid_request", response["Location"]) + self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) + + def test_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) + + def test_access_token_and_id_token_post_auth_allow(self): + """ + Test authorization code is given for an allowed request with response_type: token + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": True, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_skip_authorization_completely(self): + """ + If application.skip_authorization = True, should skip the authorization page. + """ + self.client.login(username="test_user", password="123456") + self.application.skip_authorization = True + self.application.save() + + query_data = { + "client_id": self.application.client_id, + "response_type": "id_token token", + "state": "random_state_string", + "nonce": "random_nonce_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 302) + self.assertIn("http://example.org#", response["Location"]) + self.assertIn("access_token=", response["Location"]) + self.assertIn("id_token=", response["Location"]) + self.assertIn("state=random_state_string", response["Location"]) + + uri_query = urlparse(response["Location"]).fragment + uri_query_params = dict(parse_qs(uri_query, keep_blank_values=True, strict_parsing=True)) + id_token = uri_query_params["id_token"][0] + jwt_token = jwt.JWT(key=self.key, jwt=id_token) + claims = json.loads(jwt_token.claims) + self.assertIn("nonce", claims) + self.assertIn("at_hash", claims) + + def test_access_token_and_id_token_post_auth_deny(self): + """ + Test error when resource owner deny access + """ + self.client.login(username="test_user", password="123456") + + form_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + "response_type": "id_token token", + "allow": False, + } + + response = self.client.post(reverse("oauth2_provider:authorize"), data=form_data) + self.assertEqual(response.status_code, 302) + self.assertIn("error=access_denied", response["Location"]) diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 5fc12b6b1..9f871cdea 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -1,6 +1,7 @@ import calendar import datetime +import pytest from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse @@ -11,9 +12,10 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ScopedProtectedResourceView +from . import presets + try: from unittest import mock @@ -78,6 +80,8 @@ def json(self): @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionAuth(TestCase): """ Tests for Authorization through token introspection @@ -114,16 +118,9 @@ def setUp(self): scope="read write dolphin", ) - oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = "http://example.org/introspection" - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL = None - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = None self.resource_server_token.delete() self.application.delete() AccessToken.objects.all().delete() @@ -136,9 +133,9 @@ def test_get_token_from_authentication_server_not_existing_token(self, mock_get) """ token = self.validator._get_token_from_authentication_server( self.resource_server_token.token, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsNone(token) @@ -149,9 +146,9 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): """ token = self.validator._get_token_from_authentication_server( "foo", - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, - oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, - oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + self.oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) self.assertIsInstance(token, AccessToken) self.assertEqual(token.user.username, "foo_user") diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 5b3fc58f8..0f68320ca 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -1,14 +1,15 @@ import calendar import datetime +import pytest from django.contrib.auth import get_user_model from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings +from . import presets from .utils import get_basic_auth_header @@ -17,6 +18,8 @@ UserModel = get_user_model() +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) class TestTokenIntrospectionViews(TestCase): """ Tests for Authorized Token Introspection Views @@ -74,12 +77,7 @@ def setUp(self): scope="read write dolphin", ) - oauth2_settings._SCOPES = ["read", "write", "introspection", "dolphin"] - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] AccessToken.objects.all().delete() Application.objects.all().delete() UserModel.objects.all().delete() diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 793a5b4b4..1294b75cb 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -1,13 +1,25 @@ +import logging + +import pytest from django.core.exceptions import ImproperlyConfigured +from django.http import HttpResponse from django.test import RequestFactory, TestCase from django.views.generic import View from oauthlib.oauth2 import Server from oauth2_provider.oauth2_backends import OAuthLibCore from oauth2_provider.oauth2_validators import OAuth2Validator -from oauth2_provider.views.mixins import OAuthLibMixin, ProtectedResourceMixin, ScopedResourceMixin +from oauth2_provider.views.mixins import ( + OAuthLibMixin, + OIDCOnlyMixin, + ProtectedResourceMixin, + ScopedResourceMixin, +) + +from . import presets +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): @classmethod def setUpClass(cls): @@ -16,32 +28,55 @@ def setUpClass(cls): class TestOAuthLibMixin(BaseTest): - def test_missing_oauthlib_backend_class(self): + def test_missing_oauthlib_backend_class_uses_fallback(self): + class CustomOauthLibBackend: + def __init__(self, *args, **kwargs): + pass + + self.oauth2_settings.OAUTH2_BACKEND_CLASS = CustomOauthLibBackend + class TestView(OAuthLibMixin, View): server_class = Server validator_class = OAuth2Validator test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_oauthlib_backend_class) + self.assertEqual(CustomOauthLibBackend, test_view.get_oauthlib_backend_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core, CustomOauthLibBackend)) + + def test_missing_server_class_uses_fallback(self): + class CustomServer: + def __init__(self, *args, **kwargs): + pass + + self.oauth2_settings.OAUTH2_SERVER_CLASS = CustomServer - def test_missing_server_class(self): class TestView(OAuthLibMixin, View): validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_server) + self.assertEqual(CustomServer, test_view.get_server_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core.server, CustomServer)) + + def test_missing_validator_class_uses_fallback(self): + class CustomValidator: + pass + + self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator - def test_missing_validator_class(self): class TestView(OAuthLibMixin, View): server_class = Server oauthlib_backend_class = OAuthLibCore test_view = TestView() - self.assertRaises(ImproperlyConfigured, test_view.get_server) + self.assertEqual(CustomValidator, test_view.get_validator_class()) + core = test_view.get_oauthlib_core() + self.assertTrue(isinstance(core.server.request_validator, CustomValidator)) def test_correct_server(self): class TestView(OAuthLibMixin, View): @@ -99,3 +134,38 @@ class TestView(ProtectedResourceMixin, View): view = TestView.as_view() response = view(request) self.assertEqual(response.status_code, 200) + + +@pytest.fixture +def oidc_only_view(): + class TView(OIDCOnlyMixin, View): + def get(self, *args, **kwargs): + return HttpResponse("OK") + + return TView.as_view() + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): + assert oauth2_settings.OIDC_ENABLED + rsp = oidc_only_view(rf.get("/")) + assert rsp.status_code == 200 + assert rsp.content.decode("utf-8") == "OK" + + +def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_only_view): + assert oauth2_settings.OIDC_ENABLED is False + settings.DEBUG = True + with pytest.raises(ImproperlyConfigured) as exc: + oidc_only_view(rf.get("/")) + assert "OIDC views are not enabled" in str(exc.value) + + +def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, oidc_only_view, caplog): + assert oauth2_settings.OIDC_ENABLED is False + settings.DEBUG = False + with caplog.at_level(logging.WARNING, logger="oauth2_provider"): + rsp = oidc_only_view(rf.get("/")) + assert rsp.status_code == 404 + assert len(caplog.records) == 1 + assert "OIDC views are not enabled" in caplog.records[0].message diff --git a/tests/test_models.py b/tests/test_models.py index afcd6b419..7b37486ca 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -10,9 +10,11 @@ get_access_token_model, get_application_model, get_grant_model, + get_id_token_model, get_refresh_token_model, ) -from oauth2_provider.settings import oauth2_settings + +from . import presets Application = get_application_model() @@ -20,6 +22,7 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() UserModel = get_user_model() +IDToken = get_id_token_model() class BaseTestModels(TestCase): @@ -108,6 +111,7 @@ def test_scopes_property(self): OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL="tests.SampleRefreshToken", OAUTH2_PROVIDER_GRANT_MODEL="tests.SampleGrant", ) +@pytest.mark.usefixtures("oauth2_settings") class TestCustomModels(BaseTestModels): def test_custom_application_model(self): """ @@ -126,22 +130,16 @@ def test_custom_application_model(self): def test_custom_application_model_incorrect_format(self): # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" + self.oauth2_settings.APPLICATION_MODEL = "IncorrectApplicationFormat" self.assertRaises(ValueError, get_application_model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" - def test_custom_application_model_not_installed(self): # Patch oauth2 settings to use a custom Application model - oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" + self.oauth2_settings.APPLICATION_MODEL = "tests.ApplicationNotInstalled" self.assertRaises(LookupError, get_application_model) - # Revert oauth2 settings - oauth2_settings.APPLICATION_MODEL = "oauth2_provider.Application" - def test_custom_access_token_model(self): """ If a custom access token model is installed, it should be present in @@ -158,22 +156,16 @@ def test_custom_access_token_model(self): def test_custom_access_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" + self.oauth2_settings.ACCESS_TOKEN_MODEL = "IncorrectAccessTokenFormat" self.assertRaises(ValueError, get_access_token_model) - # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" - def test_custom_access_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" + self.oauth2_settings.ACCESS_TOKEN_MODEL = "tests.AccessTokenNotInstalled" self.assertRaises(LookupError, get_access_token_model) - # Revert oauth2 settings - oauth2_settings.ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" - def test_custom_refresh_token_model(self): """ If a custom refresh token model is installed, it should be present in @@ -190,22 +182,16 @@ def test_custom_refresh_token_model(self): def test_custom_refresh_token_model_incorrect_format(self): # Patch oauth2 settings to use a custom RefreshToken model - oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" + self.oauth2_settings.REFRESH_TOKEN_MODEL = "IncorrectRefreshTokenFormat" self.assertRaises(ValueError, get_refresh_token_model) - # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" - def test_custom_refresh_token_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" + self.oauth2_settings.REFRESH_TOKEN_MODEL = "tests.RefreshTokenNotInstalled" self.assertRaises(LookupError, get_refresh_token_model) - # Revert oauth2 settings - oauth2_settings.REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" - def test_custom_grant_model(self): """ If a custom grant model is installed, it should be present in @@ -222,22 +208,16 @@ def test_custom_grant_model(self): def test_custom_grant_model_incorrect_format(self): # Patch oauth2 settings to use a custom Grant model - oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" + self.oauth2_settings.GRANT_MODEL = "IncorrectGrantFormat" self.assertRaises(ValueError, get_grant_model) - # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" - def test_custom_grant_model_not_installed(self): # Patch oauth2 settings to use a custom AccessToken model - oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" + self.oauth2_settings.GRANT_MODEL = "tests.GrantNotInstalled" self.assertRaises(LookupError, get_grant_model) - # Revert oauth2 settings - oauth2_settings.GRANT_MODEL = "oauth2_provider.Grant" - class TestGrantModel(BaseTestModels): def setUp(self): @@ -310,6 +290,7 @@ def test_str(self): self.assertEqual("%s" % refresh_token, refresh_token.token) +@pytest.mark.usefixtures("oauth2_settings") class TestClearExpired(BaseTestModels): def setUp(self): super().setUp() @@ -341,11 +322,11 @@ def setUp(self): ) def test_clear_expired_tokens(self): - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 assert clear_expired() is None def test_clear_expired_tokens_incorect_timetype(self): - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" with pytest.raises(ImproperlyConfigured) as excinfo: clear_expired() result = excinfo.value.__class__.__name__ @@ -353,7 +334,7 @@ def test_clear_expired_tokens_incorect_timetype(self): def test_clear_expired_tokens_with_tokens(self): self.client.login(username="test_user", password="123456") - oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 ttokens = AccessToken.objects.count() expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert ttokens == 2 @@ -361,3 +342,93 @@ def test_clear_expired_tokens_with_tokens(self): clear_expired() expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() assert expiredt == 0 + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_id_token_methods(oidc_tokens, rf): + id_token = IDToken.objects.get() + + # Token was just created, so should be valid + assert id_token.is_valid() + + # if expires is None, it should always be expired + # the column is NOT NULL, but could be NULL in sub-classes + id_token.expires = None + assert id_token.is_expired() + + # if no scopes are passed, they should be valid + assert id_token.allow_scopes(None) + + # if the requested scopes are in the token, they should be valid + assert id_token.allow_scopes(["openid"]) + + # if the requested scopes are not in the token, they should not be valid + assert id_token.allow_scopes(["fizzbuzz"]) is False + + # we should be able to get a list of the scopes on the token + assert id_token.scopes == {"openid": "OpenID connect"} + + # the id token should stringify as the JWT token + id_token_str = str(id_token) + assert str(id_token.jti) in id_token_str + assert id_token_str.endswith(str(id_token.user_id)) + + # revoking the token should delete it + id_token.revoke() + assert IDToken.objects.filter(jti=id_token.jti).count() == 0 + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_key(oauth2_settings, application): + # RS256 key + key = application.jwk_key + assert key.key_type == "RSA" + + # RS256 key, but not configured + oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + with pytest.raises(ImproperlyConfigured) as exc: + application.jwk_key + assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) + + # HS256 key + application.algorithm = Application.HS256_ALGORITHM + key = application.jwk_key + assert key.key_type == "oct" + + # No algorithm + application.algorithm = Application.NO_ALGORITHM + with pytest.raises(ImproperlyConfigured) as exc: + application.jwk_key + assert "This application does not support signed tokens" == str(exc.value) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean(oauth2_settings, application): + # RS256, RSA key is configured + application.clean() + + # RS256, RSA key is not configured + oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You must set OIDC_RSA_PRIVATE_KEY" in str(exc.value) + + # HS256 algorithm, auth code + confidential -> allowed + application.algorithm = Application.HS256_ALGORITHM + application.clean() + + # HS256, auth code + public -> forbidden + application.client_type = Application.CLIENT_PUBLIC + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You cannot use HS256" in str(exc.value) + + # HS256, hybrid + confidential -> forbidden + application.client_type = Application.CLIENT_CONFIDENTIAL + application.authorization_grant_type = Application.GRANT_OPENID_HYBRID + with pytest.raises(ValidationError) as exc: + application.clean() + assert "You cannot use HS256" in str(exc.value) diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index f318ccde1..860cbb461 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,5 +1,6 @@ import json +import pytest from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core @@ -12,15 +13,16 @@ import mock +@pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): def setUp(self): self.factory = RequestFactory() self.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): - with mock.patch("oauth2_provider.oauth2_backends.oauth2_settings.OAUTH2_SERVER_CLASS"): - oauthlib_core = OAuthLibCore() - self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) + self.oauth2_settings.OAUTH2_SERVER_CLASS = mock.MagicMock + oauthlib_core = OAuthLibCore() + self.assertTrue(isinstance(oauthlib_core.server, mock.MagicMock)) def test_form_urlencoded_extract_params(self): payload = "grant_type=password&username=john&password=123456" diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 21b0fcfa2..7997d3bca 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -1,15 +1,21 @@ import contextlib import datetime +import json +import pytest from django.contrib.auth import get_user_model from django.test import TestCase, TransactionTestCase from django.utils import timezone +from jwcrypto import jwt from oauthlib.common import Request from oauth2_provider.exceptions import FatalClientError from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model +from oauth2_provider.oauth2_backends import get_oauthlib_core from oauth2_provider.oauth2_validators import OAuth2Validator +from . import presets + try: from unittest import mock @@ -440,3 +446,77 @@ def test_response_when_auth_server_response_return_404(self): "Not Found.\nNoneType: None", mock_log.output, ) + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_endpoint_generation(oauth2_settings, rf): + oauth2_settings.OIDC_ISS_ENDPOINT = "" + django_request = rf.get("/") + request = Request("/", headers=django_request.META) + validator = OAuth2Validator() + oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) + assert oidc_issuer_endpoint == "http://testserver/o" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_oidc_endpoint_generation_ssl(oauth2_settings, rf, settings): + oauth2_settings.OIDC_ISS_ENDPOINT = "" + django_request = rf.get("/", secure=True) + # Calling the settings method with a django https request should generate a https url + oidc_issuer_endpoint = oauth2_settings.oidc_issuer(django_request) + assert oidc_issuer_endpoint == "https://testserver/o" + + # Should also work with an oauthlib request (via validator) + core = get_oauthlib_core() + uri, http_method, body, headers = core._extract_params(django_request) + request = Request(uri=uri, http_method=http_method, body=body, headers=headers) + validator = OAuth2Validator() + oidc_issuer_endpoint = validator.get_oidc_issuer_endpoint(request) + assert oidc_issuer_endpoint == "https://testserver/o" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_get_jwt_bearer_token(oauth2_settings, mocker): + # oauthlib instructs us to make get_jwt_bearer_token call get_id_token + request = mocker.MagicMock(wraps=Request) + validator = OAuth2Validator() + mock_get_id_token = mocker.patch.object(validator, "get_id_token") + validator.get_jwt_bearer_token(None, None, request) + assert mock_get_id_token.call_count == 1 + assert mock_get_id_token.call_args[0] == (None, None, request) + assert mock_get_id_token.call_args[1] == {} + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_expired_jwt(oauth2_settings, mocker, oidc_tokens): + mocker.patch("oauth2_provider.oauth2_validators.jwt.JWT", side_effect=jwt.JWTExpired) + validator = OAuth2Validator() + status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_no_token(oauth2_settings, mocker): + validator = OAuth2Validator() + status = validator.validate_id_token("", ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): + oidc_tokens.application.delete() + validator = OAuth2Validator() + status = validator.validate_id_token(oidc_tokens.id_token, ["openid"], mocker.sentinel.request) + assert status is False + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, oidc_key): + token = jwt.JWT(header=json.dumps({"alg": "RS256"}), claims=json.dumps({"bad": "token"})) + token.make_signed_token(oidc_key) + validator = OAuth2Validator() + status = validator.validate_id_token(token.serialize(), ["openid"], mocker.sentinel.request) + assert status is False diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py new file mode 100644 index 000000000..3e3a5538c --- /dev/null +++ b/tests/test_oidc_views.py @@ -0,0 +1,139 @@ +import pytest +from django.test import TestCase +from django.urls import reverse + +from oauth2_provider.oauth2_validators import OAuth2Validator + +from . import presets + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestConnectDiscoveryInfoView(TestCase): + def test_get_connect_discovery_info(self): + expected_response = { + "issuer": "http://localhost", + "authorization_endpoint": "http://localhost/o/authorize/", + "token_endpoint": "http://localhost/o/token/", + "userinfo_endpoint": "http://localhost/userinfo/", + "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_without_issuer_url(self): + self.oauth2_settings.OIDC_ISS_ENDPOINT = None + self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None + expected_response = { + "issuer": "http://testserver/o", + "authorization_endpoint": "http://testserver/o/authorize/", + "token_endpoint": "http://testserver/o/token/", + "userinfo_endpoint": "http://testserver/o/userinfo/", + "jwks_uri": "http://testserver/o/.well-known/jwks.json", + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_without_rsa_key(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json()["id_token_signing_alg_values_supported"] == ["HS256"] + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +class TestJwksInfoView(TestCase): + def test_get_jwks_info(self): + expected_response = { + "keys": [ + { + "alg": "RS256", + "use": "sig", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "e": "AQAB", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa + } + ] + } + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_jwks_info_no_rsa_key(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == {"keys": []} + + +@pytest.mark.django_db +@pytest.mark.parametrize("method", ["get", "post"]) +def test_userinfo_endpoint(oidc_tokens, client, method): + auth_header = "Bearer %s" % oidc_tokens.access_token + rsp = getattr(client, method)( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_tokens.user.pk) + + +@pytest.mark.django_db +def test_userinfo_endpoint_bad_token(oidc_tokens, client): + # No access token + rsp = client.get(reverse("oauth2_provider:user-info")) + assert rsp.status_code == 401 + # Bad access token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION="Bearer not-a-real-token", + ) + assert rsp.status_code == 401 + + +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self, request): + return {"state": "very nice"} + + oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_tokens.user.pk) + assert "state" in data + assert data["state"] == "very nice" diff --git a/tests/test_password.py b/tests/test_password.py index f50404f9f..953b076e2 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -1,11 +1,11 @@ import json +import pytest from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ProtectedResourceView from .utils import get_basic_auth_header @@ -21,6 +21,7 @@ def get(self, request, *args, **kwargs): return "This is a protected resource" +@pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -34,9 +35,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_PASSWORD, ) - oauth2_settings._SCOPES = ["read", "write"] - oauth2_settings._DEFAULT_SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() @@ -60,8 +58,8 @@ def test_get_token(self): content = json.loads(response.content.decode("utf-8")) self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + self.assertEqual(set(content["scope"].split()), {"read", "write"}) + self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) def test_bad_credentials(self): """ diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index f23891dca..a25611b93 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -1,5 +1,6 @@ from datetime import timedelta +import pytest from django.conf.urls import include from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured @@ -22,13 +23,8 @@ TokenMatchesOASRequirements, ) from oauth2_provider.models import get_access_token_model, get_application_model -from oauth2_provider.settings import oauth2_settings - -try: - from unittest import mock -except ImportError: - import mock +from . import presets Application = get_application_model() @@ -131,10 +127,10 @@ class AuthenticationNoneOAuth2View(MockView): @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): def setUp(self): - oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "resource1"] - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") @@ -154,9 +150,6 @@ def setUp(self): application=self.application, ) - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] - def _create_authorization_header(self, token): return "Bearer {0}".format(token) @@ -311,8 +304,8 @@ def test_resource_scoped_permission_post_denied(self): response = self.client.post("/oauth2-resource-scoped-test/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 403) - @mock.patch.object(oauth2_settings, "ERROR_RESPONSE_WITH_SCOPES", new=True) def test_required_scope_in_response(self): + self.oauth2_settings.ERROR_RESPONSE_WITH_SCOPES = True self.access_token.scope = "scope2" self.access_token.save() diff --git a/tests/test_scopes.py b/tests/test_scopes.py index d2efa5856..a310e223a 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -1,13 +1,13 @@ import json from urllib.parse import parse_qs, urlparse +import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.test import RequestFactory, TestCase from django.urls import reverse from oauth2_provider.models import get_access_token_model, get_application_model, get_grant_model -from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView from .utils import get_basic_auth_header @@ -42,6 +42,19 @@ def post(self, request, *args, **kwargs): return "This is a write protected resource" +SCOPE_SETTINGS = { + "SCOPES": { + "read": "Read scope", + "write": "Write scope", + "scope1": "Custom scope 1", + "scope2": "Custom scope 2", + "scope3": "Custom scope 3", + }, +} + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(SCOPE_SETTINGS) class BaseTest(TestCase): def setUp(self): self.factory = RequestFactory() @@ -56,12 +69,7 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write", "scope1", "scope2", "scope3"] - oauth2_settings.READ_SCOPE = "read" - oauth2_settings.WRITE_SCOPE = "write" - def tearDown(self): - oauth2_settings._SCOPES = ["read", "write"] self.application.delete() self.test_user.delete() self.dev_user.delete() @@ -325,27 +333,27 @@ def get_access_token(self, scopes): return content["access_token"] def test_improperly_configured(self): - oauth2_settings.SCOPES = {"scope1": "Scope 1"} + self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} - oauth2_settings.READ_SCOPE = "ciccia" + self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) def test_properly_configured(self): - oauth2_settings.SCOPES = {"scope1": "Scope 1"} + self.oauth2_settings.SCOPES = {"scope1": "Scope 1"} request = self.factory.get("/fake") view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) - oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} - oauth2_settings.READ_SCOPE = "ciccia" + self.oauth2_settings.SCOPES = {"read": "Read Scope", "write": "Write Scope"} + self.oauth2_settings.READ_SCOPE = "ciccia" view = ReadWriteResourceView.as_view() self.assertRaises(ImproperlyConfigured, view, request) diff --git a/tests/test_scopes_backend.py b/tests/test_scopes_backend.py index 5f629613e..925a4e3c5 100644 --- a/tests/test_scopes_backend.py +++ b/tests/test_scopes_backend.py @@ -3,9 +3,9 @@ def test_settings_scopes_get_available_scopes(): scopes = SettingsScopes() - assert scopes.get_available_scopes() == ["read", "write"] + assert set(scopes.get_available_scopes()) == {"read", "write"} def test_settings_scopes_get_default_scopes(): scopes = SettingsScopes() - assert scopes.get_default_scopes() == ["read", "write"] + assert set(scopes.get_default_scopes()) == {"read", "write"} diff --git a/tests/test_settings.py b/tests/test_settings.py index 379d12c2e..52bdafe03 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,20 +1,27 @@ +import pytest +from django.core.exceptions import ImproperlyConfigured from django.test import TestCase from django.test.utils import override_settings +from oauthlib.common import Request from oauth2_provider.admin import ( get_access_token_admin_class, get_application_admin_class, get_grant_admin_class, + get_id_token_admin_class, get_refresh_token_admin_class, ) -from oauth2_provider.settings import OAuth2ProviderSettings, oauth2_settings +from oauth2_provider.settings import OAuth2ProviderSettings, oauth2_settings, perform_import from tests.admin import ( CustomAccessTokenAdmin, CustomApplicationAdmin, CustomGrantAdmin, + CustomIDTokenAdmin, CustomRefreshTokenAdmin, ) +from . import presets + class TestAdminClass(TestCase): def test_import_error_message_maintained(self): @@ -47,7 +54,15 @@ def test_get_grant_admin_class(self): """ grant_admin_class = get_grant_admin_class() default_grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS - assert grant_admin_class, default_grant_admin_class + assert grant_admin_class == default_grant_admin_class + + def test_get_id_token_admin_class(self): + """ + Test for getting class for ID token admin. + """ + id_token_admin_class = get_id_token_admin_class() + default_id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS + assert id_token_admin_class == default_id_token_admin_class def test_get_refresh_token_admin_class(self): """ @@ -81,6 +96,14 @@ def test_get_custom_grant_admin_class(self): grant_admin_class = get_grant_admin_class() assert grant_admin_class == CustomGrantAdmin + @override_settings(OAUTH2_PROVIDER={"ID_TOKEN_ADMIN_CLASS": "tests.admin.CustomIDTokenAdmin"}) + def test_get_custom_id_token_admin_class(self): + """ + Test for getting custom class for ID token admin. + """ + id_token_admin_class = get_id_token_admin_class() + assert id_token_admin_class == CustomIDTokenAdmin + @override_settings(OAUTH2_PROVIDER={"REFRESH_TOKEN_ADMIN_CLASS": "tests.admin.CustomRefreshTokenAdmin"}) def test_get_custom_refresh_token_admin_class(self): """ @@ -88,3 +111,59 @@ def test_get_custom_refresh_token_admin_class(self): """ refresh_token_admin_class = get_refresh_token_admin_class() assert refresh_token_admin_class == CustomRefreshTokenAdmin + + +def test_perform_import_when_none(): + assert perform_import(None, "REFRESH_TOKEN_ADMIN_CLASS") is None + + +def test_perform_import_list(): + imports = ["tests.admin.CustomIDTokenAdmin", "tests.admin.CustomGrantAdmin"] + assert perform_import(imports, "SOME_CLASSES") == [CustomIDTokenAdmin, CustomGrantAdmin] + + +def test_perform_import_already_imported(): + cls = perform_import(CustomRefreshTokenAdmin, "REFRESH_TOKEN_ADMIN_CLASS") + assert cls == CustomRefreshTokenAdmin + + +def test_invalid_scopes_raises_error(): + settings = OAuth2ProviderSettings( + { + "SCOPES": {"foo": "foo scope"}, + "DEFAULT_SCOPES": ["bar"], + } + ) + with pytest.raises(ImproperlyConfigured) as exc: + settings._DEFAULT_SCOPES + assert str(exc.value) == "Defined DEFAULT_SCOPES not present in SCOPES" + + +def test_missing_mandatory_setting_raises_error(): + settings = OAuth2ProviderSettings( + user_settings={}, defaults={"very_important": None}, mandatory=["very_important"] + ) + with pytest.raises(AttributeError) as exc: + settings.very_important + assert str(exc.value) == "OAuth2Provider setting: very_important is mandatory" + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +@pytest.mark.parametrize("issuer_setting", ["http://foo.com/", None]) +@pytest.mark.parametrize("request_type", ["django", "oauthlib"]) +def test_generating_iss_endpoint(oauth2_settings, issuer_setting, request_type, rf): + oauth2_settings.OIDC_ISS_ENDPOINT = issuer_setting + if request_type == "django": + request = rf.get("/") + elif request_type == "oauthlib": + request = Request("/", headers=rf.get("/").META) + expected = issuer_setting or "http://testserver/o" + assert oauth2_settings.oidc_issuer(request) == expected + + +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_generating_iss_endpoint_type_error(oauth2_settings): + oauth2_settings.OIDC_ISS_ENDPOINT = None + with pytest.raises(TypeError) as exc: + oauth2_settings.oidc_issuer(None) + assert str(exc.value) == "request must be a django or oauthlib request: got None" diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 5274ee13e..1ed1c9119 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -6,7 +6,6 @@ from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model -from oauth2_provider.settings import oauth2_settings Application = get_application_model() @@ -29,8 +28,6 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - oauth2_settings._SCOPES = ["read", "write"] - def tearDown(self): self.application.delete() self.test_user.delete() diff --git a/tests/test_validators.py b/tests/test_validators.py index 82930a9d7..0760e0290 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,10 +1,11 @@ +import pytest from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.settings import oauth2_settings from oauth2_provider.validators import RedirectURIValidator +@pytest.mark.usefixtures("oauth2_settings") class TestValidators(TestCase): def test_validate_good_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) @@ -37,7 +38,7 @@ def test_validate_custom_uri_scheme(self): def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) - oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] bad_uris = [ "http:/example.com", "HTTP://localhost", diff --git a/tests/urls.py b/tests/urls.py index f4b22a4d4..0661a9336 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -7,7 +7,5 @@ urlpatterns = [ path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("admin/", admin.site.urls), ] - - -urlpatterns += [path("admin/", admin.site.urls)] diff --git a/tests/utils.py b/tests/utils.py index ec2590512..b7dc2001a 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,4 +1,5 @@ import base64 +from unittest import mock def get_basic_auth_header(user, password): @@ -13,3 +14,19 @@ def get_basic_auth_header(user, password): } return auth_headers + + +def spy_on(meth): + """ + Util function to add a spy onto a method of a class. + """ + spy = mock.MagicMock() + + def wrapper(self, *args, **kwargs): + spy(self, *args, **kwargs) + return_value = meth(self, *args, **kwargs) + spy.returned = return_value + return return_value + + wrapper.spy = spy + return wrapper diff --git a/tox.ini b/tox.ini index 8d0611633..3016d024c 100644 --- a/tox.ini +++ b/tox.ini @@ -14,10 +14,17 @@ python = [pytest] django_find_project = false +addopts = + --cov=oauth2_provider + --cov-report= + --cov-append + -s +markers = + oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture [testenv] commands = - pytest --cov=oauth2_provider --cov-report= --cov-append {posargs} + pytest {posargs} coverage report coverage xml setenv = @@ -31,14 +38,16 @@ deps = djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 + jwcrypto coverage pytest pytest-cov pytest-django pytest-xdist + pytest-mock requests passenv = - PYTEST_ADDOPTS + PYTEST_ADDOPTS [testenv:py{38,39}-djmain] ignore_errors = true @@ -57,6 +66,7 @@ deps = m2r>=0.2.1 sphinx-rtd-theme livedocs: sphinx-autobuild + jwcrypto [testenv:flake8] basepython = python3.8 @@ -84,6 +94,9 @@ commands = source = oauth2_provider omit = */migrations/* +[coverage:report] +show_missing = True + [flake8] max-line-length = 110 exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ From cc767e4111ab1b13b840f6836a0f65bac9555f8e Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 18 Mar 2021 09:28:07 -0400 Subject: [PATCH 370/722] Replace reference to defunct Google Group with opening a GitHub Issue (#942) --- docs/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.rst b/docs/index.rst index 4f83249f0..d2d4e8c3c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ See our :doc:`Changelog <changelog>` for information on updates. Support ------- -If you need support please send a message to the `Django OAuth Toolkit Google Group <http://groups.google.com/group/django-oauth-toolkit>`_ +If you need help please submit a `question <https://github.com/jazzband/django-oauth-toolkit/issues/new?assignees=&labels=question&template=question.md&title=>`_. Requirements ------------ From 671bfda021c3dc3eec213e2a0757e43370f9c7fa Mon Sep 17 00:00:00 2001 From: Aryan Iyappan <69184573+CosmicReindeer@users.noreply.github.com> Date: Wed, 17 Mar 2021 13:48:58 +0530 Subject: [PATCH 371/722] update namespace error in example Because the app_name attribute is not set in the included module (oauth2_provider), we cannot directly include endpoints with the namespace oauth2_provider. We need to pass in a tuple of the endpoints and app-name instead. Document the same in the tutorial example. --- docs/tutorial/tutorial_02.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index b05877d7a..cdc94540c 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -65,7 +65,9 @@ URL this view will respond to: urlpatterns = [ # OAuth 2 endpoints: - path('o/', include(oauth2_endpoint_views, namespace="oauth2_provider")), + # need to pass in a tuple of the endpoints as well as the app's name + # because the app_name attribute is not set in the included module + path('o/', include((oauth2_endpoint_views, 'oauth2_provider'), namespace="oauth2_provider")), path('api/hello', ApiEndpoint.as_view()), # an example resource endpoint ] From 592398c32ffb24099993d1e34869d916773ae764 Mon Sep 17 00:00:00 2001 From: Aryan Iyappan <69184573+CosmicReindeer@users.noreply.github.com> Date: Wed, 17 Mar 2021 13:51:11 +0530 Subject: [PATCH 372/722] Update AUTHORS --- AUTHORS | 1 + 1 file changed, 1 insertion(+) diff --git a/AUTHORS b/AUTHORS index ae3d17dec..71c8f9b89 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,6 +12,7 @@ Alessandro De Angelis Aleksander Vaskevich Alan Crosswell Anvesh Agarwal +Aryan Iyappan Asif Saif Uddin Ash Christopher Aristóbulo Meneses From 5d53d240014c3e97a4ff9a13b4acefc75b764deb Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 22 Mar 2021 08:01:13 -0400 Subject: [PATCH 373/722] Release 1.5.0 (#947) Added all co-authors of the OIDC PR to AUTHORS and sorted alphabetically. --- AUTHORS | 29 +++++++++++++++++------------ CHANGELOG.md | 7 +++++-- setup.cfg | 2 +- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index 71c8f9b89..da2570ef7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,38 +8,43 @@ Contributors ============ Abhishek Patel -Alessandro De Angelis -Aleksander Vaskevich Alan Crosswell +Aleksander Vaskevich +Alessandro De Angelis +Allisson Azevedo Anvesh Agarwal +Aristóbulo Meneses Aryan Iyappan -Asif Saif Uddin Ash Christopher -Aristóbulo Meneses +Asif Saif Uddin Bart Merenda Bas van Oostveen +Dave Burkholder David Fischer +David Smith Diego Garcia +Dulmandakh Sukhbaatar +Dylan Giesler Emanuele Palazzetti Federico Dolce +Frederico Vieira Hiroki Kiyohara Jens Timmerman Jerome Leclanche Jim Graham Jonathan Steffan +Jun Zhou +Kristian Rune Larsen Paul Oswald Pavel Tvrdík -pySilver Rodney Richardson +Rustem Saiargaliev Sandro Rodrigues +Shaun Stanworth Silvano Cerza +Spencer Carroll Stéphane Raimbault -Jun Zhou -David Smith -Łukasz Skarżyński Tom Evans -Dylan Giesler -Spencer Carroll -Dulmandakh Sukhbaatar Will Beaufoy -Rustem Saiargaliev +pySilver +Łukasz Skarżyński diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f279398..534ba1c86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [unreleased] +## [1.5.0] 2021-03-18 ### Added * #915 Add optional OpenID Connect support. -## [1.4.1] +### Changed +* #942 Help via defunct Google group replaced with using GitHub issues + +## [1.4.1] 2021-03-12 ### Changed * #925 OAuth2TokenMiddleware converted to new style middleware, and no longer extends MiddlewareMixin. diff --git a/setup.cfg b/setup.cfg index 03d614a7f..13d6cd0f9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.4.1 +version = 1.5.0 description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst From 39a4577385faa97883bbc73555d0e8cfee744598 Mon Sep 17 00:00:00 2001 From: JadielTeofilo <teofilojadiel@gmail.com> Date: Mon, 22 Mar 2021 10:59:51 -0300 Subject: [PATCH 374/722] Fix #524 - Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True (#948) * Add breaking tests * Add fix for breaking tests Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. * Update authors file * Update changelog file * Update the docs * Fix broken tests (missing import) Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 2 ++ CHANGELOG.md | 2 ++ docs/settings.rst | 9 +++++++++ oauth2_provider/oauth2_validators.py | 2 +- tests/test_introspection_auth.py | 21 +++++++++++++++++++++ 5 files changed, 35 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index da2570ef7..a81fed695 100644 --- a/AUTHORS +++ b/AUTHORS @@ -46,5 +46,7 @@ Spencer Carroll Stéphane Raimbault Tom Evans Will Beaufoy +Rustem Saiargaliev +Jadiel Teófilo pySilver Łukasz Skarżyński diff --git a/CHANGELOG.md b/CHANGELOG.md index 534ba1c86..660ebefb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #915 Add optional OpenID Connect support. +### Fixed +* #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. ### Changed * #942 Help via defunct Google group replaced with using GitHub issues diff --git a/docs/settings.rst b/docs/settings.rst index afca76e01..67ea7b37a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -310,3 +310,12 @@ OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED Default: ``["client_secret_post", "client_secret_basic"]`` The authentication methods that are advertised to be supported by this server. + + +Settings imported from Django project +-------------------------- + +USE_TZ +~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Used to determine whether or not to make token expire dates timezone aware. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f91c06011..25266d04d 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -357,7 +357,7 @@ def _get_token_from_authentication_server( expires = max_caching_time scope = content.get("scope", "") - expires = make_aware(expires) + expires = make_aware(expires) if settings.USE_TZ else expires access_token, _created = AccessToken.objects.update_or_create( token=token, diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 9f871cdea..8b2a6daf0 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -2,6 +2,7 @@ import datetime import pytest +from django.conf import settings from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse @@ -12,6 +13,7 @@ from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.settings import oauth2_settings from oauth2_provider.views import ScopedProtectedResourceView from . import presets @@ -154,6 +156,25 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): self.assertEqual(token.user.username, "foo_user") self.assertEqual(token.scope, "read write dolphin") + @mock.patch("requests.post", side_effect=mocked_requests_post) + def test_get_token_from_authentication_server_expires_timezone(self, mock_get): + """ + Test method _get_token_from_authentication_server for projects with USE_TZ False + """ + settings_use_tz_backup = settings.USE_TZ + settings.USE_TZ = False + try: + self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + ) + except ValueError as exception: + self.fail(str(exception)) + finally: + settings.USE_TZ = settings_use_tz_backup + @mock.patch("requests.post", side_effect=mocked_requests_post) def test_validate_bearer_token(self, mock_get): """ From 9d2aac2480b2a1875eb52612661992f73606bade Mon Sep 17 00:00:00 2001 From: ShaheedHaque <shaheedhaque@gmail.com> Date: Mon, 22 Mar 2021 15:06:30 +0000 Subject: [PATCH 375/722] Provide django.contrib.auth.authenticate() with a request for compatibiity with more backends. (#949) * Provide django.contrib.auth.authenticate() with a request for compatibiity with more backends. Resolves #712. Resolves #636. Resolves #808. Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + CHANGELOG.md | 11 +++++++++-- oauth2_provider/oauth2_validators.py | 11 ++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index a81fed695..aba1e22f4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -50,3 +50,4 @@ Rustem Saiargaliev Jadiel Teófilo pySilver Łukasz Skarżyński +Shaheed Haque diff --git a/CHANGELOG.md b/CHANGELOG.md index 660ebefb7..e7b0e35cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,13 +14,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [1.5.0] 2021-03-18 +## [unreleased] ### Added -* #915 Add optional OpenID Connect support. +* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` + to provide compatibility with backends that need one. + ### Fixed * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. +## [1.5.0] 2021-03-18 + +### Added +* #915 Add optional OpenID Connect support. + ### Changed * #942 Help via defunct Google group replaced with using GitHub issues diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 25266d04d..f3a24e258 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -14,6 +14,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q +from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ @@ -664,7 +665,15 @@ def validate_user(self, username, password, client, request, *args, **kwargs): """ Check username and password correspond to a valid and active User """ - u = authenticate(username=username, password=password) + # Passing the optional HttpRequest adds compatibility for backends + # which depend on its presence. Create one with attributes likely + # to be used. + http_request = HttpRequest() + http_request.path = request.uri + http_request.method = request.http_method + getattr(http_request, request.http_method).update(dict(request.decoded_body)) + http_request.META = request.headers + u = authenticate(http_request, username=username, password=password) if u is not None and u.is_active: request.user = u return True From 27bd0af6d86864268c0fcb6a0e3dbb2a08ace74d Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 5 Apr 2021 16:31:39 -0400 Subject: [PATCH 376/722] doc: missing argument to get_userinfo_claims --- docs/oidc.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 29c9406bd..87fadce57 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -267,7 +267,7 @@ token, so you will probably want to re-use that:: class CustomOAuth2Validator(OAuth2Validator): def get_userinfo_claims(self, request): - claims = super().get_userinfo_claims() + claims = super().get_userinfo_claims(request) claims["color_scheme"] = get_color_scheme(request.user) return claims From b4f418be1c9e716f15eebd32445f719e4b4bcce9 Mon Sep 17 00:00:00 2001 From: Jonas Nygaard Pedersen <dollarklavs@users.noreply.github.com> Date: Mon, 12 Apr 2021 12:08:40 +0200 Subject: [PATCH 377/722] Fix double oauth2_provider mountpoint in oidc view (#957) * Fix double oauth2_provider mountpoint in oidc view Fixes the doubling of mountpoint path in the OIDC endpoints values for `.well-known/openid-configuration/` * Updated tests According to the `django-oauth-toolkit` documentation for [OIDC_ISS_ENDPOINT](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#oidc-iss-endpoint) this settings variable should enable discovery at `OIDC_ISS_ENDPOINT` + `/.well-known/openid-configuration/`. But if you use the variable as described you'll end up with the correct URL for the `issuer` value but incorrect URL's for the values of `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint`, and `jwks_uri`. So if the `OIDC_ISS_ENDPOINT` is `http://localhost:8001/some-initial-path/o` the `issuer` will be `http://localhost:8001/some-initial-path/o` but `authorization_endpoint` will be `http://localhost:8001/some-initial-path/o/some-initial-path/o/authorize/`. Same pattern for `token_endpoint`, `userinfo_endpoint`, and `jwks_uri` This commit updates the tests to expect `OIDC_ISS_ENDPOINT` to end in `/o` * Updated AUTHORS * Update CHANGELOG * updated CHANGELOG To include possible breaking change message Co-authored-by: Jonas Nygaard Pedersen <jnp@bolighed.dk> --- AUTHORS | 1 + CHANGELOG.md | 4 +++- oauth2_provider/views/oidc.py | 12 ++++++++---- tests/presets.py | 4 ++-- tests/test_oidc_views.py | 4 ++-- 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/AUTHORS b/AUTHORS index aba1e22f4..d8973f29f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -32,6 +32,7 @@ Hiroki Kiyohara Jens Timmerman Jerome Leclanche Jim Graham +Jonas Nygaard Pedersen Jonathan Steffan Jun Zhou Kristian Rune Larsen diff --git a/CHANGELOG.md b/CHANGELOG.md index e7b0e35cb..c28031a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,9 +19,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` to provide compatibility with backends that need one. - + ### Fixed * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. +* #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. + Breaks existing OIDC discovery output ## [1.5.0] 2021-03-18 diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index ac3a2a172..00c8c3fa4 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,4 +1,5 @@ import json +from urllib.parse import urlparse from django.http import HttpResponse, JsonResponse from django.urls import reverse @@ -32,12 +33,15 @@ def get(self, request, *args, **kwargs): ) jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) else: - authorization_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:authorize")) - token_endpoint = "{}{}".format(issuer_url, reverse("oauth2_provider:token")) + parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT) + host = parsed_url.scheme + "://" + parsed_url.netloc + authorization_endpoint = "{}{}".format(host, reverse("oauth2_provider:authorize")) + token_endpoint = "{}{}".format(host, reverse("oauth2_provider:token")) userinfo_endpoint = oauth2_settings.OIDC_USERINFO_ENDPOINT or "{}{}".format( - issuer_url, reverse("oauth2_provider:user-info") + host, reverse("oauth2_provider:user-info") ) - jwks_uri = "{}{}".format(issuer_url, reverse("oauth2_provider:jwks-info")) + jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) + signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] diff --git a/tests/presets.py b/tests/presets.py index da1577bf4..214f804ef 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -9,8 +9,8 @@ DEFAULT_SCOPES_RO = {"DEFAULT_SCOPES": ["read"]} OIDC_SETTINGS_RW = { "OIDC_ENABLED": True, - "OIDC_ISS_ENDPOINT": "http://localhost", - "OIDC_USERINFO_ENDPOINT": "http://localhost/userinfo/", + "OIDC_ISS_ENDPOINT": "http://localhost/o", + "OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/", "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, "SCOPES": { "read": "Reading scope", diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 3e3a5538c..5cbae5402 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -12,10 +12,10 @@ class TestConnectDiscoveryInfoView(TestCase): def test_get_connect_discovery_info(self): expected_response = { - "issuer": "http://localhost", + "issuer": "http://localhost/o", "authorization_endpoint": "http://localhost/o/authorize/", "token_endpoint": "http://localhost/o/token/", - "userinfo_endpoint": "http://localhost/userinfo/", + "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", "response_types_supported": [ "code", From e5ecd562b8a001b5761b47cc4f48cc285016a31e Mon Sep 17 00:00:00 2001 From: Paul Dekkers <paul.dekkers@surf.nl> Date: Mon, 12 Apr 2021 15:39:37 +0200 Subject: [PATCH 378/722] Allow loopback redirect URIs using ports as described in RFC8252 (#953) * Allow loopback redirect URIs using ports as described in RFC8252 * Update Changelog and Authors * Docs update and adjustment for explicit port config on loopback * Wrap and clarify Changelog * Clarify documentation * Split out redirect uri logic for easier testing This adds some unit tests for loopback IP code in particular, as part of reviewing the change Co-authored-by: Raphael Gaschignard <raphael@rtpg.co> Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Raphael Gaschignard <raphael@makeleaps.com> --- AUTHORS | 1 + CHANGELOG.md | 2 ++ docs/settings.rst | 5 +++ oauth2_provider/models.py | 65 ++++++++++++++++++++++++++--------- tests/test_oauth2_backends.py | 21 +++++++++++ 5 files changed, 77 insertions(+), 17 deletions(-) diff --git a/AUTHORS b/AUTHORS index d8973f29f..37f2cbdde 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Jonas Nygaard Pedersen Jonathan Steffan Jun Zhou Kristian Rune Larsen +Paul Dekkers Paul Oswald Pavel Tvrdík Rodney Richardson diff --git a/CHANGELOG.md b/CHANGELOG.md index c28031a26..af3fdae3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. * #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. Breaks existing OIDC discovery output +* #953 Allow loopback redirect URIs with random ports using http scheme, localhost address and no explicit port + configuration in the allowed redirect_uris for Oauth2 Applications (RFC8252) ## [1.5.0] 2021-03-18 diff --git a/docs/settings.rst b/docs/settings.rst index 67ea7b37a..de7bcf85c 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -52,6 +52,11 @@ Default: ``["http", "https"]`` A list of schemes that the ``redirect_uri`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. +For Native Apps the ``http`` scheme can be safely used with loopback addresses in the +Application (``[::1]`` or ``127.0.0.1``). In this case the ``redirect_uri`` can be +configured without explicit port specification, so that the Application accepts randomly +assigned ports. + Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a21cb868b..aa10eca16 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -125,23 +125,7 @@ def redirect_uri_allowed(self, uri): :param uri: Url to check """ - parsed_uri = urlparse(uri) - uqs_set = set(parse_qsl(parsed_uri.query)) - for allowed_uri in self.redirect_uris.split(): - parsed_allowed_uri = urlparse(allowed_uri) - - if ( - parsed_allowed_uri.scheme == parsed_uri.scheme - and parsed_allowed_uri.netloc == parsed_uri.netloc - and parsed_allowed_uri.path == parsed_uri.path - ): - - aqs_set = set(parse_qsl(parsed_allowed_uri.query)) - - if aqs_set.issubset(uqs_set): - return True - - return False + return redirect_to_uri_allowed(uri, self.redirect_uris.split()) def clean(self): from django.core.exceptions import ValidationError @@ -674,3 +658,50 @@ def clear_expired(): access_tokens.delete() grants.delete() + + +def redirect_to_uri_allowed(uri, allowed_uris): + """ + Checks if a given uri can be redirected to based on the provided allowed_uris configuration. + + On top of exact matches, this function also handles loopback IPs based on RFC 8252. + + :param uri: URI to check + :param allowed_uris: A list of URIs that are allowed + """ + + parsed_uri = urlparse(uri) + uqs_set = set(parse_qsl(parsed_uri.query)) + for allowed_uri in allowed_uris: + parsed_allowed_uri = urlparse(allowed_uri) + + # From RFC 8252 (Section 7.3) + # + # Loopback redirect URIs use the "http" scheme + # [...] + # The authorization server MUST allow any port to be specified at the + # time of the request for loopback IP redirect URIs, to accommodate + # clients that obtain an available ephemeral port from the operating + # system at the time of the request. + + allowed_uri_is_loopback = ( + parsed_allowed_uri.scheme == "http" + and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"] + and parsed_allowed_uri.port is None + ) + if ( + allowed_uri_is_loopback + and parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.hostname == parsed_uri.hostname + and parsed_allowed_uri.path == parsed_uri.path + ) or ( + parsed_allowed_uri.scheme == parsed_uri.scheme + and parsed_allowed_uri.netloc == parsed_uri.netloc + and parsed_allowed_uri.path == parsed_uri.path + ): + + aqs_set = set(parse_qsl(parsed_allowed_uri.query)) + if aqs_set.issubset(uqs_set): + return True + + return False diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 860cbb461..acff2cae9 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase from oauth2_provider.backends import get_oauthlib_core +from oauth2_provider.models import redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore @@ -110,3 +111,23 @@ def test_validate_authorization_request_unsafe_query(self): oauthlib_core = get_oauthlib_core() oauthlib_core.verify_request(request, scopes=[]) + + +@pytest.mark.parametrize( + "uri, expected_result", + # localhost is _not_ a loopback URI + [ + ("http://localhost:3456", False), + # only http scheme is supported for loopback URIs + ("https://127.0.0.1:3456", False), + ("http://127.0.0.1:3456", True), + ("http://[::1]", True), + ("http://[::1]:34", True), + ], +) +def test_uri_loopback_redirect_check(uri, expected_result): + allowed_uris = ["http://127.0.0.1", "http://[::1]"] + if expected_result: + assert redirect_to_uri_allowed(uri, allowed_uris) + else: + assert not redirect_to_uri_allowed(uri, allowed_uris) From d90bb348f00886ae8321f8194f6275be9aa08f80 Mon Sep 17 00:00:00 2001 From: Hasan Ramezani <hasan.r67@gmail.com> Date: Sun, 25 Apr 2021 12:14:53 +0430 Subject: [PATCH 379/722] Update django support (#968) * Remove support for Django 3.0. * Add support for Django 3.2 * Add supported Python and Django badges. * Added my name to AUTHORS. --- AUTHORS | 1 + CHANGELOG.md | 2 ++ README.rst | 10 +++++++++- setup.cfg | 2 +- tox.ini | 4 ++-- 5 files changed, 15 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index 37f2cbdde..50589287a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -28,6 +28,7 @@ Dylan Giesler Emanuele Palazzetti Federico Dolce Frederico Vieira +Hasan Ramezani Hiroki Kiyohara Jens Timmerman Jerome Leclanche diff --git a/CHANGELOG.md b/CHANGELOG.md index af3fdae3a..0b2deb05d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [unreleased] +* Remove support for Django 3.0 +* Add support for Django 3.2 ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` diff --git a/README.rst b/README.rst index c96cb28be..fe435987f 100644 --- a/README.rst +++ b/README.rst @@ -18,6 +18,14 @@ Django OAuth Toolkit :target: https://codecov.io/gh/jazzband/django-oauth-toolkit :alt: Coverage +.. image:: https://img.shields.io/pypi/pyversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Python versions + +.. image:: https://img.shields.io/pypi/djversions/django-oauth-toolkit.svg + :target: https://pypi.org/project/django-oauth-toolkit/ + :alt: Supported Django versions + If you are facing one or more of the following: * Your Django app exposes a web API you want to protect with OAuth2 authentication, * You need to implement an OAuth2 authorization server to provide tokens management for your infrastructure, @@ -42,7 +50,7 @@ Requirements ------------ * Python 3.6+ -* Django 2.1+ +* Django 2.2+ * oauthlib 3.1+ Installation diff --git a/setup.cfg b/setup.cfg index 13d6cd0f9..3d8c4cfec 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,8 +13,8 @@ classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 2.2 - Framework :: Django :: 3.0 Framework :: Django :: 3.1 + Framework :: Django :: 3.2 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/tox.ini b/tox.ini index 3016d024c..8371aab0a 100644 --- a/tox.ini +++ b/tox.ini @@ -2,7 +2,7 @@ envlist = flake8, docs, - py{36,37,38,39}-dj{31,30,22}, + py{36,37,38,39}-dj{32,31,22}, py{38,39}-djmain, [gh-actions] @@ -33,8 +33,8 @@ setenv = PYTHONWARNINGS = all deps = dj22: Django>=2.2,<3 - dj30: Django>=3.0,<3.1 dj31: Django>=3.1,<3.2 + dj32: Django>=3.2,<3.3 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 From 3716ec41c5bd8efdc6f4450bb777fba38cb45023 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Fri, 2 Jul 2021 04:27:48 -0400 Subject: [PATCH 380/722] Change remaining HttpResponse to JsonResponse (#989) * Change remaining HttpResponse to JsonResponse * Add Andrew-Chen-Wang to AUTHORS * Added CHANGELOG entry * Lint --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/models.py | 20 ++++++++++---------- oauth2_provider/views/introspect.py | 19 ++++--------------- 4 files changed, 16 insertions(+), 25 deletions(-) diff --git a/AUTHORS b/AUTHORS index 50589287a..8cb1b7a05 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,6 +12,7 @@ Alan Crosswell Aleksander Vaskevich Alessandro De Angelis Allisson Azevedo +Andrew Chen Wang Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2deb05d..45d63276f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] * Remove support for Django 3.0 * Add support for Django 3.2 +* #989 Change any HttpResponse to JsonResponse if possible ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index aa10eca16..526173281 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -563,56 +563,56 @@ class Meta(AbstractIDToken.Meta): def get_application_model(): - """ Return the Application model that is active in this project. """ + """Return the Application model that is active in this project.""" return apps.get_model(oauth2_settings.APPLICATION_MODEL) def get_grant_model(): - """ Return the Grant model that is active in this project. """ + """Return the Grant model that is active in this project.""" return apps.get_model(oauth2_settings.GRANT_MODEL) def get_access_token_model(): - """ Return the AccessToken model that is active in this project. """ + """Return the AccessToken model that is active in this project.""" return apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) def get_id_token_model(): - """ Return the AccessToken model that is active in this project. """ + """Return the AccessToken model that is active in this project.""" return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) def get_refresh_token_model(): - """ Return the RefreshToken model that is active in this project. """ + """Return the RefreshToken model that is active in this project.""" return apps.get_model(oauth2_settings.REFRESH_TOKEN_MODEL) def get_application_admin_class(): - """ Return the Application admin class that is active in this project. """ + """Return the Application admin class that is active in this project.""" application_admin_class = oauth2_settings.APPLICATION_ADMIN_CLASS return application_admin_class def get_access_token_admin_class(): - """ Return the AccessToken admin class that is active in this project. """ + """Return the AccessToken admin class that is active in this project.""" access_token_admin_class = oauth2_settings.ACCESS_TOKEN_ADMIN_CLASS return access_token_admin_class def get_grant_admin_class(): - """ Return the Grant admin class that is active in this project. """ + """Return the Grant admin class that is active in this project.""" grant_admin_class = oauth2_settings.GRANT_ADMIN_CLASS return grant_admin_class def get_id_token_admin_class(): - """ Return the IDToken admin class that is active in this project. """ + """Return the IDToken admin class that is active in this project.""" id_token_admin_class = oauth2_settings.ID_TOKEN_ADMIN_CLASS return id_token_admin_class def get_refresh_token_admin_class(): - """ Return the RefreshToken admin class that is active in this project. """ + """Return the RefreshToken admin class that is active in this project.""" refresh_token_admin_class = oauth2_settings.REFRESH_TOKEN_ADMIN_CLASS return refresh_token_admin_class diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index afb8ac627..08b4b4222 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -1,8 +1,7 @@ import calendar -import json from django.core.exceptions import ObjectDoesNotExist -from django.http import HttpResponse +from django.http import JsonResponse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -29,9 +28,7 @@ def get_token_response(token_value=None): get_access_token_model().objects.select_related("user", "application").get(token=token_value) ) except ObjectDoesNotExist: - return HttpResponse( - content=json.dumps({"active": False}), status=401, content_type="application/json" - ) + return JsonResponse({"active": False}, status=401) else: if token.is_valid(): data = { @@ -43,17 +40,9 @@ def get_token_response(token_value=None): data["client_id"] = token.application.client_id if token.user: data["username"] = token.user.get_username() - return HttpResponse(content=json.dumps(data), status=200, content_type="application/json") + return JsonResponse(data) else: - return HttpResponse( - content=json.dumps( - { - "active": False, - } - ), - status=200, - content_type="application/json", - ) + return JsonResponse({"active": False}) def get(self, request, *args, **kwargs): """ From 6dbde716221fc452acdce16497c66e86872b4dda Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Thu, 2 Sep 2021 11:22:44 -0400 Subject: [PATCH 381/722] Use django-cors-headers in docs (#973) * Use django-cors-headers * Add @Andrew-Chen-Wang to AUTHORS.contributors Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- docs/tutorial/tutorial_01.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 6b605c19f..17c3b447a 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -9,14 +9,14 @@ Start Your App -------------- During this tutorial you will make an XHR POST from a Heroku deployed app to your localhost instance. Since the domain that will originate the request (the app on Heroku) is different from the destination domain (your local instance), -you will need to install the `django-cors-middleware <https://github.com/zestedesavoir/django-cors-middleware>`_ app. +you will need to install the `django-cors-headers <https://github.com/adamchainz/django-cors-headers>`_ app. These "cross-domain" requests are by default forbidden by web browsers unless you use `CORS <http://en.wikipedia.org/wiki/Cross-origin_resource_sharing>`_. -Create a virtualenv and install `django-oauth-toolkit` and `django-cors-middleware`: +Create a virtualenv and install `django-oauth-toolkit` and `django-cors-headers`: :: - pip install django-oauth-toolkit django-cors-middleware + pip install django-oauth-toolkit django-cors-headers Start a Django project, add `oauth2_provider` and `corsheaders` to the installed apps, and enable admin: From 6625d3ac30e3ba5fe4a1f81d47cc9984e73af348 Mon Sep 17 00:00:00 2001 From: Jozef <knaperek@users.noreply.github.com> Date: Thu, 2 Sep 2021 17:35:45 +0200 Subject: [PATCH 382/722] Optimize DB access in AccessTokenAdmin (#988) This is needed to avoid 2 extra DB queries per each line in the list view, so that is usually +200 unnecessary queries. Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- AUTHORS | 1 + oauth2_provider/admin.py | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 8cb1b7a05..55ac95f67 100644 --- a/AUTHORS +++ b/AUTHORS @@ -36,6 +36,7 @@ Jerome Leclanche Jim Graham Jonas Nygaard Pedersen Jonathan Steffan +Jozef Knaperek Jun Zhou Kristian Rune Larsen Paul Dekkers diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 79bcf7702..58b5308fd 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -26,6 +26,7 @@ class ApplicationAdmin(admin.ModelAdmin): class AccessTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application", "expires") + list_select_related = ("application", "user") raw_id_fields = ("user", "source_refresh_token") From fa7f93550a1bc353cdfab776ac8efa5f8521a584 Mon Sep 17 00:00:00 2001 From: Alex McLarty <alexjmclarty@gmail.com> Date: Thu, 2 Sep 2021 17:22:27 +0100 Subject: [PATCH 383/722] Update settings.rst (#991) Add that REFRESH_TOKEN_EXPIRE_SECONDS can be an `Int` or `datetime.timedelta` to settings.rst. Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- docs/settings.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index de7bcf85c..fd6bbc614 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -147,6 +147,8 @@ REFRESH_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds before a refresh token gets removed from the database by the ``cleartokens`` management command. Check :ref:`cleartokens` management command for further info. +Can be an ``Int`` or ``datetime.timedelta``. + NOTE: This value is completely ignored when validating refresh tokens. If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. From b4e104a904964a5439330143c4c415569923f300 Mon Sep 17 00:00:00 2001 From: Hossein Shakiba <62024632+hossshakiba@users.noreply.github.com> Date: Wed, 8 Sep 2021 09:29:51 +0430 Subject: [PATCH 384/722] Add Farsi/fa language support (#972) * Add Farsi/fa language support * Update AUTHORS Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- AUTHORS | 1 + .../locale/fa/LC_MESSAGES/django.po | 202 ++++++++++++++++++ 2 files changed, 203 insertions(+) create mode 100644 oauth2_provider/locale/fa/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index 55ac95f67..d22585c69 100644 --- a/AUTHORS +++ b/AUTHORS @@ -30,6 +30,7 @@ Emanuele Palazzetti Federico Dolce Frederico Vieira Hasan Ramezani +Hossein Shakiba Hiroki Kiyohara Jens Timmerman Jerome Leclanche diff --git a/oauth2_provider/locale/fa/LC_MESSAGES/django.po b/oauth2_provider/locale/fa/LC_MESSAGES/django.po new file mode 100644 index 000000000..017e50ddf --- /dev/null +++ b/oauth2_provider/locale/fa/LC_MESSAGES/django.po @@ -0,0 +1,202 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <HOSSSHAKIBA@OUTLOOK.COM>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-05-01 15:33+0430\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: HOSSEIN SHAKIBA <HOSSSHAKIBA@OUTLOOK.COM>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: models.py:49 +msgid "Confidential" +msgstr "محرمانه" + +#: models.py:50 +msgid "Public" +msgstr "عمومی" + +#: models.py:59 +msgid "Authorization code" +msgstr "کد مجوز" + +#: models.py:60 +msgid "Implicit" +msgstr "ضمنی" + +#: models.py:61 +msgid "Resource owner password-based" +msgstr "صاحب منبع مبتنی بر رمز عبور" + +#: models.py:62 +msgid "Client credentials" +msgstr "اعتبار مخاطب" + +#: models.py:63 +msgid "OpenID connect hybrid" +msgstr "اتصال ترکیبی OpenID" + +#: models.py:70 +msgid "No OIDC support" +msgstr "OIDC پشتیبانی وجود ندارد از" + +#: models.py:71 +msgid "RSA with SHA-2 256" +msgstr "SHA-2 256 با RSA" + +#: models.py:72 +msgid "HMAC with SHA-2 256" +msgstr "SHA-2 256 با HMAC" + +#: models.py:87 +msgid "Allowed URIs list, space separated" +msgstr "مجاز، با فاصله از هم جدا شده‌اند URIs فهرست" + +#: models.py:152 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "{scheme} :طرح تغییر مسیر غیرمجاز" + +#: models.py:156 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "{grant_type} خالی باشد grant_type نمی تواند با redirect_uris " + +#: models.py:162 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "را تنظیم کنید OIDC_RSA_PRIVATE_KEY باید RSA برای استفاده از الگوریتم" + +#: models.py:171 +msgid "You cannot use HS256 with public grants or clients" +msgstr "" + +#: oauth2_validators.py:181 +msgid "The access token is invalid." +msgstr "توکن دسترسی نامعتبر است" + +#: oauth2_validators.py:188 +msgid "The access token has expired." +msgstr "توکن دسترسی منقضی شده است" + +#: oauth2_validators.py:195 +msgid "The access token is valid but does not have enough scope." +msgstr "توکن دسترسی معتبر است اما دامنه کافی ندارد" + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "آیا مطمئن هستید که برنامه را حذف می کنید" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "لغو" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "حذف" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "شناسه(آیدی) کاربر" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "راز کاربر" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "نوع کاربر" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "نوع اعطای مجوز" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "تغییر مسیر URIs" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "بازگشت" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "ویرایش" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "ویرایش برنامه" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "ذخیره" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "برنامه شما" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "برنامه جدید" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "هیچ برنامه ای تعریف نشده است" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "اینجا کلیک کنید" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "اگر می خواهید مورد جدیدی ثبت کنید" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "ثبت یک برنامه جدید" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "اجازه دادن" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "برنامه به مجوزهای زیر نیاز دارد" + +#: templates/oauth2_provider/authorized-oob.html:12 +msgid "Success" +msgstr "موفقیت" + +#: templates/oauth2_provider/authorized-oob.html:14 +msgid "Please return to your application and enter this code:" +msgstr "لطفاً به برنامه خود برگردید و این کد را وارد کنید:" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "آیا مطمئن هستید که می خواهید این توکن را حذف کنید؟" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "توکن‌ها" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "باطل کردن" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "هنوز هیچ توکن مجازی وجود ندارد." From bc941d700dd46f0854269878b39d03d8ceabd378 Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Wed, 8 Sep 2021 14:09:05 +0200 Subject: [PATCH 385/722] Add missing space in assertion error --- oauth2_provider/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 526173281..3f173346f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -114,7 +114,7 @@ def default_redirect_uri(self): return self.redirect_uris.split().pop(0) assert False, ( - "If you are using implicit, authorization_code" + "If you are using implicit, authorization_code " "or all-in-one grant_type, you must define " "redirect_uris field in your Application model" ) From 49fb3cb6f231fa8fa9b9cd48e8ffdb6ac476197e Mon Sep 17 00:00:00 2001 From: snapperVibes <learningwithsnapper@gmail.com> Date: Sun, 13 Jun 2021 14:21:01 -0400 Subject: [PATCH 386/722] Grammer --- docs/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 427195ae9..e0d63c2ea 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -358,7 +358,7 @@ Export the credential as an environment variable export CREDENTIAL=YXhYU1NCVnV2T3lHVnpoNFB1cnZLYXE1TUhYTW03RnRySGdETWk0dToxZnV2NVdWZlI3QTVCbEYwbzE1NUg3czViTGdYbHdXTGhpM1k3cGRKOWFKdUNkbDBYVjVDeGdkMHRyaTduU3pDODBxeXJvdmg4cUZYRkhnRkFBYzBsZFBObjVaWUxhbnhTbTFTSTFyeGxScldVUDU5MXdwSERHYTNwU3BCNmRDWg== -To start the Client Credential flow you call ``/token/`` endpoint direct:: +To start the Client Credential flow you call ``/token/`` endpoint directly:: curl -X POST -H "Authorization: Basic ${CREDENTIAL}" -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "grant_type=client_credentials" From 59ab199e95247b613a8aaf00d269bd67840d9c45 Mon Sep 17 00:00:00 2001 From: Michael Howitz <mh@gocept.com> Date: Tue, 21 Sep 2021 18:24:34 +0200 Subject: [PATCH 387/722] Add missing import (#977) * Add missing import In a newly created Django project (version 3.2.1) the `include` function is not imported. * Register myself as an author Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- AUTHORS | 1 + docs/tutorial/tutorial_01.rst | 2 ++ 2 files changed, 3 insertions(+) diff --git a/AUTHORS b/AUTHORS index d22585c69..d5884126c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,6 +40,7 @@ Jonathan Steffan Jozef Knaperek Jun Zhou Kristian Rune Larsen +Michael Howitz Paul Dekkers Paul Oswald Pavel Tvrdík diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 17c3b447a..a041c49ba 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -33,6 +33,8 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python + from django.urls import path, include + urlpatterns = [ path("admin", admin.site.urls), path("o/", include('oauth2_provider.urls', namespace='oauth2_provider')), From 0658109b01e3e8d9e130263ddb990202001446aa Mon Sep 17 00:00:00 2001 From: Dylan Tack <git@dylan-tack.net> Date: Thu, 23 Sep 2021 10:31:26 -0700 Subject: [PATCH 388/722] Multiple rsa keys (#978) * Support rotation of RSA keys * add author * changelog for #950 Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/oidc.rst | 26 +++++++++++++++++++++++++- docs/settings.rst | 19 +++++++++++++++++++ oauth2_provider/settings.py | 2 ++ oauth2_provider/views/oidc.py | 19 +++++++++++++++---- tests/presets.py | 1 + tests/settings.py | 18 ++++++++++++++++++ tests/test_oidc_views.py | 26 ++++++++++++++++++++++++++ 9 files changed, 108 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index d5884126c..dc5572ce7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -26,6 +26,7 @@ David Smith Diego Garcia Dulmandakh Sukhbaatar Dylan Giesler +Dylan Tack Emanuele Palazzetti Federico Dolce Frederico Vieira diff --git a/CHANGELOG.md b/CHANGELOG.md index 45d63276f..16e98073d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` to provide compatibility with backends that need one. +* #950 Add support for RSA key rotation. ### Fixed * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. diff --git a/docs/oidc.rst b/docs/oidc.rst index 87fadce57..ba69e984f 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -100,6 +100,30 @@ change this class to derive from ``oauthlib.openid.Server`` instead of With ``RSA`` key-pairs, the public key can be generated from the private key, so there is no need to add a setting for the public key. + +Rotating the RSA private key +~~~~~~~~~~~~~~~~~~~~~~~~ +Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE`` +setting. For example::: + + OAUTH2_PROVIDER = { + "OIDC_RSA_PRIVATE_KEY": os.environ.get("OIDC_RSA_PRIVATE_KEY"), + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": [ + os.environ.get("OIDC_RSA_PRIVATE_KEY_2"), + os.environ.get("OIDC_RSA_PRIVATE_KEY_3") + ] + # ... other settings + } + +To rotate, follow these steps: + +#. Generate a new key, and add it to the inactive set. Then deploy the app. +#. Swap the active and inactive keys, then re-deploy. +#. After some reasonable amount of time, remove the inactive key. At a minimum, + you should wait ``ID_TOKEN_EXPIRE_SECONDS`` to ensure the key isn't removed + before valid tokens expire. + + Using ``HS256`` keys ~~~~~~~~~~~~~~~~~~~~ @@ -297,7 +321,7 @@ query, and other details. JwksInfoView ~~~~~~~~~~~~ -Available at ``/o/.well-known/jwks.json``, this view provides details of the key used to sign +Available at ``/o/.well-known/jwks.json``, this view provides details of the keys used to sign the JWTs generated for ID tokens, so that clients are able to verify them. diff --git a/docs/settings.rst b/docs/settings.rst index fd6bbc614..07561d3d2 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -264,6 +264,25 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. +OIDC_RSA_PRIVATE_KEYS_INACTIVE +~~~~~~~~~~~~~~~~~~~~ +Default: ``[]`` + +An array of *inactive* RSA private keys. These keys are not used to sign tokens, +but are published in the jwks_uri location. + +This is useful for providing a smooth transition during key rotation. +``OIDC_RSA_PRIVATE_KEY`` can be replaced, and recently decommissioned keys +should be retained in this inactive list. + +OIDC_JWKS_MAX_AGE_SECONDS +~~~~~~~~~~~~~~~~~~~~~~ +Default: ``3600`` + +The max-age value for the Cache-Control header on jwks_uri. + +This enables the verifier to safely cache the JWK Set and not have to re-download +the document for every token. OIDC_USERINFO_ENDPOINT ~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index b862fca7a..9a996b0c2 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -72,6 +72,8 @@ "OIDC_ISS_ENDPOINT": "", "OIDC_USERINFO_ENDPOINT": "", "OIDC_RSA_PRIVATE_KEY": "", + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": [], + "OIDC_JWKS_MAX_AGE_SECONDS": 3600, "OIDC_RESPONSE_TYPES_SUPPORTED": [ "code", "token", diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 00c8c3fa4..b4bb8869b 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -71,12 +71,23 @@ class JwksInfoView(OIDCOnlyMixin, View): def get(self, request, *args, **kwargs): keys = [] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: - key = jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) - data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} - data.update(json.loads(key.export_public())) - keys.append(data) + for pem in [ + oauth2_settings.OIDC_RSA_PRIVATE_KEY, + *oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, + ]: + + key = jwk.JWK.from_pem(pem.encode("utf8")) + data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} + data.update(json.loads(key.export_public())) + keys.append(data) response = JsonResponse({"keys": keys}) response["Access-Control-Allow-Origin"] = "*" + response["Cache-Control"] = ( + "Cache-Control: public, " + + f"max-age={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, " + + f"stale-while-revalidate={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}, " + + f"stale-if-error={oauth2_settings.OIDC_JWKS_MAX_AGE_SECONDS}" + ) return response diff --git a/tests/presets.py b/tests/presets.py index 214f804ef..438da1e03 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -12,6 +12,7 @@ "OIDC_ISS_ENDPOINT": "http://localhost/o", "OIDC_USERINFO_ENDPOINT": "http://localhost/o/userinfo/", "OIDC_RSA_PRIVATE_KEY": settings.OIDC_RSA_PRIVATE_KEY, + "OIDC_RSA_PRIVATE_KEYS_INACTIVE": settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, "SCOPES": { "read": "Reading scope", "write": "Writing scope", diff --git a/tests/settings.py b/tests/settings.py index 1d295982e..bc7a55130 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -134,6 +134,24 @@ dTnvCVtA59ne4LEVie/PMH/odQWY0SxVm/76uBZv/1vY -----END RSA PRIVATE KEY-----""" +OIDC_RSA_PRIVATE_KEYS_INACTIVE = [ + """-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDSpXNtxaD9+DKBnSWJNoV6h0PZuSKeGPyA8n0/as/O+oboiYj1 +gqQSTwPFxzt5Zy52fDmIQvzDH+2CihpGIeJh9SsUEFd8DXkP/Xk91f/mAbytBsnt +czFCtihFRxWbbBAMHh8i5HuxM+rH2nw5Hh/74GLE58zk5rtIRS1DyS+uUQIDAQAB +AoGAca57Ci4TQZ02XL8bp9610Le5hYIlzZ78fvbfY19YwYJxVoQLVzxnIb5k8dMh +JNbru2Q1hHVqhj/v5Xh0z46v5mTOeyQj8F1O6NCkzHtCfF029j8A9+pfNqyQhCa/ +nJqsNShFW+uhK67d7QfqtRRR6B30XsIHgND7QJuc14mDkdUCQQD3OpzLZugdTtuW +u+DdrdSjMBbW2p1+NFr8T20Rv+LoMvweZLSuMelAoog8fNxF6xQs7wLw+Tf5z56L +mptnur6TAkEA2h6WL3ippJ6/7H45suxP1dJI+Qal7V2KAMVGbv6Jal9rcKid0PpD +K1uPZwx2o/hkdobPY0HRIFaxpOtwC4FKCwJAYTmWodMFY0k4yA14wBT1c3uc77+n +ghM62NCvdvR8Wo56YcV+3KZaMYX5h7getAxfsdAI2xVXMxG4KvSROvjQqwJAaZ+W +KrbLr6QQXH1jg3lbz7ddDvphL2i0g1sEmIs6EADVDmEYyzHlhQF5l/U5Hn4SaDMw +Cmi81GQm8i3wvCGHsQJBAJC2VVcZ4VIehr3nAbI46w6cXGP6lpBbwT2FxSydRHqz +wfGZQ+qAAThGg3OInQNMqItypEEo3oZhKKvjD1N/iTw= +-----END RSA PRIVATE KEY-----""" +] + OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "oauth2_provider.AccessToken" OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 5cbae5402..46040f86d 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -71,6 +71,7 @@ def test_get_connect_discovery_info_without_rsa_key(self): @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestJwksInfoView(TestCase): def test_get_jwks_info(self): + self.oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE = [] expected_response = { "keys": [ { @@ -93,6 +94,31 @@ def test_get_jwks_info_no_rsa_key(self): self.assertEqual(response.status_code, 200) assert response.json() == {"keys": []} + def test_get_jwks_info_multiple_rsa_keys(self): + expected_response = { + "keys": [ + { + "alg": "RS256", + "e": "AQAB", + "kid": "s4a1o8mFEd1tATAIH96caMlu4hOxzBUaI2QTqbYNBHs", + "kty": "RSA", + "n": "mwmIeYdjZkLgalTuhvvwjvnB5vVQc7G9DHgOm20Hw524bLVTk49IXJ2Scw42HOmowWWX-oMVT_ca3ZvVIeffVSN1-TxVy2zB65s0wDMwhiMoPv35z9IKHGMZgl9vlyso_2b7daVF_FQDdgIayUn8TQylBxEU1RFfW0QSYOBdAt8", # noqa + "use": "sig", + }, + { + "alg": "RS256", + "e": "AQAB", + "kid": "AJ_IkYJUFWqiKKE2FvPIESroTvownbaj0OzL939oIIE", + "kty": "RSA", + "n": "0qVzbcWg_fgygZ0liTaFeodD2bkinhj8gPJ9P2rPzvqG6ImI9YKkEk8Dxcc7eWcudnw5iEL8wx_tgooaRiHiYfUrFBBXfA15D_15PdX_5gG8rQbJ7XMxQrYoRUcVm2wQDB4fIuR7sTPqx9p8OR4f--BixOfM5Oa7SEUtQ8kvrlE", # noqa + "use": "sig", + }, + ] + } + response = self.client.get(reverse("oauth2_provider:jwks-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + @pytest.mark.django_db @pytest.mark.parametrize("method", ["get", "post"]) From 621574cdaf7f8a7345dc08c427223e56ac24ebec Mon Sep 17 00:00:00 2001 From: sdwoodbury <stuartwx@yahoo.com> Date: Fri, 24 Sep 2021 19:53:17 -0400 Subject: [PATCH 389/722] Update tutorial_03.rst `AUTHENTICATION_BACKENDS` and `MIDDLEWARE` should be arrays, not tuples. Using tuples seems to work, but everything else in the settings.py file is an array. --- docs/tutorial/tutorial_03.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index ad56e310a..52868c01f 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -15,21 +15,21 @@ which takes care of token verification. In your settings.py: .. code-block:: python - AUTHENTICATION_BACKENDS = ( + AUTHENTICATION_BACKENDS = [ 'oauth2_provider.backends.OAuth2Backend', # Uncomment following if you want to access the admin - #'django.contrib.auth.backends.ModelBackend' + #'django.contrib.auth.backends.ModelBackend', '...', - ) + ] - MIDDLEWARE = ( + MIDDLEWARE = [ '...', # If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. # SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'oauth2_provider.middleware.OAuth2TokenMiddleware', '...', - ) + ] You will likely use the `django.contrib.auth.backends.ModelBackend` along with the OAuth2 backend (or you might not be able to log in into the admin), only pay attention to the order in which From d67210ed2e2b5c00b280fe00643ed23ac04c254d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Oct 2021 16:59:39 +0000 Subject: [PATCH 390/722] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - https://github.com/ambv/black → https://github.com/psf/black - [github.com/psf/black: 20.8b1 → 21.9b0](https://github.com/psf/black/compare/20.8b1...21.9b0) - [github.com/pre-commit/pre-commit-hooks: v3.2.0 → v4.0.1](https://github.com/pre-commit/pre-commit-hooks/compare/v3.2.0...v4.0.1) - [github.com/PyCQA/isort: 5.6.3 → 5.9.3](https://github.com/PyCQA/isort/compare/5.6.3...5.9.3) - https://gitlab.com/pycqa/flake8 → https://github.com/PyCQA/flake8 - [github.com/PyCQA/flake8: 3.8.4 → 4.0.1](https://github.com/PyCQA/flake8/compare/3.8.4...4.0.1) --- .pre-commit-config.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 323a7fcff..6b4956dc1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - - repo: https://github.com/ambv/black - rev: 20.8b1 + - repo: https://github.com/psf/black + rev: 21.9b0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v3.2.0 + rev: v4.0.1 hooks: - id: check-ast - id: trailing-whitespace @@ -16,12 +16,12 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.6.3 + rev: 5.9.3 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.4 + - repo: https://github.com/PyCQA/flake8 + rev: 4.0.1 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 6085a2db8afd3d7927ea007caf01fb5cdc61668e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Oct 2021 17:00:06 +0000 Subject: [PATCH 391/722] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- LICENSE | 8 ++++---- docs/getting_started.rst | 6 +++--- docs/tutorial/tutorial_01.rst | 2 +- docs/tutorial/tutorial_04.rst | 14 +++++++------- docs/views/token.rst | 2 +- .../oauth2_provider/application_list.html | 2 +- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/LICENSE b/LICENSE index 13606c9fb..423ba01fb 100644 --- a/LICENSE +++ b/LICENSE @@ -2,13 +2,13 @@ Copyright (c) 2013, Massimiliano Pippi, Federico Frenguelli and contributors All rights reserved. Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. + list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. + and/or other materials provided with the distribution. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED @@ -22,5 +22,5 @@ ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. The views and conclusions contained in the software and documentation are those -of the authors and should not be interpreted as representing official policies, +of the authors and should not be interpreted as representing official policies, either expressed or implied, of the FreeBSD Project. diff --git a/docs/getting_started.rst b/docs/getting_started.rst index e0d63c2ea..3ea4f7e58 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -87,7 +87,7 @@ Edit :file:`users/models.py` adding the code below: .. code-block:: python from django.contrib.auth.models import AbstractUser - + class User(AbstractUser): pass @@ -213,8 +213,8 @@ Create a user:: Username: wiliam Email address: me@wiliam.dev - Password: - Password (again): + Password: + Password (again): Superuser created successfully. OAuth2 Authorization Grants diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index a041c49ba..f0b8cb3ed 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -80,7 +80,7 @@ the API, subject to approval by its users. Let's register your application. -You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that +You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that point your browser to http://localhost:8000/o/applications/ and add an Application instance. `Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index 3908579da..c13974e18 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -1,4 +1,4 @@ -Part 4 - Revoking an OAuth2 Token +Part 4 - Revoking an OAuth2 Token ================================= Scenario @@ -11,10 +11,10 @@ Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` i `Oauthlib <https://github.com/idan/oauthlib>`_ is compliant with https://tools.ietf.org/html/rfc7009, so as specified, the revocation request requires: -- token: REQUIRED, this is the :term:`Access Token` you want to revoke -- token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. +- token: REQUIRED, this is the :term:`Access Token` you want to revoke +- token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. -Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. +Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. Setup a Request --------------- @@ -26,8 +26,8 @@ Depending on the client type you're using, the token revocation request you may Content-Type: application/x-www-form-urlencoded token=XXXX&client_id=XXXX -Where token is :term:`Access Token` specified above, and client_id is the `Client id` obtained in -obtained in :doc:`part 1 <tutorial_01>`. If your application type is `Confidential` , it requires a `Client secret`, you will have to add it as one of the parameters: +Where token is :term:`Access Token` specified above, and client_id is the `Client id` obtained in +obtained in :doc:`part 1 <tutorial_01>`. If your application type is `Confidential` , it requires a `Client secret`, you will have to add it as one of the parameters: :: @@ -36,7 +36,7 @@ obtained in :doc:`part 1 <tutorial_01>`. If your application type is `Confidenti token=XXXX&client_id=XXXX&client_secret=XXXX -The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. +The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. :: diff --git a/docs/views/token.rst b/docs/views/token.rst index 02f6bf53e..ead0d023d 100644 --- a/docs/views/token.rst +++ b/docs/views/token.rst @@ -8,7 +8,7 @@ Every view provides access only to the tokens that have been granted to the user Granted Token views are listed at the url `authorized_tokens/`. -For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. +For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. .. automodule:: oauth2_provider.views.token diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index b8e4f3af4..807c050d3 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -13,7 +13,7 @@ <h3 class="block-center-heading">{% trans "Your applications" %}</h3> <a class="btn btn-success" href="{% url "oauth2_provider:register" %}">{% trans "New Application" %}</a> {% else %} - + <p>{% trans "No applications defined" %}. <a href="{% url 'oauth2_provider:register' %}">{% trans "Click here" %}</a> {% trans "if you want to register a new one" %}</p> {% endif %} </div> From d35f030960617cb4d0dbe9a3e89b797df2e7cf0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20Szab=C3=B3=E2=80=AE?= <kreatemore@gmail.com> Date: Tue, 19 Oct 2021 08:16:56 +0200 Subject: [PATCH 392/722] Handles ValueErrors with invalid hex values in query strings (#954) (#963) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Handles ValueErrors with invalid hex values in query strings and reraises them as SuspiciousOperations (#954) * Unified erorr naming (err and error) when handling ValueErrors * Added Alex Szabó to AUTHORS * Adds fix message to CHANGELOG.md * Narrows handling of ValueErrors to a specific error (invalid hex in query string) * Fixes formatting Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- AUTHORS | 1 + CHANGELOG.md | 5 +++-- oauth2_provider/backends.py | 15 ++++++++++++--- oauth2_provider/views/mixins.py | 11 +++++++++-- tests/test_auth_backends.py | 21 ++++++++++++++++++++ tests/test_client_credential.py | 34 +++++++++++++++++++++++++-------- 6 files changed, 72 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index dc5572ce7..d5c30ebbf 100644 --- a/AUTHORS +++ b/AUTHORS @@ -11,6 +11,7 @@ Abhishek Patel Alan Crosswell Aleksander Vaskevich Alessandro De Angelis +Alex Szabó Allisson Azevedo Andrew Chen Wang Anvesh Agarwal diff --git a/CHANGELOG.md b/CHANGELOG.md index 16e98073d..9194c8781 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,10 +26,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. -* #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. - Breaks existing OIDC discovery output * #953 Allow loopback redirect URIs with random ports using http scheme, localhost address and no explicit port configuration in the allowed redirect_uris for Oauth2 Applications (RFC8252) +* #954 Query strings with invalid hex values now raise a SuspiciousOperation exception +* #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. + Breaks existing OIDC discovery output ## [1.5.0] 2021-03-18 diff --git a/oauth2_provider/backends.py b/oauth2_provider/backends.py index 3f6fab9af..2570cd62b 100644 --- a/oauth2_provider/backends.py +++ b/oauth2_provider/backends.py @@ -1,4 +1,5 @@ from django.contrib.auth import get_user_model +from django.core.exceptions import SuspiciousOperation from .oauth2_backends import get_oauthlib_core @@ -14,9 +15,17 @@ class OAuth2Backend: def authenticate(self, request=None, **credentials): if request is not None: - valid, r = OAuthLibCore.verify_request(request, scopes=[]) - if valid: - return r.user + try: + valid, request = OAuthLibCore.verify_request(request, scopes=[]) + except ValueError as error: + if str(error) == "Invalid hex encoding in query string.": + raise SuspiciousOperation(error) + else: + raise + else: + if valid: + return request.user + return None def get_user(self, user_id): diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index 477d24e24..ebb654216 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -1,7 +1,7 @@ import logging from django.conf import settings -from django.core.exceptions import ImproperlyConfigured +from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation from django.http import HttpResponseForbidden, HttpResponseNotFound from ..exceptions import FatalClientError @@ -150,7 +150,14 @@ def verify_request(self, request): :param request: The current django.http.HttpRequest object """ core = self.get_oauthlib_core() - return core.verify_request(request, scopes=self.get_scopes()) + + try: + return core.verify_request(request, scopes=self.get_scopes()) + except ValueError as error: + if str(error) == "Invalid hex encoding in query string.": + raise SuspiciousOperation(error) + else: + raise def get_scopes(self): """ diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 151fc30d2..8eeb8ef12 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -1,5 +1,9 @@ +from unittest.mock import patch + +import pytest from django.contrib.auth import get_user_model from django.contrib.auth.models import AnonymousUser +from django.core.exceptions import SuspiciousOperation from django.http import HttpResponse from django.test import RequestFactory, TestCase from django.test.utils import modify_settings, override_settings @@ -51,6 +55,23 @@ def test_authenticate(self): u = backend.authenticate(**credentials) self.assertEqual(u, self.user) + def test_authenticate_raises_error_with_invalid_hex_in_query_params(self): + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", + } + request = self.factory.get("/a-resource?auth_token=%%7A", **auth_headers) + credentials = {"request": request} + + with pytest.raises(SuspiciousOperation): + OAuth2Backend().authenticate(**credentials) + + @patch("oauth2_provider.backends.OAuthLibCore.verify_request") + def test_value_errors_are_reraised(self, patched_verify_request): + patched_verify_request.side_effect = ValueError("Generic error") + + with pytest.raises(ValueError): + OAuth2Backend().authenticate(request={}) + def test_authenticate_fail(self): auth_headers = { "HTTP_AUTHORIZATION": "Bearer " + "badstring", diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8b9aa3bc2..8159d55db 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,8 +1,10 @@ import json +from unittest.mock import patch from urllib.parse import quote_plus import pytest from django.contrib.auth import get_user_model +from django.core.exceptions import SuspiciousOperation from django.test import RequestFactory, TestCase from django.urls import reverse from django.views.generic import View @@ -101,6 +103,15 @@ def test_client_credential_user_is_none_on_access_token(self): self.assertIsNone(access_token.user) +class TestView(OAuthLibMixin, View): + server_class = BackendApplicationServer + validator_class = OAuth2Validator + oauthlib_backend_class = OAuthLibCore + + def get_scopes(self): + return ["read", "write"] + + class TestExtendedRequest(BaseTest): @classmethod def setUpClass(cls): @@ -108,14 +119,6 @@ def setUpClass(cls): super().setUpClass() def test_extended_request(self): - class TestView(OAuthLibMixin, View): - server_class = BackendApplicationServer - validator_class = OAuth2Validator - oauthlib_backend_class = OAuthLibCore - - def get_scopes(self): - return ["read", "write"] - token_request_data = { "grant_type": "client_credentials", } @@ -143,6 +146,21 @@ def get_scopes(self): self.assertEqual(r.client, self.application) self.assertEqual(r.scopes, ["read", "write"]) + def test_raises_error_with_invalid_hex_in_query_params(self): + request = self.request_factory.get("/fake-req?auth_token=%%7A") + + with pytest.raises(SuspiciousOperation): + TestView().verify_request(request) + + @patch("oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core") + def test_reraises_value_errors_as_is(self, patched_core): + patched_core.return_value.verify_request.side_effect = ValueError("Generic error") + + request = self.request_factory.get("/fake-req") + + with pytest.raises(ValueError): + TestView().verify_request(request) + class TestClientResourcePasswordBased(BaseTest): def test_client_resource_password_based(self): From 4384566500656f036c0a1c569f723158f8eecc25 Mon Sep 17 00:00:00 2001 From: Jazzband Bot <jazzband-bot@users.noreply.github.com> Date: Fri, 22 Oct 2021 17:52:20 +0200 Subject: [PATCH 393/722] Jazzband: Created local 'CODE_OF_CONDUCT.md' from remote 'CODE_OF_CONDUCT.md' (#1021) --- CODE_OF_CONDUCT.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..e0d5efab5 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,46 @@ +# Code of Conduct + +As contributors and maintainers of the Jazzband projects, and in the interest of +fostering an open and welcoming community, we pledge to respect all people who +contribute through reporting issues, posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in the Jazzband a harassment-free experience +for everyone, regardless of the level of experience, gender, gender identity and +expression, sexual orientation, disability, personal appearance, body size, race, +ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery +- Personal attacks +- Trolling or insulting/derogatory comments +- Public or private harassment +- Publishing other's private information, such as physical or electronic addresses, + without explicit permission +- Other unethical or unprofessional conduct + +The Jazzband roadies have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are not +aligned to this Code of Conduct, or to ban temporarily or permanently any contributor +for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +By adopting this Code of Conduct, the roadies commit themselves to fairly and +consistently applying these principles to every aspect of managing the jazzband +projects. Roadies who do not follow or enforce the Code of Conduct may be permanently +removed from the Jazzband roadies. + +This code of conduct applies both within project spaces and in public spaces when an +individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by +contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and appropriate to +the circumstances. Roadies are obligated to maintain confidentiality with regard to the +reporter of an incident. + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version +1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] + +[homepage]: https://contributor-covenant.org +[version]: https://contributor-covenant.org/version/1/3/0/ From ab74586865ce062862e09f06f080dc465ad09ee5 Mon Sep 17 00:00:00 2001 From: Dylan Tack <git@dylan-tack.net> Date: Tue, 26 Oct 2021 01:19:42 -0700 Subject: [PATCH 394/722] Require redirect_uri if multiple URIs are registered (#981) * Require redirect_uri if multiple uris are registered * update changelog for #981 Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- CHANGELOG.md | 1 + oauth2_provider/models.py | 9 ++++++--- tests/test_authorization_code.py | 18 ++++++++++++++++++ tests/test_hybrid.py | 3 +++ tests/test_implicit.py | 2 ++ 5 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9194c8781..e2ccef370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Remove support for Django 3.0 * Add support for Django 3.2 * #989 Change any HttpResponse to JsonResponse if possible +* #981 redirect_uri is now required in authorization requests when multiple URIs are registered. ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 3f173346f..d7b767a78 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -12,6 +12,7 @@ from django.utils.translation import gettext_lazy as _ from jwcrypto import jwk from jwcrypto.common import base64url_encode +from oauthlib.oauth2.rfc6749 import errors from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend @@ -107,11 +108,13 @@ def __str__(self): @property def default_redirect_uri(self): """ - Returns the default redirect_uri extracting the first item from - the :attr:`redirect_uris` string + Returns the default redirect_uri, *if* only one is registered. """ if self.redirect_uris: - return self.redirect_uris.split().pop(0) + uris = self.redirect_uris.split() + if len(uris) == 1: + return self.redirect_uris.split().pop(0) + raise errors.MissingRedirectURIError() assert False, ( "If you are using implicit, authorization_code " diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index ea1bee86d..c9bef0f5c 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -257,6 +257,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: code """ self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_data = { "client_id": self.application.client_id, @@ -269,6 +271,21 @@ def test_pre_auth_default_redirect(self): form = response.context["form"] self.assertEqual(form["redirect_uri"].value(), "http://localhost") + def test_pre_auth_missing_redirect(self): + """ + Test response if redirect_uri is missing and multiple URIs are registered. + @see https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3 + """ + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + self.assertEqual(response.status_code, 400) + def test_pre_auth_forbibben_redirect(self): """ Test error when passing a forbidden redirect_uri in query string with response_type: code @@ -293,6 +310,7 @@ def test_pre_auth_wrong_response_type(self): query_data = { "client_id": self.application.client_id, "response_type": "WRONG", + "redirect_uri": "http://example.org", } response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index d198988f6..4f9753979 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -370,6 +370,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: code """ self.client.login(username="hy_test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_string = urlencode( { @@ -413,6 +415,7 @@ def test_pre_auth_wrong_response_type(self): { "client_id": self.application.client_id, "response_type": "WRONG", + "redirect_uri": "http://example.org", } ) url = "{url}?{qs}".format(url=reverse("oauth2_provider:authorize"), qs=query_string) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index a5863401c..5fcad62b0 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -110,6 +110,8 @@ def test_pre_auth_default_redirect(self): Test for default redirect uri if omitted from query string with response_type: token """ self.client.login(username="test_user", password="123456") + self.application.redirect_uris = "http://localhost" + self.application.save() query_data = { "client_id": self.application.client_id, From 0394bff7bb4eb37ba790e0d1cd4ee195ceb75137 Mon Sep 17 00:00:00 2001 From: Vinay Karanam <vinayinvicible@gmail.com> Date: Mon, 1 Nov 2021 12:09:31 +0530 Subject: [PATCH 395/722] Replaced pkg_resources usage with importlib.metadata --- AUTHORS | 1 + oauth2_provider/__init__.py | 7 +++++-- setup.cfg | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index d5c30ebbf..5345c4869 100644 --- a/AUTHORS +++ b/AUTHORS @@ -60,3 +60,4 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Vinay Karanam diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index c4e8c4eb4..8565f8a42 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,6 +1,9 @@ -import pkg_resources +try: + from importlib.metadata import version +except ImportError: + from importlib_metadata import version -__version__ = pkg_resources.require("django-oauth-toolkit")[0].version +__version__ = version("django-oauth-toolkit") default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/setup.cfg b/setup.cfg index 3d8c4cfec..0ea293642 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ install_requires = requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 + importlib-metadata; python_version < "3.8" six [options.packages.find] From 1f106af2d56d8075970172c56117a94f0a18a228 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Wed, 3 Nov 2021 22:49:37 +0600 Subject: [PATCH 396/722] six should be dropped (#1023) --- setup.cfg | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 0ea293642..e9d1e8c9b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,6 @@ install_requires = oauthlib >= 3.1.0 jwcrypto >= 0.8.0 importlib-metadata; python_version < "3.8" - six [options.packages.find] exclude = tests From 20ed23483c0a8089ee67efa4bd8a32bfba3b28bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 1 Nov 2021 17:09:22 +0000 Subject: [PATCH 397/722] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.9b0 → 21.10b0](https://github.com/psf/black/compare/21.9b0...21.10b0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6b4956dc1..a0be335c3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 21.9b0 + rev: 21.10b0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 853e1e777138f82ce7da720f43f36cb7ec25ce23 Mon Sep 17 00:00:00 2001 From: Vinay Karanam <vinayinvicible@gmail.com> Date: Sat, 6 Nov 2021 22:11:54 +0530 Subject: [PATCH 398/722] Moved version info from setup.cfg into package It is better to make setup.cfg infer version info from the package instead of vice versa. Previous method only works where the package is "installed". It doesn't work if we were to use this as a git submodule or frozen environments like nuitka. --- oauth2_provider/__init__.py | 8 +------- setup.cfg | 3 +-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 8565f8a42..ebb0b28b5 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,9 +1,3 @@ -try: - from importlib.metadata import version -except ImportError: - from importlib_metadata import version - - -__version__ = version("django-oauth-toolkit") +__version__ = "1.5.0" default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/setup.cfg b/setup.cfg index e9d1e8c9b..b72ad7275 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = django-oauth-toolkit -version = 1.5.0 +version = attr: oauth2_provider.__version__ description = OAuth2 Provider for Django long_description = file: README.rst long_description_content_type = text/x-rst @@ -36,7 +36,6 @@ install_requires = requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 - importlib-metadata; python_version < "3.8" [options.packages.find] exclude = tests From 8b37b306232ca1f6ebd7f972080f847c1515b915 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Nov 2021 17:06:11 +0000 Subject: [PATCH 399/722] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: 5.9.3 → 5.10.0](https://github.com/PyCQA/isort/compare/5.9.3...5.10.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0be335c3..cb1dcad00 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.9.3 + rev: 5.10.0 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From fb60d04a2aea48125639450f765abfb44cc795ee Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 15 Nov 2021 17:07:42 +0000 Subject: [PATCH 400/722] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: 5.10.0 → 5.10.1](https://github.com/PyCQA/isort/compare/5.10.0...5.10.1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb1dcad00..392072a3c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.10.0 + rev: 5.10.1 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 3a9541f903f8f945ff3f75a13b4953091717fffa Mon Sep 17 00:00:00 2001 From: Andrea Greco <AndreaGreco@users.noreply.github.com> Date: Fri, 19 Nov 2021 09:01:04 +0100 Subject: [PATCH 401/722] OpenID: Add claims to Well know (#967) * OpenID: Claims: Add claims inside well-known Some client can't use userinfo, and get propelty from claims. Add claims key inside wellknow. * OpenID: Claims: Additional test in well-know update test * OpenID: Claims: Docs: Add docs wellknow claims * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- AUTHORS | 2 ++ docs/oidc.rst | 19 ++++++++++--------- oauth2_provider/oauth2_validators.py | 25 +++++++++++++++++-------- oauth2_provider/views/oidc.py | 8 ++++++++ tests/test_oidc_views.py | 24 ++++++++++++++++++++---- 5 files changed, 57 insertions(+), 21 deletions(-) diff --git a/AUTHORS b/AUTHORS index 5345c4869..68960486a 100644 --- a/AUTHORS +++ b/AUTHORS @@ -60,4 +60,6 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Andrea Greco Vinay Karanam + diff --git a/docs/oidc.rst b/docs/oidc.rst index ba69e984f..eae9a67d4 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -245,16 +245,17 @@ required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to -our custom validator:: - +our custom validator. +Standard claim ``sub`` is included by default, for remove it override ``get_claim_list``:: class CustomOAuth2Validator(OAuth2Validator): - - def get_additional_claims(self, request): - return { - "sub": request.user.email, - "first_name": request.user.first_name, - "last_name": request.user.last_name, - } + def get_additional_claims(self): + def get_user_email(request): + return request.user.get_full_name() + + # Element name, callback to obtain data + claims_list = [ ("email", get_sub_cod), + ("username", get_user_email) ] + return claims_list .. note:: This ``request`` object is not a ``django.http.Request`` object, but an diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f3a24e258..461c40d53 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -728,15 +728,24 @@ def _save_id_token(self, jti, request, expires, *args, **kwargs): def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_oidc_claims(self, token, token_handler, request): - # Required OIDC claims - claims = { - "sub": str(request.user.id), - } + def get_claim_list(self): + def get_sub_code(request): + return str(request.user.id) + + list = [("sub", get_sub_code)] # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - claims.update(**self.get_additional_claims(request)) + add = self.get_additional_claims() + list.extend(add) + + return list + def get_oidc_claims(self, token, token_handler, request): + data = self.get_claim_list() + claims = {} + + for k, call in data: + claims[k] = call(request) return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -889,5 +898,5 @@ def get_userinfo_claims(self, request): """ return self.get_oidc_claims(None, None, request) - def get_additional_claims(self, request): - return {} + def get_additional_claims(self): + return [] diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index b4bb8869b..0cd24fc85 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -45,6 +45,13 @@ def get(self, request, *args, **kwargs): signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + oidc_claims = [] + for el, _ in validator.get_claim_list(): + oidc_claims.append(el) + data = { "issuer": issuer_url, "authorization_endpoint": authorization_endpoint, @@ -57,6 +64,7 @@ def get(self, request, *args, **kwargs): "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), + "claims_supported": oidc_claims, } response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 46040f86d..719d10e98 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -29,6 +29,7 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -55,6 +56,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -146,11 +148,21 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 +EXAMPLE_EMAIL = "example.email@example.com" + + +def claim_user_email(request): + return EXAMPLE_EMAIL + + @pytest.mark.django_db def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): - def get_additional_claims(self, request): - return {"state": "very nice"} + def get_additional_claims(self): + return [ + ("username", claim_user_email), + ("email", claim_user_email), + ] oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_tokens.access_token @@ -161,5 +173,9 @@ def get_additional_claims(self, request): data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_tokens.user.pk) - assert "state" in data - assert data["state"] == "very nice" + + assert "username" in data + assert data["username"] == EXAMPLE_EMAIL + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL From 401d85606b80e4c3dbacd179d4fe2d7907a85094 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 22 Nov 2021 17:15:58 +0000 Subject: [PATCH 402/722] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.10b0 → 21.11b1](https://github.com/psf/black/compare/21.10b0...21.11b1) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 392072a3c..602549a79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 21.10b0 + rev: 21.11b1 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 345122881424897a2907312db52fafad9bbaa5f1 Mon Sep 17 00:00:00 2001 From: Peter Carnesciali <pcarn9@gmail.com> Date: Fri, 10 Dec 2021 10:05:37 -0600 Subject: [PATCH 403/722] Removes default_app_config for Django Deprecation Warning (#1035) * Removes default_app_config for Django Deprecation Warning * Update AUTHORS * Update __init__.py * import django * Install django so tox tests work * Pin mistune to fix docs --- .github/workflows/test.yml | 2 +- AUTHORS | 1 + docs/requirements.txt | 1 + oauth2_provider/__init__.py | 6 +++++- tox.ini | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d257b465..f1f734271 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions + python -m pip install --upgrade tox tox-gh-actions django - name: Tox tests run: | diff --git a/AUTHORS b/AUTHORS index 68960486a..e47665e66 100644 --- a/AUTHORS +++ b/AUTHORS @@ -46,6 +46,7 @@ Michael Howitz Paul Dekkers Paul Oswald Pavel Tvrdík +Peter Carnesciali Rodney Richardson Rustem Saiargaliev Sandro Rodrigues diff --git a/docs/requirements.txt b/docs/requirements.txt index c1f72699b..69501a2c6 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ Django>=3.0,<3.1 oauthlib>=3.1.0 m2r>=0.2.1 +mistune<2 . diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index ebb0b28b5..6ae4b308a 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,3 +1,7 @@ +import django + + __version__ = "1.5.0" -default_app_config = "oauth2_provider.apps.DOTConfig" +if django.VERSION < (3, 2): + default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/tox.ini b/tox.ini index 8371aab0a..ebd9ffb6e 100644 --- a/tox.ini +++ b/tox.ini @@ -64,6 +64,7 @@ deps = sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 + mistune<2 sphinx-rtd-theme livedocs: sphinx-autobuild jwcrypto From 0204383f7b6f8739322f2009f2bb25e8ac9bced2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 11 Dec 2021 11:01:05 +0600 Subject: [PATCH 404/722] [pre-commit.ci] pre-commit autoupdate (#1036) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.11b1 → 21.12b0](https://github.com/psf/black/compare/21.11b1...21.12b0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 602549a79..177f2a25f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 21.11b1 + rev: 21.12b0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 78feec86de03b73664fb09291b117c696db4427d Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Tue, 14 Dec 2021 11:37:42 -0500 Subject: [PATCH 405/722] Update CHANGELOG.md for 1.6.0 --- CHANGELOG.md | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2ccef370..36615dcc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,10 +15,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [unreleased] -* Remove support for Django 3.0 -* Add support for Django 3.2 -* #989 Change any HttpResponse to JsonResponse if possible -* #981 redirect_uri is now required in authorization requests when multiple URIs are registered. + +## [Added] +* #968 Add support for Django 3.2 +* #949 Provide django.contrib.auth.authenticate() with a request for compatibiity with more backends. +* #953 Allow loopback redirect URIs using ports as described in RFC8252 +* #972 Add Farsi/fa language support +* #978 Multiple rsa keys +* #967 OpenID: Add claims to Well know +* #1019 #1024 #1026 #1030 #1033 #1036 [pre-commit.ci] pre-commit autoupdate +* #1021 Jazzband: Synced file(s) with jazzband/.github + +## [Changed] +* #1022 Replaced pkg_resources usage with importlib.metadata +* #981 Require redirect_uri if multiple URIs are registered +* #963 Handles ValueErrors with invalid hex values in query strings (#954) +* #989 Change remaining HttpResponse to JsonResponse +* #988 Optimize DB access in AccessTokenAdmin +* #973 Use django-cors-headers in docs +* #1009 Add missing space in assertion error +* #1025 Moved version info from setup.cfg into package +* #991 Update settings.rst with text +* #956 doc: missing argument to get_userinfo_claims +* #985 Documentation grammar +* #977 doc: Add missing import +* #1014 Update tutorial_03.rst to use arrays instead of tuples in the settings.py file + +## [Fixed] +* #948 Fix #524 - Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True +* #957 Fix double oauth2_provider mountpoint in oidc view + +## [Removed] +* #968 Remove support for Django 3.0 +* #1035 Removes default_app_config for Django Deprecation Warning +* #1023 six should be dropped + +## [1.6.0] 2021-12-14 ### Added * #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` From 2980117c522b536d975ee19b03d051ea13978e56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= <petr.dlouhy@email.cz> Date: Thu, 16 Dec 2021 15:26:45 +0100 Subject: [PATCH 406/722] upgrades to admin: search_fields, list_filters and raw_id_field (#1041) * upgrades to admin: search_fields, list_filters and raw_id_field * update CHANGELOG * add name to AUTHORS * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/admin.py | 10 ++++++++++ 3 files changed, 12 insertions(+) diff --git a/AUTHORS b/AUTHORS index e47665e66..deb0c7ce4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -47,6 +47,7 @@ Paul Dekkers Paul Oswald Pavel Tvrdík Peter Carnesciali +Petr Dlouhý Rodney Richardson Rustem Saiargaliev Sandro Rodrigues diff --git a/CHANGELOG.md b/CHANGELOG.md index 36615dcc4..768e577ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #967 OpenID: Add claims to Well know * #1019 #1024 #1026 #1030 #1033 #1036 [pre-commit.ci] pre-commit autoupdate * #1021 Jazzband: Synced file(s) with jazzband/.github +* #1041 Admin: make extensive fields raw_id, add search fields ## [Changed] * #1022 Replaced pkg_resources usage with importlib.metadata diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index 58b5308fd..bd26dddb1 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -1,4 +1,5 @@ from django.contrib import admin +from django.contrib.auth import get_user_model from oauth2_provider.models import ( get_access_token_admin_class, @@ -14,6 +15,9 @@ ) +has_email = hasattr(get_user_model(), "email") + + class ApplicationAdmin(admin.ModelAdmin): list_display = ("id", "name", "user", "client_type", "authorization_grant_type") list_filter = ("client_type", "authorization_grant_type", "skip_authorization") @@ -28,6 +32,8 @@ class AccessTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application", "expires") list_select_related = ("application", "user") raw_id_fields = ("user", "source_refresh_token") + search_fields = ("token",) + (("user__email",) if has_email else ()) + list_filter = ("application",) class GrantAdmin(admin.ModelAdmin): @@ -38,11 +44,15 @@ class GrantAdmin(admin.ModelAdmin): class IDTokenAdmin(admin.ModelAdmin): list_display = ("jti", "user", "application", "expires") raw_id_fields = ("user",) + search_fields = ("token",) + (("user__email",) if has_email else ()) + list_filter = ("application",) class RefreshTokenAdmin(admin.ModelAdmin): list_display = ("token", "user", "application") raw_id_fields = ("user", "access_token") + search_fields = ("token",) + (("user__email",) if has_email else ()) + list_filter = ("application",) application_model = get_application_model() From 4ac0ecd5e58ea6151de09542862eaaa417973c70 Mon Sep 17 00:00:00 2001 From: Asif Saif Uddin <auvipy@gmail.com> Date: Thu, 16 Dec 2021 21:02:45 +0600 Subject: [PATCH 407/722] =?UTF-8?q?Bump=20version:=201.5.0=20=E2=86=92=201?= =?UTF-8?q?.6.0=20(#1042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- oauth2_provider/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 6ae4b308a..12e29a6d0 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.5.0" +__version__ = "1.6.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From d25cb274cc66259ecedf4bf0bfe69194125dd10b Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Sat, 18 Dec 2021 15:26:10 -0500 Subject: [PATCH 408/722] Reformat changelog (#1043) --- CHANGELOG.md | 31 +++++++++++++------------------ 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 768e577ff..2d369884c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [unreleased] +## [Unreleased] -## [Added] +## [1.6.0] - 2021-12-14 +### Added +* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` + to provide compatibility with backends that need one. +* #950 Add support for RSA key rotation. * #968 Add support for Django 3.2 * #949 Provide django.contrib.auth.authenticate() with a request for compatibiity with more backends. * #953 Allow loopback redirect URIs using ports as described in RFC8252 @@ -27,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1021 Jazzband: Synced file(s) with jazzband/.github * #1041 Admin: make extensive fields raw_id, add search fields -## [Changed] +### Changed * #1022 Replaced pkg_resources usage with importlib.metadata * #981 Require redirect_uri if multiple URIs are registered * #963 Handles ValueErrors with invalid hex values in query strings (#954) @@ -42,23 +46,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #977 doc: Add missing import * #1014 Update tutorial_03.rst to use arrays instead of tuples in the settings.py file -## [Fixed] +### Fixed * #948 Fix #524 - Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True * #957 Fix double oauth2_provider mountpoint in oidc view - -## [Removed] -* #968 Remove support for Django 3.0 -* #1035 Removes default_app_config for Django Deprecation Warning -* #1023 six should be dropped - -## [1.6.0] 2021-12-14 - -### Added -* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` - to provide compatibility with backends that need one. -* #950 Add support for RSA key rotation. - -### Fixed * #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. * #953 Allow loopback redirect URIs with random ports using http scheme, localhost address and no explicit port configuration in the allowed redirect_uris for Oauth2 Applications (RFC8252) @@ -66,6 +56,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. Breaks existing OIDC discovery output +## Removed +* #968 Remove support for Django 3.0 +* #1035 Removes default_app_config for Django Deprecation Warning +* #1023 six should be dropped + ## [1.5.0] 2021-03-18 ### Added From 6aeb1b2961624a6ca7f1e5994d1a0583a2470068 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Sun, 19 Dec 2021 16:13:47 -0500 Subject: [PATCH 409/722] Add support for Dj40, drop Py36 and Dj31 (#1039) * Add support for Dj40, drop Py36 and Dj31 * Update tox.ini * remove python 3.10 from CI * Per django/django#15205 make minimum version of dj40 be 4.0.1. * Add py310 to GH test action. * Add back Python 3.10 and add Dj4.0.0 constraint * Installation should block 4.0.0 in my opinion to avoid anyone's production sit e from going down due to this package. This is only the case if someone runs mak emigrations on their production server (for any bad reason). * Updated docs to reflect the changes in this PR * Revert tox.ini Django 4.0.1 constraint * As of Dec 19, 2021, Django 4.0.1 has not released changes to fix a regression.It is a user-end regression that does not affect usability, but it does affect user's belief that they need to create a migration. In @Andrew-Chen-Wang's past experience, that has led to production errors and an emergency migration of one of his past packages... * Ignore dj310-djmain * Update CHANGELOG.md (only for user relevant changes). per https://django-oauth-toolkit.readthedocs.io/en/stable/contributing.html#pull-requests. Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 56 +++++++++++++------------------------- README.rst | 4 +-- docs/index.rst | 4 +-- setup.cfg | 6 ++-- tox.ini | 12 ++++---- 6 files changed, 34 insertions(+), 50 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f1f734271..08d2ddc77 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.7', '3.8', '3.9', '3.10'] steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d369884c..dbf5eee2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,51 +16,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.6.0] - 2021-12-14 +## [1.6.0] 2021-12-19 ### Added -* #712, #636, #808. Calls to `django.contrib.auth.authenticate()` now pass a `request` - to provide compatibility with backends that need one. -* #950 Add support for RSA key rotation. -* #968 Add support for Django 3.2 -* #949 Provide django.contrib.auth.authenticate() with a request for compatibiity with more backends. -* #953 Allow loopback redirect URIs using ports as described in RFC8252 -* #972 Add Farsi/fa language support -* #978 Multiple rsa keys -* #967 OpenID: Add claims to Well know -* #1019 #1024 #1026 #1030 #1033 #1036 [pre-commit.ci] pre-commit autoupdate -* #1021 Jazzband: Synced file(s) with jazzband/.github -* #1041 Admin: make extensive fields raw_id, add search fields +* #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). +* #968, #1039 Add support for Django 3.2 and 4.0. +* #953 Allow loopback redirect URIs using random ports as described in [RFC8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). +* #972 Add Farsi/fa language support. +* #978 OIDC: Add support for [rotating multiple RSA private keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#rotating-the-rsa-private-key). +* #978 OIDC: Add new [OIDC_JWKS_MAX_AGE_SECONDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#oidc-jwks-max-age-seconds) to improve `jwks_uri` caching. +* #967 OIDC: Add [additional claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) beyond `sub` to the id_token. +* #1041 Add a search field to the Admin UI (e.g. for search for tokens by email address). ### Changed -* #1022 Replaced pkg_resources usage with importlib.metadata -* #981 Require redirect_uri if multiple URIs are registered -* #963 Handles ValueErrors with invalid hex values in query strings (#954) -* #989 Change remaining HttpResponse to JsonResponse -* #988 Optimize DB access in AccessTokenAdmin -* #973 Use django-cors-headers in docs -* #1009 Add missing space in assertion error -* #1025 Moved version info from setup.cfg into package -* #991 Update settings.rst with text -* #956 doc: missing argument to get_userinfo_claims -* #985 Documentation grammar -* #977 doc: Add missing import -* #1014 Update tutorial_03.rst to use arrays instead of tuples in the settings.py file - -### Fixed -* #948 Fix #524 - Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True -* #957 Fix double oauth2_provider mountpoint in oidc view -* #524 Restrict usage of timezone aware expire dates to Django projects with USE_TZ set to True. -* #953 Allow loopback redirect URIs with random ports using http scheme, localhost address and no explicit port - configuration in the allowed redirect_uris for Oauth2 Applications (RFC8252) -* #954 Query strings with invalid hex values now raise a SuspiciousOperation exception -* #955 Avoid doubling of `oauth2_provider` urls mountpath in json response for OIDC view `ConnectDiscoveryInfoView`. - Breaks existing OIDC discovery output +* #981 Require redirect_uri if multiple URIs are registered per [RFC6749 section 3.1.2.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2.3) +* #991 Update documentation of [REFRESH_TOKEN_EXPIRE_SECONDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#refresh-token-expire-seconds) to indicate it may be `int` or `datetime.timedelta`. +* #977 Update [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/stable/tutorial/tutorial_01.html#) to show required `include`. ## Removed -* #968 Remove support for Django 3.0 +* #968 Remove support for Django 3.0 & 3.1 and Python 3.6 * #1035 Removes default_app_config for Django Deprecation Warning * #1023 six should be dropped +### Fixed +* #963 Fix handling invalid hex values in client query strings with a 400 error rather than 500. +* #973 [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_01.html#start-your-app) updated to use `django-cors-headers`. +* #956 OIDC: Update documentation of [get_userinfo_claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-information-to-the-userinfo-service) to add the missing argument. + + ## [1.5.0] 2021-03-18 ### Added diff --git a/README.rst b/README.rst index fe435987f..06ca3dce7 100644 --- a/README.rst +++ b/README.rst @@ -49,8 +49,8 @@ Please report any security issues to the JazzBand security team at <security@jaz Requirements ------------ -* Python 3.6+ -* Django 2.2+ +* Python 3.7+ +* Django 2.2, 3.2, or >=4.0.1 * oauthlib 3.1+ Installation diff --git a/docs/index.rst b/docs/index.rst index d2d4e8c3c..fdd8131b7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,8 +21,8 @@ If you need help please submit a `question <https://github.com/jazzband/django-o Requirements ------------ -* Python 3.6+ -* Django 2.2+ +* Python 3.7+ +* Django 2.2, 3.2, 4.0.1+ * oauthlib 3.1+ Index diff --git a/setup.cfg b/setup.cfg index b72ad7275..4a8de4569 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,16 +13,16 @@ classifiers = Environment :: Web Environment Framework :: Django Framework :: Django :: 2.2 - Framework :: Django :: 3.1 Framework :: Django :: 3.2 + Framework :: Django :: 4.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.6 Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 Topic :: Internet :: WWW/HTTP [options] @@ -32,7 +32,7 @@ zip_safe = False # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 2.2 + django >= 2.2, != 4.0.0 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 diff --git a/tox.ini b/tox.ini index ebd9ffb6e..23a84d805 100644 --- a/tox.ini +++ b/tox.ini @@ -2,15 +2,17 @@ envlist = flake8, docs, - py{36,37,38,39}-dj{32,31,22}, - py{38,39}-djmain, + py{37,38,39}-dj22, + py{37,38,39,310}-dj32, + py{38,39,310}-dj40, + py{38,39,310}-djmain, [gh-actions] python = - 3.6: py36 3.7: py37 3.8: py38, docs, flake8 3.9: py39 + 3.10: py310 [pytest] django_find_project = false @@ -33,8 +35,8 @@ setenv = PYTHONWARNINGS = all deps = dj22: Django>=2.2,<3 - dj31: Django>=3.1,<3.2 dj32: Django>=3.2,<3.3 + dj40: Django>=4.0.0,<4.1 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 @@ -49,7 +51,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{38,39}-djmain] +[testenv:py{38,39,310}-djmain] ignore_errors = true ignore_outcome = true From 2909e5557ae1f142df6102128680aa5bf7805375 Mon Sep 17 00:00:00 2001 From: Peter Carnesciali <pcarn9@gmail.com> Date: Mon, 20 Dec 2021 10:14:42 -0600 Subject: [PATCH 410/722] Move tox django dependency to proper place (#1048) --- .github/workflows/test.yml | 2 +- tox.ini | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08d2ddc77..6409b6861 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,7 +37,7 @@ jobs: - name: Install Python dependencies run: | python -m pip install --upgrade pip - python -m pip install --upgrade tox tox-gh-actions django + python -m pip install --upgrade tox tox-gh-actions - name: Tox tests run: | diff --git a/tox.ini b/tox.ini index 23a84d805..6976c1119 100644 --- a/tox.ini +++ b/tox.ini @@ -70,6 +70,7 @@ deps = sphinx-rtd-theme livedocs: sphinx-autobuild jwcrypto + django [testenv:flake8] basepython = python3.8 From f338975786983cf87499db5dfd94318415e2e905 Mon Sep 17 00:00:00 2001 From: Jannis Leidel <jannis@leidel.info> Date: Tue, 21 Dec 2021 15:09:24 +0100 Subject: [PATCH 411/722] Remove Django pinning in doc requirements. (#1050) * Remove Django pinning in doc requirements. This will fix RTD installing conflicting Django versions. * Update Python version. * Install as an editable. * Use up-to-date format for readthedocs config file. --- .readthedocs.yml | 24 +++++++++++++++++++----- docs/requirements.txt | 4 ++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.readthedocs.yml b/.readthedocs.yml index eef926c3b..e6f30f627 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -1,15 +1,29 @@ -# .readthedocs.yml +# .readthedocs.yaml # Read the Docs configuration file # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details # Required version: 2 +# Set the version of Python and other tools you might need +build: + os: ubuntu-20.04 + tools: + python: "3.9" + # You can also specify other tool versions: + # nodejs: "16" + # rust: "1.55" + # golang: "1.17" + # Build documentation in the docs/ directory with Sphinx sphinx: - configuration: docs/conf.py + configuration: docs/conf.py + +# If using Sphinx, optionally build your docs in additional formats such as PDF +# formats: +# - pdf +# Optionally declare the Python requirements required to build your docs python: - version: 3.7 - install: - - requirements: docs/requirements.txt + install: + - requirements: docs/requirements.txt diff --git a/docs/requirements.txt b/docs/requirements.txt index 69501a2c6..4f5593f9b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ -Django>=3.0,<3.1 +Django oauthlib>=3.1.0 m2r>=0.2.1 mistune<2 -. +-e . From ded35b21cb99eac4d021d6049ac1af5abc16f9d2 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 22 Dec 2021 12:31:11 -0500 Subject: [PATCH 412/722] Update contribution guidelines to request PR review by project team. (#1044) --- docs/contributing.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index c336d0422..03aa0aea2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -154,7 +154,8 @@ When you begin your PR, you'll be asked to provide the following: If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. -The repo managers will be notified of your pull request and it will be reviewed, in the meantime you can continue to add +Make sure to request a review by assigning Reviewer `jazzband/django-oauth-toolkit`. +This will assign the review to the project team and a member will review it. In the meantime you can continue to add commits to your topic branch (and push them up to GitHub) either if you see something that needs changing, or in response to a reviewer's comments. If a reviewer asks for changes, you do not need to close the pull and reissue it after making changes. Just make the changes locally, push them to GitHub, then add a comment to the discussion section From 6017f079abc0da9f66fb0082539a88ce56b03a42 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Wed, 22 Dec 2021 16:34:04 -0500 Subject: [PATCH 413/722] Clarify why Django 4.0.0 isn't supported (#1054) * Clarify why Django 4.0.0 isn't supported * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + README.rst | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbf5eee2e..ab5f630a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). * #968, #1039 Add support for Django 3.2 and 4.0. + * Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272) * #953 Allow loopback redirect URIs using random ports as described in [RFC8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). * #972 Add Farsi/fa language support. * #978 OIDC: Add support for [rotating multiple RSA private keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#rotating-the-rsa-private-key). diff --git a/README.rst b/README.rst index 06ca3dce7..567c48ee2 100644 --- a/README.rst +++ b/README.rst @@ -35,6 +35,10 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o `OAuthLib <https://github.com/idan/oauthlib>`_, so that everything is `rfc-compliant <http://tools.ietf.org/html/rfc6749>`_. +Note: If you have issues installing Django 4.0.0, it is because we only support +Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. +`Explanation <https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272>`. + Contributing ------------ From f111812ad276226447d3ecb6d6de7a228d8e26e5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 23 Dec 2021 15:54:00 -0500 Subject: [PATCH 414/722] 1.6.1 release (#1053) * Fix wrong level of subheading. * Move post-1.6.0 updates to 1.6.1. No backwards time travel! * Release 1.6.1 * Fix missing _ for href. --- CHANGELOG.md | 11 +++++++++-- README.rst | 2 +- oauth2_provider/__init__.py | 2 +- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab5f630a9..f10cdf145 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.6.1] 2021-12-23 + +### Changed +* Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272) + +### Fixed +* Miscellaneous 1.6.0 packaging issues. + ## [1.6.0] 2021-12-19 ### Added * #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). * #968, #1039 Add support for Django 3.2 and 4.0. - * Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272) * #953 Allow loopback redirect URIs using random ports as described in [RFC8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). * #972 Add Farsi/fa language support. * #978 OIDC: Add support for [rotating multiple RSA private keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#rotating-the-rsa-private-key). @@ -33,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #991 Update documentation of [REFRESH_TOKEN_EXPIRE_SECONDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#refresh-token-expire-seconds) to indicate it may be `int` or `datetime.timedelta`. * #977 Update [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/stable/tutorial/tutorial_01.html#) to show required `include`. -## Removed +### Removed * #968 Remove support for Django 3.0 & 3.1 and Python 3.6 * #1035 Removes default_app_config for Django Deprecation Warning * #1023 six should be dropped diff --git a/README.rst b/README.rst index 567c48ee2..8a9f333db 100644 --- a/README.rst +++ b/README.rst @@ -37,7 +37,7 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o Note: If you have issues installing Django 4.0.0, it is because we only support Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. -`Explanation <https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272>`. +`Explanation <https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272>`_. Contributing ------------ diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 12e29a6d0..68a0914f2 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.6.0" +__version__ = "1.6.1" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 461061687b59acfa5837cda092b60c5ed85f385e Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 23 Dec 2021 22:17:59 -0500 Subject: [PATCH 415/722] Replace RST header underline with one that pre-commit check-merge wont break on. (#1057) --- AUTHORS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index deb0c7ce4..8675dd995 100644 --- a/AUTHORS +++ b/AUTHORS @@ -1,11 +1,11 @@ Authors -======= +------- Massimiliano Pippi Federico Frenguelli Contributors -============ +------------ Abhishek Patel Alan Crosswell From 4a38c042abf8ff768a00e2bfd093aa040aca3e53 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 30 Dec 2021 08:28:26 -0500 Subject: [PATCH 416/722] [pre-commit.ci] pre-commit autoupdate (#1060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.0.1 → v4.1.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.0.1...v4.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 177f2a25f..5c78568ea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.1.0 hooks: - id: check-ast - id: trailing-whitespace From c9ce19cb75d0f22ee7eadbcd86b8568c40a3dfa6 Mon Sep 17 00:00:00 2001 From: Eduardo Oliveira <eduardo_y05@outlook.com> Date: Thu, 30 Dec 2021 10:44:16 -0300 Subject: [PATCH 417/722] add pt_BR locale translation (#1062) * add pt_BR locale translation * add name to Contributors * update changelog Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 2 +- CHANGELOG.md | 4 + .../locale/pt_BR/LC_MESSAGES/django.po | 202 ++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index 8675dd995..ea28cf038 100644 --- a/AUTHORS +++ b/AUTHORS @@ -64,4 +64,4 @@ pySilver Shaheed Haque Andrea Greco Vinay Karanam - +Eduardo Oliveira diff --git a/CHANGELOG.md b/CHANGELOG.md index f10cdf145..f2703e829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +* Added pt-BR translations. + ## [1.6.1] 2021-12-23 ### Changed diff --git a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po new file mode 100644 index 000000000..48d673e33 --- /dev/null +++ b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po @@ -0,0 +1,202 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# Eduardo Oliveira <eduardo_y05@outlook.com>, 2021. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-12-30 09:50-0300\n" +"PO-Revision-Date: 2021-12-30 09:50-0300\n" +"Last-Translator: Eduardo Oliveira <eduardo_y05@outlook.com>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: models.py:50 +msgid "Confidential" +msgstr "Confidencial" + +#: models.py:51 +msgid "Public" +msgstr "Público" + +#: models.py:60 +msgid "Authorization code" +msgstr "Código de Autorização" + +#: models.py:61 +msgid "Implicit" +msgstr "Implícito" + +#: models.py:62 +msgid "Resource owner password-based" +msgstr "Baseado na senha do proprietário do recurso" + +#: models.py:63 +msgid "Client credentials" +msgstr "Credenciais do cliente" + +#: models.py:64 +msgid "OpenID connect hybrid" +msgstr "Híbrido de conexão OpenID" + +#: models.py:71 +msgid "No OIDC support" +msgstr "Sem suporte a OIDC" + +#: models.py:72 +msgid "RSA with SHA-2 256" +msgstr "RSA com SHA-2 256" + +#: models.py:73 +msgid "HMAC with SHA-2 256" +msgstr "HMAC com SHA-2 256" + +#: models.py:88 +msgid "Allowed URIs list, space separated" +msgstr "Lista de URLs permitidos, separados por espaço" + +#: models.py:155 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Esquema de redirecionamento não autorizado: {scheme}" + +#: models.py:159 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris não pode ser vázio com o grant_type {grant_type}" + +#: models.py:165 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "Você precisa definir OIDC_RSA_PRIVATE_KEY para usar o algoritmo RSA" + +#: models.py:174 +msgid "You cannot use HS256 with public grants or clients" +msgstr "Você não pode usar HS256 com concessões publicas ou clientes" + +#: oauth2_validators.py:181 +msgid "The access token is invalid." +msgstr "O token de acesso é inválido." + +#: oauth2_validators.py:188 +msgid "The access token has expired." +msgstr "O token de acesso expirou." + +#: oauth2_validators.py:195 +msgid "The access token is valid but does not have enough scope." +msgstr "O token de acesso é valido porém não tem o escopo necessário." + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Tem certeza que deseja remover a aplicação?" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Cancelar" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Remover" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ID do Cliente" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Palavra-Chave Secreta do Cliente" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Tipo de Cliente" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Tipo de concessão de autorização" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "URLs de redirecionamento" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Voltar" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Editar" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Editar Aplicação" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Salvar" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Suas Aplicações" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nova Aplicação" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Não existem aplicações definidas" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Clicar aqui" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "se você deseja registrar uma nova" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Registrar uma nova aplicação" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autorizar" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "A Aplicação precisa das seguintes permissões" + +#: templates/oauth2_provider/authorized-oob.html:12 +msgid "Success" +msgstr "Sucesso" + +#: templates/oauth2_provider/authorized-oob.html:14 +msgid "Please return to your application and enter this code:" +msgstr "Por favor, retorne para a sua aplicação e insira o seguinte código:" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Você tem certeza que deseja remover esse token?" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokens" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "Revogar" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "Não existem tokens autorizados ainda." From e4c98c79e5a36a864815e7ca9be2da9834c233ef Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 30 Dec 2021 08:57:28 -0500 Subject: [PATCH 418/722] Improve documentation of the release process. (#1063) --- docs/contributing.rst | 23 +++++++++++++++-------- tox.ini | 7 ++----- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index 03aa0aea2..b9157feaa 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -256,7 +256,7 @@ The following notes are to remind the project maintainers and leads of the steps review and merge PRs and to publish a new release. Reviewing and Merging PRs ------------------------- +------------------------- - Make sure the PR description includes the `pull request template <https://github.com/jazzband/django-oauth-toolkit/blob/master/.github/pull_request_template.md>`_ @@ -272,18 +272,25 @@ PRs that are incorrectly merged may (reluctantly) be reverted by the Project Lea Publishing a Release -------------------- -Only Project Leads can publish a release to pypi.org and rtfd.io. This checklist is a reminder -of steps. +Only Project Leads can `publish a release <https://jazzband.co/about/releases>`_ to pypi.org +and rtfd.io. This checklist is a reminder of the required steps. - When planning a new release, create a `milestone <https://github.com/jazzband/django-oauth-toolkit/milestones>`_ and assign issues, PRs, etc. to that milestone. - Review all commits since the last release and confirm that they are properly - documented in the CHANGELOG. (Unfortunately, this has not always been the case - so you may be stuck documenting things that should have been documented as part of their PRs.) + documented in the CHANGELOG. Reword entries as appropriate with links to docs + to make them meaningful to users. - Make a final PR for the release that updates: - CHANGELOG to show the release date. - - setup.cfg to set `version = ...` - -- Once the final PR is committed push the new release to pypi and rtfd.io. + - `oauth2_provider/__init__.py` to set `__version__ = "..."` + +- Once the final PR is merged, create and push a tag for the release. You'll shortly + get a notification from Jazzband of the availability of two pypi packages (source tgz + and wheel). Download these locally before releasing them. +- Do a `tox -e build` and extract the downloaded and bullt wheel zip and tgz files into + temp directories and do a `diff -r` to make sure they have the same content. + (Unfortunately the checksums do not match due to timestamps in the metadata + so you need to compare all the files.) +- Once happy that the above comparison checks out, approve the releases to Pypi.org. diff --git a/tox.ini b/tox.ini index 6976c1119..a228db052 100644 --- a/tox.ini +++ b/tox.ini @@ -82,17 +82,14 @@ deps = flake8-quotes flake8-black -[testenv:install] +[testenv:build] deps = - twine setuptools>=39.0 wheel whitelist_externals = rm commands = rm -rf dist python setup.py sdist bdist_wheel - twine upload dist/* - [coverage:run] source = oauth2_provider @@ -103,7 +100,7 @@ show_missing = True [flake8] max-line-length = 110 -exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/ +exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, dist/ application-import-names = oauth2_provider inline-quotes = double extend-ignore = E203, W503 From c42423c3da272c79811b206a1d9b798eaee3fd9c Mon Sep 17 00:00:00 2001 From: Dawid Wolski <merito@tuta.io> Date: Sat, 1 Jan 2022 17:58:09 +0100 Subject: [PATCH 419/722] Batch tokens deletion in cleartokens command (#969) * Batch tokens deletion in cleartokens command * CHANGELOG.md and AUTHORS Do not check for merge conflicts in AUTHORS file, because ======= in 2nd line triggers the error. * Issue with AUTHORS file fixed in 1.6.1 Co-authored-by: Dawid Wolski <dawid.wolski@identt.pl> Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 2 ++ docs/management_commands.rst | 3 ++ docs/settings.rst | 12 +++++++ oauth2_provider/models.py | 61 ++++++++++++++++++++++++------------ oauth2_provider/settings.py | 2 ++ tests/settings.py | 3 ++ 7 files changed, 64 insertions(+), 20 deletions(-) diff --git a/AUTHORS b/AUTHORS index ea28cf038..63ed72621 100644 --- a/AUTHORS +++ b/AUTHORS @@ -24,6 +24,7 @@ Bas van Oostveen Dave Burkholder David Fischer David Smith +Dawid Wolski Diego Garcia Dulmandakh Sukhbaatar Dylan Giesler diff --git a/CHANGELOG.md b/CHANGELOG.md index f2703e829..8b3a887b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [Unreleased] +### Added +* #651 Batch expired token deletions in `cleartokens` management command ### Added diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 3930062b6..147a0bbe4 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -16,5 +16,8 @@ If ``cleartokens`` runs daily the maximum delay before a refresh token is removed is ``REFRESH_TOKEN_EXPIRE_SECONDS`` + 1 day. This is normally not a problem since refresh tokens are long lived. +To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRED_TOKENS_BATCH_SIZE`` and +``CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`` settings to adjust the process speed. + Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. diff --git a/docs/settings.rst b/docs/settings.rst index 07561d3d2..49460bc0e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -337,6 +337,18 @@ Default: ``["client_secret_post", "client_secret_basic"]`` The authentication methods that are advertised to be supported by this server. +CLEAR_EXPIRED_TOKENS_BATCH_SIZE +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``10000`` + +The size of delete batches used by ``cleartokens`` management command. + +CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``0.1`` + +Time of sleep in seconds used by ``cleartokens`` management command between batch deletions. + Settings imported from Django project -------------------------- diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index d7b767a78..2c9747ce8 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,4 +1,5 @@ import logging +import time import uuid from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -621,12 +622,31 @@ def get_refresh_token_admin_class(): def clear_expired(): + def batch_delete(queryset, query): + CLEAR_EXPIRED_TOKENS_BATCH_SIZE = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE + CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL + current_no = start_no = queryset.count() + + while current_no: + flat_queryset = queryset.values_list("id", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE] + batch_length = flat_queryset.count() + queryset.model.objects.filter(id__in=list(flat_queryset)).delete() + logger.debug(f"{batch_length} tokens deleted, {current_no-batch_length} left") + queryset = queryset.model.objects.filter(query) + time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL) + current_no = queryset.count() + + stop_no = queryset.model.objects.filter(query).count() + deleted = start_no - stop_no + return deleted + now = timezone.now() refresh_expire_at = None access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() grant_model = get_grant_model() REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + if REFRESH_TOKEN_EXPIRE_SECONDS: if not isinstance(REFRESH_TOKEN_EXPIRE_SECONDS, timedelta): try: @@ -636,31 +656,32 @@ def clear_expired(): raise ImproperlyConfigured(e) refresh_expire_at = now - REFRESH_TOKEN_EXPIRE_SECONDS - with transaction.atomic(): - if refresh_expire_at: - revoked = refresh_token_model.objects.filter( - revoked__lt=refresh_expire_at, - ) - expired = refresh_token_model.objects.filter( - access_token__expires__lt=refresh_expire_at, - ) + if refresh_expire_at: + revoked_query = models.Q(revoked__lt=refresh_expire_at) + revoked = refresh_token_model.objects.filter(revoked_query) + + revoked_deleted_no = batch_delete(revoked, revoked_query) + logger.info("%s Revoked refresh tokens deleted", revoked_deleted_no) + + expired_query = models.Q(access_token__expires__lt=refresh_expire_at) + expired = refresh_token_model.objects.filter(expired_query) - logger.info("%s Revoked refresh tokens to be deleted", revoked.count()) - logger.info("%s Expired refresh tokens to be deleted", expired.count()) + expired_deleted_no = batch_delete(expired, expired_query) + logger.info("%s Expired refresh tokens deleted", expired_deleted_no) + else: + logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) - revoked.delete() - expired.delete() - else: - logger.info("refresh_expire_at is %s. No refresh tokens deleted.", refresh_expire_at) + access_token_query = models.Q(refresh_token__isnull=True, expires__lt=now) + access_tokens = access_token_model.objects.filter(access_token_query) - access_tokens = access_token_model.objects.filter(refresh_token__isnull=True, expires__lt=now) - grants = grant_model.objects.filter(expires__lt=now) + access_tokens_delete_no = batch_delete(access_tokens, access_token_query) + logger.info("%s Expired access tokens deleted", access_tokens_delete_no) - logger.info("%s Expired access tokens to be deleted", access_tokens.count()) - logger.info("%s Expired grant tokens to be deleted", grants.count()) + grants_query = models.Q(expires__lt=now) + grants = grant_model.objects.filter(grants_query) - access_tokens.delete() - grants.delete() + grants_deleted_no = batch_delete(grants, grants_query) + logger.info("%s Expired grant tokens deleted", grants_deleted_no) def redirect_to_uri_allowed(uri, allowed_uris): diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 9a996b0c2..22e067716 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -101,6 +101,8 @@ # Whether to re-create OAuthlibCore on every request. # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, + "CLEAR_EXPIRED_TOKENS_BATCH_SIZE": 10000, + "CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL": 0.1, } # List of settings that cannot be empty diff --git a/tests/settings.py b/tests/settings.py index bc7a55130..d2fbe6a56 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -156,3 +156,6 @@ OAUTH2_PROVIDER_APPLICATION_MODEL = "oauth2_provider.Application" OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "oauth2_provider.RefreshToken" OAUTH2_PROVIDER_ID_TOKEN_MODEL = "oauth2_provider.IDToken" + +CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 1 +CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0 From e657d7b436e523c8a07f369e83ef1e2ade7efba5 Mon Sep 17 00:00:00 2001 From: Patrick <ZuSe@users.noreply.github.com> Date: Sat, 1 Jan 2022 18:41:33 +0100 Subject: [PATCH 420/722] Not existing tokens should return 200 within introspection (not 403) (#1012) * Not existing tokens should return 200 as well Compare with https://datatracker.ietf.org/doc/html/rfc7662 * Finish the PR checklist. Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 6 +++--- oauth2_provider/views/introspect.py | 4 ++-- tests/test_introspection_view.py | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/AUTHORS b/AUTHORS index 63ed72621..f236be9d5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -47,6 +47,7 @@ Michael Howitz Paul Dekkers Paul Oswald Pavel Tvrdík +Patrick Palacin Peter Carnesciali Petr Dlouhý Rodney Richardson diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b3a887b4..72dabbe90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added * #651 Batch expired token deletions in `cleartokens` management command - -### Added - * Added pt-BR translations. +### Fixed +* #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). + ## [1.6.1] 2021-12-23 ### Changed diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 08b4b4222..26254da6b 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -28,7 +28,7 @@ def get_token_response(token_value=None): get_access_token_model().objects.select_related("user", "application").get(token=token_value) ) except ObjectDoesNotExist: - return JsonResponse({"active": False}, status=401) + return JsonResponse({"active": False}, status=200) else: if token.is_valid(): data = { @@ -42,7 +42,7 @@ def get_token_response(token_value=None): data["username"] = token.user.get_username() return JsonResponse(data) else: - return JsonResponse({"active": False}) + return JsonResponse({"active": False}, status=200) def get(self, request, *args, **kwargs): """ diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 0f68320ca..95374cda5 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -199,7 +199,7 @@ def test_view_get_notexisting_token(self): reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( @@ -269,7 +269,7 @@ def test_view_post_notexisting_token(self): reverse("oauth2_provider:introspect"), {"token": "kaudawelsch"}, **auth_headers ) - self.assertEqual(response.status_code, 401) + self.assertEqual(response.status_code, 200) content = response.json() self.assertIsInstance(content, dict) self.assertDictEqual( From 250120d92fa47d1ee4e73e0d24bc7cffd63ff969 Mon Sep 17 00:00:00 2001 From: Peter Karman <pkarman@users.noreply.github.com> Date: Mon, 3 Jan 2022 09:21:49 -0600 Subject: [PATCH 421/722] Add ClientSecretField field to use Django password hashing algorithms (#1020) * Add ClientSecretField field to leverage Django password hashing algo features * Document CLIENT_SECRET_HASHER setting * changelog, authors * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix python super() call * improve test coverage * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * improve test coverage * fix bad merge * comment per reviewer feedback * Update CHANGELOG to reference the docs and fix docs RST errors. Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/settings.rst | 27 +++++++++++++++++---------- oauth2_provider/models.py | 16 +++++++++++++++- oauth2_provider/oauth2_validators.py | 12 ++++++++++++ oauth2_provider/settings.py | 1 + tests/presets.py | 3 +++ tests/test_client_credential.py | 26 ++++++++++++++++++++++++++ 8 files changed, 76 insertions(+), 11 deletions(-) diff --git a/AUTHORS b/AUTHORS index f236be9d5..ad93ff75d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -64,6 +64,7 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Peter Karman Andrea Greco Vinay Karanam Eduardo Oliveira diff --git a/CHANGELOG.md b/CHANGELOG.md index 72dabbe90..b087a48d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #651 Batch expired token deletions in `cleartokens` management command * Added pt-BR translations. +* #729 Add support for [hashed client_secret values](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#client-secret-hasher). ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). diff --git a/docs/settings.rst b/docs/settings.rst index 49460bc0e..e837d7217 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -88,6 +88,13 @@ CLIENT_SECRET_GENERATOR_LENGTH The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. +CLIENT_SECRET_HASHER +~~~~~~~~~~~~~~~~~~~~ +If set to one of the Django password hasher algorithm names, client_secret values will be +stored as `hashed Django passwords <https://docs.djangoproject.com/en/stable/topics/auth/passwords/#how-django-stores-passwords>`_. +See the official list in the django.contrib.auth.hashers namespace. +Default is none (stored as plain text). + EXTRA_SERVER_KWARGS ~~~~~~~~~~~~~~~~~~~ A dictionary to be passed to oauthlib's Server class. Three options @@ -97,19 +104,19 @@ of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). @@ -121,7 +128,7 @@ Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). @@ -154,7 +161,7 @@ If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are @@ -178,7 +185,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR -~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. @@ -265,7 +272,7 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. OIDC_RSA_PRIVATE_KEYS_INACTIVE -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``[]`` An array of *inactive* RSA private keys. These keys are not used to sign tokens, @@ -276,7 +283,7 @@ This is useful for providing a smooth transition during key rotation. should be retained in this inactive list. OIDC_JWKS_MAX_AGE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``3600`` The max-age value for the Cache-Control header on jwks_uri. @@ -351,9 +358,9 @@ Time of sleep in seconds used by ``cleartokens`` management command between batc Settings imported from Django project --------------------------- +------------------------------------- USE_TZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~ Used to determine whether or not to make token expire dates timezone aware. diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 2c9747ce8..8ca031062 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,6 +6,7 @@ from django.apps import apps from django.conf import settings +from django.contrib.auth.hashers import make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse @@ -24,6 +25,19 @@ logger = logging.getLogger(__name__) +class ClientSecretField(models.CharField): + def pre_save(self, model_instance, add): + if oauth2_settings.CLIENT_SECRET_HASHER: + plain_secret = getattr(model_instance, self.attname) + if "$" not in plain_secret: # not yet hashed + hashed_secret = make_password( + plain_secret, salt=model_instance.client_id, hasher=oauth2_settings.CLIENT_SECRET_HASHER + ) + setattr(model_instance, self.attname, hashed_secret) + return hashed_secret + return super().pre_save(model_instance, add) + + class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -90,7 +104,7 @@ class AbstractApplication(models.Model): ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) - client_secret = models.CharField( + client_secret = ClientSecretField( max_length=255, blank=True, default=generate_client_secret, db_index=True ) name = models.CharField(max_length=255, blank=True) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 461c40d53..c4f3ec8a9 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -11,6 +11,7 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q @@ -122,6 +123,17 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False + # we use the "$" as a sentinel character to determine + # whether a secret has been hashed like a Django password or not. + # We can do this because the default oauthlib.common.UNICODE_ASCII_CHARACTER_SET + # used by our default generator does not include the "$" character. + # However, if a different character set was used to generate the secret, this sentinel + # might be a false positive. + elif "$" in request.client.client_secret and request.client.client_secret != client_secret: + if not check_password(client_secret, request.client.client_secret): + log.debug("Failed basic auth: wrong hashed client secret %s" % client_secret) + return False + return True elif request.client.client_secret != client_secret: log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 22e067716..fd02b0685 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -37,6 +37,7 @@ "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "CLIENT_SECRET_HASHER": None, "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, diff --git a/tests/presets.py b/tests/presets.py index 438da1e03..efef78c2a 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -44,3 +44,6 @@ "READ_SCOPE": "read", "WRITE_SCOPE": "write", } + +# default django auth hasher as of version 3.2 +CLIENT_SECRET_HASHER = {"CLIENT_SECRET_HASHER": "pbkdf2_sha256"} diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8159d55db..936cfb6ae 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -24,6 +24,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLIENT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -44,6 +46,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + client_secret=CLIENT_SECRET, ) def tearDown(self): @@ -79,6 +82,29 @@ def test_client_credential_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") + @pytest.mark.oauth2_settings(presets.CLIENT_SECRET_HASHER) + def test_client_credential_with_hashed_client_secret(self): + """ + Verify client_secret is hashed before writing to the db, + and comparison on request uses same hashing algo. + """ + self.assertNotEqual(self.application.client_secret, CLIENT_SECRET) + self.assertIn("$", self.application.client_secret) + self.assertIn(presets.CLIENT_SECRET_HASHER["CLIENT_SECRET_HASHER"], self.application.client_secret) + + token_request_data = { + "grant_type": "client_credentials", + } + auth_headers = get_basic_auth_header(self.application.client_id, CLIENT_SECRET) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + + # secret mismatch should return a 401 + auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) + def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", From 27821a818b10c432f2790ec622bf2be8ce923213 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 4 Jan 2022 15:36:27 -0500 Subject: [PATCH 422/722] #1066: Revert #967 which incorrectly breaks API. (#1068) * #1066: Revert #967 which incorrectly breaks API. --- AUTHORS | 1 - CHANGELOG.md | 1 + docs/oidc.rst | 19 +++++++++---------- oauth2_provider/oauth2_validators.py | 25 ++++++++----------------- oauth2_provider/views/oidc.py | 8 -------- tests/test_oidc_views.py | 24 ++++-------------------- 6 files changed, 22 insertions(+), 56 deletions(-) diff --git a/AUTHORS b/AUTHORS index ad93ff75d..92f65ed6e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -65,6 +65,5 @@ pySilver Łukasz Skarżyński Shaheed Haque Peter Karman -Andrea Greco Vinay Karanam Eduardo Oliveira diff --git a/CHANGELOG.md b/CHANGELOG.md index b087a48d7..95dd2d647 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). +* #1068 Revert #967 which incorrectly changed an API. See #1066. ## [1.6.1] 2021-12-23 diff --git a/docs/oidc.rst b/docs/oidc.rst index eae9a67d4..ba69e984f 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -245,17 +245,16 @@ required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to -our custom validator. -Standard claim ``sub`` is included by default, for remove it override ``get_claim_list``:: +our custom validator:: + class CustomOAuth2Validator(OAuth2Validator): - def get_additional_claims(self): - def get_user_email(request): - return request.user.get_full_name() - - # Element name, callback to obtain data - claims_list = [ ("email", get_sub_cod), - ("username", get_user_email) ] - return claims_list + + def get_additional_claims(self, request): + return { + "sub": request.user.email, + "first_name": request.user.first_name, + "last_name": request.user.last_name, + } .. note:: This ``request`` object is not a ``django.http.Request`` object, but an diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index c4f3ec8a9..06ef64f09 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -740,24 +740,15 @@ def _save_id_token(self, jti, request, expires, *args, **kwargs): def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_claim_list(self): - def get_sub_code(request): - return str(request.user.id) - - list = [("sub", get_sub_code)] + def get_oidc_claims(self, token, token_handler, request): + # Required OIDC claims + claims = { + "sub": str(request.user.id), + } # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - add = self.get_additional_claims() - list.extend(add) - - return list + claims.update(**self.get_additional_claims(request)) - def get_oidc_claims(self, token, token_handler, request): - data = self.get_claim_list() - claims = {} - - for k, call in data: - claims[k] = call(request) return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -910,5 +901,5 @@ def get_userinfo_claims(self, request): """ return self.get_oidc_claims(None, None, request) - def get_additional_claims(self): - return [] + def get_additional_claims(self, request): + return {} diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 0cd24fc85..b4bb8869b 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -45,13 +45,6 @@ def get(self, request, *args, **kwargs): signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] - - validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS - validator = validator_class() - oidc_claims = [] - for el, _ in validator.get_claim_list(): - oidc_claims.append(el) - data = { "issuer": issuer_url, "authorization_endpoint": authorization_endpoint, @@ -64,7 +57,6 @@ def get(self, request, *args, **kwargs): "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), - "claims_supported": oidc_claims, } response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 719d10e98..46040f86d 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -29,7 +29,6 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], - "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -56,7 +55,6 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], - "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -148,21 +146,11 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 -EXAMPLE_EMAIL = "example.email@example.com" - - -def claim_user_email(request): - return EXAMPLE_EMAIL - - @pytest.mark.django_db def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): - def get_additional_claims(self): - return [ - ("username", claim_user_email), - ("email", claim_user_email), - ] + def get_additional_claims(self, request): + return {"state": "very nice"} oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_tokens.access_token @@ -173,9 +161,5 @@ def get_additional_claims(self): data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_tokens.user.pk) - - assert "username" in data - assert data["username"] == EXAMPLE_EMAIL - - assert "email" in data - assert data["email"] == EXAMPLE_EMAIL + assert "state" in data + assert data["state"] == "very nice" From 366e53193769e8a60e210d73c240696a4fb690c5 Mon Sep 17 00:00:00 2001 From: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Date: Tue, 4 Jan 2022 16:24:12 -0500 Subject: [PATCH 423/722] Add migration due to noop FK from Django 4.0 (#1056) * Add migration due to noop Co-authored-by: Alan Crosswell <alan@columbia.edu> --- CHANGELOG.md | 1 + .../migrations/0005_auto_20211222_2352.py | 39 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 oauth2_provider/migrations/0005_auto_20211222_2352.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 95dd2d647..9675564c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). * #1068 Revert #967 which incorrectly changed an API. See #1066. +* #1056 Add missing migration triggered by [Django 4.0 changes to the migrations autodetector](https://docs.djangoproject.com/en/4.0/releases/4.0/#migrations-autodetector-changes). ## [1.6.1] 2021-12-23 diff --git a/oauth2_provider/migrations/0005_auto_20211222_2352.py b/oauth2_provider/migrations/0005_auto_20211222_2352.py new file mode 100644 index 000000000..ebff59f80 --- /dev/null +++ b/oauth2_provider/migrations/0005_auto_20211222_2352.py @@ -0,0 +1,39 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('oauth2_provider', '0004_auto_20200902_2022'), + ] + + operations = [ + migrations.AlterField( + model_name='accesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='application', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='grant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='idtoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='refreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + ] From 58f4f5fabb97185e215395dc2877558581ce77a9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 7 Jan 2022 18:03:08 -0500 Subject: [PATCH 424/722] Add migration that alters client_secret to ClientSecretField. (#1075) --- .../0006_alter_application_client_secret.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 oauth2_provider/migrations/0006_alter_application_client_secret.py diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py new file mode 100644 index 000000000..ae6f3d843 --- /dev/null +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -0,0 +1,20 @@ +# Generated by Django 4.0.1 on 2022-01-07 22:40 + +from django.db import migrations +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20211222_2352'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255), + ), + ] From e06a9db7d6b892056235ab31efddd1bd65645828 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 8 Jan 2022 02:21:50 -0500 Subject: [PATCH 425/722] Add 1.6.2 hotfix changes to version and CHANGELOG. (#1073) Co-authored-by: Asif Saif Uddin <auvipy@gmail.com> --- CHANGELOG.md | 10 +++++++++- oauth2_provider/__init__.py | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9675564c5..e733c3d00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [Unreleased] + ### Added * #651 Batch expired token deletions in `cleartokens` management command * Added pt-BR translations. @@ -22,8 +23,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). -* #1068 Revert #967 which incorrectly changed an API. See #1066. + +## [1.6.2] 2022-01-06 + +**NOTE: This release reverts an inadvertently-added breaking change.** + +### Fixed + * #1056 Add missing migration triggered by [Django 4.0 changes to the migrations autodetector](https://docs.djangoproject.com/en/4.0/releases/4.0/#migrations-autodetector-changes). +* #1068 Revert #967 which incorrectly changed an API. See #1066. ## [1.6.1] 2021-12-23 diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 68a0914f2..4848693a0 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.6.1" +__version__ = "1.6.2" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 94d42a8a2bf2307f32f1c50803732ed78d20feeb Mon Sep 17 00:00:00 2001 From: Peter Karman <pkarman@users.noreply.github.com> Date: Sun, 9 Jan 2022 13:52:50 -0600 Subject: [PATCH 426/722] Revert client secret hash #1020 (#1082) * Revert "Add migration that alters client_secret to ClientSecretField. (#1075)" This reverts commit 58f4f5fabb97185e215395dc2877558581ce77a9. * revert 250120d92fa47d1ee4e73e0d24bc7cffd63ff969 * bad merge --- AUTHORS | 1 - CHANGELOG.md | 1 - docs/settings.rst | 27 +++++++------------ .../0006_alter_application_client_secret.py | 20 -------------- oauth2_provider/models.py | 16 +---------- oauth2_provider/oauth2_validators.py | 12 --------- oauth2_provider/settings.py | 1 - tests/presets.py | 3 --- tests/test_client_credential.py | 26 ------------------ 9 files changed, 11 insertions(+), 96 deletions(-) delete mode 100644 oauth2_provider/migrations/0006_alter_application_client_secret.py diff --git a/AUTHORS b/AUTHORS index 92f65ed6e..295bbe6e1 100644 --- a/AUTHORS +++ b/AUTHORS @@ -64,6 +64,5 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque -Peter Karman Vinay Karanam Eduardo Oliveira diff --git a/CHANGELOG.md b/CHANGELOG.md index e733c3d00..75aa14ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #651 Batch expired token deletions in `cleartokens` management command * Added pt-BR translations. -* #729 Add support for [hashed client_secret values](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#client-secret-hasher). ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). diff --git a/docs/settings.rst b/docs/settings.rst index e837d7217..49460bc0e 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -88,13 +88,6 @@ CLIENT_SECRET_GENERATOR_LENGTH The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. -CLIENT_SECRET_HASHER -~~~~~~~~~~~~~~~~~~~~ -If set to one of the Django password hasher algorithm names, client_secret values will be -stored as `hashed Django passwords <https://docs.djangoproject.com/en/stable/topics/auth/passwords/#how-django-stores-passwords>`_. -See the official list in the django.contrib.auth.hashers namespace. -Default is none (stored as plain text). - EXTRA_SERVER_KWARGS ~~~~~~~~~~~~~~~~~~~ A dictionary to be passed to oauthlib's Server class. Three options @@ -104,19 +97,19 @@ of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL -~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS -~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). @@ -128,7 +121,7 @@ Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). @@ -161,7 +154,7 @@ If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are @@ -185,7 +178,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR -~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. @@ -272,7 +265,7 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. OIDC_RSA_PRIVATE_KEYS_INACTIVE -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~ Default: ``[]`` An array of *inactive* RSA private keys. These keys are not used to sign tokens, @@ -283,7 +276,7 @@ This is useful for providing a smooth transition during key rotation. should be retained in this inactive list. OIDC_JWKS_MAX_AGE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~ Default: ``3600`` The max-age value for the Cache-Control header on jwks_uri. @@ -358,9 +351,9 @@ Time of sleep in seconds used by ``cleartokens`` management command between batc Settings imported from Django project -------------------------------------- +-------------------------- USE_TZ -~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~ Used to determine whether or not to make token expire dates timezone aware. diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py deleted file mode 100644 index ae6f3d843..000000000 --- a/oauth2_provider/migrations/0006_alter_application_client_secret.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 4.0.1 on 2022-01-07 22:40 - -from django.db import migrations -import oauth2_provider.generators -import oauth2_provider.models - - -class Migration(migrations.Migration): - - dependencies = [ - ('oauth2_provider', '0005_auto_20211222_2352'), - ] - - operations = [ - migrations.AlterField( - model_name='application', - name='client_secret', - field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255), - ), - ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 8ca031062..2c9747ce8 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,7 +6,6 @@ from django.apps import apps from django.conf import settings -from django.contrib.auth.hashers import make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse @@ -25,19 +24,6 @@ logger = logging.getLogger(__name__) -class ClientSecretField(models.CharField): - def pre_save(self, model_instance, add): - if oauth2_settings.CLIENT_SECRET_HASHER: - plain_secret = getattr(model_instance, self.attname) - if "$" not in plain_secret: # not yet hashed - hashed_secret = make_password( - plain_secret, salt=model_instance.client_id, hasher=oauth2_settings.CLIENT_SECRET_HASHER - ) - setattr(model_instance, self.attname, hashed_secret) - return hashed_secret - return super().pre_save(model_instance, add) - - class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -104,7 +90,7 @@ class AbstractApplication(models.Model): ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) - client_secret = ClientSecretField( + client_secret = models.CharField( max_length=255, blank=True, default=generate_client_secret, db_index=True ) name = models.CharField(max_length=255, blank=True) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 06ef64f09..f3a24e258 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -11,7 +11,6 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q @@ -123,17 +122,6 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - # we use the "$" as a sentinel character to determine - # whether a secret has been hashed like a Django password or not. - # We can do this because the default oauthlib.common.UNICODE_ASCII_CHARACTER_SET - # used by our default generator does not include the "$" character. - # However, if a different character set was used to generate the secret, this sentinel - # might be a false positive. - elif "$" in request.client.client_secret and request.client.client_secret != client_secret: - if not check_password(client_secret, request.client.client_secret): - log.debug("Failed basic auth: wrong hashed client secret %s" % client_secret) - return False - return True elif request.client.client_secret != client_secret: log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index fd02b0685..22e067716 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -37,7 +37,6 @@ "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, - "CLIENT_SECRET_HASHER": None, "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, diff --git a/tests/presets.py b/tests/presets.py index efef78c2a..438da1e03 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -44,6 +44,3 @@ "READ_SCOPE": "read", "WRITE_SCOPE": "write", } - -# default django auth hasher as of version 3.2 -CLIENT_SECRET_HASHER = {"CLIENT_SECRET_HASHER": "pbkdf2_sha256"} diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 936cfb6ae..8159d55db 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -24,8 +24,6 @@ AccessToken = get_access_token_model() UserModel = get_user_model() -CLIENT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" - # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -46,7 +44,6 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, - client_secret=CLIENT_SECRET, ) def tearDown(self): @@ -82,29 +79,6 @@ def test_client_credential_access_allowed(self): response = view(request) self.assertEqual(response, "This is a protected resource") - @pytest.mark.oauth2_settings(presets.CLIENT_SECRET_HASHER) - def test_client_credential_with_hashed_client_secret(self): - """ - Verify client_secret is hashed before writing to the db, - and comparison on request uses same hashing algo. - """ - self.assertNotEqual(self.application.client_secret, CLIENT_SECRET) - self.assertIn("$", self.application.client_secret) - self.assertIn(presets.CLIENT_SECRET_HASHER["CLIENT_SECRET_HASHER"], self.application.client_secret) - - token_request_data = { - "grant_type": "client_credentials", - } - auth_headers = get_basic_auth_header(self.application.client_id, CLIENT_SECRET) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - - # secret mismatch should return a 401 - auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 401) - def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", From 68da292330b851a63c4f0efbb368f5c714c98161 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 9 Jan 2022 22:38:08 -0500 Subject: [PATCH 427/722] Add tox test to check if migrations were missed. (#1081) * Add tox test to check if migrations were missed. * Document how to contribute migrations. --- docs/contributing.rst | 18 ++++++ tests/mig_settings.py | 125 ++++++++++++++++++++++++++++++++++++++++++ tox.ini | 10 +++- 3 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 tests/mig_settings.py diff --git a/docs/contributing.rst b/docs/contributing.rst index b9157feaa..00b4dbedc 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -96,6 +96,24 @@ When deploying your app, don't forget to compile the messages with:: django-admin compilemessages +Migrations +========== + +If you alter any models, a new migration will need to be generated. This step is frequently missed +by new contributors. You can check if a new migration is needed with:: + + tox -e migrations + +And, if a new migration is needed, use:: + + django-admin makemigrations --settings tests.mig_settings + +Auto migrations frequently have ugly names like `0004_auto_20200902_2022`. You can make your migration +name "better" by adding the `-n name` option:: + + django-admin makemigrations --settings tests.mig_settings -n widget + + Pull requests ============= diff --git a/tests/mig_settings.py b/tests/mig_settings.py new file mode 100644 index 000000000..8f77d1190 --- /dev/null +++ b/tests/mig_settings.py @@ -0,0 +1,125 @@ +""" +Django settings for CI testing if migrations have been missed. + +Generated by 'django-admin startproject' using Django 4.0.1. + +For more information on this file, see +https://docs.djangoproject.com/en/4.0/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.0/ref/settings/ +""" + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-9$j0^ot%41l5r(nj9hg02up-n+$59kld!0%l6pvqbd()u%z2as" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "oauth2_provider", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": ["templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "tutorial.wsgi.application" + +LOGIN_URL = "/admin/login/" + +# Database +# https://docs.djangoproject.com/en/4.0/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.0/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.0/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.0/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.0/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" diff --git a/tox.ini b/tox.ini index a228db052..03241132f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = flake8, + migrations, docs, py{37,38,39}-dj22, py{37,38,39,310}-dj32, @@ -10,7 +11,7 @@ envlist = [gh-actions] python = 3.7: py37 - 3.8: py38, docs, flake8 + 3.8: py38, docs, flake8, migrations 3.9: py39 3.10: py310 @@ -82,6 +83,13 @@ deps = flake8-quotes flake8-black +[testenv:migrations] +setenv = + DJANGO_SETTINGS_MODULE = tests.mig_settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all +commands = django-admin makemigrations --dry-run --check + [testenv:build] deps = setuptools>=39.0 From baba2ed5c3166043560af105ed735eaf3239f95e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Dlouh=C3=BD?= <petr.dlouhy@email.cz> Date: Tue, 11 Jan 2022 14:37:15 +0100 Subject: [PATCH 428/722] fix #1083 ('token' not valid search field), add search fields to all remaining admin classes (#1085) * fix #1083 ('token' not valid search field), add search fields to all remaining admin classes --- oauth2_provider/admin.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index bd26dddb1..cf41ec5b2 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -25,6 +25,7 @@ class ApplicationAdmin(admin.ModelAdmin): "client_type": admin.HORIZONTAL, "authorization_grant_type": admin.VERTICAL, } + search_fields = ("name",) + (("user__email",) if has_email else ()) raw_id_fields = ("user",) @@ -39,12 +40,13 @@ class AccessTokenAdmin(admin.ModelAdmin): class GrantAdmin(admin.ModelAdmin): list_display = ("code", "application", "user", "expires") raw_id_fields = ("user",) + search_fields = ("code",) + (("user__email",) if has_email else ()) class IDTokenAdmin(admin.ModelAdmin): list_display = ("jti", "user", "application", "expires") raw_id_fields = ("user",) - search_fields = ("token",) + (("user__email",) if has_email else ()) + search_fields = ("user__email",) if has_email else () list_filter = ("application",) From 689269e426730372006fd5d2ecc0887dda92d1db Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 11 Jan 2022 09:49:33 -0500 Subject: [PATCH 429/722] Add 1.6.3 into master. (#1086) --- CHANGELOG.md | 8 ++++++++ oauth2_provider/__init__.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75aa14ef2..2f09ff664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). +## [1.6.3] 2022-01-11 + +### Fixed +* #1085 Fix for #1083 admin UI search for idtoken results in `django.core.exceptions.FieldError: Cannot resolve keyword 'token' into field.` + +### Added +* #1085 Add admin UI search fields for additional models. + ## [1.6.2] 2022-01-06 **NOTE: This release reverts an inadvertently-added breaking change.** diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 4848693a0..487f0a884 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.6.2" +__version__ = "1.6.3" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From ac201526843ff63ad3861bb37e4099521cff091a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 12 Jan 2022 18:53:33 -0500 Subject: [PATCH 430/722] Enhance clear_expired tests. (#1088) --- .../management/commands/cleartokens.py | 2 +- tests/test_models.py | 93 ++++++++++++++----- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/oauth2_provider/management/commands/cleartokens.py b/oauth2_provider/management/commands/cleartokens.py index 3fb1827f6..9d58361bc 100644 --- a/oauth2_provider/management/commands/cleartokens.py +++ b/oauth2_provider/management/commands/cleartokens.py @@ -3,7 +3,7 @@ from ...models import clear_expired -class Command(BaseCommand): +class Command(BaseCommand): # pragma: no cover help = "Can be run as a cronjob or directly to clean out expired tokens" def handle(self, *args, **options): diff --git a/tests/test_models.py b/tests/test_models.py index 7b37486ca..9ce1e5eb7 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,5 @@ +from datetime import timedelta + import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured, ValidationError @@ -294,7 +296,11 @@ def test_str(self): class TestClearExpired(BaseTestModels): def setUp(self): super().setUp() - # Insert two tokens on database. + # Insert many tokens, both expired and not, and grants. + self.num_tokens = 100 + now = timezone.now() + earlier = now - timedelta(seconds=100) + later = now + timedelta(seconds=100) app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", @@ -302,23 +308,54 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - AccessToken.objects.create( - token="555", - expires=timezone.now(), - scope=2, - application=app, - user=self.user, - created=timezone.now(), - updated=timezone.now(), + # make 200 access tokens, half current and half expired. + expired_access_tokens = AccessToken.objects.bulk_create( + AccessToken(token="expired AccessToken {}".format(i), expires=earlier) + for i in range(self.num_tokens) ) - AccessToken.objects.create( - token="666", - expires=timezone.now(), - scope=2, - application=app, - user=self.user, - created=timezone.now(), - updated=timezone.now(), + current_access_tokens = AccessToken.objects.bulk_create( + AccessToken(token=f"current AccessToken {i}", expires=later) for i in range(self.num_tokens) + ) + # Give the first half of the access tokens a refresh token, + # alternating between current and expired ones. + RefreshToken.objects.bulk_create( + RefreshToken( + token=f"expired AT's refresh token {i}", + application=app, + access_token=expired_access_tokens[i].pk, + user=self.user, + ) + for i in range(0, len(expired_access_tokens) // 2, 2) + ) + RefreshToken.objects.bulk_create( + RefreshToken( + token=f"current AT's refresh token {i}", + application=app, + access_token=current_access_tokens[i].pk, + user=self.user, + ) + for i in range(1, len(current_access_tokens) // 2, 2) + ) + # Make some grants, half of which are expired. + Grant.objects.bulk_create( + Grant( + user=self.user, + code=f"old grant code {i}", + application=app, + expires=earlier, + redirect_uri="https://localhost/redirect", + ) + for i in range(self.num_tokens) + ) + Grant.objects.bulk_create( + Grant( + user=self.user, + code=f"new grant code {i}", + application=app, + expires=later, + redirect_uri="https://localhost/redirect", + ) + for i in range(self.num_tokens) ) def test_clear_expired_tokens(self): @@ -333,15 +370,21 @@ def test_clear_expired_tokens_incorect_timetype(self): assert result == "ImproperlyConfigured" def test_clear_expired_tokens_with_tokens(self): - self.client.login(username="test_user", password="123456") - self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 0 - ttokens = AccessToken.objects.count() - expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() - assert ttokens == 2 - assert expiredt == 2 + self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 10 + self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0.0 + at_count = AccessToken.objects.count() + assert at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." + rt_count = RefreshToken.objects.count() + assert rt_count == self.num_tokens // 2, f"{self.num_tokens // 2} refresh tokens should exist." + gt_count = Grant.objects.count() + assert gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." clear_expired() - expiredt = AccessToken.objects.filter(expires__lte=timezone.now()).count() - assert expiredt == 0 + at_count = AccessToken.objects.count() + assert at_count == self.num_tokens, "Half the access tokens should not have been deleted." + rt_count = RefreshToken.objects.count() + assert rt_count == self.num_tokens // 2, "Half of the refresh tokens should have been deleted." + gt_count = Grant.objects.count() + assert gt_count == self.num_tokens, "Half the grants should have been deleted." @pytest.mark.django_db From a6a21d3566d634e1bc016c3146fe1560469d9813 Mon Sep 17 00:00:00 2001 From: Dominik George <nik@naturalnet.de> Date: Tue, 18 Jan 2022 21:59:36 +0100 Subject: [PATCH 431/722] Add Celery task for cleantokens (#1070) --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/management_commands.rst | 5 +++++ oauth2_provider/tasks.py | 8 ++++++++ 4 files changed, 15 insertions(+) create mode 100644 oauth2_provider/tasks.py diff --git a/AUTHORS b/AUTHORS index 295bbe6e1..a6c6ef1d2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -66,3 +66,4 @@ pySilver Shaheed Haque Vinay Karanam Eduardo Oliveira +Dominik George diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f09ff664..b5a70cd5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #651 Batch expired token deletions in `cleartokens` management command * Added pt-BR translations. +* #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html) ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 147a0bbe4..727ff9e98 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -21,3 +21,8 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. + +The ``cleartokens`` action can also be scheduled as a `Celery periodic task`_ +by using the ``clear_tokens`` task (automatically registered when using Celery). + +.. _Celery periodic task: https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html diff --git a/oauth2_provider/tasks.py b/oauth2_provider/tasks.py new file mode 100644 index 000000000..d86c33720 --- /dev/null +++ b/oauth2_provider/tasks.py @@ -0,0 +1,8 @@ +from celery import shared_task + + +@shared_task +def clear_tokens(): + from ...models import clear_expired # noqa + + clear_expired() From 4b13743e7a8f3a5ae8198f6c559887bd3faff13a Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 19 Jan 2022 09:22:03 -0500 Subject: [PATCH 432/722] Issue 1087/default batch interval (#1090) * Set default value for CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL to sleep(0). * Document default value of 0 for CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL. Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- docs/settings.rst | 5 ++++- oauth2_provider/settings.py | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 49460bc0e..01baaaf4b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -345,10 +345,13 @@ The size of delete batches used by ``cleartokens`` management command. CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -Default: ``0.1`` +Default: ``0`` Time of sleep in seconds used by ``cleartokens`` management command between batch deletions. +Set this to a non-zero value (e.g. `0.1`) to add a pause between batch sizes to reduce system +load when clearing large batches of expired tokens. + Settings imported from Django project -------------------------- diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 22e067716..3b7dea3f8 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -102,7 +102,7 @@ # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, "CLEAR_EXPIRED_TOKENS_BATCH_SIZE": 10000, - "CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL": 0.1, + "CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL": 0, } # List of settings that cannot be empty From 2a288fddd306ffa54034c52e69ce7cb9dd3c1545 Mon Sep 17 00:00:00 2001 From: Andrea Greco <a.greco@4sigma.it> Date: Fri, 23 Apr 2021 11:13:39 +0200 Subject: [PATCH 433/722] OpenID: Claims: Add claims inside well-known Some client can't use userinfo, and get propelty from claims. Add claims key inside well-known. --- AUTHORS | 1 + docs/oidc.rst | 19 ++++++++++--------- oauth2_provider/oauth2_validators.py | 25 +++++++++++++++++-------- oauth2_provider/views/oidc.py | 8 ++++++++ tests/test_oidc_views.py | 24 ++++++++++++++++++++---- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/AUTHORS b/AUTHORS index a6c6ef1d2..a1591b6da 100644 --- a/AUTHORS +++ b/AUTHORS @@ -66,4 +66,5 @@ pySilver Shaheed Haque Vinay Karanam Eduardo Oliveira +Andrea Greco Dominik George diff --git a/docs/oidc.rst b/docs/oidc.rst index ba69e984f..7eccaad91 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -245,16 +245,17 @@ required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to -our custom validator:: - +our custom validator. +Standard claim ``sub`` is included by default, to remove it override ``get_claim_list``:: class CustomOAuth2Validator(OAuth2Validator): - - def get_additional_claims(self, request): - return { - "sub": request.user.email, - "first_name": request.user.first_name, - "last_name": request.user.last_name, - } + def get_additional_claims(self): + def get_user_email(request): + return request.user.get_full_name() + + # Element name, callback to obtain data + claims_list = [ ("email", get_sub_cod), + ("username", get_user_email) ] + return claims_list .. note:: This ``request`` object is not a ``django.http.Request`` object, but an diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f3a24e258..f6517415c 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -728,15 +728,24 @@ def _save_id_token(self, jti, request, expires, *args, **kwargs): def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_oidc_claims(self, token, token_handler, request): - # Required OIDC claims - claims = { - "sub": str(request.user.id), - } + def get_claim_list(self): + def get_sub_code(request): + return str(request.user.id) + + list = [ ("sub", get_sub_code) ] # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - claims.update(**self.get_additional_claims(request)) + add = self.get_additional_claims() + list.extend(add) + + return list + def get_oidc_claims(self, token, token_handler, request): + data = self.get_claim_list() + claims = {} + + for k, call in data: + claims[k] = call(request) return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -889,5 +898,5 @@ def get_userinfo_claims(self, request): """ return self.get_oidc_claims(None, None, request) - def get_additional_claims(self, request): - return {} + def get_additional_claims(self): + return [] diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index b4bb8869b..0cd24fc85 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -45,6 +45,13 @@ def get(self, request, *args, **kwargs): signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: signing_algorithms = [Application.RS256_ALGORITHM, Application.HS256_ALGORITHM] + + validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS + validator = validator_class() + oidc_claims = [] + for el, _ in validator.get_claim_list(): + oidc_claims.append(el) + data = { "issuer": issuer_url, "authorization_endpoint": authorization_endpoint, @@ -57,6 +64,7 @@ def get(self, request, *args, **kwargs): "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), + "claims_supported": oidc_claims, } response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 46040f86d..719d10e98 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -29,6 +29,7 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -55,6 +56,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) self.assertEqual(response.status_code, 200) @@ -146,11 +148,21 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 +EXAMPLE_EMAIL = "example.email@example.com" + + +def claim_user_email(request): + return EXAMPLE_EMAIL + + @pytest.mark.django_db def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): - def get_additional_claims(self, request): - return {"state": "very nice"} + def get_additional_claims(self): + return [ + ("username", claim_user_email), + ("email", claim_user_email), + ] oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_tokens.access_token @@ -161,5 +173,9 @@ def get_additional_claims(self, request): data = rsp.json() assert "sub" in data assert data["sub"] == str(oidc_tokens.user.pk) - assert "state" in data - assert data["state"] == "very nice" + + assert "username" in data + assert data["username"] == EXAMPLE_EMAIL + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL From 29d61cb19ee351db8095733115c3161c359b32ba Mon Sep 17 00:00:00 2001 From: Dominik George <nik@naturalnet.de> Date: Tue, 4 Jan 2022 23:03:36 +0100 Subject: [PATCH 434/722] OpenID: Fix get_additional_claims API * always propagate request * have get_additional_claims return a dict again * allow get_additional_claims to return plain data instead of callables --- CHANGELOG.md | 1 + docs/oidc.rst | 17 ++++++++----- oauth2_provider/oauth2_validators.py | 24 +++++++++--------- oauth2_provider/views/oidc.py | 4 +-- tests/test_oidc_views.py | 38 +++++++++++++++++++++++----- 5 files changed, 57 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5a70cd5a..f86c13edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #651 Batch expired token deletions in `cleartokens` management command * Added pt-BR translations. * #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html) +* #1069 OIDC: Re-introduce [additional claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) beyond `sub` to the id_token. ### Fixed * #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). diff --git a/docs/oidc.rst b/docs/oidc.rst index 7eccaad91..27daf3b72 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -245,17 +245,22 @@ required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to -our custom validator. +our custom validator. It should return a dictionary mapping a claim name to +either the claim data, or a callable that will be called with the request to +produce the claim data. Standard claim ``sub`` is included by default, to remove it override ``get_claim_list``:: class CustomOAuth2Validator(OAuth2Validator): - def get_additional_claims(self): + def get_additional_claims(self, request): def get_user_email(request): - return request.user.get_full_name() + return request.user.get_user_email() + claims = {} # Element name, callback to obtain data - claims_list = [ ("email", get_sub_cod), - ("username", get_user_email) ] - return claims_list + claims["email"] = get_user_email + # Element name, plain data returned + claims["username"] = request.user.get_full_name() + + return claims .. note:: This ``request`` object is not a ``django.http.Request`` object, but an diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index f6517415c..73f27ec15 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -728,24 +728,24 @@ def _save_id_token(self, jti, request, expires, *args, **kwargs): def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) - def get_claim_list(self): - def get_sub_code(request): - return str(request.user.id) + def get_claim_dict(self, request): + def get_sub_code(inner_request): + return str(inner_request.user.id) - list = [ ("sub", get_sub_code) ] + claims = {"sub": get_sub_code} # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - add = self.get_additional_claims() - list.extend(add) + add = self.get_additional_claims(request) + claims.update(add) - return list + return claims def get_oidc_claims(self, token, token_handler, request): - data = self.get_claim_list() + data = self.get_claim_dict(request) claims = {} - for k, call in data: - claims[k] = call(request) + for k, v in data.items(): + claims[k] = v(request) if callable(v) else v return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -898,5 +898,5 @@ def get_userinfo_claims(self, request): """ return self.get_oidc_claims(None, None, request) - def get_additional_claims(self): - return [] + def get_additional_claims(self, request): + return {} diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 0cd24fc85..5e929bd6e 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -48,9 +48,7 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() - oidc_claims = [] - for el, _ in validator.get_claim_list(): - oidc_claims.append(el) + oidc_claims = list(validator.get_claim_dict(request).keys()) data = { "issuer": issuer_url, diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 719d10e98..63d788eac 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -156,13 +156,39 @@ def claim_user_email(request): @pytest.mark.django_db -def test_userinfo_endpoint_custom_claims(oidc_tokens, client, oauth2_settings): +def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): - def get_additional_claims(self): - return [ - ("username", claim_user_email), - ("email", claim_user_email), - ] + def get_additional_claims(self, request): + return { + "username": claim_user_email, + "email": claim_user_email, + } + + oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_tokens.user.pk) + + assert "username" in data + assert data["username"] == EXAMPLE_EMAIL + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL + + +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self, request): + return { + "username": EXAMPLE_EMAIL, + "email": EXAMPLE_EMAIL, + } oidc_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator auth_header = "Bearer %s" % oidc_tokens.access_token From 1b3fc516767752b8710762e0423a4db452882f59 Mon Sep 17 00:00:00 2001 From: Dominik George <nik@naturalnet.de> Date: Tue, 4 Jan 2022 23:45:25 +0100 Subject: [PATCH 435/722] OpenID: Add get_discovery_claims This splits get_additional_claims into two forms. See documentation change for rationale. --- docs/oidc.rst | 37 ++++++++++++++++++++++------ oauth2_provider/oauth2_validators.py | 24 ++++++++++++++---- oauth2_provider/views/oidc.py | 4 ++- tests/test_oidc_views.py | 2 +- 4 files changed, 53 insertions(+), 14 deletions(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 27daf3b72..143bec5e5 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -245,23 +245,46 @@ required claims, eg ``iss``, ``aud``, ``exp``, ``iat``, ``auth_time`` etc), and the ``sub`` claim will use the primary key of the user as the value. You'll probably want to customize this and add additional claims or change what is sent for the ``sub`` claim. To do so, you will need to add a method to -our custom validator. It should return a dictionary mapping a claim name to -either the claim data, or a callable that will be called with the request to -produce the claim data. -Standard claim ``sub`` is included by default, to remove it override ``get_claim_list``:: +our custom validator. It takes one of two forms: + +The first form gets passed a request object, and should return a dictionary +mapping a claim name to claim data:: class CustomOAuth2Validator(OAuth2Validator): def get_additional_claims(self, request): + claims = {} + claims["email"] = request.user.get_user_email() + claims["username"] = request.user.get_full_name() + + return claims + +The second form gets no request object, and should return a dictionary +mapping a claim name to a callable, accepting a request and producing +the claim data:: + class CustomOAuth2Validator(OAuth2Validator): + def get_additional_claims(self): def get_user_email(request): return request.user.get_user_email() claims = {} - # Element name, callback to obtain data claims["email"] = get_user_email - # Element name, plain data returned - claims["username"] = request.user.get_full_name() + claims["username"] = lambda r: r.user.get_full_name() return claims +Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. + +In some cases, it might be desirable to not list all claims in discovery info. To customize +which claims are advertised, you can override the ``get_discovery_claims`` method to return +a list of claim names to advertise. If your ``get_additional_claims`` uses the first form +and you still want to advertise claims, you can also override ``get_discovery_claims``. + +In order to help lcients discover claims early, they can be advertised in the discovery +info, under the ``claims_supported`` key. In order for the discovery info view to automatically +add all claims your validator returns, you need to use the second form (producing callables), +because the discovery info views are requested with an unauthenticated request, so directly +producing claim data would fail. If you use the first form, producing claim data directly, +your claims will not be added to discovery info. + .. note:: This ``request`` object is not a ``django.http.Request`` object, but an ``oauthlib.common.Request`` object. This has a number of attributes that diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 73f27ec15..4d9480be1 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,6 +1,7 @@ import base64 import binascii import http.client +import inspect import json import logging import uuid @@ -725,21 +726,34 @@ def _save_id_token(self, jti, request, expires, *args, **kwargs): ) return id_token + @classmethod + def _get_additional_claims_is_request_agnostic(cls): + return len(inspect.signature(cls.get_additional_claims).parameters) == 1 + def get_jwt_bearer_token(self, token, token_handler, request): return self.get_id_token(token, token_handler, request) def get_claim_dict(self, request): - def get_sub_code(inner_request): - return str(inner_request.user.id) - - claims = {"sub": get_sub_code} + if self._get_additional_claims_is_request_agnostic(): + claims = {"sub": lambda r: str(r.user.id)} + else: + claims = {"sub": str(request.user.id)} # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims - add = self.get_additional_claims(request) + if self._get_additional_claims_is_request_agnostic(): + add = self.get_additional_claims() + else: + add = self.get_additional_claims(request) claims.update(add) return claims + def get_discovery_claims(self, request): + claims = ["sub"] + if self._get_additional_claims_is_request_agnostic(): + claims += list(self.get_claim_dict(request).keys()) + return claims + def get_oidc_claims(self, token, token_handler, request): data = self.get_claim_dict(request) claims = {} diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 5e929bd6e..a0206a501 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -48,7 +48,9 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() - oidc_claims = list(validator.get_claim_dict(request).keys()) + oidc_claims = validator.get_discovery_claims(request) + if "sub" not in oidc_claims: + oidc_claims.append("sub") data = { "issuer": issuer_url, diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 63d788eac..fa514ac92 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -158,7 +158,7 @@ def claim_user_email(request): @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): - def get_additional_claims(self, request): + def get_additional_claims(self): return { "username": claim_user_email, "email": claim_user_email, From 9fbe840338d756574b3216fc9374030540d81544 Mon Sep 17 00:00:00 2001 From: Dominik George <nik@naturalnet.de> Date: Sun, 23 Jan 2022 00:10:25 +0100 Subject: [PATCH 436/722] OpenID: Ensure claims_supported lists each claim only once --- oauth2_provider/views/oidc.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index a0206a501..e66b30a86 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -48,9 +48,7 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() - oidc_claims = validator.get_discovery_claims(request) - if "sub" not in oidc_claims: - oidc_claims.append("sub") + oidc_claims = list(set(validator.get_discovery_claims(request))) data = { "issuer": issuer_url, From a6bd0d01e8e73f25502d8d5edaf17c708b10165c Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 23 Jan 2022 17:26:09 -0500 Subject: [PATCH 437/722] Release 1.7.0 (#1096) --- CHANGELOG.md | 18 ++++++++++++------ oauth2_provider/__init__.py | 2 +- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f86c13edc..b66e0822d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,16 +14,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [Unreleased] +## [1.7.0] 2022-01-23 ### Added -* #651 Batch expired token deletions in `cleartokens` management command -* Added pt-BR translations. -* #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html) -* #1069 OIDC: Re-introduce [additional claims](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) beyond `sub` to the id_token. +* #969 Add batching of expired token deletions in `cleartokens` management command and `models.clear_expired()` + to improve performance for removal of large numers of expired tokens. Configure with + [`CLEAR_EXPIRED_TOKENS_BATCH_SIZE`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-size) and + [`CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-interval). +* #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html). +* #1062 Add Brazilian Portuguese (pt-BR) translations. +* #1069 OIDC: Add an alternate form of + [get_additional_claims()](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#adding-claims-to-the-id-token) + which makes the list of additional `claims_supported` available at the OIDC auto-discovery endpoint (`.well-known/openid-configuration`). ### Fixed -* #1012 Return status for introspecting a nonexistent token from 401 to the correct value of 200 per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). +* #1012 Return 200 status code with `{"active": false}` when introspecting a nonexistent token + per [RFC 7662](https://datatracker.ietf.org/doc/html/rfc7662#section-2.2). It had been incorrectly returning 401. ## [1.6.3] 2022-01-11 diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 487f0a884..805f886e8 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.6.3" +__version__ = "1.7.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 691870c11dc113038f742665a16fef39eecc8d01 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 25 Jan 2022 10:00:18 -0500 Subject: [PATCH 438/722] Hash application client secrets using Django password hashing (#1093) * Add ClientSecretField field to use Django password hashing algorithms (#1020) Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Co-authored-by: Peter Karman <pkarman@users.noreply.github.com> Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 12 +++++ docs/settings.rst | 20 ++++---- .../0006_alter_application_client_secret.py | 31 ++++++++++++ oauth2_provider/models.py | 23 ++++++++- oauth2_provider/oauth2_validators.py | 5 +- tests/conftest.py | 6 ++- tests/test_authorization_code.py | 48 ++++++++++--------- tests/test_client_credential.py | 38 +++++++-------- tests/test_commands.py | 3 +- tests/test_hybrid.py | 31 ++++++------ tests/test_introspection_view.py | 13 ++--- tests/test_oauth2_validators.py | 24 +++++----- tests/test_password.py | 9 ++-- tests/test_scopes.py | 15 +++--- tests/test_token_revocation.py | 15 +++--- 16 files changed, 185 insertions(+), 109 deletions(-) create mode 100644 oauth2_provider/migrations/0006_alter_application_client_secret.py diff --git a/AUTHORS b/AUTHORS index a1591b6da..c6e66453d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -64,6 +64,7 @@ Jadiel Teófilo pySilver Łukasz Skarżyński Shaheed Haque +Peter Karman Vinay Karanam Eduardo Oliveira Andrea Greco diff --git a/CHANGELOG.md b/CHANGELOG.md index b66e0822d..da07b6cab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [unreleased] + +## [2.0.0] unreleased + +### Changed +* #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) + client_secret values. This is a **breaking change** that will migrate all your existing + cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm + and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the + auto-generated or manually-entered `client_secret` before hitting Save. + + ## [1.7.0] 2022-01-23 ### Added diff --git a/docs/settings.rst b/docs/settings.rst index 01baaaf4b..0ba12df11 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -97,19 +97,19 @@ of those three can be a callable) must be passed here directly and classes must be instantiated (callables should accept request as their only argument). GRANT_MODEL -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~ The import string of the class (model) representing your grants. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.models.Grant``). APPLICATION_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your application admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.ApplicationAdmin``). ACCESS_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your access token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.AccessTokenAdmin``). @@ -121,7 +121,7 @@ Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.GrantAdmin``). REFRESH_TOKEN_ADMIN_CLASS -~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ The import string of the class (model) representing your refresh token admin class. Overwrite this value if you wrote your own implementation (subclass of ``oauth2_provider.admin.RefreshTokenAdmin``). @@ -154,7 +154,7 @@ If you don't change the validator code and don't run cleartokens all refresh tokens will last until revoked or the end of time. You should change this. REFRESH_TOKEN_GRACE_PERIOD_SECONDS -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The number of seconds between when a refresh token is first used when it is expired. The most common case of this for this is native mobile applications that run into issues of network connectivity during the refresh cycle and are @@ -178,7 +178,7 @@ See also: validator's rotate_refresh_token method can be overridden to make this when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR -~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~ See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. Defaults to access token generator if not provided. @@ -265,7 +265,7 @@ Default: ``""`` The RSA private key used to sign OIDC ID tokens. If not set, OIDC is disabled. OIDC_RSA_PRIVATE_KEYS_INACTIVE -~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``[]`` An array of *inactive* RSA private keys. These keys are not used to sign tokens, @@ -276,7 +276,7 @@ This is useful for providing a smooth transition during key rotation. should be retained in this inactive list. OIDC_JWKS_MAX_AGE_SECONDS -~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``3600`` The max-age value for the Cache-Control header on jwks_uri. @@ -354,9 +354,9 @@ load when clearing large batches of expired tokens. Settings imported from Django project --------------------------- +------------------------------------- USE_TZ -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~ Used to determine whether or not to make token expire dates timezone aware. diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py new file mode 100644 index 000000000..88e148274 --- /dev/null +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -0,0 +1,31 @@ +from django.db import migrations +from django.contrib.auth.hashers import identify_hasher, make_password +import logging +import oauth2_provider.generators +import oauth2_provider.models + + +def forwards_func(apps, schema_editor): + """ + Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. + """ + Application = apps.get_model('oauth2_provider', 'application') + applications = Application.objects.all() + for application in applications: + application.save(update_fields=['client_secret']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0005_auto_20211222_2352'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), + ), + migrations.RunPython(forwards_func), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 2c9747ce8..1ded7a4e2 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -6,6 +6,7 @@ from django.apps import apps from django.conf import settings +from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured from django.db import models, transaction from django.urls import reverse @@ -24,6 +25,20 @@ logger = logging.getLogger(__name__) +class ClientSecretField(models.CharField): + def pre_save(self, model_instance, add): + secret = getattr(model_instance, self.attname) + try: + hasher = identify_hasher(secret) + logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") + except ValueError: + logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.") + hashed_secret = make_password(secret) + setattr(model_instance, self.attname, hashed_secret) + return hashed_secret + return super().pre_save(model_instance, add) + + class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -90,8 +105,12 @@ class AbstractApplication(models.Model): ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) - client_secret = models.CharField( - max_length=255, blank=True, default=generate_client_secret, db_index=True + client_secret = ClientSecretField( + max_length=255, + blank=True, + default=generate_client_secret, + db_index=True, + help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4d9480be1..00c5e7de0 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -12,6 +12,7 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model +from django.contrib.auth.hashers import check_password from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q @@ -123,7 +124,7 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False else: @@ -148,7 +149,7 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False - elif request.client.client_secret != client_secret: + elif not check_password(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False else: diff --git a/tests/conftest.py b/tests/conftest.py index a3274aa33..520b6cbac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class OAuthSettingsWrapper: """ @@ -101,12 +103,14 @@ def application(): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) @pytest.fixture def hybrid_application(application): application.authorization_grant_type = application.GRANT_OPENID_HYBRID + application.client_secret = CLEARTEXT_SECRET application.save() return application @@ -141,7 +145,7 @@ def oidc_tokens(oauth2_settings, application, test_user, client): "code": code, "redirect_uri": "http://example.org", "client_id": application.client_id, - "client_secret": application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index c9bef0f5c..91fd06bd1 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -34,6 +34,7 @@ URI_OOB = "urn:ietf:wg:oauth:2.0:oob" URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" # mocking a protected resource view @@ -60,6 +61,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -677,7 +679,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -699,7 +701,7 @@ def test_refresh(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -744,7 +746,7 @@ def test_refresh_with_grace_period(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -795,7 +797,7 @@ def test_refresh_invalidates_old_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -827,7 +829,7 @@ def test_refresh_no_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -855,7 +857,7 @@ def test_refresh_bad_scopes(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -881,7 +883,7 @@ def test_refresh_fail_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -911,7 +913,7 @@ def test_refresh_repeating_requests(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -948,7 +950,7 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -977,7 +979,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -989,7 +991,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1014,7 +1016,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1049,7 +1051,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -1070,7 +1072,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1445,7 +1447,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1480,7 +1482,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1520,7 +1522,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1565,7 +1567,7 @@ def test_oob_as_html(self): "code": authorization_code, "redirect_uri": URI_OOB, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1605,7 +1607,7 @@ def test_oob_as_json(self): "code": authorization_code, "redirect_uri": URI_OOB_AUTO, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1681,7 +1683,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1715,7 +1717,7 @@ def test_id_token(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", } @@ -1761,7 +1763,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1819,7 +1821,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 8159d55db..38265c3d9 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -1,6 +1,5 @@ import json from unittest.mock import patch -from urllib.parse import quote_plus import pytest from django.contrib.auth import get_user_model @@ -24,6 +23,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "abcdefghijklmnopqrstuvwxyz1234567890" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -44,6 +45,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -55,35 +57,28 @@ def tearDown(self): class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): """ - Request an access token using Client Credential Flow + Request an access token using Client Credential Flow with hashed secrets """ + self.assertNotEqual(self.application.client_secret, CLEARTEXT_SECRET) + token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) - content = json.loads(response.content.decode("utf-8")) - access_token = content["access_token"] - - # use token to access the resource - auth_headers = { - "HTTP_AUTHORIZATION": "Bearer " + access_token, - } - request = self.factory.get("/fake-resource", **auth_headers) - request.user = self.test_user - - view = ResourceView.as_view() - response = view(request) - self.assertEqual(response, "This is a protected resource") + # secret mismatch should return a 401 + auth_headers = get_basic_auth_header(self.application.client_id, "not-the-secret") + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 401) def test_client_credential_does_not_issue_refresh_token(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -93,7 +88,7 @@ def test_client_credential_does_not_issue_refresh_token(self): def test_client_credential_user_is_none_on_access_token(self): token_request_data = {"grant_type": "client_credentials"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -122,7 +117,7 @@ def test_extended_request(self): token_request_data = { "grant_type": "client_credentials", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -174,12 +169,11 @@ def test_client_resource_password_based(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) token_request_data = {"grant_type": "password", "username": "test_user", "password": "123456"} - auth_headers = get_basic_auth_header( - quote_plus(self.application.client_id), quote_plus(self.application.client_secret) - ) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) diff --git a/tests/test_commands.py b/tests/test_commands.py index ff5deba4e..13b0eeb3d 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,6 +1,7 @@ from io import StringIO from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError from django.test import TestCase @@ -83,7 +84,7 @@ def test_application_created_with_client_secret(self): ) app = Application.objects.get() - self.assertEqual(app.client_secret, "SECRET") + self.assertTrue(check_password("SECRET", app.client_secret)) def test_application_created_with_client_id(self): call_command( diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 4f9753979..3f4048698 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -30,6 +30,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -62,6 +64,7 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, ) self.application.save() @@ -829,7 +832,7 @@ def test_basic_auth(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -850,7 +853,7 @@ def test_basic_auth_bad_authcode(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -862,7 +865,7 @@ def test_basic_auth_bad_granttype(self): self.client.login(username="hy_test_user", password="123456") token_request_data = {"grant_type": "UNKNOWN", "code": "BLAH", "redirect_uri": "http://example.org"} - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -887,7 +890,7 @@ def test_basic_auth_grant_expired(self): "code": "BLAH", "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -922,7 +925,7 @@ def test_basic_auth_wrong_auth_type(self): "redirect_uri": "http://example.org", } - user_pass = "{0}:{1}".format(self.application.client_id, self.application.client_secret) + user_pass = "{0}:{1}".format(self.application.client_id, CLEARTEXT_SECRET) auth_string = base64.b64encode(user_pass.encode("utf-8")) auth_headers = { "HTTP_AUTHORIZATION": "Wrong " + auth_string.decode("utf-8"), @@ -943,7 +946,7 @@ def test_request_body_params(self): "code": authorization_code, "redirect_uri": "http://example.org", "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, } response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) @@ -1056,7 +1059,7 @@ def test_code_exchange_succeed_when_redirect_uri_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1091,7 +1094,7 @@ def test_code_exchange_fails_when_redirect_uri_does_not_match(self): "code": authorization_code, "redirect_uri": "http://example.org?foo=baraa", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -1126,7 +1129,7 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1163,7 +1166,7 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu "code": authorization_code, "redirect_uri": "http://example.com?bar=baz&foo=bar", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -1200,7 +1203,7 @@ def test_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1239,7 +1242,7 @@ def test_id_token_resource_access_allowed(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -1351,7 +1354,7 @@ def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_app "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) @@ -1422,7 +1425,7 @@ def test_claims_passed_to_code_generation( "code": code, "redirect_uri": "http://example.org", "client_id": hybrid_application.client_id, - "client_secret": hybrid_application.client_secret, + "client_secret": CLEARTEXT_SECRET, "scope": "openid", }, ) diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 95374cda5..b19c521d5 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -17,6 +17,8 @@ AccessToken = get_access_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.INTROSPECTION_SETTINGS) @@ -35,6 +37,7 @@ def setUp(self): user=self.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) self.resource_server_token = AccessToken.objects.create( @@ -281,7 +284,7 @@ def test_view_post_notexisting_token(self): def test_view_post_valid_client_creds_basic_auth(self): """Test HTTP basic auth working""" - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -301,9 +304,7 @@ def test_view_post_valid_client_creds_basic_auth(self): def test_view_post_invalid_client_creds_basic_auth(self): """Must fail for invalid client credentials""" - auth_headers = get_basic_auth_header( - self.application.client_id, self.application.client_secret + "_so_wrong" - ) + auth_headers = get_basic_auth_header(self.application.client_id, f"{CLEARTEXT_SECRET}_so_wrong") response = self.client.post( reverse("oauth2_provider:introspect"), {"token": self.valid_token.token}, **auth_headers ) @@ -316,7 +317,7 @@ def test_view_post_valid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, }, ) self.assertEqual(response.status_code, 200) @@ -340,7 +341,7 @@ def test_view_post_invalid_client_creds_plaintext(self): { "token": self.valid_token.token, "client_id": self.application.client_id, - "client_secret": self.application.client_secret + "_so_wrong", + "client_secret": f"{CLEARTEXT_SECRET}_so_wrong", }, ) self.assertEqual(response.status_code, 403) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7997d3bca..fd06a1eda 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -15,6 +15,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets +from .utils import get_basic_auth_header try: @@ -28,6 +29,8 @@ AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + @contextlib.contextmanager def always_invalid_token(): @@ -51,7 +54,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, @@ -69,7 +72,7 @@ def test_authenticate_request_body(self): self.request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.request)) - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) def test_extract_basic_auth(self): @@ -86,26 +89,22 @@ def test_extract_basic_auth(self): def test_authenticate_basic_auth(self): self.request.encoding = "utf-8" - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_default_encoding(self): self.request.encoding = None - # client_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOmNsaWVudF9zZWNyZXQ=\n"} + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_id(self): self.request.encoding = "utf-8" - # wrong_id:client_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic d3JvbmdfaWQ6Y2xpZW50X3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("wrong_id", CLEARTEXT_SECRET) self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_wrong_client_secret(self): self.request.encoding = "utf-8" - # client_id:wrong_secret - self.request.headers = {"HTTP_AUTHORIZATION": "Basic Y2xpZW50X2lkOndyb25nX3NlY3JldA==\n"} + self.request.headers = get_basic_auth_header("client_id", "wrong_secret") self.assertFalse(self.validator._authenticate_basic_auth(self.request)) def test_authenticate_basic_auth_not_b64_auth_string(self): @@ -116,7 +115,6 @@ def test_authenticate_basic_auth_not_b64_auth_string(self): def test_authenticate_basic_auth_invalid_b64_string(self): self.request.encoding = "utf-8" - # client_id:wrong_secret self.request.headers = {"HTTP_AUTHORIZATION": "Basic ZHVtbXk=:ZHVtbXk=\n"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) @@ -140,7 +138,7 @@ def test_client_authentication_required(self): self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.headers = {} self.request.client_id = "client_id" - self.request.client_secret = "client_secret" + self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator.client_authentication_required(self.request)) self.request.client_secret = "" self.assertFalse(self.validator.client_authentication_required(self.request)) @@ -327,7 +325,7 @@ def setUp(self): self.validator = OAuth2Validator() self.application = Application.objects.create( client_id="client_id", - client_secret="client_secret", + client_secret=CLEARTEXT_SECRET, user=self.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, diff --git a/tests/test_password.py b/tests/test_password.py index 953b076e2..ab0f49228 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -14,6 +14,8 @@ Application = get_application_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ResourceView(ProtectedResourceView): @@ -33,6 +35,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -51,7 +54,7 @@ def test_get_token(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) @@ -70,7 +73,7 @@ def test_bad_credentials(self): "username": "test_user", "password": "NOT_MY_PASS", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) @@ -83,7 +86,7 @@ def test_password_resource_access_allowed(self): "username": "test_user", "password": "123456", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_scopes.py b/tests/test_scopes.py index a310e223a..39601ed3b 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -18,6 +18,8 @@ Grant = get_grant_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + # mocking a protected resource view class ScopeResourceView(ScopedProtectedResourceView): @@ -67,6 +69,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -123,7 +126,7 @@ def test_scopes_save_in_access_token(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -159,7 +162,7 @@ def test_scopes_protection_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -201,7 +204,7 @@ def test_scopes_protection_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -243,7 +246,7 @@ def test_multi_scope_fail(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -285,7 +288,7 @@ def test_multi_scope_valid(self): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) @@ -326,7 +329,7 @@ def get_access_token(self, scopes): "code": authorization_code, "redirect_uri": "http://example.org", } - auth_headers = get_basic_auth_header(self.application.client_id, self.application.client_secret) + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) content = json.loads(response.content.decode("utf-8")) diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 1ed1c9119..b4f5af7dd 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -13,6 +13,8 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + class BaseTest(TestCase): def setUp(self): @@ -26,6 +28,7 @@ def setUp(self): user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, ) def tearDown(self): @@ -46,7 +49,7 @@ def test_revoke_access_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, } url = reverse("oauth2_provider:revoke-token") @@ -93,7 +96,7 @@ def test_revoke_access_token_with_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "access_token", } @@ -115,7 +118,7 @@ def test_revoke_access_token_with_invalid_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "bad_hint", } @@ -139,7 +142,7 @@ def test_revoke_refresh_token(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": rtok.token, } @@ -164,7 +167,7 @@ def test_revoke_refresh_token_with_revoked_access_token(self): for token in (tok.token, rtok.token): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": token, } @@ -194,7 +197,7 @@ def test_revoke_token_with_wrong_hint(self): data = { "client_id": self.application.client_id, - "client_secret": self.application.client_secret, + "client_secret": CLEARTEXT_SECRET, "token": tok.token, "token_type_hint": "refresh_token", } From f46439e69844feb12fc8e5a6dcb99dd569abec3f Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 27 Jan 2022 12:23:16 -0500 Subject: [PATCH 439/722] OIDC: Add "scopes_supported" to openid-configuration. (#1106) * OIDC: Add "scopes_supported" to openid-configuration. --- CHANGELOG.md | 4 ++++ oauth2_provider/views/oidc.py | 7 ++++++- tests/test_oidc_views.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index da07b6cab..6a18b1bd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] unreleased +### Added +* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). + This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). + ### Changed * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index e66b30a86..bb47d4f43 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -18,7 +18,8 @@ class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): """ - View used to show oidc provider configuration information + View used to show oidc provider configuration information per + `OpenID Provider Metadata <https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata>`_ """ def get(self, request, *args, **kwargs): @@ -49,6 +50,9 @@ def get(self, request, *args, **kwargs): validator_class = oauth2_settings.OAUTH2_VALIDATOR_CLASS validator = validator_class() oidc_claims = list(set(validator.get_discovery_claims(request))) + scopes_class = oauth2_settings.SCOPES_BACKEND_CLASS + scopes = scopes_class() + scopes_supported = [scope for scope in scopes.get_available_scopes()] data = { "issuer": issuer_url, @@ -56,6 +60,7 @@ def get(self, request, *args, **kwargs): "token_endpoint": token_endpoint, "userinfo_endpoint": userinfo_endpoint, "jwks_uri": jwks_uri, + "scopes_supported": scopes_supported, "response_types_supported": oauth2_settings.OIDC_RESPONSE_TYPES_SUPPORTED, "subject_types_supported": oauth2_settings.OIDC_SUBJECT_TYPES_SUPPORTED, "id_token_signing_alg_values_supported": signing_algorithms, diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index fa514ac92..5eb9c2d77 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -17,6 +17,7 @@ def test_get_connect_discovery_info(self): "token_endpoint": "http://localhost/o/token/", "userinfo_endpoint": "http://localhost/o/userinfo/", "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", @@ -44,6 +45,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "token_endpoint": "http://testserver/o/token/", "userinfo_endpoint": "http://testserver/o/userinfo/", "jwks_uri": "http://testserver/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], "response_types_supported": [ "code", "token", From 492a867499b50f348c28db4ef3e429e8f46dc412 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 1 Feb 2022 09:17:19 -0500 Subject: [PATCH 440/722] OIDC: Standard scopes to determine which claims are returned (#1108) * Add configurable attribute to restrict returned claims based on scopes. --- CHANGELOG.md | 8 ++- docs/oidc.rst | 94 +++++++++++++++++++--------- oauth2_provider/oauth2_validators.py | 37 ++++++++++- tests/conftest.py | 40 ++++++++++++ tests/presets.py | 2 + tests/test_oidc_views.py | 56 +++++++++++++++++ 6 files changed, 203 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a18b1bd2..23035d0b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.0.0] unreleased ### Added -* #1106 Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). +* #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). ### Changed @@ -28,7 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm and can not be reversed. When adding or modifying an Application in the Admin console, you must copy the auto-generated or manually-entered `client_secret` before hitting Save. +* #1108 OIDC: (**Breaking**) Add default configurable OIDC standard scopes that determine which claims are returned. + If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses) + and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`. +* #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`. +### Fixed +* #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. ## [1.7.0] 2022-01-23 diff --git a/docs/oidc.rst b/docs/oidc.rst index 143bec5e5..8dfea2e16 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -102,7 +102,7 @@ so there is no need to add a setting for the public key. Rotating the RSA private key -~~~~~~~~~~~~~~~~~~~~~~~~ +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Extra keys can be published in the jwks_uri with the ``OIDC_RSA_PRIVATE_KEYS_INACTIVE`` setting. For example::: @@ -143,7 +143,7 @@ scopes in your ``settings.py``:: # ... any other settings you want } -.. info:: +.. note:: If you want to enable ``RS256`` at a later date, you can do so - just add the private key as described above. @@ -250,54 +250,88 @@ our custom validator. It takes one of two forms: The first form gets passed a request object, and should return a dictionary mapping a claim name to claim data:: class CustomOAuth2Validator(OAuth2Validator): + # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, + # otherwise the OIDC standard scopes are used. + def get_additional_claims(self, request): - claims = {} - claims["email"] = request.user.get_user_email() - claims["username"] = request.user.get_full_name() + return { + "given_name": request.user.first_name, + "family_name": request.user.last_name, + "name": ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": request.user.username, + "email": request.user.email, + } - return claims The second form gets no request object, and should return a dictionary mapping a claim name to a callable, accepting a request and producing the claim data:: class CustomOAuth2Validator(OAuth2Validator): - def get_additional_claims(self): - def get_user_email(request): - return request.user.get_user_email() + # Extend the standard scopes to add a new "permissions" scope + # which returns a "permissions" claim: + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"permissions": "permissions"}) + + def get_additional_claims(self): + return { + "given_name": lambda request: request.user.first_name, + "family_name": lambda request: request.user.last_name, + "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": lambda request: request.user.username, + "email": lambda request: request.user.email, + "permissions": lambda request: list(request.user.get_group_permissions()), + } - claims = {} - claims["email"] = get_user_email - claims["username"] = lambda r: r.user.get_full_name() - - return claims Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. -In some cases, it might be desirable to not list all claims in discovery info. To customize -which claims are advertised, you can override the ``get_discovery_claims`` method to return -a list of claim names to advertise. If your ``get_additional_claims`` uses the first form -and you still want to advertise claims, you can also override ``get_discovery_claims``. +Supported claims discovery +-------------------------- -In order to help lcients discover claims early, they can be advertised in the discovery +In order to help clients discover claims early, they can be advertised in the discovery info, under the ``claims_supported`` key. In order for the discovery info view to automatically add all claims your validator returns, you need to use the second form (producing callables), because the discovery info views are requested with an unauthenticated request, so directly producing claim data would fail. If you use the first form, producing claim data directly, your claims will not be added to discovery info. +In some cases, it might be desirable to not list all claims in discovery info. To customize +which claims are advertised, you can override the ``get_discovery_claims`` method to return +a list of claim names to advertise. If your ``get_additional_claims`` uses the first form +and you still want to advertise claims, you can also override ``get_discovery_claims``. + +Using OIDC scopes to determine which claims are returned +-------------------------------------------------------- + +The ``oidc_claim_scope`` OAuth2Validator class attribute implements OIDC's +`5.4 Requesting Claims using Scope Values`_ feature. +For example, a ``given_name`` claim is only returned if the ``profile`` scope was granted. + +To change the list of claims and which scopes result in their being returned, +override ``oidc_claim_scope`` with a dict keyed by claim with a value of scope. +The following example adds instructions to return the ``foo`` claim when the ``bar`` scope is granted:: + class CustomOAuth2Validator(OAuth2Validator): + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"foo": "bar"}) + +Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes. + +You have to make sure you've added addtional claims via ``get_additional_claims`` +and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work. + .. note:: This ``request`` object is not a ``django.http.Request`` object, but an ``oauthlib.common.Request`` object. This has a number of attributes that you can use to decide what claims to put in to the ID token: - * ``request.scopes`` - a list of the scopes requested by the client when - making an authorization request. - * ``request.claims`` - a dictionary of the requested claims, using the - `OIDC claims requesting system`_. These must be requested by the client - when making an authorization request. - * ``request.user`` - the django user object. + * ``request.scopes`` - the list of granted scopes. + * ``request.claims`` - the requested claims per OIDC's `5.5 Requesting Claims using the "claims" Request Parameter`_. + These must be requested by the client when making an authorization request. + * ``request.user`` - the `Django User`_ object. -.. _OIDC claims requesting system: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +.. _5.4 Requesting Claims using Scope Values: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims +.. _5.5 Requesting Claims using the "claims" Request Parameter: https://openid.net/specs/openid-connect-core-1_0.html#ClaimsParameter +.. _Django User: https://docs.djangoproject.com/en/stable/ref/contrib/auth/#user-model What claims you decide to put in to the token is up to you to determine based upon what the scopes and / or claims means to your provider. @@ -307,11 +341,11 @@ Adding information to the ``UserInfo`` service ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The ``UserInfo`` service is supplied as part of the OIDC service, and is used -to retrieve more information about the user than was supplied in the ID token -when the user logged in to the OIDC client application. It is optional to use -the service. The service is accessed by making a request to the +to retrieve information about the user given their Access Token. +It is optional to use the service. The service is accessed by making a request to the ``UserInfo`` endpoint, eg ``/o/userinfo/`` and supplying the access token -retrieved at login as a ``Bearer`` token. +retrieved at login as a ``Bearer`` token or as a form-encoded ``access_token`` body parameter +for a POST request. Again, to modify the content delivered, we need to add a function to our custom validator. The default implementation adds the claims from the ID diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 00c5e7de0..b33c80f39 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -65,6 +65,34 @@ class OAuth2Validator(RequestValidator): + # Return the given claim only if the given scope is present. + # Extended as needed for non-standard OIDC claims/scopes. + # Override by setting to None to ignore scopes. + # see https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims + # For example, for the "nickname" claim, you need the "profile" scope. + oidc_claim_scope = { + "sub": "openid", + "name": "profile", + "family_name": "profile", + "given_name": "profile", + "middle_name": "profile", + "nickname": "profile", + "preferred_username": "profile", + "profile": "profile", + "picture": "profile", + "website": "profile", + "gender": "profile", + "birthdate": "profile", + "zoneinfo": "profile", + "locale": "profile", + "updated_at": "profile", + "email": "email", + "email_verified": "email", + "address": "address", + "phone_number": "phone", + "phone_number_verified": "phone", + } + def _extract_basic_auth(self, request): """ Return authentication string if request contains basic auth credentials, @@ -397,7 +425,7 @@ def validate_bearer_token(self, token, scopes, request): if access_token and access_token.is_valid(scopes): request.client = access_token.application request.user = access_token.user - request.scopes = scopes + request.scopes = list(access_token.scopes) # this is needed by django rest framework request.access_token = access_token @@ -759,8 +787,11 @@ def get_oidc_claims(self, token, token_handler, request): data = self.get_claim_dict(request) claims = {} + # TODO if request.claims then return only the claims requested, but limited by granted scopes. + for k, v in data.items(): - claims[k] = v(request) if callable(v) else v + if not self.oidc_claim_scope or self.oidc_claim_scope.get(k) in request.scopes: + claims[k] = v(request) if callable(v) else v return claims def get_id_token_dictionary(self, token, token_handler, request): @@ -911,7 +942,7 @@ def get_userinfo_claims(self, request): current user's claims. """ - return self.get_oidc_claims(None, None, request) + return self.get_oidc_claims(request.access_token, None, request) def get_additional_claims(self, request): return {} diff --git a/tests/conftest.py b/tests/conftest.py index 520b6cbac..14db54aa5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -158,3 +158,43 @@ def oidc_tokens(oauth2_settings, application, test_user, client): id_token=token_data["id_token"], oauth2_settings=oauth2_settings, ) + + +@pytest.fixture +def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): + oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE) + client.force_login(test_user) + auth_rsp = client.post( + reverse("oauth2_provider:authorize"), + data={ + "client_id": application.client_id, + "state": "random_state_string", + "scope": "openid email", + "redirect_uri": "http://example.org", + "response_type": "code", + "allow": True, + }, + ) + assert auth_rsp.status_code == 302 + code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] + client.logout() + token_rsp = client.post( + reverse("oauth2_provider:token"), + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": "http://example.org", + "client_id": application.client_id, + "client_secret": CLEARTEXT_SECRET, + "scope": "openid email", + }, + ) + assert token_rsp.status_code == 200 + token_data = token_rsp.json() + return SimpleNamespace( + user=test_user, + application=application, + access_token=token_data["access_token"], + id_token=token_data["id_token"], + oauth2_settings=oauth2_settings, + ) diff --git a/tests/presets.py b/tests/presets.py index 438da1e03..fa2d7a34c 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -22,6 +22,8 @@ } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] +OIDC_SETTINGS_EMAIL_SCOPE = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"}) OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] REST_FRAMEWORK_SCOPES = { diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 5eb9c2d77..7b379d1b3 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -160,6 +160,8 @@ def claim_user_email(request): @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): + oidc_claim_scope = None + def get_additional_claims(self): return { "username": claim_user_email, @@ -183,9 +185,38 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims_email_scope_callable( + oidc_email_scope_tokens, client, oauth2_settings +): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self): + return { + "username": claim_user_email, + "email": claim_user_email, + } + + oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_email_scope_tokens.user.pk) + + assert "username" not in data + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL + + @pytest.mark.django_db def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): + oidc_claim_scope = None + def get_additional_claims(self, request): return { "username": EXAMPLE_EMAIL, @@ -207,3 +238,28 @@ def get_additional_claims(self, request): assert "email" in data assert data["email"] == EXAMPLE_EMAIL + + +@pytest.mark.django_db +def test_userinfo_endpoint_custom_claims_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings): + class CustomValidator(OAuth2Validator): + def get_additional_claims(self, request): + return { + "username": EXAMPLE_EMAIL, + "email": EXAMPLE_EMAIL, + } + + oidc_email_scope_tokens.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CustomValidator + auth_header = "Bearer %s" % oidc_email_scope_tokens.access_token + rsp = client.get( + reverse("oauth2_provider:user-info"), + HTTP_AUTHORIZATION=auth_header, + ) + data = rsp.json() + assert "sub" in data + assert data["sub"] == str(oidc_email_scope_tokens.user.pk) + + assert "username" not in data + + assert "email" in data + assert data["email"] == EXAMPLE_EMAIL From 769c8e7ba6ef33658d2b4f32065352af6e468ecf Mon Sep 17 00:00:00 2001 From: Brian Helba <brian.helba@kitware.com> Date: Wed, 16 Feb 2022 10:36:02 -0500 Subject: [PATCH 441/722] Prevent the tests/migrations directory from getting packaged (#1118) Simply excluding "tests" is not enough to prevent subpackages from also being excluded. If a wildcard is provided to "find_packages(exclude=", it will only match a single hierarchy level. Without this, a directory "site-packages/tests/migrations" gets created upon package install. --- AUTHORS | 1 + setup.cfg | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index c6e66453d..69905c31f 100644 --- a/AUTHORS +++ b/AUTHORS @@ -21,6 +21,7 @@ Ash Christopher Asif Saif Uddin Bart Merenda Bas van Oostveen +Brian Helba Dave Burkholder David Fischer David Smith diff --git a/setup.cfg b/setup.cfg index 4a8de4569..7fc5a9243 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,4 +38,6 @@ install_requires = jwcrypto >= 0.8.0 [options.packages.find] -exclude = tests +exclude = + tests + tests.* From c48b7516fe6aebf89c5964e4855ecf129274c3c3 Mon Sep 17 00:00:00 2001 From: David Hill <dave@muscovy.net> Date: Wed, 23 Feb 2022 15:52:28 -0500 Subject: [PATCH 442/722] Topic/1112 (#1113) --- AUTHORS | 1 + docs/tutorial/tutorial_03.rst | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/AUTHORS b/AUTHORS index 69905c31f..2ba7f3325 100644 --- a/AUTHORS +++ b/AUTHORS @@ -70,3 +70,4 @@ Vinay Karanam Eduardo Oliveira Andrea Greco Dominik George +David Hill diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 52868c01f..30c8317e6 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -78,3 +78,35 @@ Now supposing your access token value is `123456` you can try to access your aut :: curl -H "Authorization: Bearer 123456" -X GET http://localhost:8000/secret + +Working with Rest_framework generic class based views +----------------------------------------------------- + +If you have completed the `Django REST framework tutorial +<https://www.django-rest-framework.org/tutorial/3-class-based-views/#using-generic-class-based-views>`_, +you will be familiar with the 'Snippet' example, in particular the SnippetList and SnippetDetail classes. + +It would be nice to reuse those views **and** support token handling. Instead of reworking +those classes to be ProtectedResourceView based, the solution is much simpler than that. + +Assume you have already modified the settings as was already shown. +The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly. + +.. code-block:: python + + from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope + + class SnippetList(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + + class SnippetDetail(generics.ListCreateAPIView): + ... + permission_classes = [TokenHasReadWriteScope] + +Note that this example overrides the Django default permission class setting. There are several other +ways this can be solved. Overriding the class function *get_permission_classes* is another way +to solve the problem. + +A detailed dive into the `Dango REST framework permissions is here. <https://www.django-rest-framework.org/api-guide/permissions/>`_ + From 5531cb92b43126565216610e320881f34639a9a1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 18 Mar 2022 14:45:07 -0400 Subject: [PATCH 443/722] [pre-commit.ci] pre-commit autoupdate (#1109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 21.12b0 → 22.1.0](https://github.com/psf/black/compare/21.12b0...22.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5c78568ea..3b883f488 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 21.12b0 + rev: 22.1.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From e761ebc22fbac1aa1837c3f851fe3bac38421331 Mon Sep 17 00:00:00 2001 From: Carl Schwan <carl@carlschwan.eu> Date: Fri, 18 Mar 2022 20:35:26 +0100 Subject: [PATCH 444/722] Fix broken import in doc (#1121) * Fix broken import in doc * Add Carl Schwan to AUTHORS Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + docs/oidc.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 2ba7f3325..7f3f21276 100644 --- a/AUTHORS +++ b/AUTHORS @@ -22,6 +22,7 @@ Asif Saif Uddin Bart Merenda Bas van Oostveen Brian Helba +Carl Schwan Dave Burkholder David Fischer David Smith diff --git a/docs/oidc.rst b/docs/oidc.rst index 8dfea2e16..f4fdfd09c 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -80,7 +80,7 @@ Now we need to add this key to our settings and allow the ``openid`` scope to be used. Assuming we have set an environment variable called ``OIDC_RSA_PRIVATE_KEY``, we can make changes to our ``settings.py``:: - import os.environ + import os OAUTH2_PROVIDER = { "OIDC_ENABLED": True, From fc291ce040f2eec6722cca9882eac88ec49b2250 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 19 Mar 2022 14:30:02 -0400 Subject: [PATCH 445/722] Security BCP: Remove OOB (#1124) * Remove OOB * Indicate that this is a security fix. --- CHANGELOG.md | 6 ++ .../oauth2_provider/authorized-oob.html | 23 ----- oauth2_provider/views/base.py | 36 +------- tests/test_authorization_code.py | 90 ------------------- 4 files changed, 8 insertions(+), 147 deletions(-) delete mode 100644 oauth2_provider/templates/oauth2_provider/authorized-oob.html diff --git a/CHANGELOG.md b/CHANGELOG.md index 23035d0b2..c3b10068b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. +### Removed +* #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced + by [RFC 8252](https://datatracker.ietf.org/doc/html/rfc8252) "OAuth 2.0 for Native Apps" BCP. Google has + [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with + a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. + ## [1.7.0] 2022-01-23 ### Added diff --git a/oauth2_provider/templates/oauth2_provider/authorized-oob.html b/oauth2_provider/templates/oauth2_provider/authorized-oob.html deleted file mode 100644 index 78399da7c..000000000 --- a/oauth2_provider/templates/oauth2_provider/authorized-oob.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "oauth2_provider/base.html" %} - -{% load i18n %} - -{% block title %} -Success code={{code}} -{% endblock %} - -{% block content %} - <div class="block-center"> - {% if not error %} - <h2>{% trans "Success" %}</h2> - - <p>{% trans "Please return to your application and enter this code:" %}</p> - - <p><code>{{ code }}</code></p> - - {% else %} - <h2>Error: {{ error.error }}</h2> - <p>{{ error.description }}</p> - {% endif %} - </div> -{% endblock %} diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index e46a49d10..211da45ed 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,11 +1,8 @@ import json import logging -import urllib.parse from django.contrib.auth.mixins import LoginRequiredMixin -from django.http import HttpResponse, JsonResponse -from django.shortcuts import render -from django.urls import reverse +from django.http import HttpResponse from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -207,42 +204,13 @@ def get(self, request, *args, **kwargs): credentials=credentials, allow=True, ) - return self.redirect(uri, application, token) + return self.redirect(uri, application) except OAuthToolkitError as error: return self.error_response(error, application) return self.render_to_response(self.get_context_data(**kwargs)) - def redirect(self, redirect_to, application, token=None): - - if not redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob"): - return super().redirect(redirect_to, application) - - parsed_redirect = urllib.parse.urlparse(redirect_to) - code = urllib.parse.parse_qs(parsed_redirect.query)["code"][0] - - if redirect_to.startswith("urn:ietf:wg:oauth:2.0:oob:auto"): - - response = { - "access_token": code, - "token_uri": redirect_to, - "client_id": application.client_id, - "client_secret": application.client_secret, - "revoke_uri": reverse("oauth2_provider:revoke-token"), - } - - return JsonResponse(response) - - else: - return render( - request=self.request, - template_name="oauth2_provider/authorized-oob.html", - context={ - "code": code, - }, - ) - @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 91fd06bd1..25447b9dd 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -2,7 +2,6 @@ import datetime import hashlib import json -import re from urllib.parse import parse_qs, urlparse import pytest @@ -32,8 +31,6 @@ RefreshToken = get_refresh_token_model() UserModel = get_user_model() -URI_OOB = "urn:ietf:wg:oauth:2.0:oob" -URI_OOB_AUTO = "urn:ietf:wg:oauth:2.0:oob:auto" CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" @@ -56,7 +53,6 @@ def setUp(self): name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" - " " + URI_OOB + " " + URI_OOB_AUTO ), user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, @@ -1532,92 +1528,6 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - def test_oob_as_html(self): - """ - Test out-of-band authentication. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], r"^text/html") - - content = response.content.decode("utf-8") - - # "A lot of applications, for legacy reasons, use this and regex - # to extract the token, risking summoning zalgo in the process." - # -- https://github.com/jazzband/django-oauth-toolkit/issues/235 - - matches = re.search(r".*<code>([^<>]*)</code>", content) - self.assertIsNotNone(matches, msg="OOB response contains code inside <code> tag") - self.assertEqual(len(matches.groups()), 1, msg="OOB response contains multiple <code> tags") - authorization_code = matches.groups()[0] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB, - "client_id": self.application.client_id, - "client_secret": CLEARTEXT_SECRET, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - - def test_oob_as_json(self): - """ - Test out-of-band authentication, with a JSON response. - """ - self.client.login(username="test_user", password="123456") - - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": URI_OOB_AUTO, - "response_type": "code", - "allow": True, - } - - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - self.assertEqual(response.status_code, 200) - self.assertRegex(response["Content-Type"], "^application/json") - - parsed_response = json.loads(response.content.decode("utf-8")) - - self.assertIn("access_token", parsed_response) - authorization_code = parsed_response["access_token"] - - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": URI_OOB_AUTO, - "client_id": self.application.client_id, - "client_secret": CLEARTEXT_SECRET, - } - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) - self.assertEqual(response.status_code, 200) - - content = json.loads(response.content.decode("utf-8")) - self.assertEqual(content["token_type"], "Bearer") - self.assertEqual(content["scope"], "read write") - self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) - @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): From 2212144242f650207f71525eb088413e81587208 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 19 Mar 2022 18:25:34 -0400 Subject: [PATCH 446/722] Revert 1070 (Celery tasks.py) (#1126) * Revert #1070: tasks.py raises an import exception with Celery and conflicts with Huey. --- CHANGELOG.md | 7 +++++++ docs/management_commands.rst | 5 ----- oauth2_provider/tasks.py | 8 -------- 3 files changed, 7 insertions(+), 13 deletions(-) delete mode 100644 oauth2_provider/tasks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c3b10068b..e6b089f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [deprecated use of oob](https://developers.googleblog.com/2022/02/making-oauth-flows-safer.html?m=1#disallowed-oob) with a final end date of 2022-10-03. If you still rely on oob support in django-oauth-toolkit, do not upgrade to this release. +## [1.7.1] 2022-03-19 + +### Removed +* #1126 Reverts #1070 which incorrectly added Celery auto-discovery tasks.py (as described in #1123) and because it conflicts + with Huey's auto-discovery which also uses tasks.py as described in #1114. If you are using Celery or Huey, you'll need + to separately implement these tasks. + ## [1.7.0] 2022-01-23 ### Added diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 727ff9e98..147a0bbe4 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -21,8 +21,3 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. - -The ``cleartokens`` action can also be scheduled as a `Celery periodic task`_ -by using the ``clear_tokens`` task (automatically registered when using Celery). - -.. _Celery periodic task: https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html diff --git a/oauth2_provider/tasks.py b/oauth2_provider/tasks.py deleted file mode 100644 index d86c33720..000000000 --- a/oauth2_provider/tasks.py +++ /dev/null @@ -1,8 +0,0 @@ -from celery import shared_task - - -@shared_task -def clear_tokens(): - from ...models import clear_expired # noqa - - clear_expired() From 56c8c66b11d01991897858082e1b9ff38b260ea3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 27 Mar 2022 08:42:05 -0400 Subject: [PATCH 447/722] Pin Jinja2 version per https://github.com/sphinx-doc/sphinx/issues/10291 (#1134) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 03241132f..7232ecef7 100644 --- a/tox.ini +++ b/tox.ini @@ -64,6 +64,7 @@ commands = docs: make html livedocs: make livehtml deps = + Jinja2<3.1 sphinx<3 oauthlib>=3.1.0 m2r>=0.2.1 From a62195b20edf03c755a476416b2e79d65899505b Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 27 Mar 2022 09:01:52 -0400 Subject: [PATCH 448/722] Missed updating master branch version to 1.7.1 (#1133) --- oauth2_provider/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 805f886e8..9024b6f63 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.7.0" +__version__ = "1.7.1" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From c8eee2cea6c937951dd23d9dee2f2dbfaecabd01 Mon Sep 17 00:00:00 2001 From: Daniel <vector.kerr@gmail.com> Date: Tue, 29 Mar 2022 00:35:48 +1100 Subject: [PATCH 449/722] Update `createapplication` command (#1132) * Add --algorithm argument and fix --skip-authorization help text for createapplication command * Add unit test for update to createapplication command * Add to AUTHORS * Update changelog for createapplication command changes * Add documentation for 'createapplication' command to 'management_commands.rst' Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + CHANGELOG.md | 2 + docs/management_commands.rst | 37 +++++++++++++++++++ .../management/commands/createapplication.py | 7 +++- tests/test_commands.py | 17 +++++++++ 5 files changed, 63 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 7f3f21276..962cc7d00 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Bart Merenda Bas van Oostveen Brian Helba Carl Schwan +Daniel 'Vector' Kerr Dave Burkholder David Fischer David Smith diff --git a/CHANGELOG.md b/CHANGELOG.md index e6b089f5f..da0fede00 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,9 +32,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 If you've [customized OIDC responses](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#customizing-the-oidc-responses) and want to retain the pre-2.x behavior, set `oidc_claim_scope = None` in your subclass of `OAuth2Validator`. * #1108 OIDC: Make the `access_token` available to `get_oidc_claims` when called from `get_userinfo_claims`. +* #1132: Added `--algorithm` argument to `createapplication` management command ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. +* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command ### Removed * #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 147a0bbe4..956ce9ef9 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -4,6 +4,8 @@ Management commands Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means (eg: cron) .. _cleartokens: +.. _createapplication: + cleartokens ~~~~~~~~~~~ @@ -21,3 +23,38 @@ To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRE Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. + + + +createapplication +~~~~~~~~~~~~~~~~~ + +The ``createapplication`` management command provides a shortcut to create a new application in a programmatic way. + +.. code-block:: sh + + usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] + [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] + [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + [--skip-checks] + client_type authorization_grant_type + + Shortcut to create a new application in a programmatic way + + positional arguments: + client_type The client type, can be confidential or public + authorization_grant_type + The type of authorization grant to be used + + optional arguments: + -h, --help show this help message and exit + --client-id CLIENT_ID + The ID of the new application + --user USER The user the application belongs to + --redirect-uris REDIRECT_URIS + The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + --client-secret CLIENT_SECRET + The secret for this application + --name NAME The name this application + --skip-authorization The ID of the new application + ... diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 92c4ae46b..f8575a8b0 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -49,7 +49,12 @@ def add_arguments(self, parser): parser.add_argument( "--skip-authorization", action="store_true", - help="The ID of the new application", + help="If set, completely bypass the authorization form, even on the first use of the application", + ) + parser.add_argument( + "--algorithm", + type=str, + help="The OIDC token signing algorithm for this application (e.g., 'RS256' or 'HS256')", ) def handle(self, *args, **options): diff --git a/tests/test_commands.py b/tests/test_commands.py index 13b0eeb3d..f9a9f5ade 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,5 +1,6 @@ from io import StringIO +import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import check_password from django.core.management import call_command @@ -8,6 +9,8 @@ from oauth2_provider.models import get_application_model +from . import presets + Application = get_application_model() @@ -112,6 +115,20 @@ def test_application_created_with_user(self): self.assertEqual(app.user, user) + @pytest.mark.usefixtures("oauth2_settings") + @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) + def test_application_created_with_algorithm(self): + call_command( + "createapplication", + "confidential", + "authorization-code", + "--redirect-uris=http://example.com http://example2.com", + "--algorithm=RS256", + ) + app = Application.objects.get() + + self.assertEqual(app.algorithm, "RS256") + def test_validation_failed_message(self): output = StringIO() call_command( From e8ffc9ca31975d1e281d199609356057a612f109 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 28 Mar 2022 13:06:16 -0400 Subject: [PATCH 450/722] Add tutorial for Celery task setup. (#1128) * Add tutorial for Celery task setup. --- CHANGELOG.md | 2 + docs/management_commands.rst | 3 +- docs/tutorial/admin+celery.png | Bin 0 -> 67088 bytes docs/tutorial/celery+add.png | Bin 0 -> 72877 bytes docs/tutorial/tutorial.rst | 2 + docs/tutorial/tutorial_05.rst | 169 +++++++++++++++++++++++++++++++++ 6 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 docs/tutorial/admin+celery.png create mode 100644 docs/tutorial/celery+add.png create mode 100644 docs/tutorial/tutorial_05.rst diff --git a/CHANGELOG.md b/CHANGELOG.md index da0fede00..148d5e50e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). This completes the view to provide all the REQUIRED and RECOMMENDED [OpenID Provider Metadata](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata). +* #1128 Documentation: [Tutorial](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_05.html) + on using Celery to automate clearing expired tokens. ### Changed * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 956ce9ef9..3029f1345 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -1,7 +1,8 @@ Management commands =================== -Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means (eg: cron) +Django OAuth Toolkit exposes some useful management commands that can be run via shell or by other means such as cron +or :doc:`Celery <tutorial/tutorial_05>`. .. _cleartokens: .. _createapplication: diff --git a/docs/tutorial/admin+celery.png b/docs/tutorial/admin+celery.png new file mode 100644 index 0000000000000000000000000000000000000000..b9e25ea1977357a1900d9250bccf8c6ba583d00c GIT binary patch literal 67088 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 Mq42y+?A5#f1tHCoj{pDw literal 0 HcmV?d00001 diff --git a/docs/tutorial/celery+add.png b/docs/tutorial/celery+add.png new file mode 100644 index 0000000000000000000000000000000000000000..eca8e02e2bda4995606deaf3516b681c6d963164 GIT binary patch literal 72877 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 !eIkZK-tXG*aWzt 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 literal 0 HcmV?d00001 diff --git a/docs/tutorial/tutorial.rst b/docs/tutorial/tutorial.rst index 0de799a6e..5a0662507 100644 --- a/docs/tutorial/tutorial.rst +++ b/docs/tutorial/tutorial.rst @@ -8,3 +8,5 @@ Tutorials tutorial_02 tutorial_03 tutorial_04 + tutorial_05 + diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst new file mode 100644 index 000000000..784d489a8 --- /dev/null +++ b/docs/tutorial/tutorial_05.rst @@ -0,0 +1,169 @@ +Part 5 - Using Celery to Automate Maintenance Chores +==================================================== + +Scenario +-------- +In :doc:`Part 1 <tutorial_01>` you created your own :term:`Authorization Server` and it's running along just fine. +However, the database is getting cluttered with expired tokens. You can periodically run +the :doc:`cleartokens management command <../management_commands>`, but why not automate this with +`Celery <https://docs.celeryq.dev/>`_? + +Set up RabbitMQ +--------------- +Celery components communicate via a message queue. We'll use `RabbitMQ <https://www.rabbitmq.com/>`_. + +Install RabbitMQ on MacOS +~~~~~~~~~~~~~~~~~~~~~~~~~~ +If you are using MacOS it's likely you are already using `Homebrew <https://brew.sh/>`_. If not, now's +the time to install this fantastic package manager. + +:: + + brew install rabbitmq + brew service start rabbitmq + +Install RabbitMQ with Docker +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This will start up a docker image that just works: +:: + + docker run -it --rm --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3.9-management + + + +Install RabbitMQ on Windows +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +See the `RabbitMQ Installing on Windows <https://www.rabbitmq.com/install-windows.html>`_ instructions. + + +Add Celery +---------- +Make sure you virtualenv is active and install `celery` and +`django-celery-beat <https://django-celery-beat.readthedocs.io/>`_. + +:: + + pip install celery django-celery-beat + +Update your list of installed apps to include both your :term:`Authorization Server` app -- we'll call it ``tutorial``, +and ``django_celery_beat`` which extends your Django project to store your periodic task schedule +in the database and adds a Django Admin interface for configuring them. + +.. code-block:: python + + INSTALLED_APPS = { + # ... + "tutorial", + "django_celery_beat", + } + + +Now add a new file to your app to add Celery: ``tutorial/celery.py``: + +.. code-block:: python + + import os + + from celery import Celery + + # Set the default Django settings module for the 'celery' program. + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings') + app = Celery('tutorial', broker="pyamqp://guest@localhost//") + app.config_from_object('django.conf:settings', namespace='CELERY') + + # Load task modules from all registered Django apps. + app.autodiscover_tasks() + +This will autodiscover any ``tasks.py`` files in the list of installed apps. +We'll add ours now in ``tutorial/tasks.py``: + +.. code-block:: python + + from celery import shared_task + + @shared_task + def clear_tokens(): + from oauth2_provider.models import clear_expired + + clear_expired() + +Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: + +.. code-block:: python + + from .celery import app as celery_app + + __all__ = ('celery_app',) + + +Run Celery Beat and the Worker +------------------------------ + +RabbitMQ should already be running; it's the "glue" between Beat and the Worker. + +It's best to run each of these in its own terminal window so you can see the log messages. + +Start Celery Beat +~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial beat -l INFO --scheduler django_celery_beat.schedulers:DatabaseScheduler + +Start Celery Worker +~~~~~~~~~~~~~~~~~~~ + +:: + + celery -A tutorial worker -l INFO + +Configure the ``clear_tokens`` task +----------------------------------- + +Go into `Django Admin <http://127.0.0.1:8000/admin/>`_ and you'll see a new section for periodic tasks: + +.. image:: admin+celery.png + :width: 500 + :alt: Django Admin interface screenshot + +Now let's define a fairly short (10 second) interval. Go to: http://127.0.0.1:8000/admin/django_celery_beat/intervalschedule/ +and select Add Interval, set number of intervals to 10 and interval period to seconds and Save. + +Then go to http://127.0.0.1:8000/admin/django_celery_beat/periodictask/ to add a new periodic task by +selecting `Add Periodic Task <http://127.0.0.1:8000/admin/django_celery_beat/periodictask/add/>`_ and +select ``tutorial.tasks.clear_tokens``, choose the ``every 10 seconds`` interval schedule, and "Save." + +.. image:: celery+add.png + :width: 500 + :alt: Django Admin interface screenshot + + +Now your Celery Beat and Celery Workers should start running the task every 10 seconds. + +The Beat console will look like this: + +:: + + [2022-03-19 22:06:35,605: INFO/MainProcess] Scheduler: Sending due task clear stale tokens (tutorial.tasks.clear_tokens) + +And the Workers console like this: + +:: + + [2022-03-19 22:06:35,614: INFO/MainProcess] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] received + [2022-03-19 22:06:35,616: INFO/ForkPoolWorker-8] refresh_expire_at is None. No refresh tokens deleted. + [2022-03-19 22:06:35,629: INFO/ForkPoolWorker-8] 0 Expired access tokens deleted + [2022-03-19 22:06:35,631: INFO/ForkPoolWorker-8] 0 Expired grant tokens deleted + [2022-03-19 22:06:35,632: INFO/ForkPoolWorker-8] Task tutorial.tasks.clear_tokens[5ec25fb8-5ce3-4d15-b9ad-750b80fc07e0] succeeded in 0.016124433999999965s: None + + +References +---------- + +The preceding is based on these references: + +https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html + +https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers + +https://django-celery-beat.readthedocs.io/en/latest/index.html From c79eae268949ad2a418315a62324033103d0776d Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Tue, 29 Mar 2022 12:54:57 -0400 Subject: [PATCH 451/722] chore: .gitignore local development files (#1137) * chore: .gitignore local development files - venv, my company's development team keeps venvs in project folders. - coverage.xml is generated by tox * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 3643335d4..4d15af97f 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ _build # Sqlite database files *.sqlite + +/venv/ +/coverage.xml From e647d51bee0685d21ab692b205dc0fa65b4c4d8a Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Tue, 29 Mar 2022 13:12:33 -0400 Subject: [PATCH 452/722] feat: Update PKCE_REQUIRED to true by default (#1129) * feat: default PKCE_REQUIRED to True BREAKING CHANGE: set to False to maintain legacy behavior Co-authored-by: Alan Crosswell <alan@columbia.edu> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 5 +++++ docs/settings.rst | 16 ++++++++++++++-- oauth2_provider/settings.py | 2 +- tests/presets.py | 1 + tests/settings.py | 1 + tests/test_authorization_code.py | 17 +++++------------ tests/test_hybrid.py | 2 +- tests/test_scopes.py | 7 +++++++ tests/test_settings.py | 5 +++++ 10 files changed, 41 insertions(+), 16 deletions(-) diff --git a/AUTHORS b/AUTHORS index 962cc7d00..8ec6667e2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -73,3 +73,4 @@ Eduardo Oliveira Andrea Greco Dominik George David Hill +Darrel O'Pry diff --git a/CHANGELOG.md b/CHANGELOG.md index 148d5e50e..baae70de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on using Celery to automate clearing expired tokens. ### Changed +* #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without + PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) + in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). + If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False ` in your settings.py + * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm diff --git a/docs/settings.rst b/docs/settings.rst index 0ba12df11..2ac31ccda 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -253,9 +253,21 @@ will be used. PKCE_REQUIRED ~~~~~~~~~~~~~ -Default: ``False`` +Default: ``True`` + +Can be either a bool or a callable that takes a client id and returns a bool. + +Whether or not `Proof Key for Code Exchange <https://oauth.net/2/pkce/>`_ is required. + +According to `OAuth 2.0 Security Best Current Practice <https://oauth.net/2/oauth-best-practice/>`_ related to the +`Authorization Code Grant <https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1.>`_ + +- Public clients MUST use PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ +- For confidential clients, the use of PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ is RECOMMENDED. + + + -Whether or not PKCE is required. Can be either a bool or a callable that takes a client id and returns a bool. OIDC_RSA_PRIVATE_KEY diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 3b7dea3f8..00a4e631c 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -97,7 +97,7 @@ "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, # Whether or not PKCE is required - "PKCE_REQUIRED": False, + "PKCE_REQUIRED": True, # Whether to re-create OAuthlibCore on every request. # Should only be required in testing. "ALWAYS_RELOAD_OAUTHLIB_CORE": False, diff --git a/tests/presets.py b/tests/presets.py index fa2d7a34c..6411687a4 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -19,6 +19,7 @@ "openid": "OpenID connect", }, "DEFAULT_SCOPES": ["read", "write"], + "PKCE_REQUIRED": False, } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] diff --git a/tests/settings.py b/tests/settings.py index d2fbe6a56..27dcfe9a3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -159,3 +159,4 @@ CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 1 CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0 +PKCE_REQUIRED = False diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 25447b9dd..8bface719 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -48,6 +48,7 @@ def setUp(self): self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.PKCE_REQUIRED = False self.application = Application.objects.create( name="Test Application", @@ -73,6 +74,7 @@ class TestRegressionIssue315(BaseTest): """ def test_request_is_not_overwritten(self): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") response = self.client.get( reverse("oauth2_provider:authorize"), @@ -94,6 +96,7 @@ def test_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") self.application.skip_authorization = True self.application.save() @@ -132,6 +135,7 @@ def test_pre_auth_valid_client(self): """ Test response for a valid client_id with response_type: code """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") query_data = { @@ -644,7 +648,6 @@ def get_pkce_auth(self, code_challenge, code_challenge_method): """ Helper method to retrieve a valid authorization code using pkce """ - self.oauth2_settings.PKCE_REQUIRED = True authcode_data = { "client_id": self.application.client_id, "state": "random_state_string", @@ -1115,7 +1118,6 @@ def test_public_pkce_S256_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1143,7 +1145,6 @@ def test_public_pkce_plain_authorize_get(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1171,7 +1172,6 @@ def test_public_pkce_S256(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1200,7 +1200,6 @@ def test_public_pkce_plain(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1228,7 +1227,6 @@ def test_public_pkce_invalid_algorithm(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("invalid") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1250,13 +1248,13 @@ def test_public_pkce_missing_code_challenge(self): Request an access token using client_type: public and PKCE enabled but with the code_challenge missing """ + self.oauth2_settings.PKCE_REQUIRED = True self.client.login(username="test_user", password="123456") self.application.client_type = Application.CLIENT_PUBLIC self.application.skip_authorization = True self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1282,7 +1280,6 @@ def test_public_pkce_missing_code_challenge_method(self): self.application.client_type = Application.CLIENT_PUBLIC self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") - self.oauth2_settings.PKCE_REQUIRED = True query_data = { "client_id": self.application.client_id, @@ -1308,7 +1305,6 @@ def test_public_pkce_S256_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1332,7 +1328,6 @@ def test_public_pkce_plain_invalid_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1356,7 +1351,6 @@ def test_public_pkce_S256_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("S256") authorization_code = self.get_pkce_auth(code_challenge, "S256") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", @@ -1379,7 +1373,6 @@ def test_public_pkce_plain_missing_code_verifier(self): self.application.save() code_verifier, code_challenge = self.generate_pkce_codes("plain") authorization_code = self.get_pkce_auth(code_challenge, "plain") - self.oauth2_settings.PKCE_REQUIRED = True token_request_data = { "grant_type": "authorization_code", diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 3f4048698..2e85b05b1 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -52,7 +52,7 @@ def setUp(self): self.factory = RequestFactory() self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - + self.oauth2_settings.PKCE_REQUIRED = False self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] self.application = Application( diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 39601ed3b..548cc060c 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -83,6 +83,7 @@ def test_scopes_saved_in_grant(self): """ Test scopes are properly saved in grant """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -105,6 +106,7 @@ def test_scopes_save_in_access_token(self): """ Test scopes are properly saved in access token """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -141,6 +143,7 @@ def test_scopes_protection_valid(self): """ Test access to a scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -183,6 +186,7 @@ def test_scopes_protection_fail(self): """ Test access to a scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -225,6 +229,7 @@ def test_multi_scope_fail(self): """ Test access to a multi-scope protected resource with wrong scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -267,6 +272,7 @@ def test_multi_scope_valid(self): """ Test access to a multi-scope protected resource with correct scopes provided """ + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code @@ -308,6 +314,7 @@ def test_multi_scope_valid(self): class TestReadWriteScope(BaseTest): def get_access_token(self, scopes): + self.oauth2_settings.PKCE_REQUIRED = False self.client.login(username="test_user", password="123456") # retrieve a valid authorization code diff --git a/tests/test_settings.py b/tests/test_settings.py index 52bdafe03..f9f540339 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -167,3 +167,8 @@ def test_generating_iss_endpoint_type_error(oauth2_settings): with pytest.raises(TypeError) as exc: oauth2_settings.oidc_issuer(None) assert str(exc.value) == "request must be a django or oauthlib request: got None" + + +def test_pkce_required_is_default(): + settings = OAuth2ProviderSettings() + assert settings.PKCE_REQUIRED is True From 4a9039eb2af2df3409a02f492a68b77a922884a2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 23 Apr 2022 17:50:16 -0400 Subject: [PATCH 453/722] [pre-commit.ci] pre-commit autoupdate (#1139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.1.0 → 22.3.0](https://github.com/psf/black/compare/22.1.0...22.3.0) - [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b883f488..647f79c66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/psf/black - rev: 22.1.0 + rev: 22.3.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: check-ast - id: trailing-whitespace From e506fceffaf9a89aca77732eb2e9de86c950bfe9 Mon Sep 17 00:00:00 2001 From: Julien Palard <julien@palard.fr> Date: Sun, 24 Apr 2022 13:42:45 +0200 Subject: [PATCH 454/722] sphinx-lint (#1142) * Testing sphinx-lint. * Add sphinx-lint to pre-commit-config, tox, and github workflow. * Add self to AUTHORS. --- .pre-commit-config.yaml | 4 +++ AUTHORS | 1 + docs/glossary.rst | 2 +- docs/management_commands.rst | 16 +++++----- docs/oidc.rst | 47 +++++++++++++++-------------- docs/rest-framework/permissions.rst | 2 +- docs/templates.rst | 2 +- docs/tutorial/tutorial_05.rst | 4 +-- docs/views/mixins.rst | 2 +- tox.ini | 9 +++++- 10 files changed, 51 insertions(+), 38 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 647f79c66..386d28c9c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,3 +25,7 @@ repos: hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - repo: https://github.com/sphinx-contrib/sphinx-lint + rev: v0.3 + hooks: + - id: sphinx-lint diff --git a/AUTHORS b/AUTHORS index 8ec6667e2..a5f652ea0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -44,6 +44,7 @@ Jim Graham Jonas Nygaard Pedersen Jonathan Steffan Jozef Knaperek +Julien Palard Jun Zhou Kristian Rune Larsen Michael Howitz diff --git a/docs/glossary.rst b/docs/glossary.rst index c1536f801..7819129b1 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -39,4 +39,4 @@ Glossary Refresh Token A token the authorization server may issue to clients and can be swapped for a brand new access token, without - repeating the authorization process. It has no expire time. \ No newline at end of file + repeating the authorization process. It has no expire time. diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 3029f1345..085b130ec 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -35,27 +35,27 @@ The ``createapplication`` management command provides a shortcut to create a new .. code-block:: sh usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] - [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] - [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] - [--skip-checks] - client_type authorization_grant_type + [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] + [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + [--skip-checks] + client_type authorization_grant_type Shortcut to create a new application in a programmatic way positional arguments: client_type The client type, can be confidential or public authorization_grant_type - The type of authorization grant to be used + The type of authorization grant to be used optional arguments: -h, --help show this help message and exit --client-id CLIENT_ID - The ID of the new application + The ID of the new application --user USER The user the application belongs to --redirect-uris REDIRECT_URIS - The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + The redirect URIs, this must be a space separated string e.g 'URI1 URI2' --client-secret CLIENT_SECRET - The secret for this application + The secret for this application --name NAME The name this application --skip-authorization The ID of the new application ... diff --git a/docs/oidc.rst b/docs/oidc.rst index f4fdfd09c..4b427ba86 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -249,38 +249,39 @@ our custom validator. It takes one of two forms: The first form gets passed a request object, and should return a dictionary mapping a claim name to claim data:: + class CustomOAuth2Validator(OAuth2Validator): - # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, - # otherwise the OIDC standard scopes are used. + # Set `oidc_claim_scope = None` to ignore scopes that limit which claims to return, + # otherwise the OIDC standard scopes are used. def get_additional_claims(self, request): - return { - "given_name": request.user.first_name, - "family_name": request.user.last_name, - "name": ' '.join([request.user.first_name, request.user.last_name]), - "preferred_username": request.user.username, - "email": request.user.email, - } + return { + "given_name": request.user.first_name, + "family_name": request.user.last_name, + "name": ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": request.user.username, + "email": request.user.email, + } The second form gets no request object, and should return a dictionary mapping a claim name to a callable, accepting a request and producing the claim data:: class CustomOAuth2Validator(OAuth2Validator): - # Extend the standard scopes to add a new "permissions" scope - # which returns a "permissions" claim: - oidc_claim_scope = OAuth2Validator.oidc_claim_scope - oidc_claim_scope.update({"permissions": "permissions"}) - - def get_additional_claims(self): - return { - "given_name": lambda request: request.user.first_name, - "family_name": lambda request: request.user.last_name, - "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), - "preferred_username": lambda request: request.user.username, - "email": lambda request: request.user.email, - "permissions": lambda request: list(request.user.get_group_permissions()), - } + # Extend the standard scopes to add a new "permissions" scope + # which returns a "permissions" claim: + oidc_claim_scope = OAuth2Validator.oidc_claim_scope + oidc_claim_scope.update({"permissions": "permissions"}) + + def get_additional_claims(self): + return { + "given_name": lambda request: request.user.first_name, + "family_name": lambda request: request.user.last_name, + "name": lambda request: ' '.join([request.user.first_name, request.user.last_name]), + "preferred_username": lambda request: request.user.username, + "email": lambda request: request.user.email, + "permissions": lambda request: list(request.user.get_group_permissions()), + } Standard claim ``sub`` is included by default, to remove it override ``get_claim_dict``. diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index 1058aed3f..ee398d9fc 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -114,4 +114,4 @@ The following is a minimal OAS declaration that shows the same required alternat to try it in the `swagger editor <https://editor.swagger.io>`_. .. literalinclude:: openapi.yaml - :language: YAML \ No newline at end of file + :language: YAML diff --git a/docs/templates.rst b/docs/templates.rst index 4f6320bf7..8ebcd4127 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -242,4 +242,4 @@ This template gets passed the following template context variable: .. important:: To override successfully this template you should provide a form that posts to the same URL, example: - ``<form method="post" action="">`` \ No newline at end of file + ``<form method="post" action="">`` diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index 784d489a8..1be656b88 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -83,9 +83,9 @@ We'll add ours now in ``tutorial/tasks.py``: @shared_task def clear_tokens(): - from oauth2_provider.models import clear_expired + from oauth2_provider.models import clear_expired - clear_expired() + clear_expired() Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: diff --git a/docs/views/mixins.rst b/docs/views/mixins.rst index be3541a88..a8da12414 100644 --- a/docs/views/mixins.rst +++ b/docs/views/mixins.rst @@ -2,4 +2,4 @@ Mixins for Class Based Views ============================ .. automodule:: oauth2_provider.views.mixins - :members: \ No newline at end of file + :members: diff --git a/tox.ini b/tox.ini index 7232ecef7..117a5e901 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = flake8, migrations, docs, + sphinxlint, py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, @@ -11,7 +12,7 @@ envlist = [gh-actions] python = 3.7: py37 - 3.8: py38, docs, flake8, migrations + 3.8: py38, docs, flake8, migrations, sphinxlint 3.9: py39 3.10: py310 @@ -56,6 +57,12 @@ passenv = ignore_errors = true ignore_outcome = true +[testenv:sphinxlint] +deps = sphinx-lint +skip_install = True +commands = + sphinx-lint docs/ + [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs From ec34fe935c3d2f0710e3d000b6f531efef4080ca Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 24 Apr 2022 13:01:43 -0400 Subject: [PATCH 455/722] Corrections to glossary terms and documentation links. (#1136) --- docs/resource_server.rst | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/resource_server.rst b/docs/resource_server.rst index e19e542a8..4e623b118 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,6 +1,6 @@ Separate Resource Server ======================== -Django OAuth Toolkit allows to separate the :term:`Authentication Server` and the :term:`Resource Server.` +Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. Based on the `RFC 7662 <https://tools.ietf.org/html/rfc7662>`_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. @@ -8,7 +8,7 @@ As well the Django OAuth Toolkit allows to verify access tokens by the use of an Setup the Authentication Server ------------------------------- -Setup the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Create a OAuth2 access token for the :term:`Resource Server` and add the ``introspection``-Scope to the settings. @@ -21,7 +21,7 @@ Create a OAuth2 access token for the :term:`Resource Server` and add the ... }, -The :term:`Authentication Server` will listen for introspection requests. +The :term:`Authorization Server` will listen for introspection requests. The endpoint is located within the ``oauth2_provider.urls`` as ``/introspect/``. Example Request:: @@ -49,10 +49,10 @@ Example Response:: Setup the Resource Server ------------------------- -Setup the :term:`Resource Server` like the :term:`Authentication Server` as described in the :ref:`tutorial`. +Setup the :term:`Resource Server` like the :term:`Authorization Server` as described in the :doc:`tutorial/tutorial`. Add ``RESOURCE_SERVER_INTROSPECTION_URL`` and **either** ``RESOURCE_SERVER_AUTH_TOKEN`` **or** ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS`` as a ``(id,secret)`` tuple to your settings. -The :term:`Resource Server` will try to verify its requests on the :term:`Authentication Server`. +The :term:`Resource Server` will try to verify its requests on the :term:`Authorization Server`. .. code-block:: python @@ -66,7 +66,7 @@ The :term:`Resource Server` will try to verify its requests on the :term:`Authen ``RESOURCE_SERVER_INTROSPECTION_URL`` defines the introspection endpoint and ``RESOURCE_SERVER_AUTH_TOKEN`` an authentication token to authenticate against the -:term:`Authentication Server`. +:term:`Authorization Server`. As allowed by RFC 7662, some external OAuth 2.0 servers support HTTP Basic Authentication. For these, use: ``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS=('client_id','client_secret')`` instead From e8d0ee6868021463a7d582495c2e7213eb59f67f Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@crosswell.us> Date: Sun, 24 Apr 2022 14:01:46 -0400 Subject: [PATCH 456/722] Add help wanted to the README (#1144) * Add help wanted to the README * Apply suggestions from code review Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- README.rst | 52 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 8a9f333db..3acf459d8 100644 --- a/README.rst +++ b/README.rst @@ -39,11 +39,6 @@ Note: If you have issues installing Django 4.0.0, it is because we only support Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. `Explanation <https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272>`_. -Contributing ------------- - -We love contributions, so please feel free to fix bugs, improve things, provide documentation. Just `follow the -guidelines <https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html>`_ and submit a PR. Reporting security issues ------------------------- @@ -99,3 +94,50 @@ License ------- django-oauth-toolkit is released under the terms of the **BSD license**. Full details in ``LICENSE`` file. + +Help Wanted +----------- + +We need help maintaining and enhancing django-oauth-toolkit (DOT). + +Join the team +~~~~~~~~~~~~~ + +Please consider joining `Jazzband <https://jazzband.co>`__ (If not +already a member) and the `DOT project +team <https://jazzband.co/projects/django-oauth-toolkit>`__. + +How you can help +~~~~~~~~~~~~~~~~ + +See our +`contributing <https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html>`__ +info and the open +`issues <https://github.com/jazzband/django-oauth-toolkit/issues>`__ and +`PRs <https://github.com/jazzband/django-oauth-toolkit/pulls>`__, +especially those labeled +`help-wanted <https://github.com/jazzband/django-oauth-toolkit/labels/help-wanted>`__. + +Submit PRs and Perform Reviews +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +PR submissions and reviews are always appreciated! Since we require an +independent review of any PR before it can be merged, having your second +set of eyes looking at PRs is extremely valuable. + +Please don’t merge PRs +~~~~~~~~~~~~~~~~~~~~~~ + +Please be aware that we don’t want *every* Jazzband member to merge PRs +but just a handful of project team members so that we can maintain a +modicum of control over what goes into a release of this security oriented code base. Only `project +leads <https://jazzband.co/projects/django-oauth-toolkit>`__ are able to +publish releases to Pypi and it becomes difficult when creating a new +release for the leads to deal with “unexpected” merged PRs. + +Become a Project Lead +~~~~~~~~~~~~~~~~~~~~~ + +If you are interested in stepping up to be a Project Lead, please join +the +`discussion <https://github.com/orgs/jazzband/teams/django-oauth-toolkit>`__. From 025cd1b7d901150cdb671194970273081de34bc7 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 24 Apr 2022 14:26:59 -0400 Subject: [PATCH 457/722] Release 2.0.0 (#1145) --- CHANGELOG.md | 9 +++++---- oauth2_provider/__init__.py | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index baae70de8..7819fe616 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] -## [2.0.0] unreleased +## [2.0.0] 2022-04-24 + +This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: ### Added * #1106 OIDC: Add "scopes_supported" to the [ConnectDiscoveryInfoView](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#connectdiscoveryinfoview). @@ -28,8 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1129 (**Breaking**) Changed default value of PKCE_REQUIRED to True. This is a **breaking change**. Clients without PKCE enabled will fail to authenticate. This breaks with [section 5 of RFC7636](https://datatracker.ietf.org/doc/html/rfc7636) in favor of the [OAuth2 Security Best Practices for Authorization Code Grants](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1). - If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False ` in your settings.py - + If you want to retain the pre-2.x behavior, set `PKCE_REQUIRED = False` in your settings.py * #1093 (**Breaking**) Changed to implement [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client_secret values. This is a **breaking change** that will migrate all your existing cleartext `application.client_secret` values to be hashed with Django's default password hashing algorithm @@ -43,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1108 OIDC: Fix `validate_bearer_token()` to properly set `request.scopes` to the list of granted scopes. -* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command +* #1132: Fixed help text for `--skip-authorization` argument of the `createapplication` management command. ### Removed * #1124 (**Breaking**, **Security**) Removes support for insecure `urn:ietf:wg:oauth:2.0:oob` and `urn:ietf:wg:oauth:2.0:oob:auto` which are replaced diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 9024b6f63..49a4433da 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "1.7.1" +__version__ = "2.0.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 6f3ebcf7e675be098d6fb593ed87b2dee70a68d3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 7 May 2022 14:35:27 -0400 Subject: [PATCH 458/722] Enhance createapplication command to display autogenerated secret (#1152) * createapplication command display autogenerated secret before it gets hashed. --- CHANGELOG.md | 3 ++ docs/management_commands.rst | 45 +++++++++++++++---- .../management/commands/createapplication.py | 18 ++++++-- tests/test_commands.py | 2 +- 4 files changed, 55 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7819fe616..02d598034 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Changed +* #1152 `createapplication` management command enhanced to display an auto-generated secret before it gets hashed. + ## [2.0.0] 2022-04-24 This is a major release with **BREAKING** changes. Please make sure to review these changes before upgrading: diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 085b130ec..8e6eaaac2 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -34,18 +34,25 @@ The ``createapplication`` management command provides a shortcut to create a new .. code-block:: sh - usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] - [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--version] [-v {0,1,2,3}] - [--settings SETTINGS] [--pythonpath PYTHONPATH] [--traceback] [--no-color] [--force-color] + usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] + [--redirect-uris REDIRECT_URIS] + [--client-secret CLIENT_SECRET] + [--name NAME] [--skip-authorization] + [--algorithm ALGORITHM] [--version] + [-v {0,1,2,3}] [--settings SETTINGS] + [--pythonpath PYTHONPATH] [--traceback] + [--no-color] [--force-color] [--skip-checks] client_type authorization_grant_type Shortcut to create a new application in a programmatic way positional arguments: - client_type The client type, can be confidential or public + client_type The client type, one of: confidential, public authorization_grant_type - The type of authorization grant to be used + The type of authorization grant to be used, one of: + authorization-code, implicit, password, client- + credentials, openid-hybrid optional arguments: -h, --help show this help message and exit @@ -53,9 +60,31 @@ The ``createapplication`` management command provides a shortcut to create a new The ID of the new application --user USER The user the application belongs to --redirect-uris REDIRECT_URIS - The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + The redirect URIs, this must be a space separated + string e.g 'URI1 URI2' --client-secret CLIENT_SECRET The secret for this application --name NAME The name this application - --skip-authorization The ID of the new application - ... + --skip-authorization If set, completely bypass the authorization form, even + on the first use of the application + --algorithm ALGORITHM + The OIDC token signing algorithm for this application, + one of: RS256, HS256 + --version Show program's version number and exit. + -v {0,1,2,3}, --verbosity {0,1,2,3} + Verbosity level; 0=minimal output, 1=normal output, + 2=verbose output, 3=very verbose output + --settings SETTINGS The Python path to a settings module, e.g. + "myproject.settings.main". If this isn't provided, the + DJANGO_SETTINGS_MODULE environment variable will be + used. + --pythonpath PYTHONPATH + A directory to add to the Python path, e.g. + "/home/djangoprojects/myproject". + --traceback Raise on CommandError exceptions. + --no-color Don't colorize the command output. + --force-color Force colorization of the command output. + --skip-checks Skip system checks. + +If you let `createapplication` auto-generate the secret then it displays the value before hashing it. + diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index f8575a8b0..12d7aa280 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -14,12 +14,13 @@ def add_arguments(self, parser): parser.add_argument( "client_type", type=str, - help="The client type, can be confidential or public", + help="The client type, one of: %s" % ", ".join([ctype[0] for ctype in Application.CLIENT_TYPES]), ) parser.add_argument( "authorization_grant_type", type=str, - help="The type of authorization grant to be used", + help="The type of authorization grant to be used, one of: %s" + % ", ".join([gtype[0] for gtype in Application.GRANT_TYPES]), ) parser.add_argument( "--client-id", @@ -54,7 +55,8 @@ def add_arguments(self, parser): parser.add_argument( "--algorithm", type=str, - help="The OIDC token signing algorithm for this application (e.g., 'RS256' or 'HS256')", + help="The OIDC token signing algorithm for this application, one of: %s" + % ", ".join([atype[0] for atype in Application.ALGORITHM_TYPES if atype[0]]), ) def handle(self, *args, **options): @@ -82,5 +84,13 @@ def handle(self, *args, **options): ) self.stdout.write(self.style.ERROR("Please correct the following errors:\n %s" % errors)) else: + cleartext_secret = new_application.client_secret new_application.save() - self.stdout.write(self.style.SUCCESS("New application created successfully")) + # Display the newly-created client_name or id. + client_name_or_id = application_data.get("name", new_application.client_id) + self.stdout.write( + self.style.SUCCESS("New application %s created successfully." % client_name_or_id) + ) + # Print out the cleartext client_secret if it was autogenerated. + if "client_secret" not in application_data: + self.stdout.write(self.style.SUCCESS("client_secret: %s" % cleartext_secret)) diff --git a/tests/test_commands.py b/tests/test_commands.py index f9a9f5ade..8861f5698 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -27,7 +27,7 @@ def test_command_creates_application(self): stdout=output, ) self.assertEqual(Application.objects.count(), 1) - self.assertIn("New application created successfully", output.getvalue()) + self.assertIn("created successfully", output.getvalue()) def test_missing_required_args(self): self.assertEqual(Application.objects.count(), 0) From 78c91d99b0b4e28f48b80213190f5fea408dc236 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 10 May 2022 18:55:18 -0400 Subject: [PATCH 459/722] [pre-commit.ci] pre-commit autoupdate (#1149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/sphinx-contrib/sphinx-lint: v0.3 → v0.4.1](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.3...v0.4.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Rebecca Claire Murphy <rcmurphy@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 386d28c9c..ac1b415a3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.3 + rev: v0.4.1 hooks: - id: sphinx-lint From 4f04a579707262ced127f6e7447ed4dc0093cb5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20M=20Guill=C3=A9n?= <alejandro@amguillen.dev> Date: Tue, 17 May 2022 15:59:31 +0200 Subject: [PATCH 460/722] Updates "getting started" documentation (#1159) * Updates "getting started" documentation Adds PKCE token instructions to be in sync with 2.0 version. Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + docs/getting_started.rst | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index a5f652ea0..c45ec7ae9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Contributors Abhishek Patel Alan Crosswell +Alejandro Mantecon Guillen Aleksander Vaskevich Alessandro De Angelis Alex Szabó diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 3ea4f7e58..91f14f41e 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -256,13 +256,31 @@ Export ``Client id`` and ``Client secret`` values as environment variable: export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO +Now let's generate an authentication code grant with PKCE (Proof Key for Code Exchange), useful to prevent authorization code injection. To do so, you must first generate a ``code_verifier`` random string between 43 and 128 characters, which is then encoded to produce a ``code_challenge``:: + +.. sourcecode:: python + + import random + import string + import base64 + import hashlib + + code_verifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) + code_verifier = base64.urlsafe_b64encode(code_verifier) + + code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() + code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') + +Take note of ``code_challenge`` since we will include it in the code flow URL. It should look something like ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM``. + To start the Authorization code flow go to this `URL`_ which is the same as shown below:: - http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + http://127.0.0.1:8000/o/authorize/?response_type=code&code_challenge=XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback Note the parameters we pass: * **response_type**: ``code`` +* **code_challenge**: ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM`` * **client_id**: ``vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8`` * **redirect_uri**: ``http://127.0.0.1:8000/noexist/callback`` From f76b0f50d303182c6b1911ef03f370ec5727edf6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 27 May 2022 15:12:09 -0400 Subject: [PATCH 461/722] [pre-commit.ci] pre-commit autoupdate (#1160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/sphinx-contrib/sphinx-lint: v0.4.1 → v0.6](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.4.1...v0.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ac1b415a3..0ec8dd601 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.4.1 + rev: v0.6 hooks: - id: sphinx-lint From c22c1793dcac7eb6fa28e3645bd3098beb091029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Neil=20S=C3=A1nchez?= <30764904+JordiNeil@users.noreply.github.com> Date: Mon, 30 May 2022 08:39:10 -0500 Subject: [PATCH 462/722] add spanish translations (#1166) * add spanish translations * add author and changelog info * fix some typos in spanish translations file --- AUTHORS | 1 + CHANGELOG.md | 3 + .../locale/es/LC_MESSAGES/django.po | 197 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 oauth2_provider/locale/es/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index c45ec7ae9..77ccc1eff 100644 --- a/AUTHORS +++ b/AUTHORS @@ -76,3 +76,4 @@ Andrea Greco Dominik George David Hill Darrel O'Pry +Jordi Sanchez diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d598034..675a055f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* Add spanish (es) translations. + ### Changed * #1152 `createapplication` management command enhanced to display an auto-generated secret before it gets hashed. diff --git a/oauth2_provider/locale/es/LC_MESSAGES/django.po b/oauth2_provider/locale/es/LC_MESSAGES/django.po new file mode 100644 index 000000000..f4223386b --- /dev/null +++ b/oauth2_provider/locale/es/LC_MESSAGES/django.po @@ -0,0 +1,197 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-05-29 19:04-0500\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Jordi Neil Sánchez A<jordineil8@gmail.com>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +#: oauth2_provider/models.py:66 +msgid "Confidential" +msgstr "Confidencial" + +#: oauth2_provider/models.py:67 +msgid "Public" +msgstr "Público" + +#: oauth2_provider/models.py:76 +msgid "Authorization code" +msgstr "Código de autorización" + +#: oauth2_provider/models.py:77 +msgid "Implicit" +msgstr "Implícito" + +#: oauth2_provider/models.py:78 +msgid "Resource owner password-based" +msgstr "Propiedario del recurso basado en contraseña" + +#: oauth2_provider/models.py:79 +msgid "Client credentials" +msgstr "Credenciales de cliente" + +#: oauth2_provider/models.py:80 +msgid "OpenID connect hybrid" +msgstr "OpenID connect híbrido" + +#: oauth2_provider/models.py:87 +msgid "No OIDC support" +msgstr "Sin soporte para OIDC" + +#: oauth2_provider/models.py:88 +msgid "RSA with SHA-2 256" +msgstr "RSA con SHA-2 256" + +#: oauth2_provider/models.py:89 +msgid "HMAC with SHA-2 256" +msgstr "HMAC con SHA-2 256" + +#: oauth2_provider/models.py:104 +msgid "Allowed URIs list, space separated" +msgstr "Lista de URIs permitidas, separadas por espacio" + +#: oauth2_provider/models.py:113 +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Encriptadas al guardar. Copiar ahora si este es un nuevo secreto." + +#: oauth2_provider/models.py:175 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Esquema de redirección no autorizado: {scheme}" + +#: oauth2_provider/models.py:179 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris no pueden estar vacías para el tipo de autorización {grant_type}" + +#: oauth2_provider/models.py:185 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "Debes seleccionar OIDC_RSA_PRIVATE_KEY para usar el algoritmo RSA" + +#: oauth2_provider/models.py:194 +msgid "You cannot use HS256 with public grants or clients" +msgstr "No es posible usar HS256 con autorizaciones o clientes públicos" + +#: oauth2_provider/oauth2_validators.py:211 +msgid "The access token is invalid." +msgstr "El token de acceso es inválido." + +#: oauth2_provider/oauth2_validators.py:218 +msgid "The access token has expired." +msgstr "El token de acceso ha expirado." + +#: oauth2_provider/oauth2_validators.py:225 +msgid "The access token is valid but does not have enough scope." +msgstr "El token de acceso es válido pero no tiene suficiente alcance." + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "¿Está seguro de eliminar la aplicación" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 +#: oauth2_provider/templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Cancelar" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 +#: oauth2_provider/templates/oauth2_provider/application_detail.html:38 +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Eliminar" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "Identificador de cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Secreto de cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Tipo de cliente" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Tipo de acceso de autorización" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "Uris de redirección" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:36 +#: oauth2_provider/templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Volver" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Editar" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Editar aplicación" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Guardar" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Tus aplicaciones" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nueva aplicación" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "No hay aplicaciones definidas" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Click aquí" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "si quiere regitrar una nueva" + +#: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Registrar una nueva aplicación" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:8 +#: oauth2_provider/templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autorizar" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "La aplicación requiere los siguientes permisos" + +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "¿Está seguro de que quiere eliminar este token?" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokens" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "Anular" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "No hay tokens autorizados aún." From 0f18817e074db8daa3af6822901daa373f3f5884 Mon Sep 17 00:00:00 2001 From: Jesse <jesse.richard.gibbs@gmail.com> Date: Sat, 4 Jun 2022 03:04:28 +1000 Subject: [PATCH 463/722] support prompt login (#1164) Co-authored-by: Alan Crosswell <alan@crosswell.us> Co-authored-by: Alan Crosswell <alan@crosswell.us> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/oidc.rst | 9 ++++++++ oauth2_provider/views/base.py | 33 ++++++++++++++++++++++++++++++ tests/test_authorization_code.py | 35 ++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+) diff --git a/AUTHORS b/AUTHORS index 77ccc1eff..fa3820f64 100644 --- a/AUTHORS +++ b/AUTHORS @@ -41,6 +41,7 @@ Hossein Shakiba Hiroki Kiyohara Jens Timmerman Jerome Leclanche +Jesse Gibbs Jim Graham Jonas Nygaard Pedersen Jonathan Steffan diff --git a/CHANGELOG.md b/CHANGELOG.md index 675a055f1..abc5a401d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added +* Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). * Add spanish (es) translations. ### Changed diff --git a/docs/oidc.rst b/docs/oidc.rst index 4b427ba86..2211a972a 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -359,6 +359,15 @@ token, so you will probably want to re-use that:: claims["color_scheme"] = get_color_scheme(request.user) return claims +Customizing the login flow +========================== + +Clients can request that the user logs in each time a request to the +``/authorize`` endpoint is made during the OIDC Authorization Code Flow by +adding the ``prompt=login`` query parameter and value. Only ``login`` is +currently supported. See +OIDC's `3.1.2.1 Authentication Request <https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest>`_ +for details. OIDC Views ========== diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 211da45ed..abaa81f59 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,8 +1,11 @@ import json import logging +from urllib.parse import parse_qsl, urlencode, urlparse from django.contrib.auth.mixins import LoginRequiredMixin +from django.contrib.auth.views import redirect_to_login from django.http import HttpResponse +from django.shortcuts import resolve_url from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt @@ -144,6 +147,10 @@ def get(self, request, *args, **kwargs): # Application is not available at this time. return self.error_response(error, application=None) + prompt = request.GET.get("prompt") + if prompt == "login": + return self.handle_prompt_login() + all_scopes = get_scopes_backend().get_all_scopes() kwargs["scopes_descriptions"] = [all_scopes[scope] for scope in scopes] kwargs["scopes"] = scopes @@ -211,6 +218,32 @@ def get(self, request, *args, **kwargs): return self.render_to_response(self.get_context_data(**kwargs)) + def handle_prompt_login(self): + path = self.request.build_absolute_uri() + resolved_login_url = resolve_url(self.get_login_url()) + + # If the login url is the same scheme and net location then use the + # path as the "next" url. + login_scheme, login_netloc = urlparse(resolved_login_url)[:2] + current_scheme, current_netloc = urlparse(path)[:2] + if (not login_scheme or login_scheme == current_scheme) and ( + not login_netloc or login_netloc == current_netloc + ): + path = self.request.get_full_path() + + parsed = urlparse(path) + + parsed_query = dict(parse_qsl(parsed.query)) + parsed_query.pop("prompt") + + parsed = parsed._replace(query=urlencode(parsed_query)) + + return redirect_to_login( + parsed.geturl(), + resolved_login_url, + self.get_redirect_field_name(), + ) + @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 8bface719..924bdc1db 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -5,6 +5,7 @@ from urllib.parse import parse_qs, urlparse import pytest +from django.conf import settings from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase from django.urls import reverse @@ -612,6 +613,40 @@ def test_id_token_code_post_auth_allow(self): self.assertIn("state=random_state_string", response["Location"]) self.assertIn("code=", response["Location"]) + def test_prompt_login(self): + """ + Test response for redirect when supplied with prompt: login + """ + self.oauth2_settings.PKCE_REQUIRED = False + self.client.login(username="test_user", password="123456") + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "prompt": "login", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + + self.assertEqual(response.status_code, 302) + + scheme, netloc, path, params, query, fragment = urlparse(response["Location"]) + + self.assertEqual(path, settings.LOGIN_URL) + + parsed_query = parse_qs(query) + next = parsed_query["next"][0] + + self.assertIn("redirect_uri=http%3A%2F%2Fexample.org", next) + self.assertIn("state=random_state_string", next) + self.assertIn("scope=read+write", next) + self.assertIn(f"client_id={self.application.client_id}", next) + + self.assertNotIn("prompt=login", next) + class BaseAuthorizationCodeTokenView(BaseTest): def get_auth(self, scope="read write"): From 7518956e750b0b5f215dada067c28681f8a15b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20M=20Guill=C3=A9n?= <alejandro@amguillen.dev> Date: Fri, 3 Jun 2022 19:28:42 +0200 Subject: [PATCH 464/722] Adds french translation file (#1163) * Adds french translation file Co-authored-by: Alan Crosswell <alan@crosswell.us> --- CHANGELOG.md | 3 +- .../locale/fr/LC_MESSAGES/django.po | 193 ++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 oauth2_provider/locale/fr/LC_MESSAGES/django.po diff --git a/CHANGELOG.md b/CHANGELOG.md index abc5a401d..7df17aee2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). -* Add spanish (es) translations. +* #1163 Adds French translations. +* #1166 Add spanish (es) translations. ### Changed * #1152 `createapplication` management command enhanced to display an auto-generated secret before it gets hashed. diff --git a/oauth2_provider/locale/fr/LC_MESSAGES/django.po b/oauth2_provider/locale/fr/LC_MESSAGES/django.po new file mode 100644 index 000000000..2d796cab6 --- /dev/null +++ b/oauth2_provider/locale/fr/LC_MESSAGES/django.po @@ -0,0 +1,193 @@ +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-05-19 15:36+0200\n" +"PO-Revision-Date: 2022-05-19 15:56+0200\n" +"Last-Translator: Alejandro Mantecon Guillen <alejandro.mantecon-guillen@beta.gouv.fr>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: fr-FR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" + +#: models.py:66 +msgid "Confidential" +msgstr "Confidential" + +#: models.py:67 +msgid "Public" +msgstr "Public" + +#: models.py:76 +msgid "Authorization code" +msgstr "Code d'autorisation" + +#: models.py:77 +msgid "Implicit" +msgstr "Implicite" + +#: models.py:78 +msgid "Resource owner password-based" +msgstr "Propriétaire de la resource, basé mot-de-passe" + +#: models.py:79 +msgid "Client credentials" +msgstr "Données d'identification du client" + +#: models.py:80 +msgid "OpenID connect hybrid" +msgstr "OpenID connection hybride" + +#: models.py:87 +msgid "No OIDC support" +msgstr "Pas de support OIDC" + +#: models.py:88 +msgid "RSA with SHA-2 256" +msgstr "RSA avec SHA-2 256" + +#: models.py:89 +msgid "HMAC with SHA-2 256" +msgstr "HMAC avec SHA-2 256" + +#: models.py:104 +msgid "Allowed URIs list, space separated" +msgstr "Liste des URIs autorisés, séparés par un espace" + +#: models.py:113 +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Hachage en sauvegarde. Copiez-le maintenant s'il s'agit d'un nouveau secret." + +#: models.py:175 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "Schéma de redirection non autorisé : {scheme}" + +#: models.py:179 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris ne peut pas être vide avec un grant_type {grant_type}" + +#: models.py:185 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "Vous devez renseigner OIDC_RSA_PRIVATE_KEY pour l'utilisation de l'algorithme RSA" + +#: models.py:194 +msgid "You cannot use HS256 with public grants or clients" +msgstr "Vous ne pouvez pas utiliser HS256 avec des cession publiques ou clients" + +#: oauth2_validators.py:211 +msgid "The access token is invalid." +msgstr "Le token d'accès n'est pas valide." + +#: oauth2_validators.py:218 +msgid "The access token has expired." +msgstr "Le token d'accès a expiré." + +#: oauth2_validators.py:225 +msgid "The access token is valid but does not have enough scope." +msgstr "Le token d'accès est valide, mais sa portée n'est pas suffisante." + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Êtes-vous sûr de vouloir supprimer l'application" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "Annuler" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Supprimer" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ID du client" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "Secret client" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "Type de client" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "Type de flux d'autorisation" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "URIs de redirection" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Revenir en arrière" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "Modifier" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Modifier l'application" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Sauvegarder" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Vos applications" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Nouvelle application" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Pas d'applications définies" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Cliquez ici" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "si vous voulez en enregistrer une nouvelle" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Enregistrer une application" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Autoriser" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "L'application nécessite les permissions suivantes" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Êtes-vous sûr de vouloir supprimer ce jeton ?" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Jetons" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "révoquer" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "Il n'y a pas encore de jetons." From 307d07d0037273404fc9a5b28e9ae0aea8c3d7f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20C=C3=A1nepa=20=28Scalar=29?= <73839068+gabrielcanepascalar@users.noreply.github.com> Date: Fri, 3 Jun 2022 14:46:22 -0300 Subject: [PATCH 465/722] Corrected typo (#1158) --- docs/tutorial/tutorial_03.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 30c8317e6..64ba8d495 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -108,5 +108,5 @@ Note that this example overrides the Django default permission class setting. Th ways this can be solved. Overriding the class function *get_permission_classes* is another way to solve the problem. -A detailed dive into the `Dango REST framework permissions is here. <https://www.django-rest-framework.org/api-guide/permissions/>`_ +A detailed dive into the `Django REST framework permissions is here. <https://www.django-rest-framework.org/api-guide/permissions/>`_ From 155bef3709f1373f6e61270dd0fdf0a77095bf6e Mon Sep 17 00:00:00 2001 From: Joseph Abrahams <joseph@abrahams.io> Date: Fri, 3 Jun 2022 12:15:47 -0600 Subject: [PATCH 466/722] Run hasher migration on swapped models (#1147) * Run hasher migration on swapped models * Test swapped migrations --- AUTHORS | 1 + .../0006_alter_application_client_secret.py | 7 +- tests/migrations/0001_initial.py | 102 +----- tests/migrations/0002_swapped_models.py | 346 ++++++++++++++++++ tests/settings.py | 1 + tests/settings_swapped.py | 6 + tox.ini | 11 +- 7 files changed, 381 insertions(+), 93 deletions(-) create mode 100644 tests/migrations/0002_swapped_models.py create mode 100644 tests/settings_swapped.py diff --git a/AUTHORS b/AUTHORS index fa3820f64..0c46746e7 100644 --- a/AUTHORS +++ b/AUTHORS @@ -45,6 +45,7 @@ Jesse Gibbs Jim Graham Jonas Nygaard Pedersen Jonathan Steffan +Joseph Abrahams Jozef Knaperek Julien Palard Jun Zhou diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py index 88e148274..c63c08bb2 100644 --- a/oauth2_provider/migrations/0006_alter_application_client_secret.py +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -1,6 +1,5 @@ from django.db import migrations -from django.contrib.auth.hashers import identify_hasher, make_password -import logging +from oauth2_provider import settings import oauth2_provider.generators import oauth2_provider.models @@ -9,8 +8,8 @@ def forwards_func(apps, schema_editor): """ Forward migration touches every application.client_secret which will cause it to be hashed if not already the case. """ - Application = apps.get_model('oauth2_provider', 'application') - applications = Application.objects.all() + Application = apps.get_model(settings.APPLICATION_MODEL) + applications = Application._default_manager.all() for application in applications: application.save(update_fields=['client_secret']) diff --git a/tests/migrations/0001_initial.py b/tests/migrations/0001_initial.py index 8903a5a96..4baa18a57 100644 --- a/tests/migrations/0001_initial.py +++ b/tests/migrations/0001_initial.py @@ -1,9 +1,6 @@ -# Generated by Django 2.2.6 on 2019-10-24 20:21 +# Generated by Django 4.0.4 on 2022-05-27 21:07 -from django.conf import settings from django.db import migrations, models -import django.db.models.deletion -import oauth2_provider.generators class Migration(migrations.Migration): @@ -11,112 +8,41 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - migrations.swappable_dependency(settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ] + + run_before = [ + ('oauth2_provider', '0001_initial'), ] operations = [ migrations.CreateModel( - name='SampleGrant', + name='BaseTestApplication', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('code', models.CharField(max_length=255, unique=True)), - ('expires', models.DateTimeField()), - ('redirect_uri', models.CharField(max_length=255)), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('code_challenge', models.CharField(blank=True, default='', max_length=128)), - ('code_challenge_method', models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10)), - ('custom_field', models.CharField(max_length=255)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplegrant', to=settings.AUTH_USER_MODEL)), - ("nonce", models.CharField(blank=True, max_length=255, default="")), - ("claims", models.TextField(blank=True)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='SampleApplication', + name='SampleAccessToken', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), - ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), - ('name', models.CharField(blank=True, max_length=255)), - ('skip_authorization', models.BooleanField(default=False)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('custom_field', models.CharField(max_length=255)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='SampleAccessToken', + name='SampleApplication', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255, unique=True)), - ('expires', models.DateTimeField()), - ('scope', models.TextField(blank=True)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('custom_field', models.CharField(max_length=255)), - ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('source_refresh_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_sampleaccesstoken', to=settings.AUTH_USER_MODEL)), - ('id_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( - name='BaseTestApplication', + name='SampleGrant', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('client_id', models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True)), - ('redirect_uris', models.TextField(blank=True, help_text='Allowed URIs list, space separated')), - ('client_type', models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32)), - ('authorization_grant_type', models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32)), - ('client_secret', models.CharField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255)), - ('name', models.CharField(blank=True, max_length=255)), - ('skip_authorization', models.BooleanField(default=False)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('allowed_schemes', models.TextField(blank=True)), - ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_basetestapplication', to=settings.AUTH_USER_MODEL)), - ('algorithm', models.CharField(max_length=5, choices=[('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='RS256')), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - }, ), migrations.CreateModel( name='SampleRefreshToken', fields=[ - ('id', models.BigAutoField(primary_key=True, serialize=False)), - ('token', models.CharField(max_length=255)), - ('created', models.DateTimeField(auto_now_add=True)), - ('updated', models.DateTimeField(auto_now=True)), - ('revoked', models.DateTimeField(null=True)), - ('custom_field', models.CharField(max_length=255)), - ('access_token', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL)), - ('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tests_samplerefreshtoken', to=settings.AUTH_USER_MODEL)), + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ], - options={ - 'abstract': False, - 'unique_together': {('token', 'revoked')}, - }, ), ] diff --git a/tests/migrations/0002_swapped_models.py b/tests/migrations/0002_swapped_models.py new file mode 100644 index 000000000..412f19927 --- /dev/null +++ b/tests/migrations/0002_swapped_models.py @@ -0,0 +1,346 @@ +# Generated by Django 4.0.4 on 2022-05-27 21:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import oauth2_provider.generators +import oauth2_provider.models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ('tests', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='basetestapplication', + name='algorithm', + field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + migrations.AddField( + model_name='basetestapplication', + name='allowed_schemes', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='basetestapplication', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name='basetestapplication', + name='client_id', + field=models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True), + ), + migrations.AddField( + model_name='basetestapplication', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), + ), + migrations.AddField( + model_name='basetestapplication', + name='client_type', + field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name='basetestapplication', + name='created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='basetestapplication', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='basetestapplication', + name='redirect_uris', + field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), + ), + migrations.AddField( + model_name='basetestapplication', + name='skip_authorization', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='basetestapplication', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='basetestapplication', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='application', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='custom_field', + field=models.CharField(max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='expires', + field=models.DateTimeField(), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='scope', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='source_refresh_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='token', + field=models.CharField(max_length=255, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='sampleaccesstoken', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='sampleapplication', + name='algorithm', + field=models.CharField(blank=True, choices=[('', 'No OIDC support'), ('RS256', 'RSA with SHA-2 256'), ('HS256', 'HMAC with SHA-2 256')], default='', max_length=5), + ), + migrations.AddField( + model_name='sampleapplication', + name='authorization_grant_type', + field=models.CharField(choices=[('authorization-code', 'Authorization code'), ('implicit', 'Implicit'), ('password', 'Resource owner password-based'), ('client-credentials', 'Client credentials'), ('openid-hybrid', 'OpenID connect hybrid')], max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleapplication', + name='client_id', + field=models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100, unique=True), + ), + migrations.AddField( + model_name='sampleapplication', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), + ), + migrations.AddField( + model_name='sampleapplication', + name='client_type', + field=models.CharField(choices=[('confidential', 'Confidential'), ('public', 'Public')], max_length=32), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleapplication', + name='created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleapplication', + name='custom_field', + field=models.CharField(max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='sampleapplication', + name='name', + field=models.CharField(blank=True, max_length=255), + ), + migrations.AddField( + model_name='sampleapplication', + name='redirect_uris', + field=models.TextField(blank=True, help_text='Allowed URIs list, space separated'), + ), + migrations.AddField( + model_name='sampleapplication', + name='skip_authorization', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='sampleapplication', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='sampleapplication', + name='user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='samplegrant', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='claims', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='samplegrant', + name='code', + field=models.CharField(max_length=255, unique=True), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='code_challenge', + field=models.CharField(blank=True, default='', max_length=128), + ), + migrations.AddField( + model_name='samplegrant', + name='code_challenge_method', + field=models.CharField(blank=True, choices=[('plain', 'plain'), ('S256', 'S256')], default='', max_length=10), + ), + migrations.AddField( + model_name='samplegrant', + name='created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='custom_field', + field=models.CharField(max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='expires', + field=models.DateTimeField(), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='nonce', + field=models.CharField(blank=True, default='', max_length=255), + ), + migrations.AddField( + model_name='samplegrant', + name='redirect_uri', + field=models.TextField(), + preserve_default=False, + ), + migrations.AddField( + model_name='samplegrant', + name='scope', + field=models.TextField(blank=True), + ), + migrations.AddField( + model_name='samplegrant', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='samplegrant', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='access_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refresh_token', to=settings.OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL), + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='application', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + preserve_default=False, + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='custom_field', + field=models.CharField(max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='revoked', + field=models.DateTimeField(null=True), + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='token', + field=models.CharField(default=1, max_length=255), + preserve_default=False, + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='updated', + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name='samplerefreshtoken', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(app_label)s_%(class)s', to=settings.AUTH_USER_MODEL), + preserve_default=False, + ), + migrations.AlterField( + model_name='basetestapplication', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sampleaccesstoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sampleapplication', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='samplegrant', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='samplerefreshtoken', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterUniqueTogether( + name='samplerefreshtoken', + unique_together={('token', 'revoked')}, + ), + ] diff --git a/tests/settings.py b/tests/settings.py index 27dcfe9a3..9315a6e39 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -54,6 +54,7 @@ "django.template.context_processors.debug", "django.template.context_processors.i18n", "django.template.context_processors.media", + "django.template.context_processors.request", "django.template.context_processors.static", "django.template.context_processors.tz", "django.contrib.messages.context_processors.messages", diff --git a/tests/settings_swapped.py b/tests/settings_swapped.py new file mode 100644 index 000000000..cb3a37571 --- /dev/null +++ b/tests/settings_swapped.py @@ -0,0 +1,6 @@ +from .settings import * # noqa + + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL = "tests.SampleAccessToken" +OAUTH2_PROVIDER_APPLICATION_MODEL = "tests.SampleApplication" +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL = "tests.SampleRefreshToken" diff --git a/tox.ini b/tox.ini index 117a5e901..63a78e773 100644 --- a/tox.ini +++ b/tox.ini @@ -2,6 +2,7 @@ envlist = flake8, migrations, + migrate_swapped, docs, sphinxlint, py{37,38,39}-dj22, @@ -12,7 +13,7 @@ envlist = [gh-actions] python = 3.7: py37 - 3.8: py38, docs, flake8, migrations, sphinxlint + 3.8: py38, docs, flake8, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 @@ -98,6 +99,14 @@ setenv = PYTHONWARNINGS = all commands = django-admin makemigrations --dry-run --check +[testenv:migrate_swapped] +setenv = + DJANGO_SETTINGS_MODULE = tests.settings_swapped + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all +commands = + django-admin migrate + [testenv:build] deps = setuptools>=39.0 From 40b0de1df9000136e4a3ee8c81c027aca0b9def5 Mon Sep 17 00:00:00 2001 From: Hamza Pervez <hamza.pervez@arbisoft.com> Date: Thu, 9 Jun 2022 02:21:33 +0500 Subject: [PATCH 467/722] fixed typo which caused incorrect display of code block (#1172) --- docs/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 91f14f41e..b82774cd4 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -256,7 +256,7 @@ Export ``Client id`` and ``Client secret`` values as environment variable: export ID=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8 export SECRET=DZFpuNjRdt5xUEzxXovAp40bU3lQvoMvF3awEStn61RXWE0Ses4RgzHWKJKTvUCHfRkhcBi3ebsEfSjfEO96vo2Sh6pZlxJ6f7KcUbhvqMMPoVxRwv4vfdWEoWMGPeIO -Now let's generate an authentication code grant with PKCE (Proof Key for Code Exchange), useful to prevent authorization code injection. To do so, you must first generate a ``code_verifier`` random string between 43 and 128 characters, which is then encoded to produce a ``code_challenge``:: +Now let's generate an authentication code grant with PKCE (Proof Key for Code Exchange), useful to prevent authorization code injection. To do so, you must first generate a ``code_verifier`` random string between 43 and 128 characters, which is then encoded to produce a ``code_challenge``: .. sourcecode:: python From f4136bff03c659d404ea1c9640c93884e917c9fa Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jun 2022 15:59:38 -0400 Subject: [PATCH 468/722] [pre-commit.ci] pre-commit autoupdate (#1174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.2.0 → v4.3.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.2.0...v4.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0ec8dd601..6238f5788 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.2.0 + rev: v4.3.0 hooks: - id: check-ast - id: trailing-whitespace From a12a56e8f44ccfd5ef03c9921afe93f595bf7cfa Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Tue, 14 Jun 2022 08:54:30 -0400 Subject: [PATCH 469/722] Remove bulk_create due to changed behavior between dj32 and dj40. (#1171) --- tests/test_models.py | 130 ++++++++++++++++++++++++++++--------------- 1 file changed, 85 insertions(+), 45 deletions(-) diff --git a/tests/test_models.py b/tests/test_models.py index 9ce1e5eb7..15f89856b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -298,9 +298,11 @@ def setUp(self): super().setUp() # Insert many tokens, both expired and not, and grants. self.num_tokens = 100 - now = timezone.now() - earlier = now - timedelta(seconds=100) - later = now + timedelta(seconds=100) + self.delta_secs = 1000 + self.now = timezone.now() + self.earlier = self.now - timedelta(seconds=self.delta_secs) + self.later = self.now + timedelta(seconds=self.delta_secs) + app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", @@ -309,58 +311,54 @@ def setUp(self): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) # make 200 access tokens, half current and half expired. - expired_access_tokens = AccessToken.objects.bulk_create( - AccessToken(token="expired AccessToken {}".format(i), expires=earlier) + expired_access_tokens = [ + AccessToken(token="expired AccessToken {}".format(i), expires=self.earlier) for i in range(self.num_tokens) - ) - current_access_tokens = AccessToken.objects.bulk_create( - AccessToken(token=f"current AccessToken {i}", expires=later) for i in range(self.num_tokens) - ) + ] + for a in expired_access_tokens: + a.save() + + current_access_tokens = [ + AccessToken(token=f"current AccessToken {i}", expires=self.later) for i in range(self.num_tokens) + ] + for a in current_access_tokens: + a.save() + # Give the first half of the access tokens a refresh token, # alternating between current and expired ones. - RefreshToken.objects.bulk_create( + for i in range(0, len(expired_access_tokens) // 2, 2): RefreshToken( token=f"expired AT's refresh token {i}", application=app, - access_token=expired_access_tokens[i].pk, + access_token=expired_access_tokens[i], user=self.user, - ) - for i in range(0, len(expired_access_tokens) // 2, 2) - ) - RefreshToken.objects.bulk_create( + ).save() + + for i in range(1, len(current_access_tokens) // 2, 2): RefreshToken( token=f"current AT's refresh token {i}", application=app, - access_token=current_access_tokens[i].pk, + access_token=current_access_tokens[i], user=self.user, - ) - for i in range(1, len(current_access_tokens) // 2, 2) - ) + ).save() + # Make some grants, half of which are expired. - Grant.objects.bulk_create( + for i in range(self.num_tokens): Grant( user=self.user, code=f"old grant code {i}", application=app, - expires=earlier, + expires=self.earlier, redirect_uri="https://localhost/redirect", - ) - for i in range(self.num_tokens) - ) - Grant.objects.bulk_create( + ).save() + for i in range(self.num_tokens): Grant( user=self.user, code=f"new grant code {i}", application=app, - expires=later, + expires=self.later, redirect_uri="https://localhost/redirect", - ) - for i in range(self.num_tokens) - ) - - def test_clear_expired_tokens(self): - self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = 60 - assert clear_expired() is None + ).save() def test_clear_expired_tokens_incorect_timetype(self): self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = "A" @@ -372,19 +370,61 @@ def test_clear_expired_tokens_incorect_timetype(self): def test_clear_expired_tokens_with_tokens(self): self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_SIZE = 10 self.oauth2_settings.CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL = 0.0 - at_count = AccessToken.objects.count() - assert at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." - rt_count = RefreshToken.objects.count() - assert rt_count == self.num_tokens // 2, f"{self.num_tokens // 2} refresh tokens should exist." - gt_count = Grant.objects.count() - assert gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." + self.oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS = self.delta_secs // 2 + + # before clear_expired(), confirm setup as expected + initial_at_count = AccessToken.objects.count() + assert initial_at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." + initial_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() + assert ( + initial_expired_at_count == self.num_tokens + ), f"{self.num_tokens} expired access tokens should exist." + initial_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() + assert ( + initial_current_at_count == self.num_tokens + ), f"{self.num_tokens} current access tokens should exist." + initial_rt_count = RefreshToken.objects.count() + assert ( + initial_rt_count == self.num_tokens // 2 + ), f"{self.num_tokens // 2} refresh tokens should exist." + initial_rt_expired_at_count = RefreshToken.objects.filter(access_token__expires__lte=self.now).count() + assert ( + initial_rt_expired_at_count == initial_rt_count / 2 + ), "half the refresh tokens should be for expired access tokens." + initial_rt_current_at_count = RefreshToken.objects.filter(access_token__expires__gt=self.now).count() + assert ( + initial_rt_current_at_count == initial_rt_count / 2 + ), "half the refresh tokens should be for current access tokens." + initial_gt_count = Grant.objects.count() + assert initial_gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." + clear_expired() - at_count = AccessToken.objects.count() - assert at_count == self.num_tokens, "Half the access tokens should not have been deleted." - rt_count = RefreshToken.objects.count() - assert rt_count == self.num_tokens // 2, "Half of the refresh tokens should have been deleted." - gt_count = Grant.objects.count() - assert gt_count == self.num_tokens, "Half the grants should have been deleted." + + # after clear_expired(): + remaining_at_count = AccessToken.objects.count() + assert ( + remaining_at_count == initial_at_count // 2 + ), "half the initial access tokens should still exist." + remaining_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() + assert remaining_expired_at_count == 0, "no remaining expired access tokens should still exist." + remaining_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() + assert ( + remaining_current_at_count == initial_current_at_count + ), "all current access tokens should still exist." + remaining_rt_count = RefreshToken.objects.count() + assert remaining_rt_count == initial_rt_count // 2, "half the refresh tokens should still exist." + remaining_rt_expired_at_count = RefreshToken.objects.filter( + access_token__expires__lte=self.now + ).count() + assert remaining_rt_expired_at_count == 0, "no refresh tokens for expired AT's should still exist." + remaining_rt_current_at_count = RefreshToken.objects.filter( + access_token__expires__gt=self.now + ).count() + assert ( + remaining_rt_current_at_count == initial_rt_current_at_count + ), "all the refresh tokens for current access tokens should still exist." + remaining_gt_count = Grant.objects.count() + assert remaining_gt_count == initial_gt_count // 2, "half the remaining grants should still exist." @pytest.mark.django_db From 007a5c4935d4531a87818b67391b4187cbc13de9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Jun 2022 11:13:26 -0400 Subject: [PATCH 470/722] [pre-commit.ci] pre-commit autoupdate (#1176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/sphinx-contrib/sphinx-lint: v0.6 → v0.6.1](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.6...v0.6.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6238f5788..523f875b2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6 + rev: v0.6.1 hooks: - id: sphinx-lint From b94f69eb0c083679a0f85e3034945f4312573bd0 Mon Sep 17 00:00:00 2001 From: Owen Gong <phith0n.ph2f@gmail.com> Date: Wed, 22 Jun 2022 03:40:31 +0800 Subject: [PATCH 471/722] Added list_select_related to reduce duplicate SQL queries in admin UI (#1177) * added list_select_related to reduce duplicate SQL queries in admin UI * added author name in contributors --- AUTHORS | 1 + oauth2_provider/admin.py | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 0c46746e7..fa0b642e0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -79,3 +79,4 @@ Dominik George David Hill Darrel O'Pry Jordi Sanchez +Owen Gong diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index cf41ec5b2..cefc75bb6 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -48,6 +48,7 @@ class IDTokenAdmin(admin.ModelAdmin): raw_id_fields = ("user",) search_fields = ("user__email",) if has_email else () list_filter = ("application",) + list_select_related = ("application", "user") class RefreshTokenAdmin(admin.ModelAdmin): From 890657d7a9799554e2e2e9483dadb2c3b176dd38 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@crosswell.us> Date: Thu, 23 Jun 2022 10:56:13 -0400 Subject: [PATCH 472/722] Release 2.1.0 (#1175) * Correct supported releases of Django to include 4.0. * Clean up Changelog for 2.1 release. * Release 2.1.0 * Per @Andrew-Chen-Wang review Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> Co-authored-by: Andrew Chen Wang <60190294+Andrew-Chen-Wang@users.noreply.github.com> --- CHANGELOG.md | 23 ++++++++++++++++++++--- oauth2_provider/__init__.py | 2 +- setup.cfg | 2 +- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7df17aee2..e505cd33c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +## [2.1.0] 2022-06-19 + +### WARNING + +Issues caused by **Release 2.0.0 breaking changes** continue to be logged. Please **make sure to carefully read these release notes** before +performing a MAJOR upgrade to 2.x. + +These issues both result in `{"error": "invalid_client"}`: + +1. The application client secret is now hashed upon save. You must copy it before it is saved. Using the hashed value will fail. + +2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. + ### Added -* Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). -* #1163 Adds French translations. -* #1166 Add spanish (es) translations. +* #1164 Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). +* #1163 Add French (fr) translations. +* #1166 Add Spanish (es) translations. ### Changed * #1152 `createapplication` management command enhanced to display an auto-generated secret before it gets hashed. +* #1172, #1159, #1158 documentation improvements. + +### Fixed +* #1147 Fixed 2.0.0 implementation of [hashed](https://docs.djangoproject.com/en/stable/topics/auth/passwords/) client secret to work with swapped models. ## [2.0.0] 2022-04-24 diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 49a4433da..9be84ded1 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "2.0.0" +__version__ = "2.1.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/setup.cfg b/setup.cfg index 7fc5a9243..d8a51fef1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -32,7 +32,7 @@ zip_safe = False # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 2.2, != 4.0.0 + django >= 2.2, <= 4.1 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 From 691b6b261b0df7693849f0986f8716c2533254e4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Jul 2022 18:53:50 -0400 Subject: [PATCH 473/722] [pre-commit.ci] pre-commit autoupdate (#1184) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 523f875b2..3ca345580 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 22.6.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 8041939307b8fb173cf5395ce4649ed2fd8409d9 Mon Sep 17 00:00:00 2001 From: Tim Gates <tim.gates@iress.com> Date: Wed, 6 Jul 2022 01:35:34 +1000 Subject: [PATCH 474/722] docs: Fix a few typos (#1183) There are small typos in: - docs/advanced_topics.rst - docs/contributing.rst - docs/oidc.rst - docs/rest-framework/permissions.rst - docs/templates.rst - oauth2_provider/management/commands/createapplication.py - tests/test_authorization_code.py Fixes: - Should read `successful` rather than `successfull`. - Should read `unneeded` rather than `unneded`. - Should read `programmatically` rather than `programmaticaly`. - Should read `overriding` rather than `overiding`. - Should read `contributors` rather than `contrbutors`. - Should read `browsable` rather than `browseable`. - Should read `additional` rather than `addtional`. --- docs/advanced_topics.rst | 2 +- docs/contributing.rst | 2 +- docs/oidc.rst | 2 +- docs/rest-framework/permissions.rst | 4 ++-- docs/templates.rst | 2 +- oauth2_provider/management/commands/createapplication.py | 2 +- tests/test_authorization_code.py | 4 ++-- 7 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 3fa1519b1..ecba6bcdd 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -97,5 +97,5 @@ Skip authorization completely for trusted applications You might want to completely bypass the authorization form, for instance if your application is an in-house product or if you already trust the application owner by other means. To this end, you have to -set ``skip_authorization = True`` on the ``Application`` model, either programmaticaly or within the +set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the Django admin. Users will *not* be prompted for authorization, even on the first use of the application. diff --git a/docs/contributing.rst b/docs/contributing.rst index 00b4dbedc..a30c7d210 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -167,7 +167,7 @@ When you begin your PR, you'll be asked to provide the following: JazzBand security team `<security@jazzband.co>`. Do not file an issue on the tracker or submit a PR until directed to do so.) -* Make sure your name is in `AUTHORS`. We want to give credit to all contrbutors! +* Make sure your name is in `AUTHORS`. We want to give credit to all contributors! If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. diff --git a/docs/oidc.rst b/docs/oidc.rst index 2211a972a..2770722f0 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -317,7 +317,7 @@ The following example adds instructions to return the ``foo`` claim when the ``b Set ``oidc_claim_scope = None`` to return all claims irrespective of the granted scopes. -You have to make sure you've added addtional claims via ``get_additional_claims`` +You have to make sure you've added additional claims via ``get_additional_claims`` and defined the ``OAUTH2_PROVIDER["SCOPES"]`` in your settings in order for this functionality to work. .. note:: diff --git a/docs/rest-framework/permissions.rst b/docs/rest-framework/permissions.rst index ee398d9fc..31e00ff2b 100644 --- a/docs/rest-framework/permissions.rst +++ b/docs/rest-framework/permissions.rst @@ -70,8 +70,8 @@ IsAuthenticatedOrTokenHasScope ------------------------------ The `IsAuthenticatedOrTokenHasScope` permission class allows access only when the current access token has been authorized for **all** the scopes listed in the `required_scopes` field of the view but according to the request's method. It also allows access to Authenticated users who are authenticated in django, but were not authenticated through the OAuth2Authentication class. -This allows for protection of the API using scopes, but still let's users browse the full browseable API. -To restrict users to only browse the parts of the browseable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. +This allows for protection of the API using scopes, but still let's users browse the full browsable API. +To restrict users to only browse the parts of the browsable API they should be allowed to see, you can combine this with the DjangoModelPermission or the DjangoObjectPermission. For example: diff --git a/docs/templates.rst b/docs/templates.rst index 8ebcd4127..eae7e6fa0 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -168,7 +168,7 @@ This template gets passed the following template context variables: .. caution:: In the default implementation this template in extended by `application_registration_form.html`_. - Be sure to provide the same blocks if you are only overiding this template. + Be sure to provide the same blocks if you are only overriding this template. application_registration_form.html ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 12d7aa280..01a72377e 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -65,7 +65,7 @@ def handle(self, *args, **options): application_fields = [field.name for field in Application._meta.fields] application_data = {} for key, value in options.items(): - # Data in options must be cleaned because there are unneded key-value like + # Data in options must be cleaned because there are unneeded key-value like # verbosity and others. Also do not pass any None to the Application # instance so default values will be generated for those fields if key in application_fields and value: diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 924bdc1db..a5394cbd7 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1145,7 +1145,7 @@ def test_public(self): def test_public_pkce_S256_authorize_get(self): """ Request an access token using client_type: public - and PKCE enabled. Tests if the authorize get is successfull + and PKCE enabled. Tests if the authorize get is successful for the S256 algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") @@ -1172,7 +1172,7 @@ def test_public_pkce_S256_authorize_get(self): def test_public_pkce_plain_authorize_get(self): """ Request an access token using client_type: public - and PKCE enabled. Tests if the authorize get is successfull + and PKCE enabled. Tests if the authorize get is successful for the plain algorithm and form data are properly passed. """ self.client.login(username="test_user", password="123456") From 08bfa042c19489eac2697b6db39be731e2fbac75 Mon Sep 17 00:00:00 2001 From: Kaleb <kaleb.porter@bellhop.com> Date: Sat, 6 Aug 2022 10:43:33 -0400 Subject: [PATCH 475/722] Add 'code_verifier' parameter to token request (#1182) * Add 'code_verifier' parameter to token request Fixes #1178 * Address feedback --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/getting_started.rst | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index fa0b642e0..f7e995f09 100644 --- a/AUTHORS +++ b/AUTHORS @@ -49,6 +49,7 @@ Joseph Abrahams Jozef Knaperek Julien Palard Jun Zhou +Kaleb Porter Kristian Rune Larsen Michael Howitz Paul Dekkers diff --git a/CHANGELOG.md b/CHANGELOG.md index e505cd33c..4bbe6d414 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [unreleased] +* Add 'code_verifier' parameter to token requests in documentation ## [2.1.0] 2022-06-19 diff --git a/docs/getting_started.rst b/docs/getting_started.rst index b82774cd4..bb18f9042 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -305,7 +305,7 @@ Export it as an environment variable: Now that you have the user authorization is time to get an access token:: - curl -X POST -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "client_id=${ID}" -d "client_secret=${SECRET}" -d "code=${CODE}" -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" + curl -X POST -H "Cache-Control: no-cache" -H "Content-Type: application/x-www-form-urlencoded" "http://127.0.0.1:8000/o/token/" -d "client_id=${ID}" -d "client_secret=${SECRET}" -d "code=${CODE}" -d "code_verifier=${CODE_VERIFIER}" -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" -d "grant_type=authorization_code" To be more easy to visualize:: @@ -316,6 +316,7 @@ To be more easy to visualize:: -d "client_id=${ID}" \ -d "client_secret=${SECRET}" \ -d "code=${CODE}" \ + -d "code_verifier=${CODE_VERIFIER}" \ -d "redirect_uri=http://127.0.0.1:8000/noexist/callback" \ -d "grant_type=authorization_code" From f835a243811aa9fcb54f559350daf5758249c66b Mon Sep 17 00:00:00 2001 From: Florian Demmer <fdemmer@gmail.com> Date: Sat, 6 Aug 2022 18:21:17 +0200 Subject: [PATCH 476/722] Fix tox env used for flake8 in contributing docs (#1192) The tox environment to run flake8 changed in 6af081c8053dc9712cb4822f5c876d18269b7851. --- docs/contributing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing.rst b/docs/contributing.rst index a30c7d210..1d88bc4b0 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -263,7 +263,7 @@ Try reading our code and grasp the overall philosophy regarding method and varia the sake of readability, keep in mind that *simple is better than complex*. If you feel the code is not straightforward, add a comment. If you think a function is not trivial, add a docstrings. -To see if your code formatting will pass muster use: `tox -e py37-flake8` +To see if your code formatting will pass muster use: `tox -e flake8` The contents of this page are heavily based on the docs from `django-admin2 <https://github.com/twoscoops/django-admin2>`_ From 1b7a08bf944c6ada94a0bf18ff440a1338b2bba5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Sep 2022 12:46:52 -0400 Subject: [PATCH 477/722] [pre-commit.ci] pre-commit autoupdate (#1189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.6.0 → 22.8.0](https://github.com/psf/black/compare/22.6.0...22.8.0) - [github.com/PyCQA/flake8: 4.0.1 → 5.0.4](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3ca345580..a0d91ee1a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.6.0 + rev: 22.8.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -21,7 +21,7 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 5.0.4 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 235e3f84c3ad86c05f22c15dfaac202ad06d7654 Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Tue, 6 Sep 2022 18:30:35 +0100 Subject: [PATCH 478/722] Test Django 4.1 and remove upper version bound (#1203) * Remove upper version bound on Django 4.1 * fully support Django 4.1 * sections in changelog --- AUTHORS | 1 + CHANGELOG.md | 8 ++++++++ setup.cfg | 3 ++- tox.ini | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index f7e995f09..07c73ed17 100644 --- a/AUTHORS +++ b/AUTHORS @@ -8,6 +8,7 @@ Contributors ------------ Abhishek Patel +Adam Johnson Alan Crosswell Alejandro Mantecon Guillen Aleksander Vaskevich diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbe6d414..b11d7537f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [unreleased] + +### Added * Add 'code_verifier' parameter to token requests in documentation +### Changed +* Support Django 4.1. + +### Fixed +* Remove upper version bound on Django, to allow upgrading to Django 4.1.1 bugfix release. + ## [2.1.0] 2022-06-19 ### WARNING diff --git a/setup.cfg b/setup.cfg index d8a51fef1..bd4817e64 100644 --- a/setup.cfg +++ b/setup.cfg @@ -15,6 +15,7 @@ classifiers = Framework :: Django :: 2.2 Framework :: Django :: 3.2 Framework :: Django :: 4.0 + Framework :: Django :: 4.1 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent @@ -32,7 +33,7 @@ zip_safe = False # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 2.2, <= 4.1 + django >= 2.2, != 4.0.0 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 diff --git a/tox.ini b/tox.ini index 63a78e773..24a34de8c 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ envlist = py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, + py{38,39,310}-dj41, py{38,39,310}-djmain, [gh-actions] @@ -40,6 +41,7 @@ deps = dj22: Django>=2.2,<3 dj32: Django>=3.2,<3.3 dj40: Django>=4.0.0,<4.1 + dj41: Django>=4.1,<4.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 From fc0906d28abe65b3dc4d7d46f3b7b24e6762dad3 Mon Sep 17 00:00:00 2001 From: islam kamel <61625045+islam-kamel@users.noreply.github.com> Date: Sun, 18 Sep 2022 16:38:09 +0200 Subject: [PATCH 479/722] Fixbug Type error a bytes-like (#1204) Added encode 'utf-8' for code_verifier --- docs/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index bb18f9042..c266599e2 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -266,7 +266,7 @@ Now let's generate an authentication code grant with PKCE (Proof Key for Code Ex import hashlib code_verifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) - code_verifier = base64.urlsafe_b64encode(code_verifier) + code_verifier = base64.urlsafe_b64encode(code_verifier.encode('utf-8')) code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') From 9383e0881fa3b80d4b73e6899ae8c2fa32fd0a96 Mon Sep 17 00:00:00 2001 From: islam kamel <61625045+islam-kamel@users.noreply.github.com> Date: Thu, 22 Sep 2022 15:50:03 +0200 Subject: [PATCH 480/722] Hotfix 'Attribute error in generate code_challenge ex.' (#1205) * Hotfix 'Attribute error in generate code_challing ex.' * Added new authors 'Islam Kamel' --- AUTHORS | 1 + docs/getting_started.rst | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 07c73ed17..9b73935e9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,6 +3,7 @@ Authors Massimiliano Pippi Federico Frenguelli +Islam Kamel Contributors ------------ diff --git a/docs/getting_started.rst b/docs/getting_started.rst index c266599e2..75feaa4c2 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -268,7 +268,7 @@ Now let's generate an authentication code grant with PKCE (Proof Key for Code Ex code_verifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) code_verifier = base64.urlsafe_b64encode(code_verifier.encode('utf-8')) - code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() + code_challenge = hashlib.sha256(code_verifier).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') Take note of ``code_challenge`` since we will include it in the code flow URL. It should look something like ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM``. From da459a12f3ce4cb5ed4db65e750ea8396c9bd129 Mon Sep 17 00:00:00 2001 From: matiseni51 <matiseni51@hotmail.com> Date: Tue, 4 Oct 2022 16:58:23 +0200 Subject: [PATCH 481/722] Hotfix- CODE_CHALLENGE instead of CODE_VERIFIER in docs (#1208) * Hotfix- CODE_CHALLENGE instead of CODE_VERIFIER in docs * HotFix- code_challenge_method added for authorization call in docs * Fix: mandatory documentation to submit PR added --- AUTHORS | 1 + CHANGELOG.md | 3 +++ docs/getting_started.rst | 3 ++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 9b73935e9..87335bf8b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -53,6 +53,7 @@ Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen +Matias Seniquiel Michael Howitz Paul Dekkers Paul Oswald diff --git a/CHANGELOG.md b/CHANGELOG.md index b11d7537f..3ef0a37f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* Add 'code_challenge_method' parameter to authorization call in documentation + ### Added * Add 'code_verifier' parameter to token requests in documentation diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 75feaa4c2..91e523794 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -275,12 +275,13 @@ Take note of ``code_challenge`` since we will include it in the code flow URL. I To start the Authorization code flow go to this `URL`_ which is the same as shown below:: - http://127.0.0.1:8000/o/authorize/?response_type=code&code_challenge=XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback + http://127.0.0.1:8000/o/authorize/?response_type=code&code_challenge=XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM&code_challenge_method=S256&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback Note the parameters we pass: * **response_type**: ``code`` * **code_challenge**: ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM`` +* **code_challenge_method**: ``S256`` * **client_id**: ``vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8`` * **redirect_uri**: ``http://127.0.0.1:8000/noexist/callback`` From be34163ea8b1119c4120d1723e764ca626c5ab23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andrej=20Zb=C3=ADn?= <scooty-az@azet.sk> Date: Mon, 10 Oct 2022 15:52:06 +0200 Subject: [PATCH 482/722] handle oauthlib errors on create token requests (#1210) Co-authored-by: andrej <zbin.andrej@gmail.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/oauth2_backends.py | 14 +++-- tests/test_oauth2_backends.py | 95 +++++++++++++++++++++++++++++- 4 files changed, 104 insertions(+), 7 deletions(-) diff --git a/AUTHORS b/AUTHORS index 87335bf8b..bfaff78ad 100644 --- a/AUTHORS +++ b/AUTHORS @@ -16,6 +16,7 @@ Aleksander Vaskevich Alessandro De Angelis Alex Szabó Allisson Azevedo +Andrej Zbín Andrew Chen Wang Anvesh Agarwal Aristóbulo Meneses diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef0a37f9..02d9b8a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * Remove upper version bound on Django, to allow upgrading to Django 4.1.1 bugfix release. +* Handle oauthlib errors on create token requests ## [2.1.0] 2022-06-19 diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index dbebd3a8e..5328e3ecd 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -152,12 +152,14 @@ def create_token_response(self, request): uri, http_method, body, headers = self._extract_params(request) extra_credentials = self._get_extra_credentials(request) - headers, body, status = self.server.create_token_response( - uri, http_method, body, headers, extra_credentials - ) - uri = headers.get("Location", None) - - return uri, headers, body, status + try: + headers, body, status = self.server.create_token_response( + uri, http_method, body, headers, extra_credentials + ) + uri = headers.get("Location", None) + return uri, headers, body, status + except OAuth2Error as exc: + return None, exc.headers, exc.json, exc.status_code def create_revocation_response(self, request): """ diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index acff2cae9..03f288e9b 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -1,10 +1,13 @@ +import base64 import json import pytest +from django.contrib.auth import get_user_model from django.test import RequestFactory, TestCase +from django.utils.timezone import now, timedelta from oauth2_provider.backends import get_oauthlib_core -from oauth2_provider.models import redirect_to_uri_allowed +from oauth2_provider.models import get_access_token_model, get_application_model, redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore @@ -50,6 +53,96 @@ def test_application_json_extract_params(self): self.assertNotIn("password=123456", body) +UserModel = get_user_model() +ApplicationModel = get_application_model() +AccessTokenModel = get_access_token_model() + + +@pytest.mark.usefixtures("oauth2_settings") +class TestOAuthLibCoreBackendErrorHandling(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.oauthlib_core = OAuthLibCore() + self.user = UserModel.objects.create_user("john", "test@example.com", "123456") + self.app = ApplicationModel.objects.create( + name="app", + client_id="app_id", + client_secret="app_secret", + client_type=ApplicationModel.CLIENT_CONFIDENTIAL, + authorization_grant_type=ApplicationModel.GRANT_PASSWORD, + user=self.user, + ) + + def tearDown(self): + self.user.delete() + self.app.delete() + + def test_create_token_response_valid(self): + payload = ( + "grant_type=password&username=john&password=123456&client_id=app_id&client_secret=app_secret" + ) + request = self.factory.post( + "/o/token/", + payload, + content_type="application/x-www-form-urlencoded", + HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), + ) + + uri, headers, body, status = self.oauthlib_core.create_token_response(request) + self.assertEqual(status, 200) + + def test_create_token_response_query_params(self): + payload = ( + "grant_type=password&username=john&password=123456&client_id=app_id&client_secret=app_secret" + ) + request = self.factory.post( + "/o/token/?test=foo", + payload, + content_type="application/x-www-form-urlencoded", + HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), + ) + uri, headers, body, status = self.oauthlib_core.create_token_response(request) + + self.assertEqual(status, 400) + self.assertDictEqual( + json.loads(body), + {"error": "invalid_request", "error_description": "URL query parameters are not allowed"}, + ) + + def test_create_revocation_response_valid(self): + AccessTokenModel.objects.create( + user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) + ) + payload = "client_id=app_id&client_secret=app_secret&token=tokstr" + request = self.factory.post( + "/o/revoke_token/", + payload, + content_type="application/x-www-form-urlencoded", + HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), + ) + uri, headers, body, status = self.oauthlib_core.create_revocation_response(request) + self.assertEqual(status, 200) + + def test_create_revocation_response_query_params(self): + token = AccessTokenModel.objects.create( + user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) + ) + payload = "client_id=app_id&client_secret=app_secret&token=tokstr" + request = self.factory.post( + "/o/revoke_token/?test=foo", + payload, + content_type="application/x-www-form-urlencoded", + HTTP_AUTHORIZATION="Basic %s" % base64.b64encode(b"john:123456").decode(), + ) + uri, headers, body, status = self.oauthlib_core.create_revocation_response(request) + self.assertEqual(status, 400) + self.assertDictEqual( + json.loads(body), + {"error": "invalid_request", "error_description": "URL query parameters are not allowed"}, + ) + token.delete() + + class TestCustomOAuthLibCoreBackend(TestCase): """ Tests that the public API behaves as expected when we override From b56332ebf854fbd58d21c0e862cf306cdca9319a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 14 Oct 2022 13:28:26 +0200 Subject: [PATCH 483/722] [pre-commit.ci] pre-commit autoupdate (#1213) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0) - [github.com/sphinx-contrib/sphinx-lint: v0.6.1 → v0.6.6](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.6.1...v0.6.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0d91ee1a..870362dfa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.8.0 + rev: 22.10.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.1 + rev: v0.6.6 hooks: - id: sphinx-lint From 6dc4f897220fed0c93f86f1b7c7b8799e561bde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= <valberg@orn.li> Date: Tue, 18 Oct 2022 18:38:17 +0200 Subject: [PATCH 484/722] Release 2.2.0 (#1214) * Release 2.2.0 * Sort AUTHORS alphabetically Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 31 ++++++++++++++++--------------- CHANGELOG.md | 29 +++++++++++++++-------------- oauth2_provider/__init__.py | 2 +- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/AUTHORS b/AUTHORS index bfaff78ad..8d8547ecd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -3,7 +3,6 @@ Authors Massimiliano Pippi Federico Frenguelli -Islam Kamel Contributors ------------ @@ -16,6 +15,7 @@ Aleksander Vaskevich Alessandro De Angelis Alex Szabó Allisson Azevedo +Andrea Greco Andrej Zbín Andrew Chen Wang Anvesh Agarwal @@ -28,26 +28,33 @@ Bas van Oostveen Brian Helba Carl Schwan Daniel 'Vector' Kerr +Darrel O'Pry Dave Burkholder David Fischer +David Hill David Smith Dawid Wolski Diego Garcia +Dominik George Dulmandakh Sukhbaatar Dylan Giesler Dylan Tack +Eduardo Oliveira Emanuele Palazzetti Federico Dolce Frederico Vieira Hasan Ramezani -Hossein Shakiba Hiroki Kiyohara +Hossein Shakiba +Islam Kamel +Jadiel Teófilo Jens Timmerman Jerome Leclanche Jesse Gibbs Jim Graham Jonas Nygaard Pedersen Jonathan Steffan +Jordi Sanchez Joseph Abrahams Jozef Knaperek Julien Palard @@ -56,32 +63,26 @@ Kaleb Porter Kristian Rune Larsen Matias Seniquiel Michael Howitz +Owen Gong +Patrick Palacin Paul Dekkers Paul Oswald Pavel Tvrdík -Patrick Palacin Peter Carnesciali +Peter Karman Petr Dlouhý Rodney Richardson Rustem Saiargaliev +Rustem Saiargaliev Sandro Rodrigues +Shaheed Haque Shaun Stanworth Silvano Cerza Spencer Carroll Stéphane Raimbault Tom Evans +Vinay Karanam +Víðir Valberg Guðmundsson Will Beaufoy -Rustem Saiargaliev -Jadiel Teófilo pySilver Łukasz Skarżyński -Shaheed Haque -Peter Karman -Vinay Karanam -Eduardo Oliveira -Andrea Greco -Dominik George -David Hill -Darrel O'Pry -Jordi Sanchez -Owen Gong diff --git a/CHANGELOG.md b/CHANGELOG.md index 02d9b8a6c..ffe572aba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,20 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] -### Added -* Add 'code_challenge_method' parameter to authorization call in documentation - -### Added -* Add 'code_verifier' parameter to token requests in documentation - -### Changed -* Support Django 4.1. - -### Fixed -* Remove upper version bound on Django, to allow upgrading to Django 4.1.1 bugfix release. -* Handle oauthlib errors on create token requests - -## [2.1.0] 2022-06-19 +## [2.2.0] 2022-10-18 ### WARNING @@ -42,6 +29,20 @@ These issues both result in `{"error": "invalid_client"}`: 2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. + +### Added +* #1208 Add 'code_challenge_method' parameter to authorization call in documentation +* #1182 Add 'code_verifier' parameter to token requests in documentation + +### Changed +* #1203 Support Django 4.1. + +### Fixed +* #1203 Remove upper version bound on Django, to allow upgrading to Django 4.1.1 bugfix release. +* #1210 Handle oauthlib errors on create token requests + +## [2.1.0] 2022-06-19 + ### Added * #1164 Support `prompt=login` for the OIDC Authorization Code Flow end user [Authentication Request](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest). * #1163 Add French (fr) translations. diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 9be84ded1..aedd5a37f 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "2.1.0" +__version__ = "2.2.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 70eaf47f357db4f73eec1d4da1a91b0a2fc5c3bc Mon Sep 17 00:00:00 2001 From: matiseni51 <matiseni51@hotmail.com> Date: Sat, 22 Oct 2022 16:35:35 +0200 Subject: [PATCH 485/722] Hotfix- authorization_code_expire_seconds docs clarified (#1212) * Hotfix- authorization_code_expire_seconds docs clarified * Fix: Minor grammatical change --- CHANGELOG.md | 4 +++- docs/settings.rst | 11 ++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe572aba..1c5bf0d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Changed +* #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. + ## [2.2.0] 2022-10-18 ### WARNING @@ -29,7 +32,6 @@ These issues both result in `{"error": "invalid_client"}`: 2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. - ### Added * #1208 Add 'code_challenge_method' parameter to authorization call in documentation * #1182 Add 'code_verifier' parameter to token requests in documentation diff --git a/docs/settings.rst b/docs/settings.rst index 2ac31ccda..efd0cc0a8 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -29,9 +29,12 @@ List of available settings ACCESS_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: ``36000`` + The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients -can cache the token for a reasonable amount of time. (default: 36000) +can cache the token for a reasonable amount of time. ACCESS_TOKEN_MODEL ~~~~~~~~~~~~~~~~~~ @@ -69,9 +72,11 @@ this value if you wrote your own implementation (subclass of AUTHORIZATION_CODE_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``60`` + The number of seconds an authorization code remains valid. Requesting an access -token after this duration will fail. :rfc:`4.1.2` recommends a -10 minutes (600 seconds) duration. +token after this duration will fail. :rfc:`4.1.2` recommends expire after a short lifetime, +with 10 minutes (600 seconds) being the maximum acceptable. CLIENT_ID_GENERATOR_CLASS ~~~~~~~~~~~~~~~~~~~~~~~~~ From e0c2fc8aebf889a17fc7478dc2864fdeeb38aab1 Mon Sep 17 00:00:00 2001 From: Josh <josh@joshthomas.dev> Date: Mon, 31 Oct 2022 09:20:40 -0500 Subject: [PATCH 486/722] Add Python 3.11 to CI, tox, and trove classifiers (#1218) * add python 3.11 to CI, tox, and trove classifiers * update CHANGELOG and AUTHORS * python 3.11 only officially supported by Django 4.1+ --- .github/workflows/test.yml | 2 +- AUTHORS | 1 + CHANGELOG.md | 1 + setup.cfg | 1 + tox.ini | 5 +++-- 5 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6409b6861..afab425b6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,7 +9,7 @@ jobs: fail-fast: false max-parallel: 5 matrix: - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v2 diff --git a/AUTHORS b/AUTHORS index 8d8547ecd..9232f01e1 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,6 +56,7 @@ Jonas Nygaard Pedersen Jonathan Steffan Jordi Sanchez Joseph Abrahams +Josh Thomas Jozef Knaperek Julien Palard Jun Zhou diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5bf0d93..c6530385e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. +* #1218 Confim support for Python 3.11. ## [2.2.0] 2022-10-18 diff --git a/setup.cfg b/setup.cfg index bd4817e64..3004811a9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -24,6 +24,7 @@ classifiers = Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 Topic :: Internet :: WWW/HTTP [options] diff --git a/tox.ini b/tox.ini index 24a34de8c..44557156f 100644 --- a/tox.ini +++ b/tox.ini @@ -8,8 +8,8 @@ envlist = py{37,38,39}-dj22, py{37,38,39,310}-dj32, py{38,39,310}-dj40, - py{38,39,310}-dj41, - py{38,39,310}-djmain, + py{38,39,310,311}-dj41, + py{38,39,310,311}-djmain, [gh-actions] python = @@ -17,6 +17,7 @@ python = 3.8: py38, docs, flake8, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 + 3.11: py311 [pytest] django_find_project = false From a2b7beef493bc796633e99ee1bf6d78bfec10d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludwig=20H=C3=A4hne?= <lhaehne@gmail.com> Date: Fri, 18 Nov 2022 22:39:38 +0100 Subject: [PATCH 487/722] Clear expired ID tokens from database (#1223) The `cleartokens` management command removed expired refresh tokens and associated access tokens but kept expired ID tokens in the database. Remove ID tokens when the associated access and refresh tokens are cleared. Preserve expired ID tokens until the associated access token is deleted to keep relationships intact and not trigger delete cascades. Fixes #1222 --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/management_commands.rst | 2 ++ oauth2_provider/models.py | 7 +++++++ tests/models.py | 7 +++++++ tests/presets.py | 1 + tests/test_models.py | 39 ++++++++++++++++++++++++++++++++++++ 7 files changed, 58 insertions(+) diff --git a/AUTHORS b/AUTHORS index 9232f01e1..d12821c31 100644 --- a/AUTHORS +++ b/AUTHORS @@ -62,6 +62,7 @@ Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen +Ludwig Hähne Matias Seniquiel Michael Howitz Owen Gong diff --git a/CHANGELOG.md b/CHANGELOG.md index c6530385e..5d8cbd1e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed * #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. * #1218 Confim support for Python 3.11. +* #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command ## [2.2.0] 2022-10-18 diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 8e6eaaac2..770543375 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -22,6 +22,8 @@ problem since refresh tokens are long lived. To prevent the CPU and RAM high peaks during deletion process use ``CLEAR_EXPIRED_TOKENS_BATCH_SIZE`` and ``CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`` settings to adjust the process speed. +The ``cleartokens`` management command will also delete expired access and ID tokens alongside expired refresh tokens. + Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 1ded7a4e2..ebbc6d794 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -663,6 +663,7 @@ def batch_delete(queryset, query): refresh_expire_at = None access_token_model = get_access_token_model() refresh_token_model = get_refresh_token_model() + id_token_model = get_id_token_model() grant_model = get_grant_model() REFRESH_TOKEN_EXPIRE_SECONDS = oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS @@ -696,6 +697,12 @@ def batch_delete(queryset, query): access_tokens_delete_no = batch_delete(access_tokens, access_token_query) logger.info("%s Expired access tokens deleted", access_tokens_delete_no) + id_token_query = models.Q(access_token__isnull=True, expires__lt=now) + id_tokens = id_token_model.objects.filter(id_token_query) + + id_tokens_delete_no = batch_delete(id_tokens, id_token_query) + logger.info("%s Expired ID tokens deleted", id_tokens_delete_no) + grants_query = models.Q(expires__lt=now) grants = grant_model.objects.filter(grants_query) diff --git a/tests/models.py b/tests/models.py index 32f9a1b7c..355bc1b57 100644 --- a/tests/models.py +++ b/tests/models.py @@ -32,6 +32,13 @@ class SampleAccessToken(AbstractAccessToken): null=True, related_name="s_refreshed_access_token", ) + id_token = models.OneToOneField( + oauth2_settings.ID_TOKEN_MODEL, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="s_access_token", + ) class SampleRefreshToken(AbstractRefreshToken): diff --git a/tests/presets.py b/tests/presets.py index 6411687a4..4b207f25c 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -20,6 +20,7 @@ }, "DEFAULT_SCOPES": ["read", "write"], "PKCE_REQUIRED": False, + "REFRESH_TOKEN_EXPIRE_SECONDS": 3600, } OIDC_SETTINGS_RO = deepcopy(OIDC_SETTINGS_RW) OIDC_SETTINGS_RO["DEFAULT_SCOPES"] = ["read"] diff --git a/tests/test_models.py b/tests/test_models.py index 15f89856b..fe1fef084 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -462,6 +462,45 @@ def test_id_token_methods(oidc_tokens, rf): assert IDToken.objects.filter(jti=id_token.jti).count() == 0 +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf): + id_token = IDToken.objects.get() + access_token = id_token.access_token + + # All tokens still valid + clear_expired() + + assert IDToken.objects.filter(jti=id_token.jti).exists() + + earlier = timezone.now() - timedelta(minutes=1) + id_token.expires = earlier + id_token.save() + + # ID token should be preserved until the access token is deleted + clear_expired() + + assert IDToken.objects.filter(jti=id_token.jti).exists() + + access_token.expires = earlier + access_token.save() + + # ID and access tokens are expired but refresh token is still valid + clear_expired() + + assert IDToken.objects.filter(jti=id_token.jti).exists() + + # Mark refresh token as expired + delta = timedelta(seconds=oauth2_settings.REFRESH_TOKEN_EXPIRE_SECONDS + 60) + access_token.expires = timezone.now() - delta + access_token.save() + + # With the refresh token expired, the ID token should be deleted + clear_expired() + + assert not IDToken.objects.filter(jti=id_token.jti).exists() + + @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_key(oauth2_settings, application): From c4fe0716d9be9c478d9b0286b4e28a6276cf8171 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 5 Dec 2022 11:01:41 -0500 Subject: [PATCH 488/722] Pin flake8 version until flake8-quotes catches up. (#1227) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 44557156f..78bfec144 100644 --- a/tox.ini +++ b/tox.ini @@ -90,7 +90,7 @@ basepython = python3.8 skip_install = True commands = flake8 {toxinidir} deps = - flake8 + flake8<6.0.0 # TODO remove this pinned version once https://github.com/zheller/flake8-quotes/pull/111 is merged. flake8-isort flake8-quotes flake8-black From bf4afd45c8f953840e618bf6a7e50ccae764b074 Mon Sep 17 00:00:00 2001 From: skyarrow87 <80459567+skyarrow87@users.noreply.github.com> Date: Wed, 7 Dec 2022 01:38:08 +0900 Subject: [PATCH 489/722] Japanese language translation (#1225) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * create ja .po file * Translated into Japanese * Add Japanese(日本語) Language Support * Edit AUTHORS --- AUTHORS | 1 + CHANGELOG.md | 3 + .../locale/ja/LC_MESSAGES/django.po | 197 ++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 oauth2_provider/locale/ja/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index d12821c31..2d720f85d 100644 --- a/AUTHORS +++ b/AUTHORS @@ -80,6 +80,7 @@ Sandro Rodrigues Shaheed Haque Shaun Stanworth Silvano Cerza +Sora Yanai Spencer Carroll Stéphane Raimbault Tom Evans diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8cbd1e9..edc5f8abc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Added +* Add Japanese(日本語) Language Support + ### Changed * #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. * #1218 Confim support for Python 3.11. diff --git a/oauth2_provider/locale/ja/LC_MESSAGES/django.po b/oauth2_provider/locale/ja/LC_MESSAGES/django.po new file mode 100644 index 000000000..75e3be247 --- /dev/null +++ b/oauth2_provider/locale/ja/LC_MESSAGES/django.po @@ -0,0 +1,197 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2022-11-28 09:45+0900\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: Sora Yanai <sora@mail.skyarrow.xyz>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: ja-JP\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=1; plural=0;\n" +#: models.py:66 +msgid "Confidential" +msgstr "プライベート" + +#: models.py:67 +msgid "Public" +msgstr "公開" + +#: models.py:76 +msgid "Authorization code" +msgstr "認証コード" + +#: models.py:77 +msgid "Implicit" +msgstr "Implicit Flow" + +#: models.py:78 +msgid "Resource owner password-based" +msgstr "リソース所有者のパスワードに基づく" + +#: models.py:79 +msgid "Client credentials" +msgstr "ユーザ証明書" + +#: models.py:80 +msgid "OpenID connect hybrid" +msgstr "OpenID Connect ハイブリットフロー" + +#: models.py:87 +msgid "No OIDC support" +msgstr "OIDCをサポートしない" + +#: models.py:88 +msgid "RSA with SHA-2 256" +msgstr "RSA with SHA-2 256" + +#: models.py:89 +msgid "HMAC with SHA-2 256" +msgstr "HMAC with SHA-2 256" + +#: models.py:104 +msgid "Allowed URIs list, space separated" +msgstr "許可されるURLのリスト(半角スペース区切り)" + +#: models.py:113 +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "保存時にハッシュ化されます。新しいシークレットであれば、今すぐコピーしてください。" + +#: models.py:175 +#, python-brace-format +msgid "Unauthorized redirect scheme: {scheme}" +msgstr "{scheme} は許可されないリダイレクトスキームです" + +#: models.py:179 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "{grant_type} 認証タイプではリダイレクトURLを空欄にすることはできません" + +#: models.py:185 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "RSAアルゴリズムを使用する場合はOIDC_RSA_PRIVATE_KEYを設定する必要があります" + +#: models.py:194 +msgid "You cannot use HS256 with public grants or clients" +msgstr "HS256を公開認証やユーザに使用することはできません" + +#: oauth2_validators.py:211 +msgid "The access token is invalid." +msgstr "アクセストークンが無効です。" + +#: oauth2_validators.py:218 +msgid "The access token has expired." +msgstr "アクセストークンの有効期限が切れています。" + +#: oauth2_validators.py:225 +msgid "The access token is valid but does not have enough scope." +msgstr "アクセストークンは有効ですが、十分な権限を持っていません。" + +#: templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "アプリケーションを本当に削除してよろしいでしょうか?" + +#: templates/oauth2_provider/application_confirm_delete.html:12 +#: templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "キャンセル" + +#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_detail.html:38 +#: templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "削除" + +#: templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "ユーザID" + +#: templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "ユーザパスワード" + +#: templates/oauth2_provider/application_detail.html:20 +msgid "Client type" +msgstr "ユーザタイプ" + +#: templates/oauth2_provider/application_detail.html:25 +msgid "Authorization Grant Type" +msgstr "認証方式" + +#: templates/oauth2_provider/application_detail.html:30 +msgid "Redirect Uris" +msgstr "リダイレクトURL" + +#: templates/oauth2_provider/application_detail.html:36 +#: templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "戻る" + +#: templates/oauth2_provider/application_detail.html:37 +msgid "Edit" +msgstr "編集" + +#: templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "アプリケーションを編集する" + +#: templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "保存" + +#: templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "アプリケーション" + +#: templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "新規アプリケーション" + +#: templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "アプリケーションがありません" + +#: templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "ここをクリック" + +#: templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "して、新しいアプリケーションを登録" + +#: templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "新規アプリケーションの登録" + +#: templates/oauth2_provider/authorize.html:8 +#: templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "認証" + +#: templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "アプリケーションには以下の権限が必要です。" + +#: templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "このトークンを本当に削除してよろしいですか?" + +#: templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "トークン" + +#: templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "取り消す" + +#: templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "認証されたトークンはありません" From d3420e4e73157c75eec897045db921b48981aae1 Mon Sep 17 00:00:00 2001 From: g-normand <guillaume.normand.gn@gmail.com> Date: Tue, 6 Dec 2022 12:03:45 -0500 Subject: [PATCH 490/722] Update getting_started (#1224) There was a missing part about the code verifier. Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/getting_started.rst | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 91e523794..beff06a5a 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -273,6 +273,14 @@ Now let's generate an authentication code grant with PKCE (Proof Key for Code Ex Take note of ``code_challenge`` since we will include it in the code flow URL. It should look something like ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM``. + +Export ``code_verifier`` value as environment variable, it should be something like: + +.. sourcecode:: sh + + export CODE_VERIFIER=N0hHRVk2WDNCUUFPQTIwVDNZWEpFSjI4UElNV1pSTlpRUFBXNTEzU0QzRTMzRE85WDFWTzU2WU9ESw== + + To start the Authorization code flow go to this `URL`_ which is the same as shown below:: http://127.0.0.1:8000/o/authorize/?response_type=code&code_challenge=XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM&code_challenge_method=S256&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback From c0993e86c16596da0b585e66e95dff8e284692d4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 6 Dec 2022 13:05:22 -0500 Subject: [PATCH 491/722] [pre-commit.ci] pre-commit autoupdate (#1216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.3.0 → v4.4.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.3.0...v4.4.0) - [github.com/PyCQA/flake8: 5.0.4 → 6.0.0](https://github.com/PyCQA/flake8/compare/5.0.4...6.0.0) - [github.com/sphinx-contrib/sphinx-lint: v0.6.6 → v0.6.7](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.6.6...v0.6.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 870362dfa..1a6800af0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.3.0 + rev: v4.4.0 hooks: - id: check-ast - id: trailing-whitespace @@ -21,11 +21,11 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 5.0.4 + rev: 6.0.0 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.6 + rev: v0.6.7 hooks: - id: sphinx-lint From 90c3481ccfa780cc0f4fa5fc861b68c046158015 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 Dec 2022 15:53:26 -0500 Subject: [PATCH 492/722] [pre-commit.ci] pre-commit autoupdate (#1230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 22.10.0 → 22.12.0](https://github.com/psf/black/compare/22.10.0...22.12.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a6800af0..7bdfdfe08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.10.0 + rev: 22.12.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 04b5b3e49020168d8decc2c536f3287ad40bcfc3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Dec 2022 15:23:07 -0500 Subject: [PATCH 493/722] [pre-commit.ci] pre-commit autoupdate (#1234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: 5.10.1 → v5.11.3](https://github.com/PyCQA/isort/compare/5.10.1...v5.11.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7bdfdfe08..02cfab2f9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: v5.11.3 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From c02d4e48a4bf994b51462d6e7ccecc6fcdba3c05 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 4 Jan 2023 14:18:47 -0500 Subject: [PATCH 494/722] tox whitelist_externals deprecated and replaced with allowlist_externals (#1241) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 78bfec144..b04d01842 100644 --- a/tox.ini +++ b/tox.ini @@ -70,7 +70,7 @@ commands = [testenv:{docs,livedocs}] basepython = python3.8 changedir = docs -whitelist_externals = make +allowlist_externals = make commands = docs: make html livedocs: make livehtml From 8772ac75cb4d4dc918fb352306e6566e3cbd8ab6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 4 Jan 2023 15:40:25 -0500 Subject: [PATCH 495/722] [pre-commit.ci] pre-commit autoupdate (#1236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: v5.11.3 → 5.11.4](https://github.com/PyCQA/isort/compare/v5.11.3...5.11.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 02cfab2f9..a281d98e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: v5.11.3 + rev: 5.11.4 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 407a6d7e947406a4eaa204d3471b15b5a8dad261 Mon Sep 17 00:00:00 2001 From: Julian <Qup42@users.noreply.github.com> Date: Fri, 20 Jan 2023 19:28:14 +0100 Subject: [PATCH 496/722] Remove incompatible python+django version combinations from testing. (#1245) --- AUTHORS | 1 + tox.ini | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 2d720f85d..9bd1ea3fc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,6 +59,7 @@ Joseph Abrahams Josh Thomas Jozef Knaperek Julien Palard +Julian Mundhahs Jun Zhou Kaleb Porter Kristian Rune Larsen diff --git a/tox.ini b/tox.ini index b04d01842..7c30c7da5 100644 --- a/tox.ini +++ b/tox.ini @@ -9,7 +9,7 @@ envlist = py{37,38,39,310}-dj32, py{38,39,310}-dj40, py{38,39,310,311}-dj41, - py{38,39,310,311}-djmain, + py{310,311}-djmain, [gh-actions] python = From 31b769499769f4b6c1091bd875800e0ac6ae99bb Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Jan 2023 15:23:06 -0500 Subject: [PATCH 497/722] [pre-commit.ci] pre-commit autoupdate (#1246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: 5.11.4 → 5.12.0](https://github.com/PyCQA/isort/compare/5.11.4...5.12.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a281d98e9..08e5d5757 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.11.4 + rev: 5.12.0 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 2073168d6e49bafdb2411fd5d33f66432e143267 Mon Sep 17 00:00:00 2001 From: Julian <Qup42@users.noreply.github.com> Date: Sun, 12 Feb 2023 14:03:35 +0100 Subject: [PATCH 498/722] Format with black 23.1.0 (#1247) --- oauth2_provider/models.py | 1 - oauth2_provider/oauth2_validators.py | 1 - oauth2_provider/views/oidc.py | 1 - tests/test_oauth2_validators.py | 2 -- 4 files changed, 5 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index ebbc6d794..723328549 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -749,7 +749,6 @@ def redirect_to_uri_allowed(uri, allowed_uris): and parsed_allowed_uri.netloc == parsed_uri.netloc and parsed_allowed_uri.path == parsed_uri.path ): - aqs_set = set(parse_qsl(parsed_allowed_uri.query)) if aqs_set.issubset(uqs_set): return True diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b33c80f39..3e921ec99 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -572,7 +572,6 @@ def save_bearer_token(self, token, request, *args, **kwargs): and isinstance(refresh_token_instance, RefreshToken) and refresh_token_instance.access_token ): - access_token = AccessToken.objects.select_for_update().get( pk=refresh_token_instance.access_token.pk ) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index bb47d4f43..38560aea1 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -86,7 +86,6 @@ def get(self, request, *args, **kwargs): oauth2_settings.OIDC_RSA_PRIVATE_KEY, *oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, ]: - key = jwk.JWK.from_pem(pem.encode("utf8")) data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} data.update(json.loads(key.export_public())) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index fd06a1eda..2c062d616 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -160,7 +160,6 @@ def test_save_bearer_token__without_user__raises_fatal_client(self): self.validator.save_bearer_token(token, mock.MagicMock()) def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(self): - rotate_token_function = mock.MagicMock() rotate_token_function.return_value = False self.validator.rotate_refresh_token = rotate_token_function @@ -190,7 +189,6 @@ def test_save_bearer_token__with_existing_tokens__does_not_create_new_tokens(sel self.assertEqual(1, AccessToken.objects.count()) def test_save_bearer_token__checks_to_rotate_tokens(self): - rotate_token_function = mock.MagicMock() rotate_token_function.return_value = False self.validator.rotate_refresh_token = rotate_token_function From 62f9261fcb1983479db9baa2deed0945f67f8d81 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 12 Feb 2023 08:45:33 -0500 Subject: [PATCH 499/722] [pre-commit.ci] pre-commit autoupdate (#1248) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 22.12.0 → 23.1.0](https://github.com/psf/black/compare/22.12.0...23.1.0) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08e5d5757..f3ed2d68d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 22.12.0 + rev: 23.1.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From fc50ff19bbf0f3db3044160d2b72af59238e94f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jordi=20Neil=20S=C3=A1nchez?= <30764904+JordiNeil@users.noreply.github.com> Date: Sun, 12 Feb 2023 09:16:47 -0500 Subject: [PATCH 500/722] documentation seems to be outdated regarding rotate_refresh_token setting known bug (#1250) Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index efd0cc0a8..8566681ff 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -177,7 +177,7 @@ this value if you wrote your own implementation (subclass of ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. -Known bugs: `False` currently has a side effect of immediately revoking both access and refresh token on refreshing. +If `False`, it will reuse the same refresh token and only update the access token with a new token value. See also: validator's rotate_refresh_token method can be overridden to make this variable (could be usable with expiring refresh tokens, in particular, so that they are rotated when close to expiration, theoretically). From 13538a6bccd91a3baafd0821cae69d5fd3e9bac8 Mon Sep 17 00:00:00 2001 From: Marcus Sonestedt <marcus.s.lindblom@gmail.com> Date: Wed, 15 Feb 2023 16:17:30 +0100 Subject: [PATCH 501/722] Doc: Replace heroku service with postman in tutorial part 1 (#1251) * Replace heroku with postman tutorial * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update tutorial_01.rst * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update AUTHORS * Update docs/tutorial/tutorial_01.rst Co-authored-by: Alan Crosswell <alan@crosswell.us> * Update tutorial_01.rst --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + docs/tutorial/tutorial_01.rst | 40 +++++++++++++++++++++++------------ 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/AUTHORS b/AUTHORS index 9bd1ea3fc..8914badcc 100644 --- a/AUTHORS +++ b/AUTHORS @@ -90,3 +90,4 @@ Víðir Valberg Guðmundsson Will Beaufoy pySilver Łukasz Skarżyński +Marcus Sonestedt diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index f0b8cb3ed..1d53de78a 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -89,7 +89,7 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati * `Redirect uris`: Applications must register at least one redirection endpoint before using the authorization endpoint. The :term:`Authorization Server` will deliver the access token to the client only if the client specifies one of the verified redirection uris. For this tutorial, paste verbatim the value - `http://django-oauth-toolkit.herokuapp.com/consumer/exchange/` + `https://www.getpostman.com/oauth2/callback` * `Client type`: this value affects the security level at which some communications between the client application and the authorization server are performed. For this tutorial choose *Confidential*. @@ -105,17 +105,28 @@ process we'll explain shortly) Test Your Authorization Server ------------------------------ Your authorization server is ready and can begin issuing access tokens. To test the process you need an OAuth2 -consumer; if you are familiar enough with OAuth2, you can use curl, requests, or anything that speaks http. For the rest -of us, there is a `consumer service <http://django-oauth-toolkit.herokuapp.com/consumer/>`_ deployed on Heroku to test -your provider. +consumer; if you are familiar enough with OAuth2, you can use curl, requests, or anything that speaks http. + +For this tutorial, we suggest using [Postman](https://www.postman.com/downloads/) : + +Open up the Authorization tab under a request and, for this tutorial, set the fields as follows: + +* Grant type: `Authorization code (With PKCE)` +* Callback URL: `https://www.getpostman.com/oauth2/callback` <- need to be in your added application +* Authorize using browser: leave unchecked +* Auth URL: `http://localhost:8000/o/authorize/` +* Access Token URL: `http://localhost:8000/o/token/` +* Client ID: `random string for this app, as generated` +* Client Secret: `random string for this app, as generated` <- must be before hashing, should not begin with 'pbkdf2_sha256' or similar + +The rest can be left to their (mostly empty) default values. Build an Authorization Link for Your Users ++++++++++++++++++++++++++++++++++++++++++ Authorizing an application to access OAuth2 protected data in an :term:`Authorization Code` flow is always initiated -by the user. Your application can prompt users to click a special link to start the process. Go to the -`Consumer <http://django-oauth-toolkit.herokuapp.com/consumer/>`_ page and complete the form by filling in your -application's details obtained from the steps in this tutorial. Submit the form, and you'll receive a link your users can -use to access the authorization page. +by the user. Your application can prompt users to click a special link to start the process. + +Here, we click "Get New Access Token" in postman, which should open your browser and show django's login. Authorize the Application +++++++++++++++++++++++++ @@ -125,18 +136,19 @@ page is login protected by django-oauth-toolkit. Login, then you should see the her authorization to the client application. Flag the *Allow* checkbox and click *Authorize*, you will be redirected again to the consumer service. -__ loginTemplate_ +Possible errors: -If you are not redirected to the correct page after logging in successfully, -you probably need to `setup your login template correctly`__. +* loginTemplate: If you are not redirected to the correct page after logging in successfully, you probably need to `setup your login template correctly`__. +* invalid client: client id and client secret needs to be correct. Secret cannot be copied from Django admin after creation. + (but you can reset it by pasting the same random string into Django admin and into Postman, to avoid recreating the app) +* invalid callback url: Add the postman link into your app in Django admin. +* invalid_request: Use "Authorization Code (With PCKE)" from postman or disable PKCE in Django Exchange the token ++++++++++++++++++ At this point your authorization server redirected the user to a special page on the consumer passing in an :term:`Authorization Code`, a special token the consumer will use to obtain the final access token. -This operation is usually done automatically by the client application during the request/response cycle, but we cannot -make a POST request from Heroku to your localhost, so we proceed manually with this step. Fill the form with the -missing data and click *Submit*. + If everything is ok, you will be routed to another page showing your access token, the token type, its lifetime and the :term:`Refresh Token`. From bd865d60ee6c9eb0a2f5dc45c32bc2cc7cb6bee2 Mon Sep 17 00:00:00 2001 From: Josh Thomas <josh@joshthomas.dev> Date: Thu, 16 Feb 2023 14:48:14 -0600 Subject: [PATCH 502/722] Parallelize CI workflow by adding Django versions to job matrix (#1219) * add `django-version` to job matrix * adjust job name * add django 2.2 and adjust exclude matrix * remove max-parallel restriction * change comments to be clearer * remove extra newline * remove whitespace * chore: add success test decouple successful from specific matrix builds to avoid GH admin intervention as we update our supported matrix. --------- Co-authored-by: Darrel O'Pry <darrel.opry@spry-group.com> --- .github/workflows/test.yml | 27 ++++++++++++++++++++++++++- tox.ini | 8 ++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index afab425b6..46532305b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -4,12 +4,27 @@ on: [push, pull_request] jobs: build: + name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false - max-parallel: 5 matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + django-version: ['2.2', '3.2', '4.0', '4.1', 'main'] + exclude: + # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django + + # Python 3.10+ is not supported by Django 2.2 + - python-version: '3.10' + django-version: '2.2' + + # Python 3.7 is not supported by Django 4.0+ + - python-version: '3.7' + django-version: '4.0' + - python-version: '3.7' + django-version: '4.1' + - python-version: '3.7' + django-version: 'main' steps: - uses: actions/checkout@v2 @@ -42,8 +57,18 @@ jobs: - name: Tox tests run: | tox -v + env: + DJANGO: ${{ matrix.django-version }} - name: Upload coverage uses: codecov/codecov-action@v1 with: name: Python ${{ matrix.python-version }} + + success: + needs: build + runs-on: ubuntu-latest + name: Test successful + steps: + - name: Success + run: echo Test successful \ No newline at end of file diff --git a/tox.ini b/tox.ini index 7c30c7da5..12431ada6 100644 --- a/tox.ini +++ b/tox.ini @@ -19,6 +19,14 @@ python = 3.10: py310 3.11: py311 +[gh-actions:env] +DJANGO = + 2.2: dj22 + 3.2: dj32 + 4.0: dj40 + 4.1: dj41 + main: djmain + [pytest] django_find_project = false addopts = From a58fe4832e70367d7f70bebe7b20a930654b33df Mon Sep 17 00:00:00 2001 From: Jimmy Merrild Krag <beruic@gmail.com> Date: Tue, 7 Mar 2023 23:05:45 +0100 Subject: [PATCH 503/722] Update settings.rst (#1253) Minor correction --- docs/settings.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/settings.rst b/docs/settings.rst index 8566681ff..6b6939c9a 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -45,7 +45,7 @@ this value if you wrote your own implementation (subclass of ACCESS_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~ Import path of a callable used to generate access tokens. -oauthlib.oauth2.tokens.random_token_generator is (normally) used if not provided. +oauthlib.oauth2.rfc6749.tokens.random_token_generator is (normally) used if not provided. ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 7bef15971566a128e0da1f6a8a249465a2120d3c Mon Sep 17 00:00:00 2001 From: Julian <Qup42@users.noreply.github.com> Date: Wed, 22 Mar 2023 14:35:55 +0100 Subject: [PATCH 504/722] Remove incompatible python+django version combinations from testing. (#1255) --- .github/workflows/test.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 46532305b..caedf7b57 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,6 +26,12 @@ jobs: - python-version: '3.7' django-version: 'main' + # < Python 3.10 is not supported by Django 5.0+ + - python-version: '3.8' + django-version: 'main' + - python-version: '3.9' + django-version: 'main' + steps: - uses: actions/checkout@v2 From 769c0a2fd668f1a0dd3ca80ef8bfd76f8082eb57 Mon Sep 17 00:00:00 2001 From: Christian Clauss <cclauss@me.com> Date: Wed, 22 Mar 2023 15:04:36 +0100 Subject: [PATCH 505/722] Upgrade GitHub Actions (#1256) Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index caedf7b57..abe3b576f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,10 +33,10 @@ jobs: django-version: 'main' steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -47,7 +47,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v2 + uses: actions/cache@v3 with: path: ${{ steps.pip-cache.outputs.dir }} key: @@ -67,7 +67,7 @@ jobs: DJANGO: ${{ matrix.django-version }} - name: Upload coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v3 with: name: Python ${{ matrix.python-version }} @@ -77,4 +77,4 @@ jobs: name: Test successful steps: - name: Success - run: echo Test successful \ No newline at end of file + run: echo Test successful From edc47bf44fa89ba673d50f476cf37e765b209dbe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Apr 2023 18:07:25 -0400 Subject: [PATCH 506/722] [pre-commit.ci] pre-commit autoupdate (#1261) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f3ed2d68d..5bc7c5358 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.1.0 + rev: 23.3.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 9dd1033f65fa251137ac2d478d2a57028534d964 Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Fri, 14 Apr 2023 19:55:59 +0100 Subject: [PATCH 507/722] Test on Django 4.2 (#1264) * Test on Django 4.2 * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .github/workflows/test.yml | 4 +++- README.rst | 7 +------ setup.cfg | 1 + tests/settings.py | 6 +++++- tox.ini | 3 +++ 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index abe3b576f..00707f35b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ jobs: fail-fast: false matrix: python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - django-version: ['2.2', '3.2', '4.0', '4.1', 'main'] + django-version: ['2.2', '3.2', '4.0', '4.1', '4.2', 'main'] exclude: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django @@ -23,6 +23,8 @@ jobs: django-version: '4.0' - python-version: '3.7' django-version: '4.1' + - python-version: '3.7' + django-version: '4.2' - python-version: '3.7' django-version: 'main' diff --git a/README.rst b/README.rst index 3acf459d8..e43ea032c 100644 --- a/README.rst +++ b/README.rst @@ -35,11 +35,6 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o `OAuthLib <https://github.com/idan/oauthlib>`_, so that everything is `rfc-compliant <http://tools.ietf.org/html/rfc6749>`_. -Note: If you have issues installing Django 4.0.0, it is because we only support -Django 4.0.1+ due to a regression in Django 4.0.0. Besides 4.0.0, Django 2.2+ is supported. -`Explanation <https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272>`_. - - Reporting security issues ------------------------- @@ -49,7 +44,7 @@ Requirements ------------ * Python 3.7+ -* Django 2.2, 3.2, or >=4.0.1 +* Django 2.2, 3.2, 4.0 (4.0.1+ due to a regression), 4.1, or 4.2 * oauthlib 3.1+ Installation diff --git a/setup.cfg b/setup.cfg index 3004811a9..8acc93c9a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,6 +16,7 @@ classifiers = Framework :: Django :: 3.2 Framework :: Django :: 4.0 Framework :: Django :: 4.1 + Framework :: Django :: 4.2 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent diff --git a/tests/settings.py b/tests/settings.py index 9315a6e39..db807947c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,3 +1,6 @@ +import django + + ADMINS = () MANAGERS = ADMINS @@ -23,7 +26,8 @@ SITE_ID = 1 USE_I18N = True -USE_L10N = True +if django.VERSION < (4, 0): + USE_L10N = True USE_TZ = True MEDIA_ROOT = "" diff --git a/tox.ini b/tox.ini index 12431ada6..b907399a5 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ envlist = py{37,38,39,310}-dj32, py{38,39,310}-dj40, py{38,39,310,311}-dj41, + py{38,39,310,311}-dj42, py{310,311}-djmain, [gh-actions] @@ -25,6 +26,7 @@ DJANGO = 3.2: dj32 4.0: dj40 4.1: dj41 + 4.2: dj42 main: djmain [pytest] @@ -51,6 +53,7 @@ deps = dj32: Django>=3.2,<3.3 dj40: Django>=4.0.0,<4.1 dj41: Django>=4.1,<4.2 + dj42: Django>=4.2,<4.3 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 From 25f6de50d4b7287a76c87e1cf511d8f9a61c6faa Mon Sep 17 00:00:00 2001 From: Egor <Akay7@users.noreply.github.com> Date: Mon, 8 May 2023 20:32:04 +0700 Subject: [PATCH 508/722] Actualize docs about AuthenticationMiddleware (#1267) * actualize docs about AuthenticationMiddleware * add myself to authors --- AUTHORS | 1 + docs/tutorial/tutorial_03.rst | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 8914badcc..8887b9919 100644 --- a/AUTHORS +++ b/AUTHORS @@ -40,6 +40,7 @@ Dulmandakh Sukhbaatar Dylan Giesler Dylan Tack Eduardo Oliveira +Egor Poderiagin Emanuele Palazzetti Federico Dolce Frederico Vieira diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 64ba8d495..09486c3d6 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -24,9 +24,9 @@ which takes care of token verification. In your settings.py: MIDDLEWARE = [ '...', - # If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. - # SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. - 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + # If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. + # AuthenticationMiddleware is NOT required for using django-oauth-toolkit. + 'django.contrib.auth.middleware.AuthenticationMiddleware', 'oauth2_provider.middleware.OAuth2TokenMiddleware', '...', ] @@ -44,8 +44,8 @@ not used at all, it will try to authenticate user with the OAuth2 access token a `request.user` and `request._cached_user` fields so that AuthenticationMiddleware (when active) will not try to get user from the session. -If you use SessionAuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. -However SessionAuthenticationMiddleware is NOT required for using django-oauth-toolkit. +If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. +However AuthenticationMiddleware is NOT required for using django-oauth-toolkit. Protect your view ----------------- From 11294ab5678691fb6bc21ecf917dca3099311a9e Mon Sep 17 00:00:00 2001 From: Julian <14220769+Qup42@users.noreply.github.com> Date: Fri, 12 May 2023 18:45:26 +0200 Subject: [PATCH 509/722] Implement OIDC RP-Initiated Logout (#1244) Implement OIDC RP-Initiated Logout see: https://openid.net/specs/openid-connect-rpinitiated-1_0.html --------- Co-authored-by: Julian Mundhahs <Qup42@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 2 +- CHANGELOG.md | 1 + docs/advanced_topics.rst | 1 + docs/management_commands.rst | 4 + docs/oidc.rst | 26 ++ docs/settings.rst | 35 ++ oauth2_provider/exceptions.py | 46 ++ oauth2_provider/forms.py | 14 + .../management/commands/createapplication.py | 6 + ...7_application_post_logout_redirect_uris.py | 18 + oauth2_provider/models.py | 15 + oauth2_provider/settings.py | 5 + .../oauth2_provider/logout_confirm.html | 37 ++ oauth2_provider/urls.py | 1 + oauth2_provider/views/__init__.py | 2 +- oauth2_provider/views/mixins.py | 24 ++ oauth2_provider/views/oidc.py | 287 ++++++++++++- tests/conftest.py | 168 ++++++-- ...tion_post_logout_redirect_uris_and_more.py | 26 ++ tests/presets.py | 9 + tests/test_mixins.py | 38 ++ tests/test_oidc_views.py | 400 +++++++++++++++++- 22 files changed, 1116 insertions(+), 49 deletions(-) create mode 100644 oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py create mode 100644 oauth2_provider/templates/oauth2_provider/logout_confirm.html create mode 100644 tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py diff --git a/AUTHORS b/AUTHORS index 8887b9919..47a2aeaf2 100644 --- a/AUTHORS +++ b/AUTHORS @@ -59,8 +59,8 @@ Jordi Sanchez Joseph Abrahams Josh Thomas Jozef Knaperek -Julien Palard Julian Mundhahs +Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen diff --git a/CHANGELOG.md b/CHANGELOG.md index edc5f8abc..8c92aa849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Add Japanese(日本語) Language Support +* [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) ### Changed * #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index ecba6bcdd..12fd7c04a 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -20,6 +20,7 @@ logo, acceptance of some user agreement and so on. * :attr:`client_id` The client identifier issued to the client during the registration process as described in :rfc:`2.2` * :attr:`user` ref to a Django user * :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space + * :attr:`post_logout_redirect_uris` The list of allowed redirect uris after an RP initiated logout. The string consists of valid URLs separated by space * :attr:`client_type` Client type as described in :rfc:`2.1` * :attr:`authorization_grant_type` Authorization flows available to the Application * :attr:`client_secret` Confidential secret issued to the client during the registration process as described in :rfc:`2.2` diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 770543375..aa36e2ebf 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -38,6 +38,7 @@ The ``createapplication`` management command provides a shortcut to create a new usage: manage.py createapplication [-h] [--client-id CLIENT_ID] [--user USER] [--redirect-uris REDIRECT_URIS] + [--post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS] [--client-secret CLIENT_SECRET] [--name NAME] [--skip-authorization] [--algorithm ALGORITHM] [--version] @@ -64,6 +65,9 @@ The ``createapplication`` management command provides a shortcut to create a new --redirect-uris REDIRECT_URIS The redirect URIs, this must be a space separated string e.g 'URI1 URI2' + --post-logout-redirect-uris POST_LOGOUT_REDIRECT_URIS + The post logout redirect URIs, this must be a space + separated string e.g 'URI1 URI2' --client-secret CLIENT_SECRET The secret for this application --name NAME The name this application diff --git a/docs/oidc.rst b/docs/oidc.rst index 2770722f0..c06af5c1a 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -23,6 +23,8 @@ We support: * OpenID Connect Implicit Flow * OpenID Connect Hybrid Flow +Furthermore ``django-oauth-toolkit`` also supports `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_. + Configuration ============= @@ -147,6 +149,23 @@ scopes in your ``settings.py``:: If you want to enable ``RS256`` at a later date, you can do so - just add the private key as described above. + +RP-Initiated Logout +~~~~~~~~~~~~~~~~~~~ +This feature has to be enabled separately as it is an extension to the core standard. + +.. code-block:: python + + OAUTH2_PROVIDER = { + # OIDC has to be enabled to use RP-Initiated Logout + "OIDC_ENABLED": True, + # Enable and configure RP-Initiated Logout + "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, + "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True, + # ... any other settings you want + } + + Setting up OIDC enabled clients =============================== @@ -403,3 +422,10 @@ UserInfoView Available at ``/o/userinfo/``, this view provides extra user details. You can customize the details included in the response as described above. + + +RPInitiatedLogoutView +~~~~~~~~~~~~~~~~~~~~~ + +Available at ``/o/rp-initiated-logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner` +is logged out at the :term:`Authorization Server` (OpenID Provider). diff --git a/docs/settings.rst b/docs/settings.rst index 6b6939c9a..f31aff533 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -313,6 +313,41 @@ this you must also provide the service at that endpoint. If unset, the default location is used, eg if ``django-oauth-toolkit`` is mounted at ``/o/``, it will be ``<server-address>/o/userinfo/``. +OIDC_RP_INITIATED_LOGOUT_ENABLED +~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``False`` + +When is set to `False` (default) the `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_ +endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party) +to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider). + +OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``True`` + +Whether to always prompt the :term:`Resource Owner` (End User) to confirm a logout requested by a +:term:`Client` (Relying Party). If it is disabled the :term:`Resource Owner` (End User) will only be prompted if required by the standard. + +OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``False`` + +Enable this setting to require `https` in post logout redirect URIs. `http` is only allowed when a :term:`Client` is `confidential`. + +OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``True`` + +Whether expired ID tokens are accepted for RP-Initiated Logout. The Tokens must still be signed by the OP and otherwise valid. + +OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``True`` + +Whether to delete the access, refresh and ID tokens of the user that is being logged out. +The types of applications for which tokens are deleted can be customized with `RPInitiatedLogoutView.token_types_to_delete`. +The default is to delete the tokens of all applications if this flag is enabled. + OIDC_ISS_ENDPOINT ~~~~~~~~~~~~~~~~~ Default: ``""`` diff --git a/oauth2_provider/exceptions.py b/oauth2_provider/exceptions.py index c4208488d..f68a651b6 100644 --- a/oauth2_provider/exceptions.py +++ b/oauth2_provider/exceptions.py @@ -17,3 +17,49 @@ class FatalClientError(OAuthToolkitError): """ pass + + +class OIDCError(Exception): + """ + General class to derive from for all OIDC related errors. + """ + + status_code = 400 + error = None + + def __init__(self, description=None): + if description is not None: + self.description = description + + message = "({}) {}".format(self.error, self.description) + super().__init__(message) + + +class InvalidRequestFatalError(OIDCError): + """ + For fatal errors. These are requests with invalid parameter values, missing parameters or otherwise + incorrect requests. + """ + + error = "invalid_request" + + +class ClientIdMissmatch(InvalidRequestFatalError): + description = "Mismatch between the Client ID of the ID Token and the Client ID that was provided." + + +class InvalidOIDCClientError(InvalidRequestFatalError): + description = "The client is unknown or no client has been included." + + +class InvalidOIDCRedirectURIError(InvalidRequestFatalError): + description = "Invalid post logout redirect URI." + + +class InvalidIDTokenError(InvalidRequestFatalError): + description = "The ID Token is expired, revoked, malformed, or otherwise invalid." + + +class LogoutDenied(OIDCError): + error = "logout_denied" + description = "Logout has been refused by the user." diff --git a/oauth2_provider/forms.py b/oauth2_provider/forms.py index 876213626..113ab3f53 100644 --- a/oauth2_provider/forms.py +++ b/oauth2_provider/forms.py @@ -12,3 +12,17 @@ class AllowForm(forms.Form): code_challenge = forms.CharField(required=False, widget=forms.HiddenInput()) code_challenge_method = forms.CharField(required=False, widget=forms.HiddenInput()) claims = forms.CharField(required=False, widget=forms.HiddenInput()) + + +class ConfirmLogoutForm(forms.Form): + allow = forms.BooleanField(required=False) + id_token_hint = forms.CharField(required=False, widget=forms.HiddenInput()) + logout_hint = forms.CharField(required=False, widget=forms.HiddenInput()) + client_id = forms.CharField(required=False, widget=forms.HiddenInput()) + post_logout_redirect_uri = forms.CharField(required=False, widget=forms.HiddenInput()) + state = forms.CharField(required=False, widget=forms.HiddenInput()) + ui_locales = forms.CharField(required=False, widget=forms.HiddenInput()) + + def __init__(self, *args, **kwargs): + self.request = kwargs.pop("request", None) + super(ConfirmLogoutForm, self).__init__(*args, **kwargs) diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index 01a72377e..dcc46e765 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -37,6 +37,12 @@ def add_arguments(self, parser): type=str, help="The redirect URIs, this must be a space separated string e.g 'URI1 URI2'", ) + parser.add_argument( + "--post-logout-redirect-uris", + type=str, + help="The post logout redirect URIs, this must be a space separated string e.g 'URI1 URI2'", + default="", + ) parser.add_argument( "--client-secret", type=str, diff --git a/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py new file mode 100644 index 000000000..6eba65118 --- /dev/null +++ b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-01-14 12:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("oauth2_provider", "0006_alter_application_client_secret"), + ] + + operations = [ + migrations.AddField( + model_name="application", + name="post_logout_redirect_uris", + field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 723328549..3779ed491 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -52,6 +52,9 @@ class AbstractApplication(models.Model): * :attr:`user` ref to a Django user * :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space + * :attr:`post_logout_redirect_uris` The list of allowed redirect uris after + an RP initiated logout. The string + consists of valid URLs separated by space * :attr:`client_type` Client type as described in :rfc:`2.1` * :attr:`authorization_grant_type` Authorization flows available to the Application @@ -103,6 +106,10 @@ class AbstractApplication(models.Model): blank=True, help_text=_("Allowed URIs list, space separated"), ) + post_logout_redirect_uris = models.TextField( + blank=True, + help_text=_("Allowed Post Logout URIs list, space separated"), + ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) client_secret = ClientSecretField( @@ -150,6 +157,14 @@ def redirect_uri_allowed(self, uri): """ return redirect_to_uri_allowed(uri, self.redirect_uris.split()) + def post_logout_redirect_uri_allowed(self, uri): + """ + Checks if given URI is one of the items in :attr:`post_logout_redirect_uris` string + + :param uri: URI to check + """ + return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split()) + def clean(self): from django.core.exceptions import ValidationError diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 00a4e631c..aa7de7351 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -88,6 +88,11 @@ "client_secret_post", "client_secret_basic", ], + "OIDC_RP_INITIATED_LOGOUT_ENABLED": False, + "OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT": True, + "OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS": False, + "OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS": True, + "OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS": True, # Special settings that will be evaluated at runtime "_SCOPES": [], "_DEFAULT_SCOPES": [], diff --git a/oauth2_provider/templates/oauth2_provider/logout_confirm.html b/oauth2_provider/templates/oauth2_provider/logout_confirm.html new file mode 100644 index 000000000..8b64f8314 --- /dev/null +++ b/oauth2_provider/templates/oauth2_provider/logout_confirm.html @@ -0,0 +1,37 @@ +{% extends "oauth2_provider/base.html" %} + +{% load i18n %} +{% block content %} + <div class="block-center"> + {% if not error %} + <form id="authorizationForm" method="post"> + {% if application %} + <h3 class="block-center-heading">Confirm Logout requested by {{ application.name }}</h3> + {% else %} + <h3 class="block-center-heading">Confirm Logout</h3> + {% endif %} + {% csrf_token %} + + {% for field in form %} + {% if field.is_hidden %} + {{ field }} + {% endif %} + {% endfor %} + + {{ form.errors }} + {{ form.non_field_errors }} + + <div class="control-group"> + <div class="controls"> + <input type="submit" class="btn btn-large" value="Cancel"/> + <input type="submit" class="btn btn-large btn-primary" name="allow" value="Logout"/> + </div> + </div> + </form> + + {% else %} + <h2>Error: {{ error.error }}</h2> + <p>{{ error.description }}</p> + {% endif %} + </div> +{% endblock %} diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 508f97c96..4d23a3a5f 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -38,6 +38,7 @@ ), re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), + re_path(r"^logout/$", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), ] diff --git a/oauth2_provider/views/__init__.py b/oauth2_provider/views/__init__.py index 0720c1aa2..9e32e17d8 100644 --- a/oauth2_provider/views/__init__.py +++ b/oauth2_provider/views/__init__.py @@ -15,5 +15,5 @@ ScopedProtectedResourceView, ) from .introspect import IntrospectTokenView -from .oidc import ConnectDiscoveryInfoView, JwksInfoView, UserInfoView +from .oidc import ConnectDiscoveryInfoView, JwksInfoView, RPInitiatedLogoutView, UserInfoView from .token import AuthorizedTokenDeleteView, AuthorizedTokensListView diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index ebb654216..b3d9ab2f2 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -326,3 +326,27 @@ def dispatch(self, *args, **kwargs): log.warning(self.debug_error_message) return HttpResponseNotFound() return super().dispatch(*args, **kwargs) + + +class OIDCLogoutOnlyMixin(OIDCOnlyMixin): + """ + Mixin for views that should only be accessible when OIDC and OIDC RP-Initiated Logout are enabled. + + If either is not enabled: + + * if DEBUG is True, raises an ImproperlyConfigured exception explaining why + * otherwise, returns a 404 response, logging the same warning + """ + + debug_error_message = ( + "The django-oauth-toolkit OIDC RP-Initiated Logout view is not enabled unless you " + "have configured OIDC_RP_INITIATED_LOGOUT_ENABLED in the settings" + ) + + def dispatch(self, *args, **kwargs): + if not oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + if settings.DEBUG: + raise ImproperlyConfigured(self.debug_error_message) + log.warning(self.debug_error_message) + return HttpResponseNotFound() + return super().dispatch(*args, **kwargs) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 38560aea1..f819388b9 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,16 +1,36 @@ import json from urllib.parse import urlparse +from django.contrib.auth import logout from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from django.views.generic import View -from jwcrypto import jwk +from django.views.generic import FormView, View +from jwcrypto import jwk, jwt +from jwcrypto.common import JWException +from jwcrypto.jws import InvalidJWSObject +from jwcrypto.jwt import JWTExpired +from oauthlib.common import add_params_to_uri -from ..models import get_application_model +from ..exceptions import ( + ClientIdMissmatch, + InvalidIDTokenError, + InvalidOIDCClientError, + InvalidOIDCRedirectURIError, + LogoutDenied, + OIDCError, +) +from ..forms import ConfirmLogoutForm +from ..http import OAuth2ResponseRedirect +from ..models import ( + get_access_token_model, + get_application_model, + get_id_token_model, + get_refresh_token_model, +) from ..settings import oauth2_settings -from .mixins import OAuthLibMixin, OIDCOnlyMixin +from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin Application = get_application_model() @@ -33,6 +53,10 @@ def get(self, request, *args, **kwargs): reverse("oauth2_provider:user-info") ) jwks_uri = request.build_absolute_uri(reverse("oauth2_provider:jwks-info")) + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + end_session_endpoint = request.build_absolute_uri( + reverse("oauth2_provider:rp-initiated-logout") + ) else: parsed_url = urlparse(oauth2_settings.OIDC_ISS_ENDPOINT) host = parsed_url.scheme + "://" + parsed_url.netloc @@ -42,6 +66,8 @@ def get(self, request, *args, **kwargs): host, reverse("oauth2_provider:user-info") ) jwks_uri = "{}{}".format(host, reverse("oauth2_provider:jwks-info")) + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + end_session_endpoint = "{}{}".format(host, reverse("oauth2_provider:rp-initiated-logout")) signing_algorithms = [Application.HS256_ALGORITHM] if oauth2_settings.OIDC_RSA_PRIVATE_KEY: @@ -69,6 +95,8 @@ def get(self, request, *args, **kwargs): ), "claims_supported": oidc_claims, } + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: + data["end_session_endpoint"] = end_session_endpoint response = JsonResponse(data) response["Access-Control-Allow-Origin"] = "*" return response @@ -120,3 +148,254 @@ def _create_userinfo_response(self, request): for k, v in headers.items(): response[k] = v return response + + +def _load_id_token(token): + """ + Loads an IDToken given its string representation for use with RP-Initiated Logout. + A tuple (IDToken, claims) is returned. Depending on the configuration expired tokens may be loaded. + If loading failed (None, None) is returned. + """ + IDToken = get_id_token_model() + validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() + + try: + key = validator._get_key_for_token(token) + except InvalidJWSObject: + # Failed to deserialize the key. + return None, None + + # Could not identify key from the ID Token. + if not key: + return None, None + + try: + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS: + # Only check the following while loading the JWT + # - claims are dict + # - the Claims defined in RFC7519 if present have the correct type (string, integer, etc.) + # The claim contents are not validated. `exp` and `nbf` in particular are not validated. + check_claims = {} + else: + # Also validate the `exp` (expiration time) and `nbf` (not before) claims. + check_claims = None + jwt_token = jwt.JWT(key=key, jwt=token, check_claims=check_claims) + claims = json.loads(jwt_token.claims) + + # Assumption: the `sub` claim and `user` property of the corresponding IDToken Object point to the + # same user. + # To verify that the IDToken was intended for the user it is therefore sufficient to check the `user` + # attribute on the IDToken Object later on. + + return IDToken.objects.get(jti=claims["jti"]), claims + + except (JWException, JWTExpired, IDToken.DoesNotExist): + return None, None + + +def _validate_claims(request, claims): + """ + Validates the claims of an IDToken for use with OIDC RP-Initiated Logout. + """ + validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() + + # Verification of `iss` claim is mandated by OIDC RP-Initiated Logout specs. + if "iss" not in claims or claims["iss"] != validator.get_oidc_issuer_endpoint(request): + # IDToken was not issued by this OP, or it can not be verified. + return False + + return True + + +def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri): + """ + Validate an OIDC RP-Initiated Logout Request. + `(prompt_logout, (post_logout_redirect_uri, application))` is returned. + + `prompt_logout` indicates whether the logout has to be confirmed by the user. This happens if the + specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. + `post_logout_redirect_uri` is the validated URI where the User should be redirected to after the + logout. Can be None. None will redirect to "/" of this app. If it is set `application` will also + be set to the Application that is requesting the logout. + + The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they + will be validated against each other. + """ + + id_token = None + must_prompt_logout = True + if id_token_hint: + # Only basic validation has been done on the IDToken at this point. + id_token, claims = _load_id_token(id_token_hint) + + if not id_token or not _validate_claims(request, claims): + raise InvalidIDTokenError() + + if id_token.user == request.user: + # A logout without user interaction (i.e. no prompt) is only allowed + # if an ID Token is provided that matches the current user. + must_prompt_logout = False + + # If both id_token_hint and client_id are given it must be verified that they match. + if client_id: + if id_token.application.client_id != client_id: + raise ClientIdMissmatch() + + # The standard states that a prompt should always be shown. + # This behaviour can be configured with OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT. + prompt_logout = must_prompt_logout or oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT + + application = None + # Determine the application that is requesting the logout. + if client_id: + application = get_application_model().objects.get(client_id=client_id) + elif id_token: + application = id_token.application + + # Validate `post_logout_redirect_uri` + if post_logout_redirect_uri: + if not application: + raise InvalidOIDCClientError() + scheme = urlparse(post_logout_redirect_uri)[0] + if not scheme: + raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.") + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and ( + scheme == "http" and application.client_type != "confidential" + ): + raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.") + if scheme not in application.get_allowed_schemes(): + raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.') + if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): + raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") + + return prompt_logout, (post_logout_redirect_uri, application) + + +class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView): + template_name = "oauth2_provider/logout_confirm.html" + form_class = ConfirmLogoutForm + # Only delete tokens for Application whose client type and authorization + # grant type are in the respective lists. + token_deletion_client_types = [ + Application.CLIENT_PUBLIC, + Application.CLIENT_CONFIDENTIAL, + ] + token_deletion_grant_types = [ + Application.GRANT_AUTHORIZATION_CODE, + Application.GRANT_IMPLICIT, + Application.GRANT_PASSWORD, + Application.GRANT_CLIENT_CREDENTIALS, + Application.GRANT_OPENID_HYBRID, + ] + + def get_initial(self): + return { + "id_token_hint": self.oidc_data.get("id_token_hint", None), + "logout_hint": self.oidc_data.get("logout_hint", None), + "client_id": self.oidc_data.get("client_id", None), + "post_logout_redirect_uri": self.oidc_data.get("post_logout_redirect_uri", None), + "state": self.oidc_data.get("state", None), + "ui_locales": self.oidc_data.get("ui_locales", None), + } + + def dispatch(self, request, *args, **kwargs): + self.oidc_data = {} + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + id_token_hint = request.GET.get("id_token_hint") + client_id = request.GET.get("client_id") + post_logout_redirect_uri = request.GET.get("post_logout_redirect_uri") + state = request.GET.get("state") + + try: + prompt, (redirect_uri, application) = validate_logout_request( + request=request, + id_token_hint=id_token_hint, + client_id=client_id, + post_logout_redirect_uri=post_logout_redirect_uri, + ) + except OIDCError as error: + return self.error_response(error) + + if not prompt: + return self.do_logout(application, redirect_uri, state) + + self.oidc_data = { + "id_token_hint": id_token_hint, + "client_id": client_id, + "post_logout_redirect_uri": post_logout_redirect_uri, + "state": state, + } + form = self.get_form(self.get_form_class()) + kwargs["form"] = form + if application: + kwargs["application"] = application + + return self.render_to_response(self.get_context_data(**kwargs)) + + def form_valid(self, form): + id_token_hint = form.cleaned_data.get("id_token_hint") + client_id = form.cleaned_data.get("client_id") + post_logout_redirect_uri = form.cleaned_data.get("post_logout_redirect_uri") + state = form.cleaned_data.get("state") + + try: + prompt, (redirect_uri, application) = validate_logout_request( + request=self.request, + id_token_hint=id_token_hint, + client_id=client_id, + post_logout_redirect_uri=post_logout_redirect_uri, + ) + + if not prompt or form.cleaned_data.get("allow"): + return self.do_logout(application, redirect_uri, state) + else: + raise LogoutDenied() + + except OIDCError as error: + return self.error_response(error) + + def do_logout(self, application=None, post_logout_redirect_uri=None, state=None): + # Delete Access Tokens + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS: + AccessToken = get_access_token_model() + RefreshToken = get_refresh_token_model() + access_tokens_to_delete = AccessToken.objects.filter( + user=self.request.user, + application__client_type__in=self.token_deletion_client_types, + application__authorization_grant_type__in=self.token_deletion_grant_types, + ) + # This queryset has to be evaluated eagerly. The queryset would be empty with lazy evaluation + # because `access_tokens_to_delete` represents an empty queryset once `refresh_tokens_to_delete` + # is evaluated as all AccessTokens have been deleted. + refresh_tokens_to_delete = list( + RefreshToken.objects.filter(access_token__in=access_tokens_to_delete) + ) + for token in access_tokens_to_delete: + # Delete the token and its corresponding refresh and IDTokens. + if token.id_token: + token.id_token.revoke() + token.revoke() + for refresh_token in refresh_tokens_to_delete: + refresh_token.revoke() + # Logout in Django + logout(self.request) + # Redirect + if post_logout_redirect_uri: + if state: + return OAuth2ResponseRedirect( + add_params_to_uri(post_logout_redirect_uri, [("state", state)]), + application.get_allowed_schemes(), + ) + else: + return OAuth2ResponseRedirect(post_logout_redirect_uri, application.get_allowed_schemes()) + else: + return OAuth2ResponseRedirect( + self.request.build_absolute_uri("/"), + oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES, + ) + + def error_response(self, error): + error_response = {"error": error} + return self.render_to_response(error_response, status=error.status_code) diff --git a/tests/conftest.py b/tests/conftest.py index 14db54aa5..3a88c5261 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import uuid +from datetime import timedelta from types import SimpleNamespace from urllib.parse import parse_qs, urlparse @@ -5,9 +7,10 @@ from django.conf import settings as test_settings from django.contrib.auth import get_user_model from django.urls import reverse -from jwcrypto import jwk +from django.utils import dateformat, timezone +from jwcrypto import jwk, jwt -from oauth2_provider.models import get_application_model +from oauth2_provider.models import get_application_model, get_id_token_model from oauth2_provider.settings import oauth2_settings as _oauth2_settings from . import presets @@ -100,6 +103,7 @@ def application(): return Application.objects.create( name="Test Application", redirect_uris="http://example.org", + post_logout_redirect_uris="http://example.org", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, @@ -107,6 +111,28 @@ def application(): ) +@pytest.fixture +def public_application(): + return Application.objects.create( + name="Other Application", + redirect_uris="http://other.org", + post_logout_redirect_uris="http://other.org", + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, + ) + + +@pytest.fixture +def loggend_in_client(test_user): + from django.test.client import Client + + client = Client() + client.force_login(test_user) + return client + + @pytest.fixture def hybrid_application(application): application.authorization_grant_type = application.GRANT_OPENID_HYBRID @@ -121,16 +147,29 @@ def test_user(): @pytest.fixture -def oidc_tokens(oauth2_settings, application, test_user, client): - oauth2_settings.update(presets.OIDC_SETTINGS_RW) +def other_user(): + return UserModel.objects.create_user("other_user", "other@example.com", "123456") + + +@pytest.fixture +def rp_settings(oauth2_settings): + oauth2_settings.update(presets.OIDC_SETTINGS_RP_LOGOUT) + return oauth2_settings + + +def generate_access_token(oauth2_settings, application, test_user, client, settings, scope, redirect_uri): + """ + A helper function that generates an access_token and ID Token for a given Application and User. + """ + oauth2_settings.update(settings) client.force_login(test_user) auth_rsp = client.post( reverse("oauth2_provider:authorize"), data={ "client_id": application.client_id, "state": "random_state_string", - "scope": "openid", - "redirect_uri": "http://example.org", + "scope": scope, + "redirect_uri": redirect_uri, "response_type": "code", "allow": True, }, @@ -143,10 +182,10 @@ def oidc_tokens(oauth2_settings, application, test_user, client): data={ "grant_type": "authorization_code", "code": code, - "redirect_uri": "http://example.org", + "redirect_uri": redirect_uri, "client_id": application.client_id, "client_secret": CLEARTEXT_SECRET, - "scope": "openid", + "scope": scope, }, ) assert token_rsp.status_code == 200 @@ -161,40 +200,85 @@ def oidc_tokens(oauth2_settings, application, test_user, client): @pytest.fixture -def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): - oauth2_settings.update(presets.OIDC_SETTINGS_EMAIL_SCOPE) - client.force_login(test_user) - auth_rsp = client.post( - reverse("oauth2_provider:authorize"), - data={ - "client_id": application.client_id, - "state": "random_state_string", - "scope": "openid email", - "redirect_uri": "http://example.org", - "response_type": "code", - "allow": True, - }, +def expired_id_token(oauth2_settings, oidc_key, test_user, application): + payload = generate_id_token_payload(oauth2_settings, application, oidc_key) + return generate_id_token(test_user, payload, oidc_key, application) + + +@pytest.fixture +def id_token_wrong_aud(oauth2_settings, oidc_key, test_user, application): + payload = generate_id_token_payload(oauth2_settings, application, oidc_key) + payload[1]["aud"] = "" + return generate_id_token(test_user, payload, oidc_key, application) + + +@pytest.fixture +def id_token_wrong_iss(oauth2_settings, oidc_key, test_user, application): + payload = generate_id_token_payload(oauth2_settings, application, oidc_key) + payload[1]["iss"] = "" + return generate_id_token(test_user, payload, oidc_key, application) + + +def generate_id_token_payload(oauth2_settings, application, oidc_key): + # Default leeway of JWT in jwcrypto is 60 seconds. This means that tokens that expired up to 60 seconds + # ago are still accepted. + expiration_time = timezone.now() - timedelta(seconds=61) + # Calculate values for the IDToken + exp = int(dateformat.format(expiration_time, "U")) + jti = str(uuid.uuid4()) + aud = application.client_id + iss = oauth2_settings.OIDC_ISS_ENDPOINT + # Construct and sign the IDToken + header = {"typ": "JWT", "alg": "RS256", "kid": oidc_key.thumbprint()} + id_token = {"exp": exp, "jti": jti, "aud": aud, "iss": iss} + return header, id_token, jti, expiration_time + + +def generate_id_token(user, payload, oidc_key, application): + header, id_token, jti, expiration_time = payload + jwt_token = jwt.JWT(header=header, claims=id_token) + jwt_token.make_signed_token(oidc_key) + # Save the IDToken in the DB. Required for later lookups from e.g. RP-Initiated Logout. + IDToken = get_id_token_model() + IDToken.objects.create(user=user, scope="", expires=expiration_time, jti=jti, application=application) + # Return the token as a string. + return jwt_token.token.serialize(compact=True) + + +@pytest.fixture +def oidc_tokens(oauth2_settings, application, test_user, client): + return generate_access_token( + oauth2_settings, + application, + test_user, + client, + presets.OIDC_SETTINGS_RW, + "openid", + "http://example.org", ) - assert auth_rsp.status_code == 302 - code = parse_qs(urlparse(auth_rsp["Location"]).query)["code"] - client.logout() - token_rsp = client.post( - reverse("oauth2_provider:token"), - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": "http://example.org", - "client_id": application.client_id, - "client_secret": CLEARTEXT_SECRET, - "scope": "openid email", - }, + + +@pytest.fixture +def oidc_email_scope_tokens(oauth2_settings, application, test_user, client): + return generate_access_token( + oauth2_settings, + application, + test_user, + client, + presets.OIDC_SETTINGS_EMAIL_SCOPE, + "openid email", + "http://example.org", ) - assert token_rsp.status_code == 200 - token_data = token_rsp.json() - return SimpleNamespace( - user=test_user, - application=application, - access_token=token_data["access_token"], - id_token=token_data["id_token"], - oauth2_settings=oauth2_settings, + + +@pytest.fixture +def oidc_non_confidential_tokens(oauth2_settings, public_application, test_user, client): + return generate_access_token( + oauth2_settings, + public_application, + test_user, + client, + presets.OIDC_SETTINGS_EMAIL_SCOPE, + "openid", + "http://other.org", ) diff --git a/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py b/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py new file mode 100644 index 000000000..8ca59c84b --- /dev/null +++ b/tests/migrations/0003_basetestapplication_post_logout_redirect_uris_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.5 on 2023-01-14 20:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ("tests", "0002_swapped_models"), + ] + + operations = [ + migrations.AddField( + model_name="basetestapplication", + name="post_logout_redirect_uris", + field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), + ), + migrations.AddField( + model_name="sampleapplication", + name="post_logout_redirect_uris", + field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), + ), + ] diff --git a/tests/presets.py b/tests/presets.py index 4b207f25c..1ac8d3279 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -28,6 +28,15 @@ OIDC_SETTINGS_EMAIL_SCOPE["SCOPES"].update({"email": "return email address"}) OIDC_SETTINGS_HS256_ONLY = deepcopy(OIDC_SETTINGS_RW) del OIDC_SETTINGS_HS256_ONLY["OIDC_RSA_PRIVATE_KEY"] +OIDC_SETTINGS_RP_LOGOUT = deepcopy(OIDC_SETTINGS_RW) +OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ENABLED"] = True +OIDC_SETTINGS_RP_LOGOUT["OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT"] = False +OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI = deepcopy(OIDC_SETTINGS_RP_LOGOUT) +OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI["OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS"] = True +OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED = deepcopy(OIDC_SETTINGS_RP_LOGOUT) +OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED["OIDC_RP_INITIATED_LOGOUT_ACCEPT_EXPIRED_TOKENS"] = False +OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS = deepcopy(OIDC_SETTINGS_RP_LOGOUT) +OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS["OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS"] = False REST_FRAMEWORK_SCOPES = { "SCOPES": { "read": "Read scope", diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 1294b75cb..327a99194 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -11,6 +11,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.views.mixins import ( OAuthLibMixin, + OIDCLogoutOnlyMixin, OIDCOnlyMixin, ProtectedResourceMixin, ScopedResourceMixin, @@ -145,6 +146,15 @@ def get(self, *args, **kwargs): return TView.as_view() +@pytest.fixture +def oidc_logout_only_view(): + class TView(OIDCLogoutOnlyMixin, View): + def get(self, *args, **kwargs): + return HttpResponse("OK") + + return TView.as_view() + + @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): assert oauth2_settings.OIDC_ENABLED @@ -153,6 +163,14 @@ def test_oidc_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): assert rsp.content.decode("utf-8") == "OK" +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_oidc_logout_only_mixin_oidc_enabled(oauth2_settings, rf, oidc_only_view): + assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED + rsp = oidc_only_view(rf.get("/")) + assert rsp.status_code == 200 + assert rsp.content.decode("utf-8") == "OK" + + def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_only_view): assert oauth2_settings.OIDC_ENABLED is False settings.DEBUG = True @@ -161,6 +179,14 @@ def test_oidc_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc assert "OIDC views are not enabled" in str(exc.value) +def test_oidc_logout_only_mixin_oidc_disabled_debug(oauth2_settings, rf, settings, oidc_logout_only_view): + assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED is False + settings.DEBUG = True + with pytest.raises(ImproperlyConfigured) as exc: + oidc_logout_only_view(rf.get("/")) + assert str(exc.value) == OIDCLogoutOnlyMixin.debug_error_message + + def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, oidc_only_view, caplog): assert oauth2_settings.OIDC_ENABLED is False settings.DEBUG = False @@ -169,3 +195,15 @@ def test_oidc_only_mixin_oidc_disabled_no_debug(oauth2_settings, rf, settings, o assert rsp.status_code == 404 assert len(caplog.records) == 1 assert "OIDC views are not enabled" in caplog.records[0].message + + +def test_oidc_logout_only_mixin_oidc_disabled_no_debug( + oauth2_settings, rf, settings, oidc_logout_only_view, caplog +): + assert oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED is False + settings.DEBUG = False + with caplog.at_level(logging.WARNING, logger="oauth2_provider"): + rsp = oidc_logout_only_view(rf.get("/")) + assert rsp.status_code == 404 + assert len(caplog.records) == 1 + assert caplog.records[0].message == OIDCLogoutOnlyMixin.debug_error_message diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 7b379d1b3..6ba100d89 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -1,8 +1,15 @@ import pytest -from django.test import TestCase +from django.contrib.auth import get_user +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory, TestCase from django.urls import reverse +from django.utils import timezone +from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError +from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator +from oauth2_provider.settings import oauth2_settings +from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request from . import presets @@ -36,6 +43,37 @@ def test_get_connect_discovery_info(self): self.assertEqual(response.status_code, 200) assert response.json() == expected_response + def expect_json_response_with_rp(self, base): + expected_response = { + "issuer": f"{base}", + "authorization_endpoint": f"{base}/authorize/", + "token_endpoint": f"{base}/token/", + "userinfo_endpoint": f"{base}/userinfo/", + "jwks_uri": f"{base}/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], + "end_session_endpoint": f"{base}/logout/", + } + response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_with_rp_logout(self): + self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True + self.expect_json_response_with_rp(self.oauth2_settings.OIDC_ISS_ENDPOINT) + def test_get_connect_discovery_info_without_issuer_url(self): self.oauth2_settings.OIDC_ISS_ENDPOINT = None self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None @@ -64,6 +102,12 @@ def test_get_connect_discovery_info_without_issuer_url(self): self.assertEqual(response.status_code, 200) assert response.json() == expected_response + def test_get_connect_discovery_info_without_issuer_url_with_rp_logout(self): + self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True + self.oauth2_settings.OIDC_ISS_ENDPOINT = None + self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None + self.expect_json_response_with_rp("http://testserver/o") + def test_get_connect_discovery_info_without_rsa_key(self): self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) @@ -124,6 +168,308 @@ def test_get_jwks_info_multiple_rsa_keys(self): assert response.json() == expected_response +def mock_request(): + """ + Dummy request with an AnonymousUser attached. + """ + return mock_request_for(AnonymousUser()) + + +def mock_request_for(user): + """ + Dummy request with the `user` attached. + """ + request = RequestFactory().get("") + request.user = user + return request + + +@pytest.mark.django_db +@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) +def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT): + rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT + oidc_tokens = oidc_tokens + application = oidc_tokens.application + client_id = application.client_id + id_token = oidc_tokens.id_token + assert validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=None, + post_logout_redirect_uri=None, + ) == (True, (None, None)) + assert validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri=None, + ) == (True, (None, application)) + assert validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (True, ("http://example.org", application)) + assert validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=id_token, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) == (ALWAYS_PROMPT, ("http://example.org", application)) + assert validate_logout_request( + request=mock_request_for(other_user), + id_token_hint=id_token, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) == (True, ("http://example.org", application)) + assert validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=id_token, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (ALWAYS_PROMPT, ("http://example.org", application)) + with pytest.raises(ClientIdMissmatch): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=id_token, + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) + with pytest.raises(InvalidOIDCClientError): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="imap://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://other.org", + ) + + +def test__load_id_token(): + assert _load_id_token("Not a Valid ID Token.") == (None, None) + + +def is_logged_in(client): + return get_user(client).is_authenticated + + +@pytest.mark.django_db +def test_rp_initiated_logout_get(loggend_in_client, rp_settings): + rsp = loggend_in_client.get(reverse("oauth2_provider:rp-initiated-logout"), data={}) + assert rsp.status_code == 200 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_id_token(loggend_in_client, oidc_tokens, rp_settings): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} + ) + assert rsp.status_code == 302 + assert rsp["Location"] == "http://testserver/" + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_revoked_id_token(loggend_in_client, oidc_tokens, rp_settings): + validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() + validator._load_id_token(oidc_tokens.id_token).revoke() + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} + ) + assert rsp.status_code == 400 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_id_token_redirect(loggend_in_client, oidc_tokens, rp_settings): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={"id_token_hint": oidc_tokens.id_token, "post_logout_redirect_uri": "http://example.org"}, + ) + assert rsp.status_code == 302 + assert rsp["Location"] == "http://example.org" + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_id_token_redirect_with_state(loggend_in_client, oidc_tokens, rp_settings): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_tokens.id_token, + "post_logout_redirect_uri": "http://example.org", + "state": "987654321", + }, + ) + assert rsp.status_code == 302 + assert rsp["Location"] == "http://example.org?state=987654321" + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_id_token_missmatch_client_id( + loggend_in_client, oidc_tokens, public_application, rp_settings +): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={"id_token_hint": oidc_tokens.id_token, "client_id": public_application.client_id}, + ) + assert rsp.status_code == 400 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_public_client_redirect_client_id( + loggend_in_client, oidc_non_confidential_tokens, public_application, rp_settings +): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_non_confidential_tokens.id_token, + "client_id": public_application.client_id, + "post_logout_redirect_uri": "http://other.org", + }, + ) + assert rsp.status_code == 302 + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_public_client_strict_redirect_client_id( + loggend_in_client, oidc_non_confidential_tokens, public_application, oauth2_settings +): + oauth2_settings.update(presets.OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI) + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_non_confidential_tokens.id_token, + "client_id": public_application.client_id, + "post_logout_redirect_uri": "http://other.org", + }, + ) + assert rsp.status_code == 400 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_get_id_token_client_id(loggend_in_client, oidc_tokens, rp_settings): + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), data={"client_id": oidc_tokens.application.client_id} + ) + assert rsp.status_code == 200 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_post(loggend_in_client, oidc_tokens, rp_settings): + form_data = { + "client_id": oidc_tokens.application.client_id, + } + rsp = loggend_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) + assert rsp.status_code == 400 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +def test_rp_initiated_logout_post_allowed(loggend_in_client, oidc_tokens, rp_settings): + form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} + rsp = loggend_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) + assert rsp.status_code == 302 + assert rsp["Location"] == "http://testserver/" + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_rp_initiated_logout_expired_tokens_accept(loggend_in_client, application, expired_id_token): + # Accepting expired (but otherwise valid and signed by us) tokens is enabled. Logout should go through. + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": expired_id_token, + "client_id": application.client_id, + }, + ) + assert rsp.status_code == 302 + assert not is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) +def test_rp_initiated_logout_expired_tokens_deny(loggend_in_client, application, expired_id_token): + # Expired tokens should not be accepted by default. + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": expired_id_token, + "client_id": application.client_id, + }, + ) + assert rsp.status_code == 400 + assert is_logged_in(loggend_in_client) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_load_id_token_accept_expired(expired_id_token): + id_token, _ = _load_id_token(expired_id_token) + assert isinstance(id_token, get_id_token_model()) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_load_id_token_wrong_aud(id_token_wrong_aud): + id_token, claims = _load_id_token(id_token_wrong_aud) + assert id_token is None + assert claims is None + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) +def test_load_id_token_deny_expired(expired_id_token): + id_token, claims = _load_id_token(expired_id_token) + assert id_token is None + assert claims is None + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_validate_claims_wrong_iss(id_token_wrong_iss): + id_token, claims = _load_id_token(id_token_wrong_iss) + assert id_token is not None + assert claims is not None + assert not _validate_claims(mock_request(), claims) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) +def test_validate_claims(oidc_tokens): + id_token, claims = _load_id_token(oidc_tokens.id_token) + assert claims is not None + assert _validate_claims(mock_request_for(oidc_tokens.user), claims) + + @pytest.mark.django_db @pytest.mark.parametrize("method", ["get", "post"]) def test_userinfo_endpoint(oidc_tokens, client, method): @@ -150,6 +496,58 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 +@pytest.mark.django_db +def test_token_deletion_on_logout(oidc_tokens, loggend_in_client, rp_settings): + AccessToken = get_access_token_model() + IDToken = get_id_token_model() + RefreshToken = get_refresh_token_model() + assert AccessToken.objects.count() == 1 + assert IDToken.objects.count() == 1 + assert RefreshToken.objects.count() == 1 + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_tokens.id_token, + "client_id": oidc_tokens.application.client_id, + }, + ) + assert rsp.status_code == 302 + assert not is_logged_in(loggend_in_client) + # Check that all tokens have either been deleted or expired. + assert all([token.is_expired() for token in AccessToken.objects.all()]) + assert all([token.is_expired() for token in IDToken.objects.all()]) + assert all([token.revoked <= timezone.now() for token in RefreshToken.objects.all()]) + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS) +def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_settings): + rp_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS = False + + AccessToken = get_access_token_model() + IDToken = get_id_token_model() + RefreshToken = get_refresh_token_model() + assert AccessToken.objects.count() == 1 + assert IDToken.objects.count() == 1 + assert RefreshToken.objects.count() == 1 + rsp = loggend_in_client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_tokens.id_token, + "client_id": oidc_tokens.application.client_id, + }, + ) + assert rsp.status_code == 302 + assert not is_logged_in(loggend_in_client) + # Check that the tokens have not been expired or deleted. + assert AccessToken.objects.count() == 1 + assert not any([token.is_expired() for token in AccessToken.objects.all()]) + assert IDToken.objects.count() == 1 + assert not any([token.is_expired() for token in IDToken.objects.all()]) + assert RefreshToken.objects.count() == 1 + assert not any([token.revoked is not None for token in RefreshToken.objects.all()]) + + EXAMPLE_EMAIL = "example.email@example.com" From 29da53072e93f167d692d5356808161d3d6189c5 Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Sun, 21 May 2023 01:31:31 +0200 Subject: [PATCH 510/722] Fix RP-initiated Logout with expired Django session (#1270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix RP-initiated Logout with exired django session * Update tests/test_oidc_views.py Co-authored-by: François Freitag <mail@franek.fr> * Update tests/test_oidc_views.py Co-authored-by: François Freitag <mail@franek.fr> * Update tests/test_oidc_views.py Co-authored-by: François Freitag <mail@franek.fr> * Check post logout redirection * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: François Freitag <mail@franek.fr> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/views/oidc.py | 22 ++++++++------ tests/test_oidc_views.py | 54 +++++++++++++++++++++++++++++++---- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/AUTHORS b/AUTHORS index 47a2aeaf2..507ef29fd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -18,6 +18,7 @@ Allisson Azevedo Andrea Greco Andrej Zbín Andrew Chen Wang +Antoine Laurent Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c92aa849..0a135d2a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. * #1218 Confim support for Python 3.11. * #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command +* #1270 Fix RP-initiated Logout with no available Django session ## [2.2.0] 2022-10-18 diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index f819388b9..d7310c58b 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -210,13 +210,14 @@ def _validate_claims(request, claims): def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri): """ Validate an OIDC RP-Initiated Logout Request. - `(prompt_logout, (post_logout_redirect_uri, application))` is returned. + `(prompt_logout, (post_logout_redirect_uri, application), token_user)` is returned. `prompt_logout` indicates whether the logout has to be confirmed by the user. This happens if the specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. `post_logout_redirect_uri` is the validated URI where the User should be redirected to after the logout. Can be None. None will redirect to "/" of this app. If it is set `application` will also - be set to the Application that is requesting the logout. + be set to the Application that is requesting the logout. `token_user` is the id_token user, which will + used to revoke the tokens if found. The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they will be validated against each other. @@ -224,6 +225,7 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir id_token = None must_prompt_logout = True + token_user = None if id_token_hint: # Only basic validation has been done on the IDToken at this point. id_token, claims = _load_id_token(id_token_hint) @@ -231,6 +233,8 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir if not id_token or not _validate_claims(request, claims): raise InvalidIDTokenError() + token_user = id_token.user + if id_token.user == request.user: # A logout without user interaction (i.e. no prompt) is only allowed # if an ID Token is provided that matches the current user. @@ -268,7 +272,7 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") - return prompt_logout, (post_logout_redirect_uri, application) + return prompt_logout, (post_logout_redirect_uri, application), token_user class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView): @@ -309,7 +313,7 @@ def get(self, request, *args, **kwargs): state = request.GET.get("state") try: - prompt, (redirect_uri, application) = validate_logout_request( + prompt, (redirect_uri, application), token_user = validate_logout_request( request=request, id_token_hint=id_token_hint, client_id=client_id, @@ -319,7 +323,7 @@ def get(self, request, *args, **kwargs): return self.error_response(error) if not prompt: - return self.do_logout(application, redirect_uri, state) + return self.do_logout(application, redirect_uri, state, token_user) self.oidc_data = { "id_token_hint": id_token_hint, @@ -341,7 +345,7 @@ def form_valid(self, form): state = form.cleaned_data.get("state") try: - prompt, (redirect_uri, application) = validate_logout_request( + prompt, (redirect_uri, application), token_user = validate_logout_request( request=self.request, id_token_hint=id_token_hint, client_id=client_id, @@ -349,20 +353,20 @@ def form_valid(self, form): ) if not prompt or form.cleaned_data.get("allow"): - return self.do_logout(application, redirect_uri, state) + return self.do_logout(application, redirect_uri, state, token_user) else: raise LogoutDenied() except OIDCError as error: return self.error_response(error) - def do_logout(self, application=None, post_logout_redirect_uri=None, state=None): + def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): # Delete Access Tokens if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS: AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() access_tokens_to_delete = AccessToken.objects.filter( - user=self.request.user, + user=token_user or self.request.user, application__client_type__in=self.token_deletion_client_types, application__authorization_grant_type__in=self.token_deletion_grant_types, ) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 6ba100d89..d1459a939 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -4,6 +4,7 @@ from django.test import RequestFactory, TestCase from django.urls import reverse from django.utils import timezone +from pytest_django.asserts import assertRedirects from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model @@ -197,37 +198,37 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp id_token_hint=None, client_id=None, post_logout_redirect_uri=None, - ) == (True, (None, None)) + ) == (True, (None, None), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri=None, - ) == (True, (None, application)) + ) == (True, (None, application), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=None, client_id=client_id, post_logout_redirect_uri="http://example.org", - ) == (True, ("http://example.org", application)) + ) == (True, ("http://example.org", application), None) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=id_token, client_id=None, post_logout_redirect_uri="http://example.org", - ) == (ALWAYS_PROMPT, ("http://example.org", application)) + ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) assert validate_logout_request( request=mock_request_for(other_user), id_token_hint=id_token, client_id=None, post_logout_redirect_uri="http://example.org", - ) == (True, ("http://example.org", application)) + ) == (True, ("http://example.org", application), oidc_tokens.user) assert validate_logout_request( request=mock_request_for(oidc_tokens.user), id_token_hint=id_token, client_id=client_id, post_logout_redirect_uri="http://example.org", - ) == (ALWAYS_PROMPT, ("http://example.org", application)) + ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) with pytest.raises(ClientIdMissmatch): validate_logout_request( request=mock_request_for(oidc_tokens.user), @@ -519,6 +520,47 @@ def test_token_deletion_on_logout(oidc_tokens, loggend_in_client, rp_settings): assert all([token.revoked <= timezone.now() for token in RefreshToken.objects.all()]) +@pytest.mark.django_db +def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settings): + AccessToken = get_access_token_model() + IDToken = get_id_token_model() + RefreshToken = get_refresh_token_model() + assert AccessToken.objects.count() == 1 + assert IDToken.objects.count() == 1 + assert RefreshToken.objects.count() == 1 + rsp = client.get( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_tokens.id_token, + "client_id": oidc_tokens.application.client_id, + }, + ) + assert rsp.status_code == 200 + assert not is_logged_in(client) + # Check that all tokens are active. + access_token = AccessToken.objects.get() + assert not access_token.is_expired() + id_token = IDToken.objects.get() + assert not id_token.is_expired() + refresh_token = RefreshToken.objects.get() + assert refresh_token.revoked is None + + rsp = client.post( + reverse("oauth2_provider:rp-initiated-logout"), + data={ + "id_token_hint": oidc_tokens.id_token, + "client_id": oidc_tokens.application.client_id, + "allow": True, + }, + ) + assertRedirects(rsp, "http://testserver/", fetch_redirect_response=False) + assert not is_logged_in(client) + # Check that all tokens have either been deleted or expired. + assert all(token.is_expired() for token in AccessToken.objects.all()) + assert all(token.is_expired() for token in IDToken.objects.all()) + assert all(token.revoked <= timezone.now() for token in RefreshToken.objects.all()) + + @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS) def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_settings): From 016c6c3bf62c282991c2ce3164e8233b81e3dd4d Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Sun, 21 May 2023 02:12:36 +0200 Subject: [PATCH 511/722] tests: Fix typo (#1271) --- tests/conftest.py | 2 +- tests/test_oidc_views.py | 90 ++++++++++++++++++++-------------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 3a88c5261..d620c3f59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -125,7 +125,7 @@ def public_application(): @pytest.fixture -def loggend_in_client(test_user): +def logged_in_client(test_user): from django.test.client import Client client = Client() diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index d1459a939..f2b8d5e1d 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -275,47 +275,47 @@ def is_logged_in(client): @pytest.mark.django_db -def test_rp_initiated_logout_get(loggend_in_client, rp_settings): - rsp = loggend_in_client.get(reverse("oauth2_provider:rp-initiated-logout"), data={}) +def test_rp_initiated_logout_get(logged_in_client, rp_settings): + rsp = logged_in_client.get(reverse("oauth2_provider:rp-initiated-logout"), data={}) assert rsp.status_code == 200 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_get_id_token(loggend_in_client, oidc_tokens, rp_settings): - rsp = loggend_in_client.get( +def test_rp_initiated_logout_get_id_token(logged_in_client, oidc_tokens, rp_settings): + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} ) assert rsp.status_code == 302 assert rsp["Location"] == "http://testserver/" - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_get_revoked_id_token(loggend_in_client, oidc_tokens, rp_settings): +def test_rp_initiated_logout_get_revoked_id_token(logged_in_client, oidc_tokens, rp_settings): validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() validator._load_id_token(oidc_tokens.id_token).revoke() - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} ) assert rsp.status_code == 400 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_get_id_token_redirect(loggend_in_client, oidc_tokens, rp_settings): - rsp = loggend_in_client.get( +def test_rp_initiated_logout_get_id_token_redirect(logged_in_client, oidc_tokens, rp_settings): + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token, "post_logout_redirect_uri": "http://example.org"}, ) assert rsp.status_code == 302 assert rsp["Location"] == "http://example.org" - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_get_id_token_redirect_with_state(loggend_in_client, oidc_tokens, rp_settings): - rsp = loggend_in_client.get( +def test_rp_initiated_logout_get_id_token_redirect_with_state(logged_in_client, oidc_tokens, rp_settings): + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, @@ -325,26 +325,26 @@ def test_rp_initiated_logout_get_id_token_redirect_with_state(loggend_in_client, ) assert rsp.status_code == 302 assert rsp["Location"] == "http://example.org?state=987654321" - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_get_id_token_missmatch_client_id( - loggend_in_client, oidc_tokens, public_application, rp_settings + logged_in_client, oidc_tokens, public_application, rp_settings ): - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token, "client_id": public_application.client_id}, ) assert rsp.status_code == 400 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_public_client_redirect_client_id( - loggend_in_client, oidc_non_confidential_tokens, public_application, rp_settings + logged_in_client, oidc_non_confidential_tokens, public_application, rp_settings ): - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_non_confidential_tokens.id_token, @@ -353,15 +353,15 @@ def test_rp_initiated_logout_public_client_redirect_client_id( }, ) assert rsp.status_code == 302 - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db def test_rp_initiated_logout_public_client_strict_redirect_client_id( - loggend_in_client, oidc_non_confidential_tokens, public_application, oauth2_settings + logged_in_client, oidc_non_confidential_tokens, public_application, oauth2_settings ): oauth2_settings.update(presets.OIDC_SETTINGS_RP_LOGOUT_STRICT_REDIRECT_URI) - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_non_confidential_tokens.id_token, @@ -370,42 +370,42 @@ def test_rp_initiated_logout_public_client_strict_redirect_client_id( }, ) assert rsp.status_code == 400 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_get_id_token_client_id(loggend_in_client, oidc_tokens, rp_settings): - rsp = loggend_in_client.get( +def test_rp_initiated_logout_get_id_token_client_id(logged_in_client, oidc_tokens, rp_settings): + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"client_id": oidc_tokens.application.client_id} ) assert rsp.status_code == 200 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_post(loggend_in_client, oidc_tokens, rp_settings): +def test_rp_initiated_logout_post(logged_in_client, oidc_tokens, rp_settings): form_data = { "client_id": oidc_tokens.application.client_id, } - rsp = loggend_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) + rsp = logged_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) assert rsp.status_code == 400 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db -def test_rp_initiated_logout_post_allowed(loggend_in_client, oidc_tokens, rp_settings): +def test_rp_initiated_logout_post_allowed(logged_in_client, oidc_tokens, rp_settings): form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} - rsp = loggend_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) + rsp = logged_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) assert rsp.status_code == 302 assert rsp["Location"] == "http://testserver/" - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) -def test_rp_initiated_logout_expired_tokens_accept(loggend_in_client, application, expired_id_token): +def test_rp_initiated_logout_expired_tokens_accept(logged_in_client, application, expired_id_token): # Accepting expired (but otherwise valid and signed by us) tokens is enabled. Logout should go through. - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": expired_id_token, @@ -413,14 +413,14 @@ def test_rp_initiated_logout_expired_tokens_accept(loggend_in_client, applicatio }, ) assert rsp.status_code == 302 - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) -def test_rp_initiated_logout_expired_tokens_deny(loggend_in_client, application, expired_id_token): +def test_rp_initiated_logout_expired_tokens_deny(logged_in_client, application, expired_id_token): # Expired tokens should not be accepted by default. - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": expired_id_token, @@ -428,7 +428,7 @@ def test_rp_initiated_logout_expired_tokens_deny(loggend_in_client, application, }, ) assert rsp.status_code == 400 - assert is_logged_in(loggend_in_client) + assert is_logged_in(logged_in_client) @pytest.mark.django_db @@ -498,14 +498,14 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): @pytest.mark.django_db -def test_token_deletion_on_logout(oidc_tokens, loggend_in_client, rp_settings): +def test_token_deletion_on_logout(oidc_tokens, logged_in_client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, @@ -513,7 +513,7 @@ def test_token_deletion_on_logout(oidc_tokens, loggend_in_client, rp_settings): }, ) assert rsp.status_code == 302 - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) # Check that all tokens have either been deleted or expired. assert all([token.is_expired() for token in AccessToken.objects.all()]) assert all([token.is_expired() for token in IDToken.objects.all()]) @@ -563,7 +563,7 @@ def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settin @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS) -def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_settings): +def test_token_deletion_on_logout_disabled(oidc_tokens, logged_in_client, rp_settings): rp_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS = False AccessToken = get_access_token_model() @@ -572,7 +572,7 @@ def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_se assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 - rsp = loggend_in_client.get( + rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ "id_token_hint": oidc_tokens.id_token, @@ -580,7 +580,7 @@ def test_token_deletion_on_logout_disabled(oidc_tokens, loggend_in_client, rp_se }, ) assert rsp.status_code == 302 - assert not is_logged_in(loggend_in_client) + assert not is_logged_in(logged_in_client) # Check that the tokens have not been expired or deleted. assert AccessToken.objects.count() == 1 assert not any([token.is_expired() for token in AccessToken.objects.all()]) From 64faa9e1afc10491ae343f368bd9bf9fd4a8aafb Mon Sep 17 00:00:00 2001 From: Adheeth P Praveen <bullionareboy@hotmail.com> Date: Wed, 31 May 2023 19:44:06 +0530 Subject: [PATCH 512/722] Allow Authorization Code flow without a client_secret - Initial commit with Patch & testcases (#1276) * Initial commit with Patch & testcases * reference the RFC where empty client secret is allowed --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/oauth2_validators.py | 2 +- tests/test_oauth2_validators.py | 24 ++++++++++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 507ef29fd..4be6ac505 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Contributors Abhishek Patel Adam Johnson +Adheeth P Praveen Alan Crosswell Alejandro Mantecon Guillen Aleksander Vaskevich diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a135d2a6..eed4b8b9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1218 Confim support for Python 3.11. * #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command * #1270 Fix RP-initiated Logout with no available Django session +* #1092 Allow Authorization Code flow without a client_secret per [RFC 6749 2.3.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) ## [2.2.0] 2022-10-18 diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 3e921ec99..ecff21880 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -170,7 +170,7 @@ def _authenticate_request_body(self, request): # TODO: check if oauthlib has already unquoted client_id and client_secret try: client_id = request.client_id - client_secret = request.client_secret + client_secret = getattr(request, "client_secret", "") except AttributeError: return False diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 2c062d616..83cf770e4 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -30,6 +30,7 @@ RefreshToken = get_refresh_token_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" +CLEARTEXT_BLANK_SECRET = "" @contextlib.contextmanager @@ -61,11 +62,25 @@ def setUp(self): ) self.request.client = self.application + self.blank_secret_request = mock.MagicMock(wraps=Request) + self.blank_secret_request.user = self.user + self.blank_secret_request.grant_type = "not client" + self.blank_secret_application = Application.objects.create( + client_id="blank_secret_client_id", + client_secret=CLEARTEXT_BLANK_SECRET, + user=self.user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, + ) + self.blank_secret_request.client = self.blank_secret_application + def tearDown(self): self.application.delete() def test_authenticate_request_body(self): self.request.client_id = "client_id" + self.assertFalse(self.validator._authenticate_request_body(self.request)) + self.request.client_secret = "" self.assertFalse(self.validator._authenticate_request_body(self.request)) @@ -75,6 +90,15 @@ def test_authenticate_request_body(self): self.request.client_secret = CLEARTEXT_SECRET self.assertTrue(self.validator._authenticate_request_body(self.request)) + self.blank_secret_request.client_id = "blank_secret_client_id" + self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request)) + + self.blank_secret_request.client_secret = CLEARTEXT_BLANK_SECRET + self.assertTrue(self.validator._authenticate_request_body(self.blank_secret_request)) + + self.blank_secret_request.client_secret = "wrong_client_secret" + self.assertFalse(self.validator._authenticate_request_body(self.blank_secret_request)) + def test_extract_basic_auth(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456") From 4c4da73b0f30bcea8c8ffe5f707e113b7e4f38b9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 31 May 2023 16:14:28 -0400 Subject: [PATCH 513/722] Revert "Pin flake8 version until flake8-quotes catches up." (#1278) This reverts commit 8f8b294130fddfe177d9144d1b2dfc60f0c8c9af. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index b907399a5..cf9390b32 100644 --- a/tox.ini +++ b/tox.ini @@ -101,7 +101,7 @@ basepython = python3.8 skip_install = True commands = flake8 {toxinidir} deps = - flake8<6.0.0 # TODO remove this pinned version once https://github.com/zheller/flake8-quotes/pull/111 is merged. + flake8 flake8-isort flake8-quotes flake8-black From 13a61435167d8ffe04dd6b79522d5d20007a08c5 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 31 May 2023 16:47:14 -0400 Subject: [PATCH 514/722] Release 2.3.0 (#1279) * sort AUTHORS alphabetically * bump minor version * Changelog organized to focus on core user-visible changes. * Update to match actual release date. --- AUTHORS | 2 +- CHANGELOG.md | 26 ++++++++++++-------------- oauth2_provider/__init__.py | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/AUTHORS b/AUTHORS index 4be6ac505..68680e4f9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -67,6 +67,7 @@ Jun Zhou Kaleb Porter Kristian Rune Larsen Ludwig Hähne +Marcus Sonestedt Matias Seniquiel Michael Howitz Owen Gong @@ -93,4 +94,3 @@ Víðir Valberg Guðmundsson Will Beaufoy pySilver Łukasz Skarżyński -Marcus Sonestedt diff --git a/CHANGELOG.md b/CHANGELOG.md index eed4b8b9d..fab13a0ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,20 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [unreleased] - -### Added -* Add Japanese(日本語) Language Support -* [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) - -### Changed -* #1211 documentation improve on 'AUTHORIZATION_CODE_EXPIRE_SECONDS'. -* #1218 Confim support for Python 3.11. -* #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command -* #1270 Fix RP-initiated Logout with no available Django session -* #1092 Allow Authorization Code flow without a client_secret per [RFC 6749 2.3.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) - -## [2.2.0] 2022-10-18 +## [2.3.0] 2023-05-31 ### WARNING @@ -40,6 +27,17 @@ These issues both result in `{"error": "invalid_client"}`: 2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. +### Added +* Add Japanese(日本語) Language Support +* #1244 implement [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) +* #1092 Allow Authorization Code flow without a client_secret per [RFC 6749 2.3.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) + +### Changed +* #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command +* #1267, #1253, #1251, #1250, #1224, #1212, #1211 Various documentation improvements + +## [2.2.0] 2022-10-18 + ### Added * #1208 Add 'code_challenge_method' parameter to authorization call in documentation * #1182 Add 'code_verifier' parameter to token requests in documentation diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index aedd5a37f..ebd93203d 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1,7 @@ import django -__version__ = "2.2.0" +__version__ = "2.3.0" if django.VERSION < (3, 2): default_app_config = "oauth2_provider.apps.DOTConfig" From 2f3dd4539b37f88f2380258fff21b76837766979 Mon Sep 17 00:00:00 2001 From: Daniel Golding <goldingd89@gmail.com> Date: Thu, 8 Jun 2023 16:25:11 +0200 Subject: [PATCH 515/722] Cache loading of JWK object from OIDC private key (#1273) * Cache loading of JWK object from OIDC private key * update AUTHORS * update changelog * test jwk_from_pem caches jwk object --- AUTHORS | 1 + CHANGELOG.md | 5 +++++ oauth2_provider/models.py | 3 ++- oauth2_provider/utils.py | 12 ++++++++++++ oauth2_provider/views/oidc.py | 5 +++-- tests/test_utils.py | 27 +++++++++++++++++++++++++++ 6 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 oauth2_provider/utils.py create mode 100644 tests/test_utils.py diff --git a/AUTHORS b/AUTHORS index 68680e4f9..16c2058b8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -29,6 +29,7 @@ Bart Merenda Bas van Oostveen Brian Helba Carl Schwan +Daniel Golding Daniel 'Vector' Kerr Darrel O'Pry Dave Burkholder diff --git a/CHANGELOG.md b/CHANGELOG.md index fab13a0ea..6d2ea4cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [unreleased] + +### Added +* #1273 Add caching of loading of OIDC private key. + ## [2.3.0] 2023-05-31 ### WARNING diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 3779ed491..d22f7ee82 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -19,6 +19,7 @@ from .generators import generate_client_id, generate_client_secret from .scopes import get_scopes_backend from .settings import oauth2_settings +from .utils import jwk_from_pem from .validators import RedirectURIValidator, WildcardSet @@ -234,7 +235,7 @@ def jwk_key(self): if self.algorithm == AbstractApplication.RS256_ALGORITHM: if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: raise ImproperlyConfigured("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm") - return jwk.JWK.from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY.encode("utf8")) + return jwk_from_pem(oauth2_settings.OIDC_RSA_PRIVATE_KEY) elif self.algorithm == AbstractApplication.HS256_ALGORITHM: return jwk.JWK(kty="oct", k=base64url_encode(self.client_secret)) raise ImproperlyConfigured("This application does not support signed tokens") diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py new file mode 100644 index 000000000..de641f74f --- /dev/null +++ b/oauth2_provider/utils.py @@ -0,0 +1,12 @@ +import functools + +from jwcrypto import jwk + + +@functools.lru_cache() +def jwk_from_pem(pem_string): + """ + A cached version of jwcrypto.JWK.from_pem. + Converting from PEM is expensive for large keys such as those using RSA. + """ + return jwk.JWK.from_pem(pem_string.encode("utf-8")) diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index d7310c58b..e98630f39 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -7,7 +7,7 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from django.views.generic import FormView, View -from jwcrypto import jwk, jwt +from jwcrypto import jwt from jwcrypto.common import JWException from jwcrypto.jws import InvalidJWSObject from jwcrypto.jwt import JWTExpired @@ -30,6 +30,7 @@ get_refresh_token_model, ) from ..settings import oauth2_settings +from ..utils import jwk_from_pem from .mixins import OAuthLibMixin, OIDCLogoutOnlyMixin, OIDCOnlyMixin @@ -114,7 +115,7 @@ def get(self, request, *args, **kwargs): oauth2_settings.OIDC_RSA_PRIVATE_KEY, *oauth2_settings.OIDC_RSA_PRIVATE_KEYS_INACTIVE, ]: - key = jwk.JWK.from_pem(pem.encode("utf8")) + key = jwk_from_pem(pem) data = {"alg": "RS256", "use": "sig", "kid": key.thumbprint()} data.update(json.loads(key.export_public())) keys.append(data) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 000000000..2c319b6ea --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,27 @@ +from oauth2_provider import utils + + +def test_jwk_from_pem_caches_jwk(): + a_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- +MGQCAQACEQCxqYaL6GtPooVMhVwcZrCfAgMBAAECECyNmdsuHvMqIEl9/Fex27kC +CQDlc0deuSVrtQIJAMY4MTw2eCeDAgkA5VzfMykQ5yECCQCgkF4Zl0nHPwIJALPv ++IAFUPv3 +-----END RSA PRIVATE KEY-----""" + + # For the same private key we expect the same object to be returned + + jwk1 = utils.jwk_from_pem(a_tiny_rsa_key) + jwk2 = utils.jwk_from_pem(a_tiny_rsa_key) + + assert jwk1 is jwk2 + + a_different_tiny_rsa_key = """-----BEGIN RSA PRIVATE KEY----- +MGMCAQACEQCvyNNNw4J201yzFVogcfgnAgMBAAECEE3oXe5bNlle+xU4EVHTUIEC +CQDpSvwIvDMSIQIJAMDk47DzG9FHAghtvg1TWpy3oQIJAL6NHlS+RBufAgkA6QLA +2GK4aDc= +-----END RSA PRIVATE KEY-----""" + + # But for a different key, a different object + jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) + + assert jwk3 is not jwk1 From 9000f45d096e9ffde087c6a07f96eb9da0780b68 Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Mon, 12 Jun 2023 17:56:15 +0200 Subject: [PATCH 516/722] tests: Fix test name (#1283) --- tests/test_oidc_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index f2b8d5e1d..144c201c0 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -374,7 +374,7 @@ def test_rp_initiated_logout_public_client_strict_redirect_client_id( @pytest.mark.django_db -def test_rp_initiated_logout_get_id_token_client_id(logged_in_client, oidc_tokens, rp_settings): +def test_rp_initiated_logout_get_client_id(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"client_id": oidc_tokens.application.client_id} ) From f28ca84067ee0502c7c378fa22baf39ab8190209 Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Mon, 12 Jun 2023 19:05:53 +0200 Subject: [PATCH 517/722] Fix 500 errors no user is found during logout (#1284) --- CHANGELOG.md | 3 +++ oauth2_provider/views/oidc.py | 8 +++++--- tests/test_oidc_views.py | 9 +++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d2ea4cca..77f9a27be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1273 Add caching of loading of OIDC private key. +- ### Fixed +* #1284 Allow to logout whith no id_token_hint even if the browser session already expired + ## [2.3.0] 2023-05-31 ### WARNING diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index e98630f39..195f7a877 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -2,6 +2,7 @@ from urllib.parse import urlparse from django.contrib.auth import logout +from django.contrib.auth.models import AnonymousUser from django.http import HttpResponse, JsonResponse from django.urls import reverse from django.utils.decorators import method_decorator @@ -362,12 +363,13 @@ def form_valid(self, form): return self.error_response(error) def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): - # Delete Access Tokens - if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS: + user = token_user or self.request.user + # Delete Access Tokens if a user was found + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS and not isinstance(user, AnonymousUser): AccessToken = get_access_token_model() RefreshToken = get_refresh_token_model() access_tokens_to_delete = AccessToken.objects.filter( - user=token_user or self.request.user, + user=user, application__client_type__in=self.token_deletion_client_types, application__authorization_grant_type__in=self.token_deletion_grant_types, ) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 144c201c0..6ff5dc5dc 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -401,6 +401,15 @@ def test_rp_initiated_logout_post_allowed(logged_in_client, oidc_tokens, rp_sett assert not is_logged_in(logged_in_client) +@pytest.mark.django_db +def test_rp_initiated_logout_post_no_session(client, oidc_tokens, rp_settings): + form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} + rsp = client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) + assert rsp.status_code == 302 + assert rsp["Location"] == "http://testserver/" + assert not is_logged_in(client) + + @pytest.mark.django_db @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_rp_initiated_logout_expired_tokens_accept(logged_in_client, application, expired_id_token): From f730b645951e59d0f9638160748e1265e0b41c76 Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Tue, 13 Jun 2023 14:26:10 +0200 Subject: [PATCH 518/722] Add post_logout_redirect_uris field to application views (#1285) * Add post_logout_redirect_uris field to application views * Update docs --- CHANGELOG.md | 1 + docs/templates.rst | 2 + .../oauth2_provider/application_detail.html | 5 +++ oauth2_provider/views/application.py | 2 + tests/test_application_views.py | 38 +++++++++++++++++++ 5 files changed, 48 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77f9a27be..292300ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1273 Add caching of loading of OIDC private key. +* #1285 Add post_logout_redirect_uris field in application views. - ### Fixed * #1284 Allow to logout whith no id_token_hint even if the browser session already expired diff --git a/docs/templates.rst b/docs/templates.rst index eae7e6fa0..7f23ae3d1 100644 --- a/docs/templates.rst +++ b/docs/templates.rst @@ -165,6 +165,7 @@ This template gets passed the following template context variables: - ``client_type`` - ``authorization_grant_type`` - ``redirect_uris`` + - ``post_logout_redirect_uris`` .. caution:: In the default implementation this template in extended by `application_registration_form.html`_. @@ -184,6 +185,7 @@ This template gets passed the following template context variable: - ``client_type`` - ``authorization_grant_type`` - ``redirect_uris`` + - ``post_logout_redirect_uris`` .. note:: In the default implementation this template extends `application_form.html`_. diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 736dc4605..f9d525aff 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -30,6 +30,11 @@ <h3 class="block-center-heading">{{ application.name }}</h3> <p><b>{% trans "Redirect Uris" %}</b></p> <textarea class="input-block-level" readonly>{{ application.redirect_uris }}</textarea> </li> + + <li> + <p><b>{% trans "Post Logout Redirect Uris" %}</b></p> + <textarea class="input-block-level" readonly>{{ application.post_logout_redirect_uris }}</textarea> + </li> </ul> <div class="btn-toolbar"> diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index e9a21a99f..9289483f6 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -37,6 +37,7 @@ def get_form_class(self): "client_type", "authorization_grant_type", "redirect_uris", + "post_logout_redirect_uris", "algorithm", ), ) @@ -95,6 +96,7 @@ def get_form_class(self): "client_type", "authorization_grant_type", "redirect_uris", + "post_logout_redirect_uris", "algorithm", ), ) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 42eb17fd0..560c68cdb 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -46,6 +46,7 @@ def test_application_registration_user(self): "client_secret": "client_secret", "client_type": Application.CLIENT_CONFIDENTIAL, "redirect_uris": "http://example.com", + "post_logout_redirect_uris": "http://other_example.com", "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, "algorithm": "", } @@ -55,6 +56,14 @@ def test_application_registration_user(self): app = get_application_model().objects.get(name="Foo app") self.assertEqual(app.user.username, "foo_user") + app = Application.objects.get() + self.assertEquals(app.name, form_data["name"]) + self.assertEquals(app.client_id, form_data["client_id"]) + self.assertEquals(app.redirect_uris, form_data["redirect_uris"]) + self.assertEquals(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEquals(app.client_type, form_data["client_type"]) + self.assertEquals(app.authorization_grant_type, form_data["authorization_grant_type"]) + self.assertEquals(app.algorithm, form_data["algorithm"]) class TestApplicationViews(BaseTest): @@ -62,6 +71,7 @@ def _create_application(self, name, user): app = Application.objects.create( name=name, redirect_uris="http://example.com", + post_logout_redirect_uris="http://other_example.com", client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, user=user, @@ -93,9 +103,37 @@ def test_application_detail_owner(self): response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.assertEqual(response.status_code, 200) + self.assertContains(response, self.app_foo_1.name) + self.assertContains(response, self.app_foo_1.redirect_uris) + self.assertContains(response, self.app_foo_1.post_logout_redirect_uris) + self.assertContains(response, self.app_foo_1.client_type) + self.assertContains(response, self.app_foo_1.authorization_grant_type) def test_application_detail_not_owner(self): self.client.login(username="foo_user", password="123456") response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_bar_1.pk,))) self.assertEqual(response.status_code, 404) + + def test_application_udpate(self): + self.client.login(username="foo_user", password="123456") + + form_data = { + "client_id": "new_client_id", + "redirect_uris": "http://new_example.com", + "post_logout_redirect_uris": "http://new_other_example.com", + "client_type": Application.CLIENT_PUBLIC, + "authorization_grant_type": Application.GRANT_OPENID_HYBRID, + } + response = self.client.post( + reverse("oauth2_provider:update", args=(self.app_foo_1.pk,)), + data=form_data, + ) + self.assertRedirects(response, reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) + + self.app_foo_1.refresh_from_db() + self.assertEquals(self.app_foo_1.client_id, form_data["client_id"]) + self.assertEquals(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) + self.assertEquals(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEquals(self.app_foo_1.client_type, form_data["client_type"]) + self.assertEquals(self.app_foo_1.authorization_grant_type, form_data["authorization_grant_type"]) From c66af1c729031e93d64c0c7a1dfea9f64994556d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Jul 2023 19:20:03 -0400 Subject: [PATCH 519/722] [pre-commit.ci] pre-commit autoupdate (#1299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5bc7c5358..cc087958b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.7.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From a7f6468ba1ffcb031d54df065d5e498ed9bbe046 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 1 Aug 2023 07:24:42 -0400 Subject: [PATCH 520/722] [pre-commit.ci] pre-commit autoupdate (#1301) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc087958b..43c296963 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 2cf7f4fec2b3684a3d30e469d401795a83fb8d88 Mon Sep 17 00:00:00 2001 From: Diyorbek Azimqulov <74812737+DiyorbekAzimqulov@users.noreply.github.com> Date: Thu, 17 Aug 2023 18:15:00 +0500 Subject: [PATCH 521/722] type for word fixed. (#1307) --- docs/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index beff06a5a..fb7ee8ed6 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -244,7 +244,7 @@ Start the development server:: Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. -Fill the form as show in the screenshot bellow and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. +Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. .. image:: _images/application-register-auth-code.png :alt: Authorization code application registration From 01dd372014dcec1150c33b2370627a8c1b2e650e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 22 Aug 2023 13:08:55 -0400 Subject: [PATCH 522/722] [pre-commit.ci] pre-commit autoupdate (#1308) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/sphinx-contrib/sphinx-lint: v0.6.7 → v0.6.8](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.6.7...v0.6.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 43c296963..a2382c91f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.7 + rev: v0.6.8 hooks: - id: sphinx-lint From a4ae1d4716bcabe45d80a787f4064022f11e584f Mon Sep 17 00:00:00 2001 From: John Byrne <13647556+jhnbyrn@users.noreply.github.com> Date: Wed, 30 Aug 2023 14:42:05 -0400 Subject: [PATCH 523/722] Issue 1185 add token to request (#1304) --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/tutorial/tutorial_03.rst | 2 ++ oauth2_provider/middleware.py | 24 ++++++++++++++ tests/test_auth_backends.py | 61 ++++++++++++++++++++++++++++++++++- 5 files changed, 88 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 16c2058b8..58ae037ee 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,6 +56,7 @@ Jens Timmerman Jerome Leclanche Jesse Gibbs Jim Graham +John Byrne Jonas Nygaard Pedersen Jonathan Steffan Jordi Sanchez diff --git a/CHANGELOG.md b/CHANGELOG.md index 292300ce2..93176fe4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added +* #1185 Add middleware for adding access token to request * #1273 Add caching of loading of OIDC private key. * #1285 Add post_logout_redirect_uris field in application views. diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index 09486c3d6..ef5d57969 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -47,6 +47,8 @@ will not try to get user from the session. If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. However AuthenticationMiddleware is NOT required for using django-oauth-toolkit. +Note, `OAuth2TokenMiddleware` adds the user to the request object. There is also an optional `OAuth2ExtraTokenMiddleware` that adds the `Token` to the request. This makes it convenient to access the `Application` object within your views. To use it just add `oauth2_provider.middleware.OAuth2ExtraTokenMiddleware` to the `MIDDLEWARE` setting. + Protect your view ----------------- The authentication backend will run smoothly with, for example, `login_required` decorators, so diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 17ba6c35f..28bd968f8 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,6 +1,13 @@ +import logging + from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers +from oauth2_provider.models import AccessToken + + +log = logging.getLogger(__name__) + class OAuth2TokenMiddleware: """ @@ -36,3 +43,20 @@ def __call__(self, request): response = self.get_response(request) patch_vary_headers(response, ("Authorization",)) return response + + +class OAuth2ExtraTokenMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + authheader = request.META.get("HTTP_AUTHORIZATION", "") + if authheader.startswith("Bearer"): + tokenstring = authheader.split()[1] + try: + token = AccessToken.objects.get(token=tokenstring) + request.access_token = token + except AccessToken.DoesNotExist as e: + log.exception(e) + response = self.get_response(request) + return response diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 8eeb8ef12..6b958ecb0 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -10,7 +10,7 @@ from django.utils.timezone import now, timedelta from oauth2_provider.backends import OAuth2Backend -from oauth2_provider.middleware import OAuth2TokenMiddleware +from oauth2_provider.middleware import OAuth2ExtraTokenMiddleware, OAuth2TokenMiddleware from oauth2_provider.models import get_access_token_model, get_application_model @@ -162,3 +162,62 @@ def test_middleware_response_header(self): response = m(request) self.assertIn("Vary", response) self.assertIn("Authorization", response["Vary"]) + + +@override_settings( + AUTHENTICATION_BACKENDS=( + "oauth2_provider.backends.OAuth2Backend", + "django.contrib.auth.backends.ModelBackend", + ), +) +@modify_settings( + MIDDLEWARE={ + "append": "oauth2_provider.middleware.OAuth2TokenMiddleware", + } +) +class TestOAuth2ExtraTokenMiddleware(BaseTest): + def setUp(self): + super().setUp() + self.anon_user = AnonymousUser() + + def dummy_get_response(self, request): + return HttpResponse() + + def test_middleware_wrong_headers(self): + m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) + request = self.factory.get("/a-resource") + m(request) + self.assertFalse(hasattr(request, "access_token")) + auth_headers = { + "HTTP_AUTHORIZATION": "Beerer " + "badstring", # a Beer token for you! + } + request = self.factory.get("/a-resource", **auth_headers) + m(request) + self.assertFalse(hasattr(request, "access_token")) + + def test_middleware_token_does_not_exist(self): + m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "badtokstr", + } + request = self.factory.get("/a-resource", **auth_headers) + m(request) + self.assertFalse(hasattr(request, "access_token")) + + def test_middleware_success(self): + m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", + } + request = self.factory.get("/a-resource", **auth_headers) + m(request) + self.assertEqual(request.access_token, self.token) + + def test_middleware_response(self): + m = OAuth2ExtraTokenMiddleware(self.dummy_get_response) + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + "tokstr", + } + request = self.factory.get("/a-resource", **auth_headers) + response = m(request) + self.assertIsInstance(response, HttpResponse) From b8763daed0df42a311541df43d949ef70c04d1a0 Mon Sep 17 00:00:00 2001 From: Martin <80222180+MT-Cash@users.noreply.github.com> Date: Mon, 11 Sep 2023 21:01:35 +0200 Subject: [PATCH 524/722] Add index to AccessToken.token (#1312) --- .../migrations/0008_alter_accesstoken_token.py | 17 +++++++++++++++++ oauth2_provider/models.py | 1 + 2 files changed, 18 insertions(+) create mode 100644 oauth2_provider/migrations/0008_alter_accesstoken_token.py diff --git a/oauth2_provider/migrations/0008_alter_accesstoken_token.py b/oauth2_provider/migrations/0008_alter_accesstoken_token.py new file mode 100644 index 000000000..5d3a9ebc8 --- /dev/null +++ b/oauth2_provider/migrations/0008_alter_accesstoken_token.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.4 on 2023-09-11 07:03 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("oauth2_provider", "0007_application_post_logout_redirect_uris"), + ] + + operations = [ + migrations.AlterField( + model_name="accesstoken", + name="token", + field=models.CharField(db_index=True, max_length=255, unique=True), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index d22f7ee82..c1dec99c5 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -357,6 +357,7 @@ class AbstractAccessToken(models.Model): token = models.CharField( max_length=255, unique=True, + db_index=True, ) id_token = models.OneToOneField( oauth2_settings.ID_TOKEN_MODEL, From adcb27626609714fe0c587e3228e607e8133cf52 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 07:27:53 -0400 Subject: [PATCH 525/722] [pre-commit.ci] pre-commit autoupdate (#1314) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a2382c91f..b027810a0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.7.0 + rev: 23.9.1 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From f8c9f369a4cee0d341790ef4c5aed8bdf40b11dc Mon Sep 17 00:00:00 2001 From: Jennifer Richards <jeni@borkbork.org> Date: Tue, 12 Sep 2023 09:07:01 -0300 Subject: [PATCH 526/722] docs: Update RFC URLs to modern location (#1315) --- README.rst | 2 +- docs/getting_started.rst | 2 +- docs/index.rst | 2 +- docs/resource_server.rst | 2 +- docs/rfc.py | 2 +- docs/tutorial/tutorial_04.rst | 2 +- oauth2_provider/generators.py | 2 +- oauth2_provider/oauth2_validators.py | 2 +- oauth2_provider/views/introspect.py | 2 +- tests/test_authorization_code.py | 2 +- tests/test_hybrid.py | 6 +++--- tests/test_implicit.py | 2 +- tests/test_oauth2_validators.py | 4 ++-- 13 files changed, 16 insertions(+), 16 deletions(-) diff --git a/README.rst b/README.rst index e43ea032c..15ff04f7b 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ If you are facing one or more of the following: Django OAuth Toolkit can help you providing out of the box all the endpoints, data and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib <https://github.com/idan/oauthlib>`_, so that everything is -`rfc-compliant <http://tools.ietf.org/html/rfc6749>`_. +`rfc-compliant <https://rfc-editor.org/rfc/rfc6749.html>`_. Reporting security issues ------------------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index fb7ee8ed6..2a7cb284f 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -416,7 +416,7 @@ Next step is :doc:`first tutorial <tutorial/tutorial_01>`. .. _Whitson Gordon: https://en.wikipedia.org/wiki/OAuth#cite_note-1 .. _User: https://docs.djangoproject.com/en/3.0/ref/contrib/auth/#django.contrib.auth.models.User .. _Django documentation: https://docs.djangoproject.com/en/3.0/topics/auth/customizing/#using-a-custom-user-model-when-starting-a-project -.. _RFC6749: https://tools.ietf.org/html/rfc6749#section-1.3 +.. _RFC6749: https://rfc-editor.org/rfc/rfc6749.html#section-1.3 .. _Grant Types: https://oauth.net/2/grant-types/ .. _URL: http://127.0.0.1:8000/o/authorize/?response_type=code&client_id=vW1RcAl7Mb0d5gyHNQIAcH110lWoOW2BmWJIero8&redirect_uri=http://127.0.0.1:8000/noexist/callback diff --git a/docs/index.rst b/docs/index.rst index fdd8131b7..caada02e4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,7 +9,7 @@ Welcome to Django OAuth Toolkit Documentation Django OAuth Toolkit can help you by providing, out of the box, all the endpoints, data, and logic needed to add OAuth2 capabilities to your Django projects. Django OAuth Toolkit makes extensive use of the excellent `OAuthLib <https://github.com/idan/oauthlib>`_, so that everything is -`rfc-compliant <http://tools.ietf.org/html/rfc6749>`_. +`rfc-compliant <https://rfc-editor.org/rfc/rfc6749.html>`_. See our :doc:`Changelog <changelog>` for information on updates. diff --git a/docs/resource_server.rst b/docs/resource_server.rst index 4e623b118..eeb0cd3ae 100644 --- a/docs/resource_server.rst +++ b/docs/resource_server.rst @@ -1,7 +1,7 @@ Separate Resource Server ======================== Django OAuth Toolkit allows to separate the :term:`Authorization Server` and the :term:`Resource Server`. -Based on the `RFC 7662 <https://tools.ietf.org/html/rfc7662>`_ Django OAuth Toolkit provides +Based on the `RFC 7662 <https://rfc-editor.org/rfc/rfc7662.html>`_ Django OAuth Toolkit provides a rfc-compliant introspection endpoint. As well the Django OAuth Toolkit allows to verify access tokens by the use of an introspection endpoint. diff --git a/docs/rfc.py b/docs/rfc.py index e5af5f476..ac929f7cd 100644 --- a/docs/rfc.py +++ b/docs/rfc.py @@ -4,7 +4,7 @@ from docutils import nodes -base_url = "http://tools.ietf.org/html/rfc6749" +base_url = "https://rfc-editor.org/rfc/rfc6749.html" def rfclink(name, rawtext, text, lineno, inliner, options={}, content=[]): diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index c13974e18..07759d1e7 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -9,7 +9,7 @@ Revoking a Token ---------------- Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` into your `urls.py` as specified in :doc:`part 1 <tutorial_01>`, you'll have a URL at `/o/revoke_token`. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. -`Oauthlib <https://github.com/idan/oauthlib>`_ is compliant with https://tools.ietf.org/html/rfc7009, so as specified, the revocation request requires: +`Oauthlib <https://github.com/idan/oauthlib>`_ is compliant with https://rfc-editor.org/rfc/rfc7009.html, so as specified, the revocation request requires: - token: REQUIRED, this is the :term:`Access Token` you want to revoke - token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. diff --git a/oauth2_provider/generators.py b/oauth2_provider/generators.py index f72bc6e7a..436a303aa 100644 --- a/oauth2_provider/generators.py +++ b/oauth2_provider/generators.py @@ -17,7 +17,7 @@ class ClientIdGenerator(BaseHashGenerator): def hash(self): """ Generate a client_id for Basic Authentication scheme without colon char - as in http://tools.ietf.org/html/rfc2617#section-2 + as in https://rfc-editor.org/rfc/rfc2617.html#section-2 """ return oauthlib_generate_client_id(length=40, chars=UNICODE_ASCII_CHARACTER_SET) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ecff21880..6847760e5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -536,7 +536,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): Save access and refresh token, If refresh token is issued, remove or reuse old refresh token as in rfc:`6` - @see: https://tools.ietf.org/html/draft-ietf-oauth-v2-31#page-43 + @see: https://rfc-editor.org/rfc/rfc6749.html#section-6 """ if "scope" not in token: diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 26254da6b..04ca92a38 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -13,7 +13,7 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): """ Implements an endpoint for token introspection based - on RFC 7662 https://tools.ietf.org/html/rfc7662 + on RFC 7662 https://rfc-editor.org/rfc/rfc7662.html To access this view the request must pass a OAuth2 Bearer Token which is allowed to access the scope `introspection`. diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index a5394cbd7..b27eb8b67 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -483,7 +483,7 @@ def test_code_post_auth_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="test_user", password="123456") diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 2e85b05b1..be631d09c 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -690,7 +690,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") @@ -713,7 +713,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") @@ -737,7 +737,7 @@ def test_code_post_auth_redirection_uri_with_querystring_code_id_token_token(sel """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="hy_test_user", password="123456") diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 5fcad62b0..e4340a18f 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -205,7 +205,7 @@ def test_implicit_redirection_uri_with_querystring(self): """ Tests that a redirection uri with query string is allowed and query string is retained on redirection. - See http://tools.ietf.org/html/rfc6749#section-3.1.2 + See https://rfc-editor.org/rfc/rfc6749.html#section-3.1.2 """ self.client.login(username="test_user", password="123456") diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 83cf770e4..7d2b0cbac 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -311,7 +311,7 @@ class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned when token authentication fails. - RFC-6750: https://tools.ietf.org/html/rfc6750 + RFC-6750: https://rfc-editor.org/rfc/rfc6750.html > If the protected resource request does not include authentication > credentials or does not contain an access token that enables access @@ -331,7 +331,7 @@ class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): > attribute to provide the client with the reason why the access > request was declined. - See https://tools.ietf.org/html/rfc6750#section-3.1 for the allowed error + See https://rfc-editor.org/rfc/rfc6750.html#section-3.1 for the allowed error codes. """ From 0965100ce9e363d026d0801b902a2d5280eeafe5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zahradn=C3=ADk?= <adam@zahradnik.xyz> Date: Wed, 13 Sep 2023 20:51:21 +0200 Subject: [PATCH 527/722] Allow the use of unhashed secrets (#1311) * enable configuration of Applications to keep the client_secret unhashed to enable properly signed JWTs --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 1 + .../application-register-auth-code.png | Bin 37074 -> 36840 bytes ...application-register-client-credential.png | Bin 33524 -> 33986 bytes docs/getting_started.rst | 4 ++- docs/oidc.rst | 3 ++ docs/tutorial/tutorial_01.rst | 5 +++ .../management/commands/createapplication.py | 9 ++++- .../migrations/0009_add_hash_client_secret.py | 18 ++++++++++ oauth2_provider/models.py | 5 +++ oauth2_provider/oauth2_validators.py | 17 ++++++++-- .../oauth2_provider/application_detail.html | 5 +++ oauth2_provider/views/application.py | 2 ++ ...application_hash_client_secret_and_more.py | 31 ++++++++++++++++++ tests/test_models.py | 30 +++++++++++++++++ tests/test_oauth2_validators.py | 19 ++++++++++- 16 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 oauth2_provider/migrations/0009_add_hash_client_secret.py create mode 100644 tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py diff --git a/AUTHORS b/AUTHORS index 58ae037ee..d24447a5c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -9,6 +9,7 @@ Contributors Abhishek Patel Adam Johnson +Adam Zahradník Adheeth P Praveen Alan Crosswell Alejandro Mantecon Guillen diff --git a/CHANGELOG.md b/CHANGELOG.md index 93176fe4b..d26ae6207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1185 Add middleware for adding access token to request * #1273 Add caching of loading of OIDC private key. * #1285 Add post_logout_redirect_uris field in application views. +* #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. - ### Fixed * #1284 Allow to logout whith no id_token_hint even if the browser session already expired diff --git a/docs/_images/application-register-auth-code.png b/docs/_images/application-register-auth-code.png index d4ef8bd5a339e1ec19bd9e331e9e484a8601d074..0231127ae8a6df6862bf02c3523a7128d9dbaf3f 100644 GIT binary patch literal 36840 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 literal 37074 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ΜZW{a&l8&OmuYgg9lod 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 M9vuUN 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 diff --git a/docs/_images/application-register-client-credential.png b/docs/_images/application-register-client-credential.png index 7f2d1cb606fba721441abc76bd6c6e291a787882..8d200d87f6a45db2ede4a5a3f3961fcb6bc08e64 100644 GIT binary patch literal 33986 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 b4UJVCt|sIkO)Y!}^H7vklPP~|^5y>kUSPrL literal 33524 zcmdqJc{rADyEd#@ny5^XF++%wDH-Yx37IpLp;YE6$viZWIm#45NM<r7M1_!qOer$Y z^E~tJ_w!q8TkBif_pa}+?|rxTwLMS2#C_k_b)M&O9Q(c>`*HawD_%Z8c8rXKgyg^# zSt(T#lI<e+e<bO4{7XrBo)rGG<38bvIw>jXpB|-dd`oL9t!1lbWoT=E%jPbLk)@Tz z-Bb5(+uXftdEeN|c4|kN1PKWf$rY)K>JG2QI~{fW*R~`#+jy=v2~0Svny6ft{dOhn zg?MeYZO-{Cmzl?EoJ9JyauQ_=SUgUQkmtO4L13fI;EH=&nP+UJaJEZQudIF{YDu=2 z-YE0F*pz#rZku4+Y?0yDg+jer9hK|Xuao1NE@-G<p~innh7WG^NxMkIR`t0qPhaFb zx;eKCcQSoEUYS!>RdxUFCyLaE*k@J_F-qj+=bt=zvT?-rSiquEDBk3geMW9>aQ#qQ zOG~yE5AkQ=obj&eB8dWmf?G3(i3>u?tbXlUr9rmT*tIQp#!zL>{uu{3Ik^{{H2C2| za)Pg~@5IDJysOgI5p@@dt-h}p@$cP?tZZy8?d>ZyuMd-XHt_Aj<vunRIQE0pVDEmV zJ^0R)TphnBd*Cr~8&WPRZ^Hr#l5*jjH@h$5*B>@BCF7f*V>>DFAJr$XaV`IcylBFC ze`;&1M%32*slVb^sQ1X~h3otD_unzNb?MR$THl?YmV7n?kB<!2MU`!BI*0RJxpL(s z7uVv%AN%oN*?m=kNsY!^S}DYv?~tTc?_8elDKEC4`uov2{KX5KmDzzyUI%MxYu$^> z&(Qb_nzeSD961u4^{X7O{P^+X%+6><-zD<(iT(Tcdr$fJ`+pY}4=XNqR#bdeT)g%( zQGVt{+2*NT-IAC1skQ0o=(M%9#ZDb1Gcz;u^YxW*S$;_FG}Bv&i&>iL+M0N#nA9}% zJz`FC(RyuhqAcz7!#t;j(FN)<4smsD?TFxD)`ICQclmA0^+5{CE6d9?w6s3FDVdpz zi;Hdxqm%^BGiS0>Q{~ju)L8DW@h}922L&;Ws6N~}SU9!qweua5f}afyT6xBeSc{qQ z+HgMUC!;HKL*cx--@bk8wfUgHo~slqafCzhmFR<1t=t=ymX@-zvWkj|&d$XJ1&bjk z)dqh=7vCy>y0$dM>M{5|;>jP?>o;y(_NRFyJTo^(C+;K=Lq)n%*y^vppC30Dm#MjV z$n)nanwsx(bMN1~H_}-wtY`G=-7Os83W|yGadN`nzkl%|4p**SYr%yuiaX)(vmGVN z&(A+jPfx|8#q|5-xx3$v9`t%bKD)3WC@kDn^uX-m#Ye%xO_QC)k_(&D4kc-tS=g-i zZ{EB~O!TZYprxg~8YwVR#pwP<nX|Jb``Wc@!R%MRG-YRJ^Yim_D8(o!DD>NWNKH*m zxE#1RKY!G$&D6poz}L6Peaj8ogL}o#0s;b}qM}MmB?z37lJ1-9E2PXqQPDv`7wBC# zRu^VwW*k=6SLUS5S_0^V3oJW*yV732eqCnsIDW`xbKOx}yFHjgNy2gVO<deXU0v6X z!h3#HrwsTb^s{ZkUjzqhX6XeaO?4J0B`3?4sB3E4_k8j6I>cxrLeCJG6c=~P%<R1T zri-|^c%yi8YpY&~!x=WV#FCO%RedKpIkm1|{}Chp=j+$qS=N&sA3lA$V6dT<q!=yw z0C{4xG2wFTY8Z>h@87>O^*%-h1u0*@{=1`thnqVsD@)R4*_xV~`rNs52Km2!{j&H| z!0bUhU_-I)VId*(l5S#W&*mm3N-HVVR}#Mxqnh?S6stb0PRfjBG$=|)c=A%hb#?wZ z>t*lu!NI|y`dBQE*qJkpU0t!CZ*~s^GKgRQ=p-0hUzMAs_mOP>ek3^)bMp#$>`n1G zCnKYw)N5%B;!aXLCZ@zMu9GJbGadP6ZPm|NC7sCv871}JTk4vbacB3pU}N<@`uh2u zcUyPB-=+yve$w;?SbjD(w$7=7{QQ>MT5di*rTMGXIKSV%Ijt|x;8YaXaP?}D#$gk6 zi|s<g!Vc&5^z`i7wd?ce&+4OF!kf7{IX3M%cdh=GP;_z$2`&DJK969d6SYnD@$q@} zN(a$%|NecPgjfkza>DjUOsuR=ew`2(k0#}cSE+4nUF3^-ZTuQ{&w!VF_&{54Bl4;# zNolCRf9IY(eV;}2^Jb}QYHGA|4FiTnJ|SP@i%!@Q$>w%*vWmm4d-t-#!^4M7A3S(~ zRD>_Hw#?{r_XoonsJXehZ{(X^b96kH{U$s63<vRR2uWnmA%^j`EJi)Wyi8#sAu1{= zUcHh+oOaHr*jRlFi;SeCp*G4L58V&@JWVpXdzYfABHBb%^|0beuF%nmiQ|-%4|*&1 z;=Vi@khLxm2-Bv=j~_2BEfq<8sqpswd;1F)9!5p|$++>}=KlQ|(N6srmVZlp)Z}n- z{}fm-Gc#l9at-SUq$qfw+uEr02M;nzj1LS*n^V%$yWqxV`l?txu;4=GzaFZYe(~6g zgHdMPgTEY^mXBv;@bT^5SX+{plj|QCxT>IV^ypEEYX!(N-F_m$H67WvFFQCC4oAD8 zd<qY-T^|v%9d1AhHvI8wq__8rVeZTqe}8|(N3Y4F3-2sDMPI4Defzd5kg?2W!1w)o z;ZV7O8HXrgtIV{t1l5!$yAOOBD|49Hu5^@&s?>dpf!U*;<<zMF5l#yACwmTU`f+eN z@jiO=2xa}od&|yJSLdzGjhQb=O0jw$offz?CO<rVx|@-akzNu9y}iKV56;KE%GGkO zLpKV{nfd?X{<Gd@WicK-YCHIyHvTC!Ir*mn8w$ehum0!k#$P^rc2G~^))$Z4RnL6> zhWVISTIzkUVN*OQEnPWK8$Q)l=3euHZMg{<2*;a!^1HYF>x>MGp&!v)TwF+neP07u zEzQ1s`LelkYl*~Vmuhozp^l}2LGMUY(u{dmsVfrG%zRBS$4mv%vGrVJY3b(X#u~!^ z+t3^<D{E(GXM}>GjSc^1Y#{mXcei$wvh1f5@>fhca;w$#2<^SP2X=Psyqs8wu&^+E zo`#y5nTg4*-1Z%N4pwO1`TqNNWFftjlvMZWrna`Fg@wuoA;=PxM~{{`EpVMDemQ@7 zxGjE9-S_Xd4;~=EESBz?A;7~4C~ow=*caqRksZi!IAO)@uYzJ@-Di5GJ01(){T8xo zA655&QjFN=3<Y4Hl@%n_V*v$7yB({egM%6>D)$yfTksYyU((py+tblSRa8i=zr7kG zMmx35O3-n(e|mb_s6J+9#YHsw@9*EfV}3R^Hl6!*$9neqmde|TZdCXjDNL+I@U^s1 zzJL3*qN1BBgYWu>Azs}gWLYor7hz!s4<5ulUm^`SCTNNZmZe`dIO9Ogr>9fq=6dPU zlgE#@OQaxld-h(sRF3n3<#Jn|W<GVQuDx9w5u~OToV;#gVnUJeSQl}fnw)%rR3)YP z^=l@Vs=?u55trp@6f%1|JDU$3)zz|x4jpRDSaxx{apMpEdFIjT!WhoWL-N(>o=b*? zZ&AjmKkgt|pF43xz3}Z%He{)echAUE)6%#(IX^WC2?<H%#-yg2A`|JAy0mw6yb2CJ z>oDDoMd%4fO05c@yF6Z3S?NidI!O_*cIM2P$jC@LJG*5GdjJ=4v6PUIqhhx#EKZS= z?%cjDW~0Z8ahmCQbF+H(>lfkS`X6lWE}irwADvw?LNqLkDxR1(y880;wxpwk>hIsv zt-4C3rKL&ZA|sDws||M+zfVo&nt6JNmVc+FgsPevj+&2*SbTwRnzHNKB1-mwLx-%) z%$#R>JwroV{HXb+*KXdveJPK3^Av~3tuH%!S#R@4Apb}{7(Gfze)sO($B(+{b0dBl zlME^!b8~acaVhalBhHKCpRHIY{ngdg*9$K_tGR<3(bF`tlEm)2JTUe8;uCARTll== zBGycnu2&TmsrGSs1Vu&BQFx&UR$la!l5ira_CG4f<u8+PPDF%9w<x6WrSD>h`QMTV z8dK)fygb|K?$67+X_=dg?X7SQdr{i1p}wxJuGTZS{(N%<r(t|}*x1;3dT#C#DSiXB zpuN2vCq^!hbwkbjr_5#ImwU;VDpwWcwW-pf{YS_=0eec^Hk?#cRB%X?Igh&L=jP7k zI)D29{kxQrprGJA<sQb}Odfh=ZUG#qR@Q8<Yt>E*2&k*5q-12cuFT$bc0TV(sQK|j zhV;NO0b_)jI@dKO@;6CIH?*}E-gHilDZay&#OLnby}Ke4XQXnspiASoPFAO7Z>y7r zckY}J3d+yOnE2wkZ|~l{IM|kEW=H-Uy2m&t?VZ(7)uMLk(j|KJyLaz0T5+y6=C+&T zwmS5sUFJ!GBJMmk?f&#gO3CWpy?fWLQ4(aOr3aR}+&NYi$q9^GOPe&7-mEgcuIuFr zyLNfT><mTL0S2%e|8;R)GAk=f2{2JsRs<(At87!$;&+~<(9n!S?1BC9a}SwB-QLil z)&PGwTnCnvmiClOY;A5PPuSrk%VR0|qq@Gd=jzp~+aEoYOZ@%oS4XL9F-`;ER6@dz zLvGpm`FC^MQS@u;>aHp&Z4HF$JMq8h9aB_JmsiTuI3db2hn&4Tu!_|*qO+|W=a}l$ zb#)ybe<SSbKz~2y(QjjIS<Y^^XJ#6GbXJy~6?vFt6uePt4o3qVOxg&NRA1r0n2(+7 zT|CRjHw7$2Mdpe9@;Z3@cb>_S?8ss7{7;`g#a;4pGxe$vT<RNBtS|P<NKfb0FMSmo z8ygb?csSS5(E-xX;|fI91W1<Nqj>bm=hsam%UlIFKR>4A)|l`2jt?zUs`>Vfg}0Z1 zfgvU)rZC02>Frhf$&Qg46{lx~&ZmWiCGQU^2%7=C_3Cexpoo=~+lqGn{(Y=01aK7T z0`Oyf<-SJ{;#_OvQgd7MO&qr2bmIy)7mBU;(BjWGPlNX<9&5~wX7uy+{&Z40scB<l z1BiQO_FmuD7UJu@4K9P)e9>N*`AAJM8vovjWL;R+tvNO#qFqUH!y_pnAwl}`IlqD$ z@&IRD7B}04#fcleG1ScJYHD-ReUY3;ees+1l3OM2TboFc(c;b;nwr#Q9`61v2Mp>a z{Z<pbCxty4;-&q(4`#f#>e>vr{8>urV*19_{f$GhQBi(P)nC4B*Pp*P;8Q=;Rd{a) zht$%9o4);Hsk^&aulE}ZL_U4`RbTsqbHxphwc~q7Mn(WZ@P){d?SJ#VuFfaK)Y5YD z%0MaI)2B}z2;=_bghEPc<`b>k_kMDJ*#Cr#InQ=PHJ-w}>e+Fr3|SSG=fh`8-8MuU z)4LiQvn@M|CjPwNvujt6qDg#a)8D_mtLL|>tE*#<KCB<|^7LF;U3GGH_G_&{;CgR+ zAjUA&cR*Urx;lv2f#uve+C%%x4;~lIyIULf@ZrNi#^9zS@l|Zs-2l=bc@4Ry-qiOJ z@kA;i63`Wp10l<fLPTw0p-71ol7_teKIVDspjfx3Y{Ir3ySpP<O7A4p=dZbT?)_Wh z7@XW>Yimo+ScDa>x*DmUXS4M50NqoLpFe+E2MZEpnaFqU-pz0xftP)|u=p8gu5nM6 z&Igv&<LqHa+Sl7@Slt$*LPMqf&k{&KI?Oy?w)5E(pn15pTXp~2o4M^05)yPm<`+mq z!^1(fLY8R*z)_1HjFA%@9UaYD(<6i|j)ls7wC*7|If)WWfH`n7Df6B^dr$}H=>W+x zQ&UHqlInAf>QPcF?(j?gIdn0pDOSw!@zMv=meh!|MyV+&Gb`)ieEP)d`@nl&wsF;q z*w`*0!K0Lvdyv@hIm5%l1qB7qo;|Ddqb@EkPD@GYEOXzQ9jGN`rlpN=oEvnljAC_q z;xgR8tf|=+A0H2JGC$I^r>mKoib}$LQ|HEw8%R*xi+rYFlNHLJPTeSQaCD@lqr=a^ zE{>LYNxfk9FgG{P)Gt%iN%vXus=2cWf(;N=q3gaRm>LuN+W0JJ#u&gg2=}E#RX*J! zsb&MC?=NR&m`jZZChKCv*G5~?>gwvm80!E0aRx(EU_T4+U9~$vrd%@qG(Z10BzDq) zp`ogZ3UD$BWIyam#g)XjZ=cbgl|i=E&<I=ZMnbr4V8Fu7J=WdbFwm&0qZ6NyaBoF3 zQ)gsyGBY7TO-Dx|uW50l$!N4WMc+x4B#8dxV=`s|r+H&%=QY%7j|R_u)L;E+Z{NE0 zh#Y`tc6$2LrSFUqF0$(al+16^(&WW*A66PzU@3qe32~^a8^etW4#%mf!vPdr7aI?e z#vPy&;<-_fjtb7odMR&(A_`kQbB7Fw5i~?BQy9?U%$XUGW_khRps=vkOx<Ff++TnH z>S<`Oi}qiakuiiMB2F*L{1s%Np@BS>pss#2^X>5HC>FdKRPn=y4~dC)u}qR%>-Xy~ z|8yq{mFvWj$+=q#Xp6jjkY0pob@T<By!Yx(LNyW*z>ovpc(=P=g*<eJ$|ff#r_s?- z_u9R@*Ymu8eXFaR_#SaKUOdu46I@|^nVc97%`GiUAbEBZzX4!#a`@M6IsHH11TsGU z{zqM;;27St@GLR#9d>bWBoPtk2~-Z;q`2*H5LV{ht@7!>x*ON8PkT3km3d3-_xWU) zdk7J2Gw=<F4yhOD!l%^K*cco7neUjzp-*MoPeyLOTNrEWv!jVq9v&N$&)bfOi59tM zFhAS~8fz=6tD@3~cp3N>iX~@bkn<ovChkP!$>|vx5ejd?Us6&=z9Ju(|GKb0XLPgX zC~g+0|3;qibFAE}SM;HBp)X$iY;9#YYp9^C++bC<A(LKnhabs%&p~=lR@U-6{0j>U zKG|hpB7xrC%Ogp#K;VgqiP}6Gz=y!H_W`sUi>j&PnnrN{YuL?e>gvO`McB@c5BKX| zol8LUj*X=yBwR{pY;JD$W^QwT{_NQgq6EaFZD4Bp%^;g*zYM7M*JsD+4j*QTJTmM~ zH!tPB?7#V*ti`psOEdQMqeq_+MbWQbA)FK6yjh(e$u+3-6?a~=T}jB}yS|dU`w#;? zE$ul}H7EtABNPCZb#*(D8Gv9TjP06QTEv0m8{#ejiQwu3{QX^AT-Mjud3bol#Kl8H zLU6^<T{hXm{QUfo9l*w-6*W~=bMo^i486TdOWmZTDxf0l-cN&#GZBb<{P=M|KpoN~ zRFSldj15Yoog_6X-YTil(b2#aTOawLTd*>a=ie&d?LanEc3rsMWW$ep2Z^j_c~5ms z4MqOG9D$7uX@ojr0H^`A|Nh|j?3|o0wP*SHy>AtR>%ZV;f=HS*&$yQhDazZMv?Cjp ztk}Gr|M>A}WasYgn<WGgNuMOS9V8^n)GL8IDFbBI`T`|2GOoA${Hdy|dunl91iuW< zbS2dsa^t4&maH<f$L@plG?bK-5EVm0)Ui^(esOYho87$G4K@Kz$>&4@t(IhVdFs$x z{C9eaMd;;Ysq!ZL<HNxJ1v>6T*&Mao=Q8TRsy>?!IDkMBFL?JYE1ro^fDD7x+Wmxi zB!}c4x%!1K4q|#&u0YT6FDep^KBzCtMC&3^hCP1srUDrw$Je~Oh~$KS?Ldwe&w&F6 z0IbP83319NbXlf5DgO0WMa9gbqR83-Q!_L9BB^fo3(DK}LbhsaY<wOT_SAd(i^pZo z0L3aQ*(D|PkA2N=p57JoZgJ60DC)q;T_>I)_qCf3bFwj$axpmusXjc>ohGY@3!Cj8 zC6V1;>f68bzewH7ydIvOU=kO8CNjyNAz8M6^m^3_LMVtAl*#yg_0i`!3i{X1K!DQJ z)VvbDon%^&DS5MYc5V*p9mk03`h5>V+y$oLS+=WJ>!3nzl@CzNrR2D3K<VnbahPON zw|h_7*4?u8S--NfvX39d{VEST6Y~J%hCyo)(11B;v4Un)1WqQV*Ku)=YVLsl&N`GJ z+29kw&=MgBqJ)5|lg6Q9g1F;jG?~a>Mnz@m7HcReP2D&u!OP1lBvg=@si>{(2m%9= zb(!>|^Ahig6DRoi0xJy;(2H1qe!Po^ikp?SZ@fJ>K_<Z0-~W}cm7arxgRwDZaE%lx z2;8abdB&YZ55NoXP1)uuH-VF#eXup-Mh3!abzu$I8);*@2gLyZ9{dw>I1i-k>{(Rb z55>iY7{q9p^X?D*`2G93ySuxGGI-P5t5Hxhj?vMf9`4(>4`%^^+dVk=)gT+vac4(I z*r^+Ki{tG`Dt?nFs{Cwhcaay(g^FtvvVbm8)xc(um7$VtP+9SlY>_D^tn%Hrvjgaq zyw~gTYql**->S2y<<Fn<5F*>!gqfI_goUY@r4kwsA3ls!<~;E`ABxG^>MHgXS;!Fu z<Z>W`g|YDk(&Ojt(%-x}!NS7A%lqS>1dgtX72nFA_g2{brLnBCr(RxJ*Yih#6hQDU zk{%axP?nb;>#qrhYM7RmcHVVWfY<K!?c1m^h+62FwEJZ=G^qY685*+tRp#X7omgDQ zZ~ywG#mE>7&G}nR4c>!1i|Y9CiN3xgR8)hbqfTI7sLk0d+FDwWtynyEZojClO&eHc zW@<_xh5YjJ<xANsS0I_76#?iv(Vm<1_ANUnCn>W>tRAstA-TCslou=hf|*Zse8@Fv zVi$_qNJ(fKnO|OBmWKSSozrjS?zL^18i$-OL75X!(A(c10xg9uV8{__>Y?G`>YJx` zA2<fe86F!OsQ<I2rSkn8z?ZqDWz4zz>;&knso-WgIjWpTWu&DYotyyouE@(fJ2}~W z&|#t6l(Dd|AS&fo6IGGe370NmQIN)KzkS0oue!rOFfic0I+E1i-~Z!>BAMsU-@m~Y z1&~G9gH6oLqGDp6EzR}U9D&SqEI?-boP@+*pp9R@Ue*~Bm1gLP+aGnKS%Os*I(_<e zqcJuV$r<O4ocl0^I#LY|%Ga-7p`(z-LCTSrukG#a1u_2~C5$jY+ea}<XaYxVnTMQ! zz7FWn*HAGJWw2gZ*=j%sK>-1BLMMG^MMcGzFOTaCw<e}elWegx%r7w-nVPO5olmys zUe42#PE^H?1J?(P?bP87uj=aww?0op@cw+WVZ;`n?m-@{n53El4GfWlHPO@4i}y-S zPhbDzXL0Aw9V4UnHUn~J&z^<g3v_+%9NH0F60wp-Kj<3;5l|Eafnx!jA3lC`y?OJd z(Z9YlG+L21q}#GGGYhh_KQ{rY00>+r#oD3x@^EqaYF)i}u?L6?eVwY@vDS>-cke{* z_3jL;s;#dFzEe?E<>KUghbZUdd<o?8;6eT?;upQ1K0=f8XsPp(qMF(fW{<5(fCPld z7&H)bbN_H#M_V!GB4(1VQ>3X|rHS(4im?(K*v^WqNn{`@P+1xpV@u0t<dNs?d9!nd z`Q{`yLnZWxeGn8itLuhbp+zn$v$SU+QQQ%8Sw79qZiu&6=L)Sf@bvWb$@t!5Ewv@# zotVf5`J=WLpL;V~3u(|`%v~(`5g~4UeXbq=@ThL}&>UXqm(<eSjJJY7BquLFH#-Y# zoRFOS64E`&CmKq|##;DG-K7I)33-oziinDcbU^C?X%g`&5)<9+zO<8M=T-Nv9a#8K zZq400cakgp`TpZa0a1i%>FDq;c6TB`r>Hcbq^3S1FGByLOYf!!d2GFvsVNXz4FBD4 zfS{qFp&&l-DJkv0e}5aWnGr>4ggVP;C7;j;5VQ`)GCyzY?ChMq-q6sHoSgir3G#2? zvuA^IE1>W3R@=jCDM@z%J1!CFCzA&d5E;Jq?Sn)<KR+)*54Gf3L<DW99Bvq7rb1AC zy+Aod{lSBUKtH3~w{vYretv$khsb2B$7hG#2Re6jOYVL=T73FmuM}1Q`lDD{T$~Ef zB-Dw9hIbZ!gz#F(6bsgxXAQrT9yoA{o4apr1xplj-rf??(xumpYymoDEkgh3!lxeV zW5<pO2ne9Lx&Od{@-JT^qoTmQa9(z7-(J+=0!|EJEFvNTu|(+%-Sy<Tb1Q(8SO#<e z>Z_|EfItYpdgTgakHe%pk-xw}K_(feTEBc@IeGF5Y7JPDOv1?6SXE`^!c-R{Gq(0k zR+c-M1lpR2XKu~RU+wJ_(W>#u>Rg2C&d%uCfz6enSkgEKhU4@P5B>RfFTleoDghoB z>gi;%cpod^VZ8Sdb_J}x)<>rYsLzjf?7^9F%Hc+PtF^TiIq`w5Ee#zVsFaq5#-bJ9 z^vg513w0)uU?64#iN1=CM(k1$pwu5ca6mWb&fWkSM3q*yfpmD(4J|DJAt6+7ERpX= zgmhJpHQFNnUP-7HWXyLgEX0j|yu!J~`X#5N0OY%N-1N4mqoEP88`lyuGcd4O7`=v) zC{}D(7m0SElY;}`3si3q;0N~~^H7<M{7iInb3^_e?`hn&Y^mb?rw3`X;}=W&_D557 z=sBS-_oTEu-0wlSWoS4EZS;h0(A06S^5^Ije;p$<F#<9E_q}|kTMF#K4(tphFOUmc z2eP^z-T|oGtEQ@&nw1r9dy?b=X&m$rKs}%zZ`cA3zfDd3-P~N!WBpoh*NNnejDYeU zxEE5;b}r(%Kra@tU6$s_=hLT8ciVsuCRQ|&Zd>;H2Q2Q`vEx6$;w;Vnrv(K<VwAf` z8rY0i0h<4}*$o=mbV*3a|8E4iE$-jY9BRwB;YBW|pg=EZ+T4}@2e<);thd)7djJJ2 zKRdg}=7W3NqtYX%PMylk%mm0`W-rnvhA}jJgy4^F-y+fFmDG^6xsHC1j?Sw$^4m5| zPK#}HU1lQJDY6~;oo|L$-8xN!(hEFGcgDbDSX4tp1H^UNH#!{rv$+`|?86H;0D5Fv zV3?b#AIciQ;y|7iUx|D+%=Ph1NlDqZZ5vK?EMR4qO$Y!VWFbi=CKBe5hzR@?GI`yA zjjM|bvKjsH<1?m6GYCz)2M>-5TXy&y;XqR6D2Ro)7Zn+~y0UVTo10Isgoe)r@Dd$n z6bs-AkULamP{R~;F7zIY9;6i&6`@TMbAj|Dh(81eKK;@VI6rJ`BE_ABCiy-*7z;z0 z^76V2{V_Z#=*5e}2w`+7Oq$+O?AushSGal=?YG|z4HrT#H{%jOZMJ%gFOxef0p|Dz z1W@y6HEf7jF$CH}9RTh`pz+h}M{^&Y_<j5Q_(bO+3;cL>Zf<54EcqGwQgDPo*o1aM z<vIf7h=ko);&>nIvTRuEf+`4zYk_(Di^#~)k&$!84X;59JUl#bhmacYK}4z$1vtR+ z>u1kFc4P?Tpi+m3{PpW#R@ho?wpKbYeD>|C!}1Jb4on;zO92P%(RNEwO9JakOHD-@ z!n<i{X(gwovUm^(1mu1o53e=>Vc{j%B|0^bSIw=!@6SCL4MB_J)w%mn4Q}1Kg|;C~ zDrm?C$ea}x=CJc5H?^|r$~V)*mE(4SYv76E;p3Au`FX-a89jEiGa+6Gvl->KH#Rnw zKRtl<+L+BQG+%XfbsO0r8;QFcIe|VHK!<c7ssjA{HXsL^o138AvA_GVTPP{=VLW)b ziG>Am1HnxWGKeL|#Wl|LJ)=Ko)A0HtX&kl~T}+FPLKUL^T49*G;vzvnN6Q{~R8&;d z!Brx53$U}XnDK%ex%w7AuCoO1g|_yLyP`)Bw!f&z3HLT^3X6dI`pjh)7cl}fnZEAN zPatF95EGBBuoXk7+yPQjhpo+zj~{;lJi|Fe4T03>2<-9h-D$2+C#Yy(0tQx4CRi^& z^Y``!=K<ry)z$v^fjH4AwC)iWZ%5d2u(Kb&*aGky9?s!Z6&)T<&A<R0nVFm{V$)yE z=)V43E)iur69`;UaT1^w)qqvT|K&fd4tf;o*vwPZXPi|~C}z8Xu`v{_;i)Mr;7=ej z7<uj*8t&e+$ErPN0KO3f%P$06HYiKp+qZ_oY*yyxVtBDX?OC_g5snfmB_$<&eMxrq zxB2<kIFIt;Qn9nxB?o|@l(@Lh<>i-PLAiQ$A}Q9**T*M9z~tvt*_Iy8l8%lG%vtD$ z9t(hMXNTha^{Wi<2Kpk9h&+OT_QJw6w6uO9l@#*?`ucvW+V!{aUf;uQJG_fVpnt-c zfD=cHkfEcyp{bdao-Stc^9?r0`Tv7?k`v&_Bb9{0Vr6A@=ujXIj@!oSj6(@}$pBdp z4^WoSZPtO~X8i+vN@Z>v<OD?jJ}RDupFh7WR0BYuQ0;z#qFO#Q$D#0Y8Z^L{suaK5 zmZ_VQlY<=e^`v5=YUPi-xVUZZelK3UfKSSgy4UGG;DazDBNN?l!fLorVBq_l92Qlw zD40Ev`igYQGBYiqS<VgBE6K^tE-eN6`<LA7+fDG6NvN!<!d^*9OM^@x_dwTna&$ZZ zq|%zM9e<L|us%kd5H}4Je)$>w2Z*6v&UOar>FH=8l=oOqn`0ZWH9kY{T3T8rer1;f znF$Ek;<Mm*fZ$I>@Rm!Ia9jUXT@Br#`+b*NG5TL%>p{$yfIneU2z>84+wTMNVk~$U zTmoPOFD5bzc`-3DI6YWcS(Tzi7i#&+Ty%^^zoH?qbNgpF5~Qec>g@3ux~&1rE)t<~ ziH*h%8o<)!J!ot|ru~STgDP|J;zfsiC`Ckt_|N;S!op>o_=-U+MLl%5@88EAnZ5SY zzKDrY{QfpE@xXD>d8BoO{@OyDetCJhX2$ggKu2J9I9&fT!fd;8M~t2hsh%_rnnX{r z2WTTwCi*+G4q_fb&z>n77#Li=dLX+6xX=0$XMl|J{P6S8&}#t9$at(QEZAJBCHK{N z6NbQ{iHVK&Z3B|))0b>)axO4^$_49u_x^p=t{q!c#GHW>hQ_S8zNC;4!q^sGBqUw* z4J#`LKrENpPMyj?jIny4B4AVWam?C3qEiBE&-du_nt=L1>@6(|V?W}(UMdVCu>`+( zF+C2wN2<6NnHnw;2+GC~&0JkwFJG=ge`y^h;5T-R9PJt>08#pbRSgZw2tVWkQsze& zf}TIWg#^6OsLj&~^#Y9|-0$P1*8BK;=<J<&PCjP@1W@Y`s}!U=QMcSTS2zgCFo2m0 zG6V{PjY8!{qC%DO^(8CO;XI09M=p&KeL!aP@Fo00Kd5Kcrpj17#wR9_%x_$~=1I=Q z$Cs6!PS1Zw_Qnl5f_I&vT8cU=YXYteNu4DbRT!rVm3to<894!`8~K2dF*84Z0lL)e z?5vE83?c3`GxIGd%gCz=>=2CeKYUn0tB5pC-)%)&PtOhB2G9s}1mX3IUx|DB_UV{4 zmH-mfk-BbZM&dBf;RuR5I;DU3aL$wP`7#8IA3uH&4O_~b>*G%!ioG70(tj$GK(X{x zd<G}>`*(##2KM+{QhBFj<>XQm68dtTq3w)~kDqX=t}GHS-L+?r7i6{iNWnB5ilQR$ z5>Y`x$};psS$}J0AqUS7^*v*Q(WcLA344Mx18z<1C;a(SX_~0}t+rN8S=nJ_md#TB zM3|AGp>M#T2>gM=WcsUTvfIaz2#NMP(D*t-pcI(N$SHZYKl!Yzu72WeCBF%d6LL88 zX~wP#=~~%@`Eq0#0x4kkF<RQhq$Id%R-kb~3_<9FM8X6D-Xs7!AKY%%)<MC+|GgZ* z@;7Ptbp^~zJ9az)9N~}n4%E*O=(QF=PEcT1)6*+0DmumtLY)U;i8mb0=qMB<diwO& zuixFRrP6Vv+;D>#2HlyS5!^V|6Ji&`qJ}DGNKd&J2OArx-4@P2E)Z8I-cBC0XJ<>9 z0;=KM+?=G|kD<BZbvjygu9nZA<7z1$>d&ycxZFK4DRa+gLHMqT$w_ijQ~>=F2jYRQ z>)J-~@$%$s^V9vbHo%*}sIYay&*)N6q=v2(9o^W_5Hcp!;lnvj3#Pq^NcfO8Wo_>Q zxio5n34(sZF`WIhRLNC=lo@S6Y^|`caIRMR__(=f^yZ9b;aZ@-Tq3X#QkJecDd(yb zBPF5w?_Y^irw;6)oZR<DH3iu)_RMXZ*RQDbXz|%i{mnofi;q{memycgT-fkC4NPCa zfQU7e8H3@Qi1}%fU@QT0+17GjV0;2ur5vv)K-XwD@oRdnUefA!9uZFy^<L)2H`Js% zMRX{LVFFv5uebNRoSe~~F3)?mwmN9M1Cyd1xIEM6oU@mS(s*?(oL1b)*3C_#@vA3D zHWXR=_Q*zLSYTEIbX*HK1e26hu3bx1=7f0RpkcnV>iva8Rj&hdi+B@&;Owz1u!BZd zPx93C^l`U`PvDdD^_4?%a&lTl%cfY;5>6&KA;4G@VKo^Ui9olxxu2W>;kIaNpM4EE z+8LZ|Y)FP|Meownzt`5L)mp+VzXgdBRkUMpaehSY$dM!2u8ST%J~enxl#!Nl?q$Rb zq6OKZwzhUgSxF+c3koxG9&BT%5zxsb9cOnk-??=Qa)z>&ma&0Bd{Po9H!3x;PfzIG z#*SUZZlHB@&T;kxL+$J2WX@BkuDYlK7eRPwYi>sSlZ=!US`|B4xk0wm(xk4wexT94 zs1u8mo#zeSJS7U1q1-Djn-IC2mJ5t=Fh)oJV*To(gb&k}k!`Y&rn*N!azV5?!nuJ~ z-zhFG^eNF>Y(V{j=Fv4U0Tt<Nc<q6G`yga>TZ?dOq>I>3>Ikoa2LZbQ9J+>QLkh)J zp^y<jpu~~Yj(JlClm@h$^Qas$sdRWC&6#+d3%o0OD1h<I%#W7ao034Izy;vZy$gR3 zW7)Txr@6Sf@6|T|zQZ~VaPp8`RYgVn-OY=#vZST6i#z=)A?i0Gq=DqIz;GV{*iPU8 z{`vFA{v7Ws#iYxq&R$+<z~gM6VrIT*&LmTh8=it)u~6PeSzlj&t_)KBObW7Xw74_; zdYcXvcIeqbxfh{-c7X{uiQ3`tuF%s{T2=K0@?&~BPk;;zgn@p3Yt2(Z#C}IY!u|E- zdmiMT1X@8;bzlKNBb+5byO5BO>B21}Ok5aD3-Otm&&qpritSE`i*I-_x_vrtVr*Q9 z0uBRcPHrwLk+nM|5x;;$Sy@e?hufW*6Ju>{?fvW-mi~#P@3Ixdy)B8dt<7Rj&q~;c zm0pQ3qpfi376m=MxrIgN0E*=|kO-V^xJY5fg<tRM1XpbD_;`uqoFP01favkr(ji${ zQ|NvqDMZ94CguY%17TTpk|e_*6Bjo#6Yvh!guuZ15BG<lbVG=Rd*F=W_m`9Qcde`# zj~wai?{B{Ac^fTa3JMBTQltrY<Z*1N&p0EvIFe4_@$&_M;S5xN8XHw$X@IL{er|3J z?m%#4pgYuo`IH4B+Uc$E!Ks87&e6ePKfTC1uRVuZL8Y7*%^RODk;b9h<C`Ibvk#e- ziCB&y)|Fs9VfM`Juht(ru-`Ba<JITRpa1jwHw#f~+`eOnPb?W43p((<s!9GpRsg*K z>xTS@{dolz4j|#av7pHn`WrSLl>6SLF1SKr)PO3FEl1Gfb77kmq)^|ndp{)3WweOU z7Xnb@wu>^jb4LK-3nv$Lwx+t;Wzm9G)OHvoSHkk>t}U{o26t49j5so9<qIx!JI^68 zfFe4W3X)S&QEA+`v4-BuW_cAQnI{BBaa!XYr6&)1v2t)+_UCx~RuanAzfZ2b4kRPF zAlkX~=JC{l=KnIqa`@{V{+YP}lAuClpY;n)7r2OxN*p<FFA5SSZ8S7dFJJKPBHwdy z*9nDd*YZ<Re9C(+d?b@q27K`<hfV#kYy26ey)+AZPQYkDLqmh*#9>87$E=A<5)9O+ z#E%|r6T9u>!Ib>4{|wE39Bl_KGLqNw4fB}2`QJ8LlOSINX_O(bYQSb}Yz*<vrb{&) zjVT1&G9di^-Ispe-PVh1_5dA1i9md#T_bwDH0T2VvWuI%yga(OadFIdDBC|h*d|L8 zUVArN3u-64b4Q8oT3NJci&_8mS7`4K9teilLfZ(l_4%~*5JI7bzJ3-O?<klQyH4zK zUrJU_jgL2gw|ytx?<N@&aSX&<h)$JEmfn*92Xcdo9D&%ZWlAKu@=5dgE8=2)ZrK*J z(}l8T?|+$Fx&m!x5%EAyfc;V*rSOg4Tr`3jLRIM-`I!S??EkcNmy<H!WZ~si{FzAN zeuyS!vrbS*=&6Ut33hg%bWToA#8LzDFtl>pXq-&6UbB9!AX1%|CSj{z5~d83x~!jX z8Vc_n=#4iU0639yS@&#r3oY;I9sp#8>V@>w(%1;WX^emN6c3Nv)`q=D0{{YiC2tTF z@YUGrKZNBKE_L5iuo`bbtDUyh)B6j5xr{$e56A^2$8t(tUvDq0_u3%1nVFkSN|Fa) zw&jDn(%a7uC;<vOmLGyTx|Td{SD45lUvXoCqPz!P0pN7dg1txBT}%S^d6mP4O`Jdo z2sn_9hRYyw3B)xXs<bEzv=l28E1H__qg`0&fYuj)CEx&d6&RX0D&r*r6C(OeG2+hN zUBC2-Y@zBqf=(*1&-Epk|9Te(@*=slel{i}HT8P7K_x6bGSBFJ!o}RyMNXfdZcb6t zx9xq{&y-x6F*s-lZjA<zK_wZGB~UXu9mx5Rw}86CwKAY>>A&BCdQuF1y!iDONsl+G z#hH%oV+r<FU;r>cz!`v32~Vz=b@wOKUTF64-+5^MDzG3LBE2bC$qCD%4nUIVSBZ;% z1iXqFC+69Taa29rgQ2OZ91qAhBXSQ?E^N!iW#0BFwRK?A4zO;b&OwwHbDHNoefk}) z2Z9jf4CpzFv$OnX&t4*hNP_GAR$aZHQ34!jb!`o4xv;SCWn`q>l`D{tq-D_X)qrVf z*w2@USui*NFS7Ra>wq(?9_T2b$%JCL*nY&^#3W9c69<U-=kq$lJ|7%3%xi4SH!0z= ziPtmRUxWHUbRhTl3!r}hNCDJKVWb3d9t(N~wq+loO3I9Jp1eE(10fC$Slb4{7B1q1 z-o`);E9;yz$3fYHk4OKI9XODYm4$|rhO+VnQd%0CU%!7({>n}$EYuQ6h8<2{O)b}c zO3$MKfrKH4wuXk+NlEYT)Vx4NV)huUkDVGHCw8$Q6-&fo*5Vs#2^#%(jf`L}cEjn3 zU)X^kfY2id-(x@w9=4X$Ys-^@|1Zjz;%n4zblT8?`InXNDmVe!;MTC%q6TBuA`hNo zY%lhao17FTPiQM?2*8wtt=n2WVc?$x*=yGxpaqP=4X?0b63%fiC6Y=>iP#yx@a2`2 z&X3N*+}!fEngYjwWQg7|9DmSDAh4VK5eQU#Ie0=&guV$m5G|=*u<}y?bC8EM0hs0F zzCj6?o<%f6CdRlQ>OE1iyq=egyMwfg_d|e0ias!1iUMDN2;&To7-<}s7Y5VPFwJ%O z@(_e`6hopkkcL4kG{t)Ot|788hK0EGwo->#3nx1`IvTG03QK+{DS&-;lN~jNxmZe9 zs4cM1Ae!O-SZ9mCXPKIg;s8N2dqVyt4y#WMfs~pW6<NYV88RP2n>bHq<_uid)YOEo z^6hLb<aYx`HA6!~+!Jz)bp?wD)D|p07(6)vv5rOgm1h!+I|2-cPYt)_F|`|>KarcC z9ufy2GNA9lS6>YGGW?pjBnX`z%64`IPo9)RNbw*rAFRgdJYvP(`u8uxv15^8XyY}G zkB?LF>VN@A^N1mfyzk<_K-O@1!2)_#abaQiKC)XNY@p)0nM6i_C;>9zgKY!p2#g)~ zFHPELoCg^QeN*@<d9>c$#6f`61#6|bkTX`?;2d#E1fPTM1Unnshr+`3#XnX6$^R2E zjVSYCP4C;=mm#MkIbpFnA=W1~(H%PmS`ILGVE=w>jRq{?0BY3KVCMyB&x5q!@XZ1G zA~20DEH+Xi89&$5_*65fxJXE{vff578cWX2%Zn+a(qW=i6e$);7?s64%q6q}B($^~ zmvAW@URCE>z!3pYh3o~j9_R_bjB}i(kpWU?j16|1gO+)uD*!)5&ZPf}B!rG3Kx0KD zc$-KB-i$a$Fy%~6z@`#E#QD<y!|4G62Ui-j7p&=395Li;wBJaXMWag{W_m%6arkip zkXgD-BN7t_P>FD76a?t!_$>VC@E%1Iq!|%`_z@9tfW8JW_z~?7Wlkwlaj1pO%^|WU zdj)*lFw@wHGXhaWzt9>T`of~3-hoR`_g_&`;t+x#gg8tGzkhnVxuW7hqHDQ-aPS#M z1DVJ%A#sdoBv;ebwYYni%_)5!H7|$@iuI@Z`YbRuetuQkxMj_4L5Fv?2%jJ)1--%c zIF;5QO(Q4&&C-_?jg~N|@V@wDH$~?O2;L2CZI?;g|NIda6ojzlYRZ8-uoaE2Glca8 zv<UPnSTU*U>sQdjXN;0Dib=@!$lj>J$C+*C2Ct%<4TptL)Wqm$5iEidu}X2u)(LR& zvPUaIiJqOG$Mpt>g<)0{Q(FdtFRc3E_s^9gw{ZG}%!y`{+vbXqu<(1-j*Zz`K7ChU zE~0p*vyt=e-6!F-m&%;`$;gPlY0M`{3x5DNu~etW@Gpd8T-~J-AZE|3TF6&s78cQ` zZ*3=3!}g1{!g<Y&I#LC{B<4tOVMi$lC}50d3@UJ3!+5l9VEiGb-n_hSL1aUx+3Hx> zU4nvw;2twGHYVS7T2L^tX#`pjcrk#B!&NLiKmwZ5uOwWZ&~p+wi6sMb(JyuJAxALK zNxHdQyts{!3}%DCKs-V|1yI!2cSm~$uPeb+9|G#$3>ZQY-yhr%UJWY@W{Bm)UjF*~ zcM}1Pe<F>;c>^G0zVuW%r5Pm+OWb!B<2}fPD0L!5;O!xyp{VTAU0-nzN>M^r@iLq% z;M6Z)2(~>&UBVZ3!+3K*h4bjK0CsXxoB^1!kSi5q#IEps4&UoR-spkGGzOT4hnJfb z-#XgNo%nYzfI<YZQ3N!!K3#qYGhc;l{kSj?iTLP#2QSoYa2^(_-r9RADiqO*Z9jh! zNP%53Q;LxDVTcEZ9pYGMVOM;G+$a0p@a|n{DJk+?B06$iK<r{MK|DJ!b_~z1COv#V zoLpRoo8oueK>ukGEzk}wc}`wlx4FRsmZ5OrqNCy;7>HnDcB6bR$Ls+iZTk2Qg12H4 z)X|cmN#V<+z~GR8aE35Gv-iDmqcQqz!l0r!UvS5QrXlE8mzSY;1MD?U{lXC-&Kx9Z zrfWUAu@3<MKP)@?sW1r@6tEM=y^}LD*$s#4qKJkg6g7jA-Gu6nj*@rp(A)r#3&$BO zby=|i^MY<vnw9hZJ;!x+Yzq_@SR94Xt<COzFoG#3NIFpNu!75$`<dThDDBRjkeC?E zIQK(*gB!8$kUdOnJv}{;Z#g)se*Qene8<EjA}mY@91u36>``^tMgptUG&QSo9frWX z;`eP^xv1=V=_pwuax${da!ll>p*hKgC7xvU(MUlv>!nXs&SO3IfKFlmip5cU^$G)a zJs_Hh1FRfGS>8XQ)<*(p;ysoNQH|hp8o9M?lNU-yynR(6We5{Dw_lbZ6gX(}sK$kH z3)~0iQ<e(ey_?3}Ln~(cy6Yxp?;*6IB7Vw69eMNS4x)0Ip*Odvt0X`F0qPIl4!=sd z=6r;Q3KPKivIB4gppgLME_GeQ_F9)y7dXrqtmhRWJdVT?r|U7oZf!$uFBWffb$*6? z&nPD7(D^X#i-S%AB_91mRMp@b_(Nddf;a1v3G-Vl1dux^GwOdwGUr=7EkY)tX9V>Y zlTrR?XQJTX)1@?!?~)P|dtS4!G=2NVB_gtnkjEO?TU&qHbsjxCTpLbD54s&Q*+uJ5 z?LUwQ9TC`Yy+L$-qQuyIs5l&_T=>rr3vU|?wkLUc*$rp3`uW5mdzoPf9y5GcA@l@a z1_xtC0S2mn$%xVLcIt49wWPJ-<aiHYzYXe?arrz{@_FhNh1ROT>qG}-$1UWV3l~16 zYv+1jCj~@^dGVsRrzcxU%E&0m*h@~hsOwez(Avrh#(OffbDzqde00Hj{MVVrgIw?h z0^C4<_Go}x9|PckG&AzpL$n?YXpwCGlkv$a1d{9Uke0UgNzu_a@<4kSTSw(YGQC6^ z@$w}`jRB1#BYmC4vu9^*T<Ff+sSZLhMJe%}90lq{9sH_-qQ3+_f@uN7ng8+gLY$o5 z%k*eKsRA)!bU{zg(#Xh*8n+2++|b}46*YCAoeF3^o(b^sC15pISR~E}I*rD)mB)h& z{yEq;P}?xRzc@Wz_+U%}gWfVSPXhy$Fk5qGRUS`BsEs~vj~WiSKNd~{SZO<!H&5c) zhz^XR;cVn9SXNux+MrGu8W}Yi3qW!~8s!bAqoQhUZ2a6~t&y&E4}Ex#Ahh9;8_}@> zKg`tmU<0Ww`}YolF|2WMadALy*b?j}fNkaR6S@o=j7``^IJuy*;J(4hur^TRCWTYd z(#lUoxJWQst);Ay#3?)US5w@*I{;wbM#L#-?xLZG#&z?$Bwz#4_C@!2=47W8GF=@= z66!53xbJX5ULLZPM6CO8Lmbia(A-QcX3+o9s7DKJ2Qde^Ko|pHDh$nukM9Ng!3|6e z4>P%)dw8k#KbgGnB1wm7h`HYNL*NX^+onhXo#8rIP;KoMloki|7$2W2C@fHHB*=nm z#L)ghd2w~!K$ZZbp6(`U8K`^c#sSeFo50rRg)dJ}%oaxw;zGj1@f|wJJv~#%vKR<< zDA_@rfvl{q9!6UV*^hdIIKMKD#lj(w;s}OWanh5LP6k*3+?SS;va-h-m>Z?325v^` z#eUWuhxfo@7=|Z;U{Q!qX8<J~9W{lNfBt+iKE*9{$r<M6zkmN?m?bqM149C{Gcy!s z_o>gK0-zKSds0Fl5!VQkx|e5m=%<2$_|Yw4A_OgKa3)?%D*^`(4n8GU)zs|5dqSCq zs)y8y-|^w4dYqJG1PvR5c-SlS9g(m6FPqpq&bt!@K3iJ>f~<_pTa+A3ND2!GjJpLB zpKo%{j5Ll-K8(o&i0$`4By{S+XDo}X8W^WRO-DD!FMk%P6c?HD_U&M9CD5;%a1`vw z8rQDDIq2_T^3UTg@XM=cXuwXh4tbr-6T+?poF4`*-My85a0mDH^`RyCv%MV;Fu|y5 zv1toeHuJOpY<t7=bE1VTBFoC$v40Rm-PR_A8#P*$V5NW{eDEO1ZUVxN(QSF#ds-Z4 zX9tIF=i`Ec`NXlGMwWlw2K;?hRaL+@5deO4bcn6?@89<)5tXS-8fFig{a?`<MN8Gf z+<aO@1`}7XX=E$iLMcNkrks$>&C64elVfqa%4Q0@gkXYg`ct{gbTYx9r#;t5(z*3Q zFK5NCIB*&u#$~@--HgN*$3IFY(~dahmk|+H#9VgDlKo@Lb2<|@atPw(fBy`gpwsNZ z?X9isBdWw_{vgrBuI^*K@R+F80ebXs@Q^Uw!#3+L70OOu{g+|y+BFOCVQUfPtnG)9 zbiVdj1MhY}A3d>C=`G3OuQyM_#)Bsbpq1tp$wjgy#vYu4LU;2f(<0N&3%iNB+y8n) z-m?2dr=yG7wau-W;KZv%!igaTAynT=tKWTl#!}7lg@olb)8p#+C(;)vDBc}EzU$3H z--kJ5qB$Ygk_g+5Q&B#@N^>VWBQ%UR=*7=dCXoY6RU3+K5!>@$2R$i?IkY9KX!&EM zgM4oC+|uA~k~cDV=Fxxp<L!d&wukSoXMnTb;n(J&Qfhnb9HN&cOtT+*jG&Ck+(k*D zK7Bf^B{C)k4riRBvA({(5gY43{-`?k;FEAP2?>Rzzc_Gl*P*k=0?<o%{``3i@6IIw z(DQ+*6A6vg#VSJd36H%Z6xg49?jGSHVfHvpv!6Y<rs>*imd9iahS^$LGfz%n{7FQa zix~<hli+{-L(UYk1S0Lq)vFIWr){O(cOy$Op0nwP<_0H7@XMDD6T7EAF8#gC=sNHG zl=I|CTK+pkfz2}SGa@zCaWJCm>`v>cZfAAX<-sT`bTjPHBPi}<prmYpio+4(K*-qK za+iVYmpC7YzTW$W70hf9hctka{~Bj&;aLut+njfnFH-|7hS<G17rPY_7Iq8<PK!S< zTm9<l+M+7@jX^)+qmv$t*}><A;Fo+?4XKi7oRg6mU07|?cNgd389y{mUubkDVYUPi z7@Szxw0Zc+AqhBi7hrinql1!>ajG;xBh8}eZQZF?UoEn=4j(y^wIQ;Jakls7?X%9` zN`rqkHKEm_qM;!K4-mR95(~|0DCihbFx1v=ud^Gl7Wr;?A1N0d5NNw*EvXB8TH)hs z1q@~OkV!B>>e9>F+Hro*r1tk@G4!x+!fjkHu{I9NQAil<8W_{FhROgeky(cf1ItVS zn#g!m4vNPq_@02Z4|*lQ0mxUjZlj>!-)=sMG=j%;U}Q8v<~CZCXtyE5;`c0sZa}p_ z52-g&(sgTN2^=El&jd<8dWbfFfpT)b{O9pBFt|Hjp0}SwrNwhnU>(8sp%scD^i`;X z`<OG&uTHpyhVkj^Araz(VP9mH%DA2%DQs10+!zlr1TyrofUi)BU<&&I8$F-<N^P=A zf{Ux`DXLnGr6gUADgX`%uIZne!r)8=<Y?Fc$(T1cm&)vi8$Q?9^GQm&!#_yyMt=kX z8641Xm{*S27CrroRR-a}QeaXBqjs&S*SMK0AmpKTIYG?=()l&9NWm&&3kb)|+=F>T z_@|7!N;l5di=B0vABHG~>1PxPxAmDSG#W&sGw})aVsi?f?WZPRLa9Y*ZL6=ZZ-F-T z;?=8lw8x#_ajml(sx#P4cV8^-l(6ZSUHZB<S!{(#CgJOC*9**{u>Qto!5G&5=1TCD zVD@%%p}W!6n1aP}Z1(!`fyIXL>f#YNA5mPGrLfd%T>7|Vu;gPx=EV6<j9_D;8q%Jo z<8bj*>D<hW37*CylYrE&gsNSD>GjFtDGWQ$<EO=q!|z8bRcHdf0`NcDv4`tq;`G8o zG)w^I=IKo%ug)4NVgd-&4b;;a@(DaE`<O$h@8H|4kBh$%vtP=~F|Cx<HrAG|&Fb+I zO^mM1MPW@VYF-_!4>kk2^%BLHro?f>P_4R&B<8%K8|yiZ5GSqn)AFBy0R*IQWZjRi zlm-F<)L?kEwm{>~NB=;8bpkAY3pvSGGW|HsD5(kf7YL&q-jb4q)*FbBO~?~gW3`@i z?S=PlW1(?K(c<|@T=JlxcQ{qp2d4rd^=GeV-(d-nVRA>!q!g3R;wesmFKDH<jo1QI z2_Fl9grLlD9K*tgt<a5Fhb=4ZXPiueWB>EZI<YYTdlM~b;Tma_1SuIAiRJDm1aIsV zF@Qfk+7TM)7KJAP?Pa#ilL?QEbVBgq=q#_U!i#taC=1iehPm2~c|eYMa0I~{PjrC4 zzRTDfZo*O+*l_HTUq)LqqQ-vx1mVW{qM)NoYcz(K+=TxDuW=vBH))djz@k$db_(%? zM`1a2#$4lS!NEjGr8bQ~21aB2c}$F(AKy;5SdcgjtYh?mQ#<jEI^+^OB@AM$xbAVT z@}IF@CIZKbJM|VOU{<L!oWn4C2i<)e8=MRYW+^<o=x6W1z_kVL&+}t#m_&O34FTpP zIJ<Om?)dyfsvY?KRwm#WG?Jpm;cHo2RCq)KvqyPP;ba1S6&M&_nWE8(L)ggBa27lh z#H7tMUn6Zn=2HLAP;u;Xw-YVwvVeRsAtC)v=vv8s==qyV9{=J8IA7WAs)GrQoE}L2 zNNgsF#eRP(5zBB$Nl56`a4~zlPz9x&^m`OUesSPdzG({v(7^UG-!;H@8=~}yEMy=H z&wa>)>OVR0&1Bc4-|cIfIQzt|{hK!sOp~tXo8pmbD!)5(KU=;Zvn`?vaJ$ixta6DI z6_4WZ;oiTKPCi`iV_c<7LQ!=ag&}5DSZQ>xHk>y*fQu9r6keUaMZoPhn=YUgmaahF zLoun%=E0OWNyW3_sD%$JDJcP&>G?}+$Uq<0SvrDEHpT8w>L;h(kxm;Wo*p1o^qbLU zsA>VK8Yzkn4#xPLU6f4LtIs!$anx~+ovkD52E!3Ds6`;BP!&)?GoA3sburYIr)lue zj`5<g-zn46)=N_a+JLWgSH~?+KpElW9DcNiLHSuk_T<n#IgQGSiXc^cipSr|*Ecrw z9S1^jJGMZ85Ifc}d?JjoH9X}B4FImQK4#*Jziz{B^}V_p6CA|RdZ@l*-P8GyA<3cd zCwvhSjpmh<EPMU>LWD2s$`}Mheb<FhXJtG-0Hj7cqZL#tTFh}a&WoX_y>W{Zl@l_# z+gzP6%nR@&J^+z|;)-Yf!Oanm(+s=!=8)u;NwyY-QQ?EcV^h|m#Q91<=`nFlv?-dH zjD+jEX)7zg&RZI9pNq08J%@40@*Z^ezRUA{w8cuxt<J#TUsH3>{UQvhcDOQhJP=wa zh2IUb3q`$>3J0&fY3_WSC|M7VppQr3;K=}M$gI~hTH#Vs!h`m*+&9Jo3K$_A;sen> zf#^5dmbJCHHU-|&IZP)F%7+0=qFjdR+EIUMyg8)_uidks<{iXD<Tpq&Xn(B0dNbK| z(h^aJ@I%R5aB{`0fep=gQNq?ezOM_?moL{xoaMkN#}l+rnSs-gc`(`fdR9JN?X7|~ zbSxy$v8kyp02m~c4)ZM>TgTCqB#lfRVy(cMp@?zqIf#d>E%XN~xy?6T1|LIOgB?R1 z(g@c`5^@gibPLn<I!Q-`0KJgP&Kmt_9$UcCbDR7yL@bxYr~{LHKQGrEcu}fTS3R?A zbKK4uTB4+dV?B0%8EF?MHmM^TlM@h$(W9|1t|A&Z(TcEI;=DjP^EBilgjqa!&4wlP zrpzv8DYSBoVqJ+Q=rm0}rMB%Pz@JN^x$PYwcHZ9Jm<l?{Z9>9C`FK=ssIM<U$0q(U z@tpsl-a*pVn=T2T0iNec{KMg;UY5OX<e_Pv-1hH*xw77`AA8a6-;Kim4+f1;*!|@5 zzhO|SW5=#*X}ye&t}7v9xFZyW=}wF!>4wui6%!dd#4tE9k+Hsf3*5RTYx&@M_Po=_ z&o{jT0tV!d9cD@Hb_7sH4$z_8_31%2ybeLfFkt=v&mWPkO}j|GnC4?HG`QwYww9Rg zzDX6QkAH8T_IUaf&ueimqEeSq{#>4}VTP;4*d$7FU|?WU?Pu|C=0Z^lZ(;U^t0zZJ zXeXuEcVht@Ivg{nBJz$V0Pv8qV1F6bDVcU_f8cWd2q-y8X(S%xUv8d$slcwJ`N)^J ztd$10Di6Jwo|);nkmB)gi~S$Vj=ycHUxcqG8A!A9_x)IhULQPmV?CzP-ThGEE-2OL zkia<bn7kJ*86c{pB<@sBVPShr(cu)9B@x*-d<hykk6x`ETxY(s&JYS*iPH%`GR`dq z-v{WDl9Kd`Y*S#UDZAf*gy8w~>EH3(j>~2F43iJ9MhP9DIsFN+@nfSw_6ZJ-L&pTF z)vk3Ss4?XHW@~*&5<Q{aPvQW*U@Tr-TC(plL`U_#-P<)lL4cFHYl{ki(7-mRsmvb4 z-WmM$N!;7TE`6nKL8(P(Pg#p#&TT!44r9i1Fz}Tbi6k|RSc^mom>fnn&aHpIxY6oV zcoH2I!&rlV!|(pY$59Z=|DKiyf8bZGe|aVW?I8@SLz8kvh+?Qz9Ek}}^jNAxWyj+u zZ1!!Sm<r+vh;L)!ODrsJrKbWPcOd14{6S_%AuGx#-}dvxJ(+ym53y*JINXDrg=C|4 ztjlRE15%oBmy6nmKZsl`6})S9FtMBFJ0ZE~yUci!<2aShe&a@=7NFVBSGpWO+XizW zrtR;eCp<DR&;mUL<qr*ic+{<cQZR}~>?c4OIL^o@iAE`Yp>(;|5AR@0IftK=Z7lvd z=ye+dJm~wP_5iOTGnxD;m;gKKxa{8R!x6#&4cU3>BP7mJTp_9~dLJ3E@EhiSkPqj@ zfC+?Ep<@AP6~dT-l$`c%iujPo)^rqYhSN9ksF&>P8ORBGF!{l~aqCtO#4`|$8_LSl ztv9STa2a;RACWFF;)wz863Z=QU~J_3c5~p(SNwN<%{J#Zmb#F<Yb@VmMIqtW;A0^y zd>gMY$c8L;8*6}j!fZGmUJNN2N)ypNfs%g815c;8ff*G*=Hmx1!vqOW_5f7VKWk6P z4Pea11)~5)@|R~`OVfZTNPX(MEtUgQO@%_vD+A$>k>RMcN^2>?E#o04ts}M|rArY; zF?eu9o)!<Z#aBqX__wR<!DVGz=rExXb>#42XtnRriv~?7oBpzIvJX!|GXDo_VJ-`= zGH*?9M;{yYiMZbYvYUO3Tg3afV`7%Nc=0n}Inp-cv16h)Kaps9Vdhm2-W62!et4s> zJ9sS~+eCa<co<NPiBE>aP!r~Eb#!#lp~aZfxZRqXlK2AJ%~S2U9RHA|gI@omy)%!d zdjI#oQ>RIT-RP9k(VU7#BvLAsN|H^<a2hn2D^V1uqDg3SBpC{Y9Zti}7)|Qr97>YP z)Fj~`WN2XD$EWW7u6uv$yY5=QKkmBguJvu5b(Uq=w)f{fJYUb}^YwhciC|%|Vj!xC z(}|!}hAKL>$YJ^rz0dEXkg~z%luQ9innrntGZ#9Pq+I6F)7Y8#8^tvKU?5S+e(@{K z#hOt<j~bRfCg_)sRZW0u8&3KY{`fitlLiQF{P@v6IA4lE(OI9XCCqvO+7laG<tj|X zuKU-r0)+QK?P25HzC*>wOhUsqjr-K+OV|@74<W~f@-SJs5qnxff`ggOH+)27O105v z4M>as_%&xj5}c^bthCpq?&U&U%aMT-paGPoZ=OsPhF|u1Gl{Q=6P*>;plGwZ@4$gl zDn<G9?NpC6w=AXfMm0g;=4n5*lN!dAdwp?aTAW({3*(_aabgMmY_w5ep2TqJBPyyb zCt`_l7fR0ot>R4P1}FRshi8;%`*Blr@6`(dBB}9_d-)y?zk)=oS+wHfzlB(Jq{`%N zTG5EeSI7FS`X_~9>200*3;(Fw(vmvEtmmDca;JTGUMVXOfdTPIMm*wEiFti(a{2Y* z+MFdql0}iVrz3EK{RUK^sGrgCxdu?mnvOeWLSNTnx+=9?UNSt&9`Dbw=CCf>O+Q0% z0ifmGXrdUTb?%`8wpH<2#XH25La>Itc?1<R&0nj)EVJq}w!@gP&-YY$VkdpDfl1(5 z8RlNbCV}RU2{Mfr#wmofpGRp(QEREYyS=$c1`^qndqxT^L1lqeB9h=!Mh?RzC=e?4 z5)hg`v4;8%#kP~r{`->8C@t~@*~-kG-d*QWK}VfGpZT@%&rlB!AqOwnIjNxPGgiA* zt9J5U@oQ1a-^<Sa^yQ1W#H^Op`#j6rn_AK^MitrfUC1U5lhVNQ*h0I}%A{~W5j4Hr zuOP3&2Sig`(B4t~zS$#2=rFZTmb+Mx3f{$CL<b>6)KMkPI<`GrQY*eym{Ye_5!B5% zSiDL_Dt4wUrHu)S1hm<yQ>RmI1i)5QFj*#7EPsP#m1V9sYSi8G@+|fmz4nhMB?oi9 zWU2WZH2pC$FvYr(?V+W*aLKMNK=Ytd!OO&GQFp#{)u!j>Uf^&R-HtFLO{RKO{!Way z@x5IC1Qq*;#5Qf)w8PjaImy|a0ncVe8|VeS)VP`CW0)3S5se0a<mDwsM{rg=0aODM znK1lHua@VVhV?nf08N^N-Qkzbn_~^4QtRfprME-Q1kU~(OO`JwiqtqkebCN`9>N)T zuBZmGUwDYT-nd(4*47=*)(1lV+^{A&?~S4S%G;z`<P;aDBT%wra%$eJ(+J(69cG=> z+R!kVRZRgWX-jLbYOXET95ZGN@m~*AN5^2@g?swkQd?71Z9F*jw+?U<y#$h^%y_{p zT+bH6o@LgoZ1?n1T#0d_w<T@*2q!~@f0wS|8J`(uRPigji~pfRDNcIOkNM1CE7{34 z6Mti?g1>mOV^@&;!iAR9C**%%CkRSsBq)sO!r;IjsPA*|;DlN%$3@n06|iTCSz%&T zF(}+pA=P^+DjSoU&hkR1<lvy7)ZyOo88W4+qNIo`HwOX^*ccEg#?{t&|4|gX;tfBK zhh^i{UBH>T<yp^<_Zd6yKfC}@vowih=&lm)H77PQ@+Qb=)Tk6qf?!hN^z^$IX`eQe zNG%eAKAsE?uFOn)h8fIUmp>+HLnM}(gLlq3jP!B2ai0bhM~&i>%V$?*?(y=<6y3(D z(Cogr!k6j|oQI?0g>;#3=VKtNa0)UGWx~DkK?w3a_qR@^`eSOhwe1`&@evss3VtZ{ zuKN1hx)hq*>=6UOj~$E5@E_XeV4+<*#Rp)@WbKlR1-nX>PgR5&rs%B*(X_1LSu&pp zA~;-A8w&ReBw?k40TCo8G#xceQ8(eS6qV*IUw7%l>c2BD*SL2(74#RIWIKo9qnc)x z_e!&=lE*TCp{^$olDWKtP1`KtOiiI%(tV-4*N&0>I~|Zb`BB1($KMLr!~KABX|jEW z$2rp_R_WJ0(;FXntyIt7QNA&8uJiO`Q?s|~nukp5A#7pT=27oeT`4Q9n&SnY`sB%` z&oxO(y$*nQRzw+ypG9((fp&D^AwizBOBOTU)NH1goAh*BFZI)>`0mNHWFEG>j|%i^ zu|ansoQN#SVqcMVSe|r{4Z<vLi0s+$yR_`R{5<GS_K0{h$%L7GAp&XDsyRG1d~;!Z zkmOsi9nh)qej6TN)xn8ujMNU&zs)Qy9j1oFr#OhA8%jzni>!+5g3{T$`PlZ;driN% zH0DSx=!s>CZEx!g+*fH%_&*7b0^sBlHze4OtUpyhA5O6_olR)`_y@XLutY+QUD~9- zfB*hwj~k7-s@W_Hr?n-}uCzxKl?nlxw@VrfI^&{}n5stQ$4*Z(I}BZo)@4;Y$S<ba zd3`WP-0Y8!=H;wzLnbeBf>(x>-vNuf{PbiMH8m$-pXRK)5uqo<AkF9}R`btCQh#gH zA51@uUFu-YCO|vd{L-a@3<*T>6=6DiT;-Z`l?@b-z~S!^HPZ|r#{j2b72^r<_rn>w z)|Xk$G~8-MbWmeUi?XKE+1!n!#^yxap~fM@;`E(YiwI<;j=pA53v-n;E6p%w2)~hC zkKGcJ`5<-cp+knmU;5I1fn!OaHvk<Pp^jk0#Ce%@`}XhW1_d_GYFBLz#Tr0YNbbf8 z+f=m*U);8_k3x+ul3k-dZk+Mc$)3&CJ4lqNFzs8U!Ab`a!;kTD@G-mJpF8Ly3WS7# zLSWl;&?trgIG`}xt5jRI>c3@DUZ)kqjcjL5(<jY4V{s*AG#+{GkQnVva|xYn^0OX8 z8(_kL>Aw;iyO|LO$fUJ6ZZF+SbPFdXR_G=sFD6`StZ_;>%>b|FGbJd8uAG$&UwlS9 zA$7Q#T7z!VO$?nt-wZVwQ@}DRAGtoeIkwa(0@zEL$Md^nR>PP6&h+TvYj3^4uyoQW z`FM6rVN1HLT0T!fH~Orsl==ZqvA43$PM{nR80%urk2S{^Dt`jT&>3g)AaxT36?wv5 zZLbbOYm_1ePzH%j?lC2hP||^HkVY)136&cJCI@Vyp!4BoF{m7#m_O7L&nY}_O5J<j z&TcFf0u<o9WHi`z#BEn<{lSrI>1}7>37zd|9ehio)dT1fFank87EWq@1AeCS&@X!M zWSXBfHlvKd!UMdLX}4)@VSn1?>8bG3<T*1HM`FSTcOiSb4}05R{~9h=2sDr$946IH zYpHvLXdPTM<1vf^2kq!IY5{Ch`CeHul>%v=6bn~yF#|K&QK}r6+B7TqdmY}vqM!g2 z+OHW3<owCl%X7RJNB=wL13)QJA|3z2(hz|8RIjgX=peib#kqKEB>56AYFo0}Yn|&e znv5{xWqW6J)&v_f*DJ$4>jY9TG?%v+WLgN+PB2qaRHWtWo!!tW>*muw$FD@anN3~I zxt7sd=9%~)*&vF@)fYG%1WP~s7#u~;!IVwR@8fWfK{ZK>V4djzEe>v~pTX@1s|)(; z>oDDD#UUp0udu-$SRpXxflVW}xkKmy-%axNWF-jFJy5Y&SKfF*hKlX6u&`kwQRLZ0 zqodEPm>cqd2eLfXEsvcSAm-Zb9$jpdDuJ|q2dVr2@T=)t?V5UJI_+mMfe;gg766hL zh|#Fq6k9%qLY3#id}G<9=Nk@@+bLS&{b911#m@uFj!pdqdD}%(g+3gUk+Cn=dfdot zRG;~SI}({&m|M?XA~e<gxA0@@Satc?M>OVS)l)Ce{>cMTQG1d{G;tz1OdGAcC?K-3 zBq^c4?hf|R(j6vHQNxs!?57XG<1F#Yc7GWdQ&a<1b#Rzy^B8Ko%fqIYmVl#2-%9$* zCHlquCqC@IP&EIHw)y}6+_i8uIgy#9kgVdRguK13vTzA}T+>Xv&GhL{zy$@;?+UNf zqTumk_$3O0YZhh4KJ*IB#b!(ly&1P$z(ws=yLpG4@}wkZs8zOILa_{E8eJEpRB90* zRxns3Iu;zfY5EWfOI@qJ9W*H^Obc1cYS#`6IJEELxO!cw1vay@bYU7lIB;_O%e902 zvbl@n)V}**L^AmLoHB&|t2}OU>__JXGZD0PW)J(hN7<2WBfdYNZ%--uH{yml0HX3& znVLRf@5Fw(T>QGBlU&JFE!}nvfjG5Ky74YeQd9$fDa=%sY^j4e3Iottvij<~`iU%* zWmIY(SPrH?$hlnY9$&m9sR+Nt>QpNelg4aC8=)+OoFM}^CC=-bg-{TxPJiKWJv8mq z{_J&>Qhx(HbGrsjeI)H7cXQay$K{UZZ?e{{Uyp}}aA7GvlL{foR)@KYeyR-3idq_k zf=|xbT~?J&N81~;1d}19=4EXEf>gve5G`xfee2slj-#l$S3lW8<0K$zht|-Xm5A3u zXgU2PI-lO2q~L&A`3F-{a>drq)7SY%^HVlPD|aA3N4>W!{q~%?E<z3OE$bLY8C{+w z11LLH`gN20E4EGx@%QR;ACaK_Kv3bl5x8&)N^<8MS*U!Dl(fWxs-;FoXeAu)nb*4| zm?4@!rEzci=0`6%!l*7o&=ffsvEYmFqfh=xP&px27~|ZtGq3XYp_hn^>Y!prGoOJe zlDgT(rab;s_3UGkDU&8S=AU1{K_73E)dAwM-ucRxLkY`XeW1r4xyVi%P2B@>HzJG) zX&o=5645%I9xcXp{9$GiK4Z^w2DJfrF<8L>DT_p+L4!0f&)I)j5obz&s53u-+DZAJ z&(?-8V=u*{_>2LkO{G%*5X1&M(Co%PM#2M7FNMtBlXfCNGked^=Igg?i4%=L#fr)& z4q9M4q6hq1d}MH8Ee&Sc?sb<+u3IE<+QHvCKaM^u8Zm8}AYVZ6r`o?^CRlM|Iwhy8 z%^*T?PpK35FQ$aFY8^1im;5M{9hLc1D_;&PaJmiDM?x!iXiwa038sEbA%=0B#}E%L z;LE-H^vQhg^h0Fz_vz|Qk(N^nBA0Sf3N_g_^8~ZyAyX?JjE!(DpG|j3_gAjkLFt-j zx~6&^n3G_p_u*z|xU(XsH`Q-9rs_4kD4+Fc^K|EohI<sJLgX}5Zz8Y7kg{PWYYdY6 zI_9?yr*sS13|yMRA*SuRXbU#;Z88fJC#S?auSjJR$C+ZVGNiZsp0isxXGofG&kI4! zKCvt{fXHh*0d_GvT%wH7m?rcEGeX->7@WWHDq)dN60JO$?>s_2`K4Ntq^jOe&P!om zb>5aok+%d2>z<PH0Mdt4$f-Mf;zT18l(P+CS3ybRZxV;tv;nsfyBi;1C*ao{Bbs(s zM4(RNq5RF3S2r%Bf<wE&6v`7m)dESuajiID09P!0x}%`?v9%SMSRd}~C|!lELdpi) zMT{w<u4;Q#={Y}yP7`|k_$wDJFWO6S$^=W~O3Z#N95w^LF~I@21$wG55p-DB$z>iA z0D&;1AnGUQ7xYw_udlza)QO&M8C49ccDz%D+&G`^J$lghd<$Z7`0fw4I8@8MW$l!c z{q(`=xnZ+3$B&O=F911WP2igk2Lw0~F+~+^X;W(=<cm<5P`r^6u^HPZT*T;+eTGaI zRKcC-KAe;vZ}(K!X}H^80YQ6sQ?=a9=2%N_%i`LW^XEr+P3h(Sj9-ehlr5I1bf$Cz z7qRF((~fyseOG!6qE^&Rjp`(~$e-)g{`J3qjlsX>!T;Ja!1}4@m2q*&&E~$Qaz8`* z3GA;&n!M%kxm{jbFEm^n5ZTFP`N){J3yptBaZkNC;C^iM0`x@`(B}><t^-pP#f#z; z^u9iF(5PwlcxdKpFSrH&1KHNiEV^(?bbG^8bl1SAzBR^psm3LLlZaOkZ@H=G?Hl*( zq!%t;?AEpGkY2)dQ_F+*d!B-}2w4i|rFTlOAgWbb2Y*NI`I}FrTRb%DypyiDZ#FNI zMFrT28}S?L+<WRo|9>Fh4H^nB)3gl!r<B<LIgR)KdF5}Cui&Vo5WvFjmR?#QEg6IW ze6fIz+pbh)J2&5N$Vh#Vbv92e)9(7N=<7LMK5Y&)_?*?aD6C_W{m!&OCFz22`(VRo zv=nf{z@L7KE$o<CeoP}Yfff+%dP!W9oOtUO7pe2?plPm6-gXCuoL}SPX+J~x(g%Y} z8lGl{Z6CcmsdOz@IbHpBLB&hu=E|>@H%o6<xl-@L?=Jb&=B6DMp7{Pc$9_&9KFK9; z&^anT_`%@IN9gbK8)p67K$5HSCX+WXC(L~Qq7U4hGBtK}ipflugUp&y7YnvXEl5=D zxYV~ojr8hi(~xu0_EkGL9af$E(JthupO<`S>8_ZPjYV4?zEez5?N&E=pM3aRSM6H2 zbc=*c#eIeeX2rE`Md1)*`hHeX+vN{#s`>1gZEKXC?WO)zX|55XH{EGJx^_>mReR>1 z{zh%l%CCp}n*Lh(%r|9C-u8?&)psNMGK12CH<`NUF0F8i>+fwK{7`tG_;lL~`o_1m zGmvFgil#8|5$Ld49nDGQ#VZOonc8NbpS*C@65j%~C)T%%e%?32x4Mt@>H}0A)Os25 z-DrOO44}llL=xOjpc7EBQ1afrYuX^(_Oy7>B4a09MM#7g3OC(7)K_!O?=@c=L-(v( zAl5OkolxU2?yT-PPruVI=iJb!w0r(^Ue&bg;a7j0FDvU5U*{C^>D5D_#t}F4m+=Au zEdJA`V=8PNP5n{%DKL$2u}P~gpGTJySIvzvscQaA8PQz@o(0v9M(5#u`!+n9r%<YY zi^zGgqk;H-`Q;kv?551L>23wjUr+V)8&_U2Z++)Z`|pjZkXP}M&L6Ph$hND-65rXY zek|%|bnuUv<Kqkaf4(WJdHs0xxhBQAE6!h9WSQHqV$ivUeqQHB{QPx{VHX{r@9$EN zTRI7!1vY-u?S#8F0D`0#K6v<$2)F_Ic;k=}-t)&;B(y+nL}+b2j}$6xr*U~)xm9gZ zQaOeab<32<0WMH90vur`qoksN5xRFGIXPMM8o%Ni_!p1|+#f0`x7Sr!w=nQXMl;B| zqv5yYYq<G%IqfGsXRLAT5o#n~ZhX$9R(H*G*Jkx8D%usPj{D^mjp_={ySZ!$*;Cw_ zmGS%R+_9rZIJci(SoX=y`MOBIyXU9m2Hlo+laA^Dzf!wXWAfVUqq=ALjj36f-f^<c zIqHt)wfLA$%C`#(UhT?LMTVr{7=}CQ8<psmZk5_lvi;!<>`PUy+Cu{nZ{Ucb(Qrc0 zGMb5+Uep5lt_3fC$0=Ecw2fdSq{uaX#GzS^!Y0Vi)J)8-Ome6|U6e_JFQ1-zY9yS1 z-pz&PWQ|$cZsLZlQKSAM!HY6V{M_$|r&H2n?@f(ERXRTRXzjLnyU~#)aj)&_6!bz* z1f21)iv4?C)3xyOjjQfhcU&&bxLSIqaQCZQf9zZLrLN#p=MIx)yF7fJmSoQLe;6?6 zKwQt{<@KNPl-Iv}`^MYqH;snl7e=n>CcfPDw~m){|H3IF4q1Un)k6jKZfJl^uiJ`F z2C2fO9Z)`SK@ah4pnw9}DgMy;Tbi1-mzSZ%*A|<1$LKu9hYC&+&uXme6;L#pJVAzH zJkwOJXL0*hxz?54n8jU|oMCQSLy?L|k%6l<yoI>Cr&d}-F<A=RvnHd?v+%c$0Gr8y zt3w;|4!Jo8I=FwZ?8scHs5xA#b;;Vu4+`$``Sect2CF0Yw2!!(7j;JCa@VaN8t+9d zaBd0oQmKml(4zEA=cR*N!-J(we_GuRjIru)D0Wi0yIk?^3H2ijb&Wl9ANYwU4g4V5 zC-2_P-(aNA@6S8xuk55W_h`sg3?l0$A1c!<v2}<@M0n6yqfK}y3#u}>v<Zr!<V$`{ zGm6*cZB@d(DR9)dcUY(LDkQ~T{;nJJP=U$@YLE6oY=iVMRonB{MeEeve)`yu5m``M z?kju#B`#bhX<TaCeCzA-tn|m{5^ajQ{?J)lW2fiNKL-yV?p3K2>hetc$+Zlf%gYmt zZB%@2nYTB`>8w>O4zOu|ANL}`UMtZ0N^iUV&Cg#=xUE)Q_`y4Jwa@nZTIR`F@7DO< zu$!BGR6cy3@vWx#sX`-4KYcoRDV&yd&nMp_P_JWEjL9A>e6r6)S(duvknUZUZB4Az z5e%M)zf5ax)%3s<Laq_L1%k^^sH3N!VMf&BS4ccL#<EFc^hpK?UeU!xxLT0@SL!D> ziKn$R#Bg!Aaa=-XVQbi=GWY9k{=;Uz(e5&7@D5pZ)ImvA*y5y?oh@&AM(hrL=W?Wr zce7t_1yP?BtEv~x4AP6V{X;!nUC+hl?RF)jo(EmD3X2tN`>gV+lBG$5drB|FSEg*| zZ@V)^b*HsBppK2_n#1#LRIxd4?{VuYO;G&P(pps&&87-L%D<cznnUfRX|U2o$|J7G z%yAA~38-|@!npy3;nDK<YWnCdufjwbHDD08s89e_3s)BLWBP}<OiQzD=SVaSm<!Aq zi8*Q|jZ@;s2R_-8x$@U>fAzN=v{ov=JwJKjL%S#O)q6WyixS-8Zf3O9_yue`I4<Rx zw#Iy|vpH8al-2$&y0Ec)%xt|5i?;1XA;0^XM#ZmQdMxG41cyFrCrQ>md>pFI-_{5_ zxl8=%kh|7U`m>0fM8NwDpgV@y5=D&1cmoq7m*8a;R1tcz7Ip*`pA>y=P83o!`HyqN zK5B&ew||7=5>_QdgMW9k!uXM!XJ;3my>9j8bwtTe8RBnrv55srp0eP@z>)&#JDY18 zDk6g|5@0;wCJ2A|<_GYbTM_%%yNg-rzJ_5JKE~%8N31Le@AxI*ih|Aio!WoyRx3WS zI<iO47RT42l4<Q91ILE8efebYlk024GcJc}@5?LA@8Vx&?b&{=Q%uB;^3%8HeZ8PJ zB<`V}cuiQd?d?12x-G$~PjB*YkHtqv&rh{YE+$v&RKf1Ov)E=iZbopOh$>|38C<QH zz~8|#<96VVXM5u5nA<O|JK9SvaQUM9kmg2)8_4Pz3KJdI;}%}Ggz|5t-9$CH&~686 z4|qX`I6MXl5uO7LsbEb~5$?2DC>RcGC%il5*cq_#P`Yy;!c4XZraULr7u+^@GqMm0 zzF8#khDUf*$H7X;-l=g>1<HbcT#i$+{qXWhyCvqf<p-sj8IP_SygB1B+_^b>!c+AC zo3N%`Ms?y)(Ygn*@5jzMFksEl`iqt?b~ILp5ANL(F-iH_xMkfhH)p4LI}RH<t!l%& zplvY`b;~-J?HUokc-iokhaF3AO_-*!@p=5|IWfVGp~{ILJUS&W{$A79v>O=TI(aHG z-X(M1Xl`xzMhe5?hHM;vF@C~HE&+N|Q4wP%2?|dzv-e$ml&@F0CZ^N)y;{yyYp7}v z+Wi{r+y0zz5XaxdZqOh@Wjo(iYK*^kBHx=}Cg`&5nl%ES)G;<zxp>m<_yMNjn=&1W zmm0t76DKk~yYz>L>3L_K^&VFqwoMkcy0udEoOp3W(Y?d=(*T&4SCu6Bzt!$t+P=DO zQl2bax>h4plG#)CM$N(`{U7M>`SMSO*JZhhs^vG^^OZ^ybE3pM6vLujlAj#b8wuoe zuNnT)ebz?PIRiyt#}A(0KKP&mowzqW#^~!{p$3v#I3^bdt!(Zn8QN!|Z)vY5_VTqx zuiTstMW4-^qg(mq)_MVw8(qA*Y9jn`xZ2z6d{j5~eovJlg&${~wI1`tV|kFNIN*vw zzE{?b!7iS_$WcLtIBPhO#DsMG^wWEe(yAj)esImGYN5G<u-I!STgR9z+oR*L`Rj^v zEq$%`>QAc3oRza+*SRa(j_#9_>tw!o{#;X+(-&u#S&WwbrQmG>yj>h)!p;?v`wkc} z8kTX8_r!S~bkUGS0cC{+uWs&)-|hXQXD)kJueIad>ugC(Mfjw~w0*m83@Pg65hvTz zUY|7A_}0plsa<rIe(F81BEHi5=~dNlJidvfke6zo9Q5^zS5(g4;v*AQ&QY_ITBK?p zKO6DHdqAOgs%F{1FJ7fNduJU68kYVUvA?x3v$OcFrSd=N1^Z|HVgFY?CzbVJO|Z@A zisj|xz<n_$smWiL8`D=2n(MY{Nhi6NGOqGAm&nG3cF2Z(iw!3Fby$+T+^;gOjlE`( z@Fm)ihkw2V0}jmj#;aUnsMM=#f*EZeI;LagUY4y(E(S#b_4sBGutC&T+n}e?<5Q#f z31%D45BSC~BZYyz<WiEy4UR74Q6yL-xJ9hkQkuL{QkXNS&p|FYkSm$OTdlp<uHxvs zENJLT4M8b!KQPtTB+hi8(Wm}JT?_rMcb2<3vhB7ycNWMLG8|6tzSdrANmHlX;sr+Y J6ZL=D_iv2SDQN%z diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 2a7cb284f..9b79f9a32 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -244,7 +244,9 @@ Start the development server:: Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. -Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret`` we will use it in a minute. +Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret``, we will use it in a minute. + +If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect <oidc>`), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's. .. image:: _images/application-register-auth-code.png :alt: Authorization code application registration diff --git a/docs/oidc.rst b/docs/oidc.rst index c06af5c1a..7a758ed65 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -133,6 +133,9 @@ If you would prefer to use just ``HS256`` keys, you don't need to create any additional keys, ``django-oauth-toolkit`` will just use the application's ``client_secret`` to sign the JWT token. +To be able to verify the JWT's signature using the ``client_secret``, you +must set the application's ``hash_client_secret`` to ``False``. + In this case, you just need to enable OIDC and add ``openid`` to your list of scopes in your ``settings.py``:: diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 1d53de78a..9f79e895f 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -99,6 +99,11 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati * `Name`: this is the name of the client application on the server, and will be displayed on the authorization request page, where users can allow/deny access to their data. + * `Hash client secret`: checking this hashes the client secret on save so it cannot be retrieved later. This should be + unchecked if you plan to use OIDC with ``HS256`` and want to check the tokens' signatures using JWT. Otherwise, + Django OAuth Toolkit cannot use `Client Secret` to sign the tokens (as it cannot be retrieved later) and the hashed + value will be used when signing. This may lead to incompatibilities with some OIDC Relying Party libraries. + Take note of the `Client id` and the `Client Secret` then logout (this is needed only for testing the authorization process we'll explain shortly) diff --git a/oauth2_provider/management/commands/createapplication.py b/oauth2_provider/management/commands/createapplication.py index dcc46e765..ad95a11b7 100644 --- a/oauth2_provider/management/commands/createapplication.py +++ b/oauth2_provider/management/commands/createapplication.py @@ -48,6 +48,13 @@ def add_arguments(self, parser): type=str, help="The secret for this application", ) + parser.add_argument( + "--no-hash-client-secret", + dest="hash_client_secret", + action="store_false", + help="Don't hash the client secret", + ) + parser.set_defaults(hash_client_secret=True) parser.add_argument( "--name", type=str, @@ -74,7 +81,7 @@ def handle(self, *args, **options): # Data in options must be cleaned because there are unneeded key-value like # verbosity and others. Also do not pass any None to the Application # instance so default values will be generated for those fields - if key in application_fields and value: + if key in application_fields and (isinstance(value, bool) or value): if key == "user": application_data.update({"user_id": value}) else: diff --git a/oauth2_provider/migrations/0009_add_hash_client_secret.py b/oauth2_provider/migrations/0009_add_hash_client_secret.py new file mode 100644 index 000000000..9452bce98 --- /dev/null +++ b/oauth2_provider/migrations/0009_add_hash_client_secret.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.5 on 2023-09-07 19:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0008_alter_accesstoken_token'), + ] + + operations = [ + migrations.AddField( + model_name='application', + name='hash_client_secret', + field=models.BooleanField(default=True), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index c1dec99c5..204579905 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -29,6 +29,10 @@ class ClientSecretField(models.CharField): def pre_save(self, model_instance, add): secret = getattr(model_instance, self.attname) + should_be_hashed = getattr(model_instance, "hash_client_secret", True) + if not should_be_hashed: + return super().pre_save(model_instance, add) + try: hasher = identify_hasher(secret) logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") @@ -120,6 +124,7 @@ class AbstractApplication(models.Model): db_index=True, help_text=_("Hashed on Save. Copy it now if this is a new secret."), ) + hash_client_secret = models.BooleanField(default=True) name = models.CharField(max_length=255, blank=True) skip_authorization = models.BooleanField(default=False) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 6847760e5..d452fd97c 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -12,12 +12,13 @@ import requests from django.conf import settings from django.contrib.auth import authenticate, get_user_model -from django.contrib.auth.hashers import check_password +from django.contrib.auth.hashers import check_password, identify_hasher from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import Q from django.http import HttpRequest from django.utils import dateformat, timezone +from django.utils.crypto import constant_time_compare from django.utils.timezone import make_aware from django.utils.translation import gettext_lazy as _ from jwcrypto import jws, jwt @@ -112,6 +113,18 @@ def _extract_basic_auth(self, request): return auth_string + def _check_secret(self, provided_secret, stored_secret): + """ + Checks whether the provided client secret is valid. + + Supports both hashed and unhashed secrets. + """ + try: + identify_hasher(stored_secret) + return check_password(provided_secret, stored_secret) + except ValueError: # Raised if the stored_secret is not hashed. + return constant_time_compare(provided_secret, stored_secret) + def _authenticate_basic_auth(self, request): """ Authenticates with HTTP Basic Auth. @@ -152,7 +165,7 @@ def _authenticate_basic_auth(self, request): elif request.client.client_id != client_id: log.debug("Failed basic auth: wrong client id %s" % client_id) return False - elif not check_password(client_secret, request.client.client_secret): + elif not self._check_secret(client_secret, request.client.client_secret): log.debug("Failed basic auth: wrong client secret %s" % client_secret) return False else: diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index f9d525aff..271eb7649 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -16,6 +16,11 @@ <h3 class="block-center-heading">{{ application.name }}</h3> <input class="input-block-level" type="text" value="{{ application.client_secret }}" readonly> </li> + <li> + <p><b>{% trans "Hash client secret" %}</b></p> + <p>{{ application.hash_client_secret|yesno:_("yes,no") }}</p> + </li> + <li> <p><b>{% trans "Client type" %}</b></p> <p>{{ application.client_type }}</p> diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 9289483f6..9b5a8ffb6 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -34,6 +34,7 @@ def get_form_class(self): "name", "client_id", "client_secret", + "hash_client_secret", "client_type", "authorization_grant_type", "redirect_uris", @@ -93,6 +94,7 @@ def get_form_class(self): "name", "client_id", "client_secret", + "hash_client_secret", "client_type", "authorization_grant_type", "redirect_uris", diff --git a/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py b/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py new file mode 100644 index 000000000..80edd057e --- /dev/null +++ b/tests/migrations/0004_basetestapplication_hash_client_secret_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 4.2.5 on 2023-09-07 19:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ('tests', '0003_basetestapplication_post_logout_redirect_uris_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='basetestapplication', + name='hash_client_secret', + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name='sampleapplication', + name='hash_client_secret', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='sampleaccesstoken', + name='id_token', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='s_access_token', to=settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ), + ] diff --git a/tests/test_models.py b/tests/test_models.py index fe1fef084..5ebb1f0f9 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,6 +2,7 @@ import pytest from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import check_password from django.core.exceptions import ImproperlyConfigured, ValidationError from django.test import TestCase from django.test.utils import override_settings @@ -19,6 +20,8 @@ from . import presets +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + Application = get_application_model() Grant = get_grant_model() AccessToken = get_access_token_model() @@ -54,6 +57,33 @@ def test_allow_scopes(self): self.assertTrue(access_token.allow_scopes([])) self.assertFalse(access_token.allow_scopes(["write", "destroy"])) + def test_hashed_secret(self): + app = Application.objects.create( + name="test_app", + redirect_uris="http://localhost http://example.com http://example.org", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, + hash_client_secret=True, + ) + + self.assertNotEqual(app.client_secret, CLEARTEXT_SECRET) + self.assertTrue(check_password(CLEARTEXT_SECRET, app.client_secret)) + + def test_unhashed_secret(self): + app = Application.objects.create( + name="test_app", + redirect_uris="http://localhost http://example.com http://example.org", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, + hash_client_secret=False, + ) + + self.assertEqual(app.client_secret, CLEARTEXT_SECRET) + def test_grant_authorization_code_redirect_uris(self): app = Application( name="test_app", diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7d2b0cbac..78d9ac982 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -4,6 +4,7 @@ import pytest from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password from django.test import TestCase, TransactionTestCase from django.utils import timezone from jwcrypto import jwt @@ -111,7 +112,16 @@ def test_extract_basic_auth(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456 789"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456 789") - def test_authenticate_basic_auth(self): + def test_authenticate_basic_auth_hashed_secret(self): + self.request.encoding = "utf-8" + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) + self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + + def test_authenticate_basic_auth_unhashed_secret(self): + self.application.client_secret = CLEARTEXT_SECRET + self.application.hash_client_secret = False + self.application.save() + self.request.encoding = "utf-8" self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) self.assertTrue(self.validator._authenticate_basic_auth(self.request)) @@ -148,6 +158,13 @@ def test_authenticate_basic_auth_not_utf8(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_check_secret(self): + hashed = make_password(CLEARTEXT_SECRET) + self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, CLEARTEXT_SECRET)) + self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, hashed)) + self.assertFalse(self.validator._check_secret(hashed, hashed)) + self.assertFalse(self.validator._check_secret(hashed, CLEARTEXT_SECRET)) + def test_authenticate_client_id(self): self.assertTrue(self.validator.authenticate_client_id("client_id", self.request)) From e4b06eb94bc40f1213f03b4be3b72e1d0cae09ca Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Fri, 15 Sep 2023 21:13:06 +0200 Subject: [PATCH 528/722] Refactor RPInitiatedLogoutView (#1274) --- docs/advanced_topics.rst | 44 ++++++++++++++++ oauth2_provider/views/oidc.py | 97 ++++++++++++++++++++++++++++++++--- tests/test_oidc_views.py | 89 +++++++++++++++++++++++++++++++- 3 files changed, 220 insertions(+), 10 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 12fd7c04a..be0e3faab 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -100,3 +100,47 @@ You might want to completely bypass the authorization form, for instance if your in-house product or if you already trust the application owner by other means. To this end, you have to set ``skip_authorization = True`` on the ``Application`` model, either programmatically or within the Django admin. Users will *not* be prompted for authorization, even on the first use of the application. + + +.. _override-views: + +Overriding views +================ + +You may want to override whole views from Django OAuth Toolkit, for instance if you want to +change the login view for unregistred users depending on some query params. + +In order to do that, you need to write a custom urlpatterns + +.. code-block:: python + + from django.urls import re_path + from oauth2_provider import views as oauth2_views + from oauth2_provider import urls + + from .views import CustomeAuthorizationView + + + app_name = "oauth2_provider" + + urlpatterns = [ + # Base urls + re_path(r"^authorize/", CustomeAuthorizationView.as_view(), name="authorize"), + re_path(r"^token/$", oauth2_views.TokenView.as_view(), name="token"), + re_path(r"^revoke_token/$", oauth2_views.RevokeTokenView.as_view(), name="revoke-token"), + re_path(r"^introspect/$", oauth2_views.IntrospectTokenView.as_view(), name="introspect"), + ] + urls.management_urlpatterns + urls.oidc_urlpatterns + +You can then replace ``oauth2_provider.urls`` with the path to your urls file, but make sure you keep the +same namespace as before. + +.. code-block:: python + + from django.urls import include, path + + urlpatterns = [ + ... + path('o/', include('path.to.custom.urls', namespace='oauth2_provider')), + ] + +This method also allows to remove some of the urls (such as managements) urls if you don't want them. diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 195f7a877..26bc977f2 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,4 +1,5 @@ import json +import warnings from urllib.parse import urlparse from django.contrib.auth import logout @@ -225,6 +226,8 @@ def validate_logout_request(request, id_token_hint, client_id, post_logout_redir will be validated against each other. """ + warnings.warn("This method is deprecated and will be removed in version 2.5.0.", DeprecationWarning) + id_token = None must_prompt_logout = True token_user = None @@ -315,8 +318,7 @@ def get(self, request, *args, **kwargs): state = request.GET.get("state") try: - prompt, (redirect_uri, application), token_user = validate_logout_request( - request=request, + application, token_user = self.validate_logout_request( id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, @@ -324,8 +326,8 @@ def get(self, request, *args, **kwargs): except OIDCError as error: return self.error_response(error) - if not prompt: - return self.do_logout(application, redirect_uri, state, token_user) + if not self.must_prompt(token_user): + return self.do_logout(application, post_logout_redirect_uri, state, token_user) self.oidc_data = { "id_token_hint": id_token_hint, @@ -347,21 +349,100 @@ def form_valid(self, form): state = form.cleaned_data.get("state") try: - prompt, (redirect_uri, application), token_user = validate_logout_request( - request=self.request, + application, token_user = self.validate_logout_request( id_token_hint=id_token_hint, client_id=client_id, post_logout_redirect_uri=post_logout_redirect_uri, ) - if not prompt or form.cleaned_data.get("allow"): - return self.do_logout(application, redirect_uri, state, token_user) + if not self.must_prompt(token_user) or form.cleaned_data.get("allow"): + return self.do_logout(application, post_logout_redirect_uri, state, token_user) else: raise LogoutDenied() except OIDCError as error: return self.error_response(error) + def validate_post_logout_redirect_uri(self, application, post_logout_redirect_uri): + """ + Validate the OIDC RP-Initiated Logout Request post_logout_redirect_uri parameter + """ + + if not post_logout_redirect_uri: + return + + if not application: + raise InvalidOIDCClientError() + scheme = urlparse(post_logout_redirect_uri)[0] + if not scheme: + raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.") + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and ( + scheme == "http" and application.client_type != "confidential" + ): + raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.") + if scheme not in application.get_allowed_schemes(): + raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.') + if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): + raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") + + def validate_logout_request_user(self, id_token_hint, client_id): + """ + Validate the an OIDC RP-Initiated Logout Request user + """ + + if not id_token_hint: + return + + # Only basic validation has been done on the IDToken at this point. + id_token, claims = _load_id_token(id_token_hint) + + if not id_token or not _validate_claims(self.request, claims): + raise InvalidIDTokenError() + + # If both id_token_hint and client_id are given it must be verified that they match. + if client_id: + if id_token.application.client_id != client_id: + raise ClientIdMissmatch() + + return id_token + + def get_request_application(self, id_token, client_id): + if client_id: + return get_application_model().objects.get(client_id=client_id) + if id_token: + return id_token.application + + def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect_uri): + """ + Validate an OIDC RP-Initiated Logout Request. + `(application, token_user)` is returned. + + If it is set, `application` is the Application that is requesting the logout. + `token_user` is the id_token user, which will used to revoke the tokens if found. + + The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they + will be validated against each other. + """ + + id_token = self.validate_logout_request_user(id_token_hint, client_id) + application = self.get_request_application(id_token, client_id) + self.validate_post_logout_redirect_uri(application, post_logout_redirect_uri) + + return application, id_token.user if id_token else None + + def must_prompt(self, token_user): + """Indicate whether the logout has to be confirmed by the user. This happens if the + specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. + + A logout without user interaction (i.e. no prompt) is only allowed + if an ID Token is provided that matches the current user. + """ + return ( + oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT + or token_user is None + or token_user != self.request.user + ) + def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): user = token_user or self.request.user # Delete Access Tokens if a user was found diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 6ff5dc5dc..5ae354e56 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -10,7 +10,12 @@ from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views.oidc import _load_id_token, _validate_claims, validate_logout_request +from oauth2_provider.views.oidc import ( + RPInitiatedLogoutView, + _load_id_token, + _validate_claims, + validate_logout_request, +) from . import presets @@ -187,7 +192,9 @@ def mock_request_for(user): @pytest.mark.django_db @pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) -def test_validate_logout_request(oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT): +def test_deprecated_validate_logout_request( + oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT +): rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT oidc_tokens = oidc_tokens application = oidc_tokens.application @@ -266,6 +273,84 @@ def test_validate_logout_request(oidc_tokens, public_application, other_user, rp ) +@pytest.mark.django_db +def test_validate_logout_request(oidc_tokens, public_application): + oidc_tokens = oidc_tokens + application = oidc_tokens.application + client_id = application.client_id + id_token = oidc_tokens.id_token + view = RPInitiatedLogoutView() + view.request = mock_request_for(oidc_tokens.user) + assert view.validate_logout_request( + id_token_hint=None, + client_id=None, + post_logout_redirect_uri=None, + ) == (None, None) + assert view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri=None, + ) == (application, None) + assert view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (application, None) + assert view.validate_logout_request( + id_token_hint=id_token, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) == (application, oidc_tokens.user) + assert view.validate_logout_request( + id_token_hint=id_token, + client_id=client_id, + post_logout_redirect_uri="http://example.org", + ) == (application, oidc_tokens.user) + with pytest.raises(ClientIdMissmatch): + view.validate_logout_request( + id_token_hint=id_token, + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) + with pytest.raises(InvalidOIDCClientError): + view.validate_logout_request( + id_token_hint=None, + client_id=None, + post_logout_redirect_uri="http://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="imap://example.org", + ) + with pytest.raises(InvalidOIDCRedirectURIError): + view.validate_logout_request( + id_token_hint=None, + client_id=client_id, + post_logout_redirect_uri="http://other.org", + ) + + +@pytest.mark.django_db +@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) +def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT): + rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT + oidc_tokens = oidc_tokens + assert RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(None) is True + assert ( + RPInitiatedLogoutView(request=mock_request_for(oidc_tokens.user)).must_prompt(oidc_tokens.user) + == ALWAYS_PROMPT + ) + assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True + + def test__load_id_token(): assert _load_id_token("Not a Valid ID Token.") == (None, None) From 85bd3661b1a5af86e51f651d79d5e156098433df Mon Sep 17 00:00:00 2001 From: Savin <35384395+yurasavin@users.noreply.github.com> Date: Sat, 16 Sep 2023 03:47:29 +0800 Subject: [PATCH 529/722] Issue 1295. Added revert action for migration (#1296) --- AUTHORS | 1 + CHANGELOG.md | 4 ++++ .../0006_alter_application_client_secret.py | 17 +++++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index d24447a5c..aaedf1084 100644 --- a/AUTHORS +++ b/AUTHORS @@ -97,3 +97,4 @@ Víðir Valberg Guðmundsson Will Beaufoy pySilver Łukasz Skarżyński +Yuri Savin diff --git a/CHANGELOG.md b/CHANGELOG.md index d26ae6207..323f0346a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### WARNING +* If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted + ### Added * #1185 Add middleware for adding access token to request * #1273 Add caching of loading of OIDC private key. @@ -24,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ### Fixed * #1284 Allow to logout whith no id_token_hint even if the browser session already expired +* #1296 Added reverse function in migration 0006_alter_application_client_secret ## [2.3.0] 2023-05-31 diff --git a/oauth2_provider/migrations/0006_alter_application_client_secret.py b/oauth2_provider/migrations/0006_alter_application_client_secret.py index c63c08bb2..a940c22c9 100644 --- a/oauth2_provider/migrations/0006_alter_application_client_secret.py +++ b/oauth2_provider/migrations/0006_alter_application_client_secret.py @@ -1,7 +1,13 @@ +import logging + from django.db import migrations -from oauth2_provider import settings + import oauth2_provider.generators import oauth2_provider.models +from oauth2_provider import settings + + +logger = logging.getLogger() def forwards_func(apps, schema_editor): @@ -14,6 +20,13 @@ def forwards_func(apps, schema_editor): application.save(update_fields=['client_secret']) +def reverse_func(apps, schema_editor): + warning_color_code = "\033[93m" + end_color_code = "\033[0m" + msg = f"\n{warning_color_code}The previously hashed client_secret cannot be reverted, and it remains hashed{end_color_code}" + logger.warning(msg) + + class Migration(migrations.Migration): dependencies = [ @@ -26,5 +39,5 @@ class Migration(migrations.Migration): name='client_secret', field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255), ), - migrations.RunPython(forwards_func), + migrations.RunPython(forwards_func, reverse_func), ] From fe7b08673f377878b6e9e68213be48658990ff46 Mon Sep 17 00:00:00 2001 From: Bellaby <bellaby@outlook.com> Date: Fri, 15 Sep 2023 21:20:13 +0100 Subject: [PATCH 530/722] Updated Insert user example with scope. (#1316) The Insert user example is the same as the last example saying it will present a permission error. People reading this will get an error trying what is shown as a positive result example. --- docs/rest-framework/getting_started.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 8028a412f..531077eab 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -187,7 +187,7 @@ Grab your access_token and start using your new OAuth2 API: curl -H "Authorization: Bearer <your_access_token>" http://localhost:8000/groups/ # Insert a new user - curl -H "Authorization: Bearer <your_access_token>" -X POST -d"username=foo&password=bar" http://localhost:8000/users/ + curl -H "Authorization: Bearer <your_access_token>" -X POST -d"username=foo&password=bar&scope=write" http://localhost:8000/users/ Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`: From 6bca431a79821075791eafc203a62498efb3be27 Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Mon, 18 Sep 2023 13:34:06 +0200 Subject: [PATCH 531/722] Improve coverage (#1317) --- tests/test_oidc_views.py | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 5ae354e56..201ff0436 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -6,7 +6,12 @@ from django.utils import timezone from pytest_django.asserts import assertRedirects -from oauth2_provider.exceptions import ClientIdMissmatch, InvalidOIDCClientError, InvalidOIDCRedirectURIError +from oauth2_provider.exceptions import ( + ClientIdMissmatch, + InvalidIDTokenError, + InvalidOIDCClientError, + InvalidOIDCRedirectURIError, +) from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings @@ -236,6 +241,13 @@ def test_deprecated_validate_logout_request( client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) + with pytest.raises(InvalidIDTokenError): + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint="111", + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) with pytest.raises(ClientIdMissmatch): validate_logout_request( request=mock_request_for(oidc_tokens.user), @@ -271,10 +283,18 @@ def test_deprecated_validate_logout_request( client_id=client_id, post_logout_redirect_uri="http://other.org", ) + with pytest.raises(InvalidOIDCRedirectURIError): + rp_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS = True + validate_logout_request( + request=mock_request_for(oidc_tokens.user), + id_token_hint=None, + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) @pytest.mark.django_db -def test_validate_logout_request(oidc_tokens, public_application): +def test_validate_logout_request(oidc_tokens, public_application, rp_settings): oidc_tokens = oidc_tokens application = oidc_tokens.application client_id = application.client_id @@ -306,6 +326,12 @@ def test_validate_logout_request(oidc_tokens, public_application): client_id=client_id, post_logout_redirect_uri="http://example.org", ) == (application, oidc_tokens.user) + with pytest.raises(InvalidIDTokenError): + view.validate_logout_request( + id_token_hint="111", + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) with pytest.raises(ClientIdMissmatch): view.validate_logout_request( id_token_hint=id_token, @@ -336,6 +362,13 @@ def test_validate_logout_request(oidc_tokens, public_application): client_id=client_id, post_logout_redirect_uri="http://other.org", ) + with pytest.raises(InvalidOIDCRedirectURIError): + rp_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS = True + view.validate_logout_request( + id_token_hint=None, + client_id=public_application.client_id, + post_logout_redirect_uri="http://other.org", + ) @pytest.mark.django_db From 1eca949990b456b29d23201bd87540133eb4c23f Mon Sep 17 00:00:00 2001 From: Antoine LAURENT <antoine.c.laurent.pro@gmail.com> Date: Fri, 22 Sep 2023 12:34:03 +0200 Subject: [PATCH 532/722] Add default value for Application.post_logout_redirect_uris (#1319) --- .../migrations/0007_application_post_logout_redirect_uris.py | 2 +- oauth2_provider/models.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py index 6eba65118..f4ca37187 100644 --- a/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py +++ b/oauth2_provider/migrations/0007_application_post_logout_redirect_uris.py @@ -13,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name="application", name="post_logout_redirect_uris", - field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated"), + field=models.TextField(blank=True, help_text="Allowed Post Logout URIs list, space separated", default=""), ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 204579905..649f0cd33 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -114,6 +114,7 @@ class AbstractApplication(models.Model): post_logout_redirect_uris = models.TextField( blank=True, help_text=_("Allowed Post Logout URIs list, space separated"), + default="", ) client_type = models.CharField(max_length=32, choices=CLIENT_TYPES) authorization_grant_type = models.CharField(max_length=32, choices=GRANT_TYPES) From 9aa27c7528cdeda0b85bac5a8a00b39d696a43f9 Mon Sep 17 00:00:00 2001 From: Peter McDonald <148006+petermcd@users.noreply.github.com> Date: Tue, 26 Sep 2023 17:52:34 +0100 Subject: [PATCH 533/722] Resolved documentation issue with Code Verifier and Code Challenge (#1323) --- AUTHORS | 1 + CHANGELOG.md | 3 ++- docs/getting_started.rst | 3 +-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index aaedf1084..9fb42239e 100644 --- a/AUTHORS +++ b/AUTHORS @@ -80,6 +80,7 @@ Paul Oswald Pavel Tvrdík Peter Carnesciali Peter Karman +Peter McDonald Petr Dlouhý Rodney Richardson Rustem Saiargaliev diff --git a/CHANGELOG.md b/CHANGELOG.md index 323f0346a..a61a3ebdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. - ### Fixed -* #1284 Allow to logout whith no id_token_hint even if the browser session already expired +* #1322 Instructions in documentation on how to create a code challenge and code verifier +* #1284 Allow to logout with no id_token_hint even if the browser session already expired * #1296 Added reverse function in migration 0006_alter_application_client_secret ## [2.3.0] 2023-05-31 diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 9b79f9a32..388afa300 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -268,9 +268,8 @@ Now let's generate an authentication code grant with PKCE (Proof Key for Code Ex import hashlib code_verifier = ''.join(random.choice(string.ascii_uppercase + string.digits) for _ in range(random.randint(43, 128))) - code_verifier = base64.urlsafe_b64encode(code_verifier.encode('utf-8')) - code_challenge = hashlib.sha256(code_verifier).digest() + code_challenge = hashlib.sha256(code_verifier.encode('utf-8')).digest() code_challenge = base64.urlsafe_b64encode(code_challenge).decode('utf-8').replace('=', '') Take note of ``code_challenge`` since we will include it in the code flow URL. It should look something like ``XRi41b-5yHtTojvCpXFpsLUnmGFz6xR15c3vpPANAvM``. From 41591adf4dd43fd01c585b38c7cb8b95ded34a72 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Thu, 5 Oct 2023 09:46:54 -0400 Subject: [PATCH 534/722] fix: rtd build missing dependencies (#1331) see: https://blog.readthedocs.com/defaulting-latest-build-tools/ --- docs/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index 4f5593f9b..b47039487 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,6 @@ Django oauthlib>=3.1.0 m2r>=0.2.1 mistune<2 +sphinx==7.2.6 +sphinx-rtd-theme==1.3.0 -e . From 1c01da70caaf39d8ceb033fa8aa46fc85c1456b6 Mon Sep 17 00:00:00 2001 From: Alex Manning <alexander.manning@stfc.ac.uk> Date: Sat, 7 Oct 2023 19:45:26 +0100 Subject: [PATCH 535/722] Fix unhashed secret to work with request body authentication. (#1334) --- AUTHORS | 1 + oauth2_provider/oauth2_validators.py | 2 +- tests/test_oauth2_validators.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 9fb42239e..b3a533f0b 100644 --- a/AUTHORS +++ b/AUTHORS @@ -15,6 +15,7 @@ Alan Crosswell Alejandro Mantecon Guillen Aleksander Vaskevich Alessandro De Angelis +Alex Manning Alex Szabó Allisson Azevedo Andrea Greco diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d452fd97c..ae6b92813 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -190,7 +190,7 @@ def _authenticate_request_body(self, request): if self._load_application(client_id, request) is None: log.debug("Failed body auth: Application %s does not exists" % client_id) return False - elif not check_password(client_secret, request.client.client_secret): + elif not self._check_secret(client_secret, request.client.client_secret): log.debug("Failed body auth: wrong client secret %s" % client_secret) return False else: diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 78d9ac982..5694982b0 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -100,6 +100,18 @@ def test_authenticate_request_body(self): self.blank_secret_request.client_secret = "wrong_client_secret" self.assertFalse(self.validator._authenticate_request_body(self.blank_secret_request)) + def test_authenticate_request_body_unhashed_secret(self): + self.application.client_secret = CLEARTEXT_SECRET + self.application.hash_client_secret = False + self.application.save() + + self.request.client_id = "client_id" + self.request.client_secret = CLEARTEXT_SECRET + self.assertTrue(self.validator._authenticate_request_body(self.request)) + + self.application.hash_client_secret = True + self.application.save() + def test_extract_basic_auth(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic 123456"} self.assertEqual(self.validator._extract_basic_auth(self.request), "123456") From b39ec01333ecee0278cdfb87554fa082bb4e1071 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Oct 2023 14:53:54 -0400 Subject: [PATCH 536/722] [pre-commit.ci] pre-commit autoupdate (#1335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b027810a0..d746cd662 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -5,7 +5,7 @@ repos: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: check-ast - id: trailing-whitespace From 1c4a997ac8ef375c1a89065628d8d5c3474fa22c Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <darrel.opry@spry-group.com> Date: Thu, 5 Oct 2023 10:54:10 -0400 Subject: [PATCH 537/722] fix: AUTHORS alpha sort - L with stroke should be treated as L - pySilver out of place, do we need a real name here for posterity? --- AUTHORS | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index b3a533f0b..8020dd235 100644 --- a/AUTHORS +++ b/AUTHORS @@ -71,6 +71,7 @@ Jun Zhou Kaleb Porter Kristian Rune Larsen Ludwig Hähne +Łukasz Skarżyński Marcus Sonestedt Matias Seniquiel Michael Howitz @@ -83,6 +84,7 @@ Peter Carnesciali Peter Karman Peter McDonald Petr Dlouhý +pySilver Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev @@ -97,6 +99,4 @@ Tom Evans Vinay Karanam Víðir Valberg Guðmundsson Will Beaufoy -pySilver -Łukasz Skarżyński Yuri Savin From e1b89a56b3e05b72a1dc469eaf7b38fb877f425d Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 11 Dec 2022 13:43:32 +0400 Subject: [PATCH 538/722] Fix CORS by passing 'Origin' header to OAuthLib It is possible to control CORS by overriding is_origin_allowed method of RequestValidator class. OAuthLib allows origin if: - is_origin_allowed returns True for particular request - Request connection is secure - Request has 'Origin' header --- AUTHORS | 1 + oauth2_provider/oauth2_backends.py | 2 + tests/test_cors.py | 117 +++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/test_cors.py diff --git a/AUTHORS b/AUTHORS index 8020dd235..bbceaadb0 100644 --- a/AUTHORS +++ b/AUTHORS @@ -17,6 +17,7 @@ Aleksander Vaskevich Alessandro De Angelis Alex Manning Alex Szabó +Aliaksei Kanstantsinau Allisson Azevedo Andrea Greco Andrej Zbín diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 5328e3ecd..c99a8699b 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -75,6 +75,8 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + if "HTTP_ORIGIN" in headers: + headers["Origin"] = headers["HTTP_ORIGIN"] if request.is_secure(): headers["X_DJANGO_OAUTH_TOOLKIT_SECURE"] = "1" elif "X_DJANGO_OAUTH_TOOLKIT_SECURE" in headers: diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 000000000..4ddc0e141 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,117 @@ +from urllib.parse import parse_qs, urlparse + +import pytest +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from oauth2_provider.models import get_application_model +from oauth2_provider.oauth2_validators import OAuth2Validator + +from . import presets +from .utils import get_basic_auth_header + + +class CorsOAuth2Validator(OAuth2Validator): + def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): + """Enable CORS in OAuthLib""" + return True + + +Application = get_application_model() +UserModel = get_user_model() + +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + +# CORS is allowed for https only +CLIENT_URI = "https://example.org" + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class CorsTest(TestCase): + """ + Test that CORS headers can be managed by OAuthLib. + The objective is: http request 'Origin' header should be passed to OAuthLib + """ + + def setUp(self): + self.factory = RequestFactory() + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + self.oauth2_settings.PKCE_REQUIRED = False + + self.application = Application.objects.create( + name="Test Application", + redirect_uris=(CLIENT_URI), + user=self.dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, + ) + + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CorsOAuth2Validator + + def tearDown(self): + self.application.delete() + self.test_user.delete() + self.dev_user.delete() + + def test_cors_header(self): + """ + Test that /token endpoint has Access-Control-Allow-Origin + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["origin"] = CLIENT_URI + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + + def test_no_cors_header(self): + """ + Test that /token endpoint does not have Access-Control-Allow-Origin + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + # No CORS headers, because request did not have Origin + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + + def _get_authorization_code(self): + self.client.login(username="test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "https://example.org", + "response_type": "code", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + return query_dict["code"].pop() From 70074b71ec19130719893e650719c77f8593c6e5 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 19 Feb 2023 15:34:51 +0300 Subject: [PATCH 539/722] Fixed tests for Access-Control-Allow-Origin header returned by oauthlib --- tests/test_cors.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_cors.py b/tests/test_cors.py index 4ddc0e141..9d7260bc9 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -29,7 +29,7 @@ def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) -class CorsTest(TestCase): +class TestCors(TestCase): """ Test that CORS headers can be managed by OAuthLib. The objective is: http request 'Origin' header should be passed to OAuthLib @@ -74,8 +74,7 @@ def test_cors_header(self): } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["origin"] = CLIENT_URI - + auth_headers["HTTP_ORIGIN"] = CLIENT_URI response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) From 4d38e4efba4d3cd0d12d997aeeebcff3067be69f Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Fri, 29 Sep 2023 22:12:25 +0300 Subject: [PATCH 540/722] Added Allowed Origins application setting --- docs/tutorial/tutorial_01.rst | 4 ++ .../0010_application_allowed_origins.py | 18 ++++++++ oauth2_provider/models.py | 40 +++++++++++++++-- oauth2_provider/oauth2_backends.py | 2 + oauth2_provider/oauth2_validators.py | 9 ++++ oauth2_provider/views/application.py | 2 + tests/conftest.py | 1 + ...estapplication_allowed_origins_and_more.py | 26 +++++++++++ tests/test_cors.py | 44 +++++++++++++++---- 9 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 oauth2_provider/migrations/0010_application_allowed_origins.py create mode 100644 tests/migrations/0005_basetestapplication_allowed_origins_and_more.py diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 9f79e895f..5462a32fb 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -91,6 +91,10 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `https://www.getpostman.com/oauth2/callback` + * `Allowed origins`: Web applications use Cross-Origin Resource Sharing (CORS) to request resources from origins other than their own. + You can provide list of origins of web applications that will have access to the token endpoint of :term:`Authorization Server`. + This setting controls only token endpoint and it is not related with Django CORS Headers settings. + * `Client type`: this value affects the security level at which some communications between the client application and the authorization server are performed. For this tutorial choose *Confidential*. diff --git a/oauth2_provider/migrations/0010_application_allowed_origins.py b/oauth2_provider/migrations/0010_application_allowed_origins.py new file mode 100644 index 000000000..39ca9af8e --- /dev/null +++ b/oauth2_provider/migrations/0010_application_allowed_origins.py @@ -0,0 +1,18 @@ +# Generated by Django 4.1.5 on 2023-09-27 20:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("oauth2_provider", "0009_add_hash_client_secret"), + ] + + operations = [ + migrations.AddField( + model_name="application", + name="allowed_origins", + field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 649f0cd33..4d31d5e19 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -20,8 +20,7 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings from .utils import jwk_from_pem -from .validators import RedirectURIValidator, WildcardSet - +from .validators import RedirectURIValidator, WildcardSet, URIValidator logger = logging.getLogger(__name__) @@ -132,7 +131,10 @@ class AbstractApplication(models.Model): created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) algorithm = models.CharField(max_length=5, choices=ALGORITHM_TYPES, default=NO_ALGORITHM, blank=True) - + allowed_origins = models.TextField( + blank=True, + help_text=_("Allowed origins list to enable CORS, space separated"), + ) class Meta: abstract = True @@ -172,6 +174,14 @@ def post_logout_redirect_uri_allowed(self, uri): """ return redirect_to_uri_allowed(uri, self.post_logout_redirect_uris.split()) + def origin_allowed(self, origin): + """ + Checks if given origin is one of the items in :attr:`allowed_origins` string + + :param origin: Origin to check + """ + return self.allowed_origins and is_origin_allowed(origin, self.allowed_origins.split()) + def clean(self): from django.core.exceptions import ValidationError @@ -202,6 +212,13 @@ def clean(self): grant_type=self.authorization_grant_type ) ) + allowed_origins = self.allowed_origins.strip().split() + if allowed_origins: + # oauthlib allows only https scheme for CORS + validator = URIValidator({"https"}) + for uri in allowed_origins: + validator(uri) + if self.algorithm == AbstractApplication.RS256_ALGORITHM: if not oauth2_settings.OIDC_RSA_PRIVATE_KEY: raise ValidationError(_("You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm")) @@ -777,3 +794,20 @@ def redirect_to_uri_allowed(uri, allowed_uris): return True return False + + +def is_origin_allowed(origin, allowed_origins): + """ + Checks if a given origin uri is allowed based on the provided allowed_origins configuration. + + :param origin: Origin URI to check + :param allowed_origins: A list of Origin URIs that are allowed + """ + + parsed_origin = urlparse(origin) + for allowed_origin in allowed_origins: + parsed_allowed_origin = urlparse(allowed_origin) + if (parsed_allowed_origin.scheme == parsed_origin.scheme + and parsed_allowed_origin.netloc == parsed_origin.netloc): + return True + return False diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index c99a8699b..401e9fc5c 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -75,6 +75,8 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] + # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, if the origin is allowed by RequestValidator.is_origin_allowed. + # https://github.com/oauthlib/oauthlib/pull/791 if "HTTP_ORIGIN" in headers: headers["Origin"] = headers["HTTP_ORIGIN"] if request.is_secure(): diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ae6b92813..6a4acc8e3 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -958,3 +958,12 @@ def get_userinfo_claims(self, request): def get_additional_claims(self, request): return {} + + def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): + if request.client is None or not request.client.client_id: + return False + application = Application.objects.filter(client_id=request.client.client_id).first() + if application: + return application.origin_allowed(origin) + else: + return False diff --git a/oauth2_provider/views/application.py b/oauth2_provider/views/application.py index 9b5a8ffb6..b896c45e3 100644 --- a/oauth2_provider/views/application.py +++ b/oauth2_provider/views/application.py @@ -39,6 +39,7 @@ def get_form_class(self): "authorization_grant_type", "redirect_uris", "post_logout_redirect_uris", + "allowed_origins", "algorithm", ), ) @@ -99,6 +100,7 @@ def get_form_class(self): "authorization_grant_type", "redirect_uris", "post_logout_redirect_uris", + "allowed_origins", "algorithm", ), ) diff --git a/tests/conftest.py b/tests/conftest.py index d620c3f59..2cc3c3901 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,6 +108,7 @@ def application(): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, + allowed_origins="https://example.com", ) diff --git a/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py b/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py new file mode 100644 index 000000000..fbc083a2b --- /dev/null +++ b/tests/migrations/0005_basetestapplication_allowed_origins_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 4.1.5 on 2023-09-27 22:25 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_ID_TOKEN_MODEL), + ("tests", "0004_basetestapplication_hash_client_secret_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="basetestapplication", + name="allowed_origins", + field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), + ), + migrations.AddField( + model_name="sampleapplication", + name="allowed_origins", + field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), + ), + ] diff --git a/tests/test_cors.py b/tests/test_cors.py index 9d7260bc9..64f2a5fec 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,3 +1,4 @@ +import json from urllib.parse import parse_qs, urlparse import pytest @@ -6,18 +7,11 @@ from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets from .utils import get_basic_auth_header -class CorsOAuth2Validator(OAuth2Validator): - def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): - """Enable CORS in OAuthLib""" - return True - - Application = get_application_model() UserModel = get_user_model() @@ -50,10 +44,10 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, + allowed_origins=CLIENT_URI, ) self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CorsOAuth2Validator def tearDown(self): self.application.delete() @@ -76,10 +70,42 @@ def test_cors_header(self): auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) auth_headers["HTTP_ORIGIN"] = CLIENT_URI response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + content = json.loads(response.content.decode("utf-8")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) - def test_no_cors_header(self): + def test_no_cors_header_origin_not_allowed(self): + """ + Test that /token endpoint does not have Access-Control-Allow-Origin + when request origin is not in Application.allowed_origins + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["HTTP_ORIGIN"] = "another_example.org" + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + + def test_no_cors_header_no_origin(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin """ From d312e48c06a46ef7ba7711800f33d0299b4d009c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 19:22:20 +0000 Subject: [PATCH 541/722] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauth2_provider/models.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 4d31d5e19..d003d99e6 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -20,7 +20,8 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings from .utils import jwk_from_pem -from .validators import RedirectURIValidator, WildcardSet, URIValidator +from .validators import RedirectURIValidator, URIValidator, WildcardSet + logger = logging.getLogger(__name__) @@ -135,6 +136,7 @@ class AbstractApplication(models.Model): blank=True, help_text=_("Allowed origins list to enable CORS, space separated"), ) + class Meta: abstract = True @@ -807,7 +809,9 @@ def is_origin_allowed(origin, allowed_origins): parsed_origin = urlparse(origin) for allowed_origin in allowed_origins: parsed_allowed_origin = urlparse(allowed_origin) - if (parsed_allowed_origin.scheme == parsed_origin.scheme - and parsed_allowed_origin.netloc == parsed_origin.netloc): + if ( + parsed_allowed_origin.scheme == parsed_origin.scheme + and parsed_allowed_origin.netloc == parsed_origin.netloc + ): return True return False From ce35a05c608e027b2b833cbe0a12a0097a38637b Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sat, 30 Sep 2023 22:19:55 +0300 Subject: [PATCH 542/722] Updated documentation --- .../application-register-auth-code.png | Bin 36840 -> 145679 bytes ...application-register-client-credential.png | Bin 33986 -> 133348 bytes docs/tutorial/tutorial_01.rst | 7 ++++--- .../oauth2_provider/application_detail.html | 5 +++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/_images/application-register-auth-code.png b/docs/_images/application-register-auth-code.png index 0231127ae8a6df6862bf02c3523a7128d9dbaf3f..86bfb402bd7e95dbfe25432de22fc84b1c72634b 100644 GIT binary patch literal 145679 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 literal 36840 zcmeFYWpo_RlddVSn3<WG87*dJmMmswuq<Y>#VlDZ28)>`i<zOt%*?&|{dec??%bJk z=bXFqZNK%&%C5?cs?5lE;*BU3C21she0VT0FeF(S2{kY<@F_4bNCsHw&lVPTyi_nS zQZQMGuNvOjXB$BVKRvoYAnACH^z7>pE1gx2y}+DCs3mK}l$?}&tDKk0<?25fg%t<Y zW=z%vj&KiX*BEBhHsp3<RNh)MT#Wj6_I-TZ7pkQ>q%%<lWNX){>{w%jho_v>9Pzi$ z-=M#Iu{et`t9d;?sA@TQx$X*d7OHtnuxpvvcYQ_`gC&!wxo@9=`sddRKAbXHC@Q%O zdhS2%iIjq2sA7?%vfl8YtzVO`Fh5&=P;S$OqKbVf12tN<cwduORaF^uc%$Vh8^K|T z!MfPBoj0u=0lP)W$jI(4gYLaMP5{IS5x0j^9M;pMj@|E`FZU_Y<)63JpZhJJ_H;AG z!sm5C2nmBMQKs?0>y)s4eEaO&zJZ9(896;Y?Nprq`OJZdLgVhhb04pdH=mcs%PEKE zjm7s4p{jwyTbJt{$<Mn!IF<bTr=#N0!<tjKrS`sN|J>oNta{bwgD!stzP~hDnIzo$ z$Lynh_Hu;#FU(K2PaT#l6gD{&@M#$|%Ky4y{#*aJ@pR?>GsO~j;%H{$jU(1{JOm@1 zTO{O-hrl^d-@=yJj%6-%forLoxH;~`VPB`i*m!1OqQde%w8tY$O4rR~XUT&JswnGp z(lDbpeWnOc$QKds*M$>+P;9-=(cjxBZYgffbl7)Z;`pSs7@KQaJMjGc0G+)=*<vOY zU(eCqbigazz)Q?u;01UD;f{``e6wf$QFd-sL!iGANFTon&VwYtQy(-cl}z5X?Ydq~ zad8-B%^c2plzVlm6P!{`l*N_<4IQ`<h7mBT-Ie-d=GFs%&z>2DQf5u{<sEf_#{ikk zF@Q(g+jdu0R#9rs#*F=CzHD!yZQCDd0h>n_C9uEy?S%KhVmP3F4HbyQGuFsSo`J<T z!qbK+w$AQYeRY<anncl8un65^<Ix0>)<5_B6)&vj;^4eUahWANzmoxZcOFxXTDs_> zftUu#4`HQa*TGgv0Iu~L<q%(tE^1RwnLLAYp~b^uCu0{(kjO%@plwIKPfOzHesEUa zlG21-79Cy9&VY@8QVo0~b@DbSbKq=#Wk4Ei(^gBUCGw^TdG!{{9G9Nd7QEeSQXGdi z=Dx}d-s>E2P@J=fiqz&uOlo5UKk{f4=w9oPHM|FbP`Wd+Y(J2kmT<hQB^FMd(j36% zA|xo@qO4i_yEHa>6=ONuQKfVSc_D?BDy*UGg?aclf)ivGV5)Ervy^(T&PfVkP_o_U zHH135bS6%g++y25*3gz^i66!BmY$l4aX21+_eAc7`iq>Y(O_uU{xbN)9Xfs5$@|>- zK4WG>v@MTy3AS2m<ssO~`NyiU{I@;?uKekgI-!)rn~9&qr3E8+Vo#CY$?9pWe(r16 zUg~vc0COVe{#u`(l`mu^+RgRdizgz;;m3?JdD0yZwlFEVHB{ekwdk9$)U|Xc;Ic5p zI3?m`NNpw|EEA5OU~K@FR)?obSlA<1GrJi%_`EnvEf?er98brt7>*ja+MuuwIHJkC zx#;6YHBV4l(EG<JTX0l8HtX7S0pXI51FQ?f3FNlT;lN1KCIQm+>R5B&Yk_7lGsE!e zCfdo6=emTA64t`7alW~(|JKlPiRmf1n(?0o6n@vd>H`0J1EuwfqDJj~XSMe?1ME(l zQ`U*N`W3l}O@CY4syzq&;Op{{J>#JpHTp7rfjeN^IcJBuRBkK$*eJOx$%(79r#|gu z<&Y!Fn}0++$%A{)pI`OvAN1u!L?u2s%lH*^eqG@h7eqhD=kIM3Hb^kpAM>%Yw8P+e z9BXpIA*sx>-PQlNvAu}m`FaqW?M5Lm0Q$BMbk5<@w@{M8`4Jr2EkOC$48HE)+pF<o z2t*b8(vyU6ZhRk>)+CPS-0D_V;m?LV8-BdqLEE3o{l%Tzr{va7yw@jadO)-D#7DNb zOxpj_3d)t<xp$|?Zm#9y`jxy(<a<4}R=1d%VyqoTWkutI!TFtFET)Wc)#iK}csb<I zY*yBgtZHSoYsV1Ty?GIAiBW-Vt{PY_&bRJ~zexw!Cc;=ambA~tP+S_TYF-LQZ7$EU z>q+eI`)H;w^{)64-8@Pc)cJE_Y;Gio<|?Kt;QPRGPsgM!cMbA-ZR0hgcUNdKlMf?q z4b-}hv9Y|VgGuY0MP&dNYPPM=$V%ARa#K?5NRm#%gDYzChQx8+dv94*=J3U62%%tY zTHicxkZ6#j*&{ds?cI1FS;7hh#8}x*y0(gpsTO$>7>T{*0yl`8tH>%vUqfsp29FY1 zJ$OlKFy>i<x6N@qYNL#TO(<>s_ieh!!t!sM_PDG}hphLnh0|tl8&Yzf7x}C-8P>l* zZeu~3-%dSO9G5GvOqknIy7~oRJZraddx);*1L+wZBv1E!R0fk!PmMQ?v=u{edM4|N z7RS~Ed=TYLfC(81P?Oq(*F}D97{-?$t95+jvZE_s_j^OmCS}C{5bIi_xh#mGo-j%T zO{^4n`V4&DCM-T~5REIA7<N3F)pnRI7Gve2&gIj>E#KFFh~1=86=_2n36#WTm3cxo ziKsrciSRBwj#GP$A$!_y{M~U0X2@R&EsM7^_S*QP{_$4+ur-fsnUa(;7JWxqxB#YP zYosR^=)luW*4fG(+7h;5z%`|gwP&U64${>jM9=~>@@Vn{>8!emL=|_EangA<LBYcG zq10pKqvzb4)s<x5+y$FLN_vn~CD;3K{-&RGUrSiox#$CuKwnu#4;tK<S?@sE=U<ST zwk6`H(RoA?lG&8{E@umoe=73uBOB_Nw0_`+6YNB)KLu5;w3Afes0`Md+?%0uf%)Er zIOrUtnYn40`VPzPi09aeWRBpOPervCLQiKzCnaF1vX=y77+YsNsccb`{1ThpWWTWm z_d{SZnx^-yn=1EK3rvf2Bo*bHqaLn%4zcZ#Rj&sPk&OF95@1A}m~dUpVcXiHR`E$W zh)0~aDx2^j%Z5XjpT6CzYE~w-5Zh@kb$7QNw^n-7t_InY==DZpaE-6oK(4K6=&(;G zT<|uB7yoXhgn{Tw&yA__WPcv+^=bK$c`Fs$JO9?frSZmhHO)$yv7YS{oT*31?}XrE ziPc6cbaLB6grR$V$*<&ZO=a%Rel7lXNbc(kv4-;N+puiR23%>4?}o?gcklA-Kej=4 z6&HpIs=fB^@ZTk<<6%ut`q=G_2!d8y^BQJkbFyCjT>jbS+FSeT1t2{rCzUBI{#@V0 zf!79llRJxB5B~8$v#Dten3bDL`vBGFHJDKlpN{mz$rVs^eIr5U-$CSm$`?Ne(1`Cu zk^IRBS2qa)gjRaVndGM)&A1FCDDI_+vqV@;*>h$ia;o_%t27D5JxGVeF!6SRIVxp0 zfX@)6s<Ro0{LyZv_$3t)n2p@oAH8qFm%5A1*vO^PC`4+2xMPtg;C{rv&bDA`tMQ$H zr{U!_H-ryGPh7AcYeA=jrnIb)LtlH0jfRyq{;Bkv21(<$I?kR%d|Rd0N58%0mWqcN zBj88*%&?SKCcB_kf-L}v$FmY}#@g1erP&|V4%nSpD(sd<7<QPp-5<bXl`_1S@qF-s zq=DeFzZY`J$>jBzyQR(EY^_`^<!w-<sN-2wxqgAsIk7EeTWa9(YOlG4Vz%0-6GZ@v zeS01db$%%`OOYW|lNzCO)@Rh|kR9+cXu<hsFZ+|lAb2@payd2<8sxaG45hnARNL#r zun?h?D7dn0qjiXL-kG)>zvK?YJQj?YI~{tq(62cl)iAV$J^8wHR(z)}!4Y&0f5vb_ z=Y(kg+7TBnzRyWW4=ARMhdSkEzs#*z=j>3LY{Xi#0~9Q7p4%%Owlb@W!J?e*PyAIC zA2|_hBKZT|hpSSm@_cDOEfSfPDYwAaf{7}2sHbo_1(_d5L59ybyrDDB#mO803~4gp zZAmbx8qffK3NvgCTl26qiPewibkr66QgL1royeS`2`R1u>A96%kHvrP16!Urc^iR< zsBqP^b72U0wlL1Nb2`yP>R5bs{uaQHr%3<`9o*dh+k8n{TFp_KJ^7%P4|nRCjkO)x zEL556^^)K)yz0kNjYQ^X@p&?XAcI%2xu-nr=)q}fwHLk^@@WW_TuC~kl__+fjeGIr z+WHa}@3ZDrzeTV7p?Ln7>gK=wJ)S6hvcB`OP7O)kY;ZD)k=JN&Euy@vs;b}sG7B_h zR%3b_V?pVdvtcyEK&ydhjo_rn9_D1xz*AxPZA^RptlbJ=<^i3B@{)CZmqJ2y3@D0< zi7U2U`EoPfsVhBvgM8%Qe<sCx2<|>W;BGJMIH+r7*4}TtyR;#YoK(l%z-c02G~qeO z22$kZ4{oW5x9r?}_n4hOGcJi++qOq(_<F)Dd+gA=2tlNL#wWc6E?r=$QXAs&VWaYz znu9EmgU?6AjZa8+6mG3T_kCCYuXht-1&)UJj%+Sl($kC!qKN+Z{5(6TU%IBcfWf1Z zY4zNb|J}@6-`eKXPrG8qeM9R1vftA8f_ROYaecn-PT8?%C1E>(Soa0V(>ESMCyd5M zq7wm6jOQ`!r0(C0SNrg+g1#*v4kt(_l9OqP)23RG2}`E^b;4wocW3AX%bq%x86}?w zl#l*{>y;?kh&Uw2N+qYqJAGG-qjes(?A>&a!<?$PV#J5Amjj$GpO5{fu2Ni8*5Tu( z;6Zjx@BCr}mn9oP{b3TPQi4`M`~&>Xl7BzKfgqT~@bD|gU&q42MI`I#VW}egf=858 z7cF%(T+$K(j491wDC5VEwmL#qmsSm>@^Z-FxYVQew)wse8djns{|$}iMtsD1Hut{^ z@GEfwg?PlJ3$3T+r6*T!mK%*fb=7HDEzitvYzwxhdqtTH<XtDFu$7f59n-^64_hmi zZukwoHq?PcisoBSgcY!NgU#n=hJ09ncTmmjFq^>-wMZq$woEIU{(~gGONRq&uB{>2 zd--RL*f9!K$P*U~S=fr9J?C$9dZXoQI4S--^Brxevmu_Ml5aOKG95S#yKzS`j5_Pn zDh-j6hwACP15x`EW}`~?e;AwKxdktSNK^w1!d^0@qhh|N?sDlsKB_JxHf5>8l92^l zay`P)H~lh2h%w)p_udH<Mw`&LSnkx4OS59LJ}w$S68ySyt6$2y53R`wIYjAVShMh` z{hWofvDKtomkaYQT1!N`uk7%3<K$w$g0B-}+y~4=LeM2sZFDW!ZcAoI3amV7V57(u zMQIwHgd||_p{x4cgIG`ggk7MXWPVmpUus)F7y^mQtOIbm?#Rf(WR}DcR%~6{(V8(U zT5CO|Oi8w^r7lSrC9<U2Gy(qruk|~^#E%!*qdsajC-_IiAesn-2FVw#3lB3Pn-IT- zE&Ket#*?uc!`8KU-KizR<{XUvz>$V!wHw=wmV|?p%+0<cjPRnSC6%Ts!Ymw>Bbi)_ zj+gdcF@Kni^=&wbsS(Q$vY=ZgWDEjsEsQ*j1b)V(!DH(9DsEE|py=}n=coW9fVe(= zFj%z_QI^_mGcX9&&q2e<P?GDrnD`wm64G${rQey1g^uV!KbLu@P5<*(Iy??}fhktj z%oXJ~_US(Wa(+D+7y>YYl0rd+Ov*x%c$&#Xv4cH&_J>vHylPUB&fOz3tQVh^abn*b z7<sJW0T?cg4x0$|-^EJRQaFo0?tfx<=}+{|{OJ5ub7=?p>7o8HGQs@MZmgDjX(-$5 z&oBIBD@W8@f)Z8aM@9R^yJi6qe*Arpr54c<ukP@f1>I4Nb>Vy=!K~|Zn@y1-rJL!S z$35@KVU3AV6-Y?=5N3ZbXnMPpu7UEyF&MNlag8zK<j~`~DSLlXrm%?xH=$*J$TGD# zJNu)1r19{y`jy9`k<!ZQn{K-Jpbd|w8{IQeRvQ-n4$l6cEhD@)3JGl<r+NnhP0sqX z3FMAun1|7^OD9{J!@7DCHd$z*%;4{0VZM>M1M`BOmGT#?zABP;Z{$tP^5l+@_`k%$ zEOi8LbAfUXPd$%u^worzvl-C4H$?UmxpCLT4zCXy_(U%-Rw?bjOXn1&c!W}7;byF% zf7s?jZx|+pHdN90!BCAQM<vWYNgtKZTTA0+<kW{0K5KU+Zu;5oq+urs2oW2m!#CH% zW$DZJtqXcGqRbPo50@TabavL{KOTaMN*cWFYdgBXv^-Qx7@qwd3s?o=PyMyy#xlD6 zD35wIL<>WRld#kH*bQ<*$IFt}5p2uiPc`GEgOW5?SS-@4P(hkU6*2`UXx=mH81gP5 zMw+kBw}xqTi#v)aRizXCy_ipsA~7VdKN7%NyHil_vpqOSxmhGx<^1jSj*yHZ`LB0t zqEG)@z0O1$jP1Su#=U5k%taH?UI{fC&v=kf`8<B%FlEa1tP=O+8~sudYIF6P0drbS zJ4%NUixL2%>Ol`EvIw(m%gQC{ZO*$5LXT?d7ob9dO37%PfR5&6f51w9Qw%!pXfa*w z&P73zWzqc8=iJyT;4hRnDsy3cBl8mzZAYNrvc5p8LY1~HD7yPN%b^<^K(Eu-unv^Q z#Gd+*4HerBsi)MXII`Kp@tWv|>(klz37b%XbilYP!kqCUiU*h%#fp_|qlp704r|uQ zETx86+E4m6gYelwLz3h2ca^mfQ^{M-HysDhY{wv=lDRtshE)lxU$%z8C$qbo^eUSA z7r*Ljd=Yf|m}&YK9FzM0mT3Xwy35HOZMd`IQd6-7iKpV2qfwWNHR$0`P|`%&W`<?G zqlG6d>$hE7BV%Jf@!saq4y=o!V{=x$)s$KN3Z>{PE1dESps}HW)%-7NUtgc2yK3ms zFZnd)neLz<;paUJxG7<UKJ`1BPlEV01lnco6Z{<&!p<<jhe0&zF>N3QWFz9D2N-n+ zy5hxjNmj3PDNpAAhqw+3Y?23u?VuNtK2t!U;TOA;D>2t5ffgfKXbauvFvqer?Y#>i zUgMY}P{r2w_B63(l!OAgF+^5+R-1SJ`pL{ai~Dh9<*^)W(J1M4+f(_Eo4Kap&17n` zJuhhOK!3)2db~W8QIzL!<q7(AjS}qVt%AuB!5bYJ*>PCQU0*I_NG*Jzqr@HmI>M6; zXj5}3yJXl>(&@@_I_RynK4aH@w8n-nW1W3QY<^xAg?@naX!VaNJ!Nr!C9*Jm4qRPr zg+@;}0^`-MMrH`@AbW6WC_Cc@Hnt#^cHp}F$hcOtcADwyqL&IRSr@U|EoM%s^f=Ay zp_^<_0b#(){&5>0OQKIh^vx2onUD`+%v{_M(bE;P5Jf*&9~{ckjk4FPUDY-|S5=c% zt%mBY4bmbFKowDx;c%Tyr!!yEzoMNc6xA5v^KFGi9}WMJeNp7M0|~Nyf|K7S*t)~J zGJ)2d*C^8c5zo$SS9RoH78bOQp`?mcG=9cH_nZz~z<#CZr3*bODqhhtkj0vKtEANP zOm*2I7yUf*97blfrmv*L#Uk4DxRzi&HZIOdkwko$^Q|TDgV%Ou436*5+B1Y6wHF`I zwf)9t%R9?h7~eq1r*P4NkmngUTjb^<gY>YDXq+{33u(h)n4?U}aiC)G!cOTF)ghGE znw087*N2_a-Z*e=_1_5Cy7zVA&$+1r?UX!xyk2tH&eJ}ddCvfz{DtEQ`T7DLH@u2s z$bC)fS5zt%XfZb)S!C-!Df0gs0S9ENl@&KO-b>+#N9H#)H5F>mJGrwBXp?T!y);~1 zIZtNuQGnCG#3JA@JHOo7^!4{UxvPF&Hclpe;jGe%nXoJ>C;(3wb}NdZ7~rD&CHL85 zpXfzc>2m@2=v8SsN{B@|{o-8v4_N$v4|3kS^obUpvaFw(oh1)K)W3tf8Lo9gLeDK! zu3xclF<>UApirg%#nZ+sW{|RWesH!@ziiJ9H~oLESSBVWL_GG;+S*zILrgN?{n_90 zcbC9CW#MOuzIv3DE6jk*;a$_|{IBbtYYseZv|@MBVg;gATJk*NBs7rW5_wBCc?Ls; zPDyr6mnj^y|70Bg-7N7~-75r~eizc_ox?DGF0qB~0k-!jg@d@6@AW?<)$L>g?Vwh* zt?KA0(~?`;@_9_xvLtOl3hcycUYU+eC#E?t^|;11dt0|dHGizM_ifl!JA6p)4~1wK z>c+j<q52;Q&&DPTeGaJA!o}x$$>D*O!`qtZHVS0#Nw{KbT3Zh0ugQ!GZGh#5fob8+ z=P6x5Ra?oGhh;qc-csY@B!0f=>4c_wb4{p;%?GtDRXm?J9INHF;gh_b>+fn&=%lXb zt#rijTE?y89QtZenhlC}X4*se2DjWvh3k{aafwaas)d-}mx5baW6XxX<m$T8{E}aE zSza(TOe0VK*$yYZfS&t4br3HNJM~+pq;h0;aw)KXJFlaUWB1+f_}Lm|s>?fF;INvK zYg9o=j}7UazYDs!@2b737`W8#HuL7lCTZdE5uh>5`t2({v>zHmbLfrxTHqJn%}KJN z$-m(M4CcI;&8tP#yC-}F9Ef3TtdIz*tD_&7h`>u5JdRTmo|G#&e(1267oM2qkG%VA zmsYq1iErM#VSKyft*Zzsx$3_#yE+nl(~?ybITOZ}$;i~;NF1z2EM<%zX*+VG9A8#V z9;EJ|nA66VHoDyoC!aGjGyMx$>~7wVNAjLa4lW+w-?M|{<nN(@>o5l|(aJCWU2d-d z$0B3bnud4J{)c-0Oo!D@43||Id-A}!i9VsKxrX(}x$)*2r*DSyi;FMJhHdi}CwM>t zd7KySF1JOH`m-zW8_UPSW7yQ;YvH~d5XUiZ>hahJctgYU^5MI9cpLES1tdvhx^v(= za5N5il__xAjz09CDiJ=dux{-*m;!xkz2xZ@arNm^20jQq>V#j$aTv-!v|gSc;=JZo zyuIBN#{@mTUwg#_tl|5uW;v$9olTLXG2b~nXH6Xnv`#cXjW~3L#|Y<L?i_aYcN-t+ zKN*Bw#=ed==B(7OH}isOy6Uz4Z^AA^k_Wqnb1SX_0%y(z-rf{|ZY3;luX9h~PCmBb zPVx>WW%XEHRzdZXk4rH^zn<|Yi5`T~UMg$IReGiv@@|Ei%KN+?%tD;jcEY-t?sFc4 zE~CS``c@r15A>Lcud#%i)|(&an)14@xZYmPfKE4{lT#lDN8ri<i~pxl4y!kg{Opgp zfiS@vAANe;e#{<qmkEm%4tKGa3GyVjRUFS#Y3<huUHfC3SMTqIQ-^lz2l^e}S(leM z0bSjfqb+75pPu~L*3K>jsC->zV{3bpBj8oN=I`EWAYA6NIVwzB^fV7ysgo9|w4Yau zSD%hGHdaTso@3c~A>&j-wK|bCvZ51wF25E3MJ?4T(OVh{Pkl^vqoU8zYjy|GN>7V< zr0O>&v3;+tkwljzMa*znyCI;8J)yFYUQc8D$fF8)7MREVjXHQf?+N%GoBhPuicD3& zyWGqcagdSay_dqDd71lz3{f=V`h(SVv-yWGytB*RG+G}bLx%H>?!(*fD7XZPhZeqt z@2q{$?zsoNI^)F3<SQaEM;>({dIxG8nk+S@OJQ_^w+%^us~3t&0x*eVO@?eC4T0Ys zKBgex8QF^W6-dRl3)omU?q`S>_p+jt#tu~98&3lkMVJdp=`vZD@CcD{OhK74cOvmB zARCT}^15OxC9-m3%BWisDteV3*o~Y}V%`{eHt=s^w07ku*Xu;PH5*)-s{4^t`$kkP z_$$q2iKEJbv|McK$7M8UOIb3{e<Eo{4J43Ucl$!59Z2_^F(S{ZA>|6l8SQI>HgGC6 z9O*29K*D@js*`Q^+2Nc5?~LvPyj|CAxCJz{7Ju;wB~mJ;p9kRIE26pN;}dn#FPxCP zorK&_@VqV1INvEUL%)EY)jpgtXR6IkgtNr1&Up%JlCC<!RtWH|TN$FZ%O~eNc2nNE z*C!pZT{UVSBndEry3+<=or(1ZI~FXC5TL)xBW$aCCi>ODzn<ERGgfV<@!iT0fN123 z1YoRaCiW`>%EF2ZJ1I9z0cOt%3dXlxo|~I^nc^_&#_5AE-FzyWHYP)F(osD#!ecA- z%+L`ijv${}YE122UBU0OHbdr7CTC|dQUN02J0cp>Ph&qH4kWej`7JvSK|xbck=aj( zWtqrpkMp-fSrfR}iup@3c;oDwmdH+{1vf%e>aN2^c-bwiY3Ctut+;L3=u=VWl|dW* z#aMI$o=@WoEiqhc^fqk*Ie>QPLnsa?H>*ZaX`4WpV#Wvc;{gJHu5O0h^06VKt|d9Y z6Vpl(vqGzU>60_O>f>>wgWcvmxB7Ei#`J3*8G7Xhy3N070Za@aOjoE+SO!o9z$zn< z#r^qJ;jI}Nicel`DkoyaJZw>>EayH@w>)Xt-tqvfr9zw^WHEi9NHQ<>PiLe(#OMj` zE$9tBl$?wK^}VqpG9a36ugjct+|lga?UfaXv(}|t@Hk~EwZYF$!h`m_Yi)KBKj<6; z!DcGz$Kj3hP9F7U1gRO3iF+?f$vkEnzic?b3oeN=7O5YOh}#xch6(m{@rF@z-d|7r zI%O`MI{^RIsC;|<5K!wI96WP$c}-I%RT0LL-Kp2UrWQxF`d5M}e>#zu5B#3T$L;z@ zdmZs`3t;7LR&VyQNv)$bIbJBxz|~7vW+AZOO+$0$DWndUGk-q8;^(l!;A@((HA-Ai zCefPb69`LKQ6T^vDuNlN{<KqW94$%#fAN(Zrg0oZSm4-$nESk7LeFzqcrBJTb*`zw zEyZyRK+{%WdFsU5cyp&lI(}2#^;3+M3xe;bS)&zPUVrWfhn{V|um&DhWeh!h{EZ|T zd=h73{LvfYE)hUN`m@(`t95!?9!yYC!|`u{m{7>wNU+}9<B%EQ>R;0sn2tD&P8_+u z?&BW2{l7qsfe(Gmg72=SNp-4e+P|aOS>=%ODe1MOZzJwMhRG!{Ic^~K88=2(%dUn# zQ;EJr^Fc@Co(B2Y)oeHOFB*tMd1ybA6m%MbwHHMuVqZO+%aiYwE&iMqRrn_biw#pK zPm<nsFV#A4qr-0!DBPj^3<kExw;9>(XpwIFAgD_N#rI?pQklEkk@Rtr8kZK|#f(fO z;3aF$Eg$XLS32Eb1ezJCg`LG=k2!BWxV!1wst@hBy;(yjzOkM3e^+teMO=T*Xzb4? z#I3}OJw01_$eUW7LdWOErBX~s=GqdzhsLu>xb^lWvrL1$U5gNy*Jn|;HDilM9(-x$ z2&Q8`0osXrfqqT=7CN=WOZ!zW9`_-au!OXU%(EZFo&4erQJ13b!}-Pv6;yhv!ROjx zbuhjG7kh$>Ow8bCv_7P}t6u!JH<~=Cpll^q8T=C%|GTvzGC;AD(RWxYF86|Z(zgi& z<RXk$pn8n@LcZEhh1lOhi(fIpE6nLa+4pz3saI$-p5aKZ@d9_$QPgKYpIDN%?fToy z3PCM@H6qKB)kF(F;%s?P(RU#dUzKo4bPSPTJew84TLjh&{OVFk&o?vD$M3krhOJTB zMysL^H6ATQAyH38D8paZ)hGp>EGRyS7@6jth;i@&+}4mMZC+!$-qZGmkUyYA82Np~ zwTa}u`FM46-0a|n-8WJXObqjbrTrpGjwGSWROHnGOsSl~&ieX+_f?yRGj$okgYaWH zD9vqtVeT!2_TtPL195H`-IC4J6AY&^eH_{BVy<^Mf^S_gx<Rq#O0EC|TwLI2xpksO z4qBXO%D&h5Obn=d0YFwx822<3k*xxem#gQFc*+zdvplRI$frCh{C|dj*kc8+Nm@_N zDl81M@fwDy*(})}5sqOs-FQy{u~q0e=Bt3_=KX?Z*jyyN)}>+W^N2W0>|STJ%Uo$Z z+&t`4dFImvYuCq7`KCFJMPV&2O%V0G$J)N>DEc_p(;u><WEOZ<SY3{W@{u%mkic@- z@aklVyarwq^7;NHj%o~8ysgW*ziFOEBF2zX%gMgC-+x3PN@Gb@M6)$}>C;BxPLFqq z*cY8NfY=7U)ePJ7jpc|$f?3cOY*JU|JvZA_JHXCU5ovU0i(mVFSY)DTPUB{v9!X!y zEO`8Afs)BPWKxvBxn=1I_s16MNX6zUy^Q_ls|0=<J`<1!ppH7TXV7h}Pd|g|+tK;s z%B@H5yDn#LPpDV{_#;yn5~tn{w;xH#K37#Km#0uB7N>;sF~Pk?|1JI6972+XWjhKX zA2G&a2!g_t=rkUvOnHtIhG{O+>-ke+0C4k@_@r55yR}<BWfPSGBd{YO0HzWb8s<e1 zh~&?0!rJz#F5^e)Jlx?Net>{B@J0Rg$T6e#i=_gmdLmoQP9WL<<!{sZ9;^jpMF={# zgv7ev-$zYX6rKV(6q)ERI_mq84N8j&%QGzDb$Kgl`}^n-yZypcb5Mv?N6Hk+rwZ~p z!`CB1mJ=MkZ69kdi!9dx>`#g|COeNbNm)FQIS?vCCLPU@mEA*E;}{MWTIjbG8i-I~ zjIt5autWv6RB}{TRH!F?Z#(D@d_tj{LLm)NXrd{g`$7OjKrTF10WgNlr5Di*vuEBN z-I$r2V<S$DFFie@47a~>9;ylV`WE0bYB>IIn3sPQw4NVNYUz)EED{4YXhN+RmA}<W z!%Ei|85L()3rUK&TSGH*^{S9dtBzjImUt7&7KsfrllGs6;iX1_Z2lO2?2Fq*wZVAe z-N(sMHGRI$IEw-#y-yTWyyZH14nwsEbm;go-W}x8NK?2Wekga;ySLB0u6wYL8%twp z7K6=`|AV{Drey-29Iv?loMw2W|BMpcxYeiGp+>uAq4(@;3l$=!8VwAI%E*-yGggv! z8B59p9L@cx@PT)6OUyi+xYXlP#$%=Ye}|n%b3f&%s;YojOYd;kTy_o)4naaPcZ&Z+ z#0V%ThvOL>==p_HEza8z0OdStLBTgE97%sD_EoJr8@(#*tO+S`@et5d0GvyTI{mNE zh=?(h@&7-%|4*WOxT&mgjImRnV#U0kr|Vrj5g%e*0VZDXT2=la{O94|RY7V%8cYdH zS>4arA15{S(@TR*JTV>3)pjVp!*-Sda^RLuX~*$$u7SVCD|PGn-?HStYGR$Lv#-p} zTYQuVnKLsMZbIgk&`0M5{AhKvWV5^2HFsPV4_IT)ULDR9nFrtEZwc46t2pAs+^Y{T z|Ba=3$*rC^+q7Yx9OTi8cv|r;&%0^VUun!_x^x`>s;)e<J3`5_64tmY>_f1Uo2%ej z4TppLU?KAL8ZN&&a(F4-IbdOTnF8m&rpi?I1wFOc>QA84w?hZ6bFy3Q`oGW05un34 zzrS)L=2K<y9DJpEkG%471n<M6bD^#7Nb<yaW0kFLK5u1Qm?RAkwJ3^bKGZ2ffa8uD zW`<X8^FNdUaLR(e`m6b2cxwV2YbL}O+H@(WMhj;*eS9}cS}CXC4D2@fqlqt_113hy zjF--%^}}PEI%PTS4F_h$ec6}J`*|h8UK^qs<x`VAUbVP|*G;7Y@Ff|<Rg9;kgzHpp zpW{gIf$r;K>UT)1PhgP8`ni4iN4zuQ8#4}PFLoH}#8LKnwnFZ>kMA}Ev{}pbHUty9 z+Bc)4GCzr7Jbq89UAcZC$W<b}oBMQ8XiJ+-eWJeC$RO;BrQ0m=x{jOJr0EB|Oujdu z;=qx38NbRDh1)mlAU91ksbwgRgE@|ABqW8`zY3iUoUF>YLy{}3u%+Ey;&b}dUNLlk zByKe}IgUC!Foo0=EY9>w5U+zAKZkP1#!13Db5~C%ft(?>$`YS=e?x5M2FO?czN^C4 zekPTxhZ%+rqe+lwk+y?2dz(v5uMl<mGioQ!PRA{fs#PT6td*6G<L`TLvWnn*TGcRS zs+{9j#PWoulq3)|8<ZRCkkR@2LtuA5G#_jCp-JCBkDV$`ymJeh*jn}PsY;!0o{$dh zlIhlg2~n16lo^1G_a@?G)^gXF?Cd4ZH^lYt8sf3$2Q4c>U1J@oVrtEj^+lv`%|0A_ z1PrwSaO+8;qc@1?5zBP)iof+LLZ)&W8XDN&mRc*H%6`3R+c~dR6+6HIoVz}5_|F&i ztei^P1{Eu`6~%0s@lb4gEy<lc$TagH;hCZk>UCL_AP)&>jhj<v+pzWEXdEx#wG@yG z+&{=|`xdfG?Wk<}kxK;H>GFy~`$Cv>CSv|B*_&UtocbajaV<p|R<77voXr9<rguwz z4hUQ4KP~j@1o3^kn=5X$`|9Yr4Arg1owj_ltH-{#ufGu~xcPE^q68y&*t1j&dt4y$ z)gT>|)wm`I8cHsRn58G-^;RD$5X#2)PUlvl;a-Hac~y<$mP6)$Z1|D~p^M*&fDxv1 zA506nVbFmwSw{bPH1+U?CE|{n2P%p3fDv9^7CKvDDH5wlQ6~PehGwIQ@!s*kVFT?I zK9ALBYvRf1vO{k8+3C2tGZLgyqQY}tPMJ<vsb_4LN<XT=w}FGz3AV9AOy!HRb;hM{ zhAq?3Y`+AcVbPQ%vJ;Zi0NBd7V|GLc{t{XJerl}8O%o1Ng7fc4;HfN<naC5>ARQNr zz?U_i9!SR7%)hWY9%v(2sV_rSPx+UNWsU?d1xjj3ViK^Evo1*`reo{|;XcnGYJHI4 z6u?uBR^+T9XjTS|hTbqcXA8;K(`3TI$>E<?a`N~^W`AHFUx$FV+|tx#2VAmFV9=m} zd!LMRzAt~TUOew!6n4yi?uh9FDDW~%qP{xAyA4SezLlZ}-W_STuntVfWI8xO=jHZA z=O*>@D{IBcP};boa7q40sRGEB<xn>nd;(*7^I<o*Z0|-yc^SbNk{EFZnNz~-?^S=& zPfRoZm5o@U`dS4=z&+|3ZK?$4XAGR!M3Pc+(2);=#BbVH8MTkzTp!y^OL4xS9o+lU zq1dqA_#?pS+$6Xu;%0**q2YS_=+OVR_a7d4YK;8lFtNw*`A5c&&%+tlFv#g*RWt>d z5KC!Na&j4LA;M|8h|4Ag0Z){2fXjP3iXoVrB;HH%VDh3fSekbPON2UBG{P2oVwET) zbi(kf`MMj$?P2qaSvYm#RVP0^=;Y|mXFwT|_H_KCL|<9xK#C|lw_FLdOLo%iV^9Cr z0{xYC75HV6K3X0>%BUk)5sE;DKySDj^21Z|QRMTpPEzz#VonInTz=HXDG*^pce`P= zgb~Hij4;3*ikMqRkG>fvUNjf%#W>c*>^>|xAIlMxR!si|jVKapdKO?(&H0*8L!L}m z_NlfaJ4FC)orQ9iAl|d=J}t}LqL@Wqv@wXplHoHT<$e983x043sB1F8Xzh+3*gs;w z471D>lAHXn5Xv}CxRsCsrxy5#$f?ND?>V=ocHiMJKis>v4$TOl`6e?lYH|Jp7av$& z$X5%uuL4;#xnTMM^F4;*k_+RtrtM)7Jc8zD`YUx(moElEpjG~J!s+ro8gJ#IU7r8U z;XgYN=2GrHd@8-Ia8<$6h>b#)aGVdzg7d|#`Uim>$T@uY#g0b88cyW0!<NM6sS~c0 zK8@YZhM3A@TR}fPa#2^jaZqX@=Jp=|Fqit%wXiX&JFm&@tTd%1BGwv=Jo&Gm1C$h3 zKCIQAH4*(F*XX0e)x2tlC(eYIavyJTzoF0f;JYe;<+=%wlbvP$ibg^IlT$%@1wJ^6 zH1YxlpKx%~B@=v1z|ZwQ#XVu}sPwN^Wv~79j4LXVOMc$X9mIm~)ic6JWy5#XFydl} za^$SBQN`Qeqm&FS(V!6vJkphe51lOWBdAb0@+0n%c#?6;zVyl9;tZ~!5IF0e+C;K` ziH}GwU9lSp;7ueIiKNy;LX{hM6mw}cK;@6Fd!a7h^yn$;Pk*H{Aw22bwJG^<r*w1W zuCv*v)Bv^S8e*et_4!YaZ{@{G0=}1qei2K^gxE$v%#avWHYDWZNo|N%tTTFC2!iQQ zBz(?FUJi;O$5cV)NM>yq6HM#bwJoj#V*WueGCi-MV8y{!-`~kR#QqhZZu;?!otN>a zz-9D<F@~vWsG;+&ZM!8FKe|+Vsb3AAfrkdyuu045jZd6EX%4`WAQBgBlnf)?Jul*g zLh)~!)MjS!%7bNooMnrd$gptu6Y?*dM}cR?s>PH}_orWD-oa>em{?S=;^Y5H#}zrN zNkxkE_{wsb5epb858NnWX)a{RZrGB)S_p*>)S1{3M?t>d@|`P*aVm{6dm3%p)b|pB zk(FvJ(UZ*Iby*vK{em$|Gt*%q;N+h@7HhiH6(%{5K(({dKQFSbp!p4R=sB2sZmOL2 z10|hPB={4tn7yeo4n?tH7HgX|(XbZu^NYcR74mzf*1I3=^I!IHkv50}4}J-HP>+M$ zu(^fe@p?|d0g!Xj>fUz|!5NrnxKa^+K02ier?}96C5jzEsXsEDxqoPW<h2X87&uSw z!QVel(fvH<c^;FNldYWTX*#Da&zuPTSd$}HLFbl{bbr$!hXI2`q+IXJ5mID+>P1t) z=9!3_x_L5wvvW3dR`n@`+cgi;k&=-3PW>Yic^{#jA+r~xs@xyOi~<S3w-z1Fz7&Tv z90lIL5VNM&g7X&+pS}%0J5oU3^H07(!gh|fLLC9l4C0RJ<O;;Rf*7mOIkY?|tnN9c z@9kjy4|??szur#E;*tw6i5;;TDl=dPq|XaFL6>_c14?O)Qxc9Fx<LcaE>Lzn7ZL5@ zul)dq0K%7)eR~mn+=N$4Y>qUTOb5S0mEfhg_OZQirrY%9iIFR~5k{S+?_caW>Fx%Q zstVpb7#w>xbovT2Dp>g%29Cu``BZjmCRQ1Aezjq%FVQb+j^Y$v1@S%b$l*mP<@IDA zqop@>%(UazP=oKHoRq!onob_Y#oD$fMTG5?tw~*P`ew3#8Q~~iX4KJBL=uLAq%N+X zq~nld$WAyg{5BnKKxco<=z01RzO~d3!{Fl_gm{F$v!_*sebS3<hK`$2>e-;3ovYwB zB?>Q!j|I^qzcue&4a{%tJ|(t~E<*PoWTo@nOQHiCY@wT2VHX@62h?CFi#$uU2O`xo z#ZMFz;RsmvejugT#|ZF(fptLti#7)`i*rFzn-o=28*65TqE5twX+J}70Ih#m+JThY z!S|@43L+4RqS+`)yo!cS6xX!G4!@z~j3q`vw|D3=cv9?39gN$rAdQ;C2E5xc=2I0Z zHu@4gx2+bx%y^Z{*@9Q2t=rs5huU!5$W9f>>yP?axuf)CIYAl*y1<Pyr?@9+tY01l zxKh%6wQxd}3U?N2w;i$)Q!J!uje9KFJ@nEHp-41=iwJwdm0=g;swonlO}{GFbrl{U zOkEgt`3RUE7!DoWv<Y8u7TIn1I=cAl0tOuK`st49>(lwM#4zdI)j2Y73n<Jjs2A5I z6#_6+B+Lv_<;`$eMbd04+|s!g6eG#ATkfzZZSotm_d^nwc{8<?Et1S&2mpcgMM3>T zLZh)KjL{GA7UxfP6GYLb9q|sQmAFgpxX1jYsXr~;mLDR_*p2Om!lENhp-UJQuM-yE zz9OoU;M_bxf95k~R)1K+dkK#@ar22{1z&W@3f@<w=Vo1=X{O_UijgmM#Nh_75GdC2 zDz>g_s10)~*-T*9hPn#eBHQ#%f4nf3{2-+7a1>19kOBF8p^Yt`a40m{z|hE-z_Otf zVuHu_EA50p9ntAGQLsX1{lc6cnfLqV$V$@u_a($9yn~sOA=(`1O13m3zL+M24~@$T z-M$x7NX3&Fd~pBL$NJ$&RZw$TXuU$fX?&HBIA`j8gqQ2nL!zQOa*T@*Gos*CG=CKN zbXa`~OJ(W^Zw0?pXWk9-6OD3Q`I2?3Ji?8DF$}ps!0~~ID-j!x2>3fHGjp=I&+N}^ za|n6ta)vUL6zQ1En*NN#zcQ0BMm0EqIXd7Ai=*vjH%PchDlVBN91tahck0Zt+!I@1 zyldMzp>a^PJ-#QXcG30hdntHXeRypPStFP;4S2y05GIk1C*1COy=b42L=&oAu`gY) z#f-klQ~SN571=ep_P09Ls!S!`VqViI3Nf=YIr}+7<LwraxD_sB&c#^>p;;%Y6T~o2 z8P=loDeVZzoEMZdrsSH+%A5aJeO^t*|EN)$0etEP^z`*Vg$JRqa&~qMa=-89A1wYC zgyJUmlYKPkF?|Yo!o#5jbB*2u=*S#tf2+xQd$%ACo$XQzO6CC;Z1<%oL!Y$%i*krh z!O_2!3jVvq{eMd--APap0_lKu-Q85}M|ba1|Do3h%Q7Fv&`H;V8626T%Rh$GQ|{$+ zuLS`!KqQ{?0jGoLV(UAUf;ENwRuH#MEBuOX7ls4r7FaDIMp-$3=56+ZoQ0_B{-3;I zw<C9fGc{RNB|u0%u*%-A{;%I@+rt*T&;oBWb;x~2kD$=RCo@`-&2)Ih|F_urPo7Tc zv%#xJ8m+bBFcYU_klU&8bN4o&-^6i+rx}TCz$+{CP}{V|^7eL+@NGc$G_5K$L092| zokGu#>v<)s%+Y1;L^CBZQ+_CftcMTZ(}LCRnN5#5JmqQtq0+llK0U`N-+x0-?o`j3 zD!ZkXL5GcR@8<A-Bb@A-3pCI*Vp>+%t70xbaT6_|axvvb7_#wY{C4~%IUicVr@ACJ z8&0I)<*6<#4HpKZUz>@ORI~C~m9lh?>l_A*4e$0pLzw`*c5egi-a(<LJ6WH}3Qb2w zNp-yX-~Gigj<`H%V5fOw){%z27dY<DU74C3-0Ci)GCKl;8YpAtkMh2}no66@2u(1% zTZJj9wKhM`CMfo-h3j*f`q@p{d<XXzc<jdtequ&%au(V!)9j9@DeS6OGiDb3Xk=MU ze|qhtl=^-<O@C0uEN*-UX7E`dOPP>tPvmdVR|$Au(|yw++=sysfT1y|IIHeZ`K6BN zCckW_5>k~N8)jEFeOR!zAdc7hPc#z}P~lOUjb;AU)P6HT;qaD68~5QG;%4_pgmFzw z!#3wrl#?8Ks981*xX3r3++QtlyCkRX*aZb`WaIo5)RTO+gQ?3E3?Dp}+6}+*!%$(; z9j#O**$QU6R?x>yz!tbep>g!j?jGT?<!!swG<;z`ClolLbnHSy5|u#rsi6YrmTSkJ zP?MYhaOvb_OR<4fWxGX~+(W$g;}H|!8sQgEz4^~+zRI*S)zoL(#vq->+?K+PFRhlF z9oYyR`qCGXXD1bTlwxYL<k6?APa}Yg5I{hapt#$4U0l%3QU^F$=#g08ZCh&{<~aGO zH{l3M9QDVx^%E-z^AFnrLC=od$o(Ap@^YS(wgriYYA8|I@+R!%kMGT={Q54&XM%(O z91J(?HxXiG3QB2LNX(3AJf&gr!&2DA-oO{-H&P$}?*k>IMV$cL3@_bjT`XDYYI5E1 z%?$C7F~ABm1_FXUUU_O={~3(bno%>gt=$wFj~iP(4FMkB?Du^I2FHBUF~I`|H+Fw9 zc7#+WQg8xA<2%b7y`4L<dBW1`CCqcIN;$)rk(r6g7v4LXv6aAiV?e{d<YN}gZ5fR@ zVo*kpSQKHOb`n!wpT-|GZr*`jkE3GDY=uuLO0(#Ydltjo@`MpFY8CMMs2Kx+&Vrt^ z?Azf{-u1YtLQ&6)4}3P<DJSJjj>C1wS4*hP^O4VMwe;*A3rUo|=H(2|m!TG_6l7(N z48Se}V;ZHVrYu%Z2rU&oFbZL5m;<0>@vH5d7XH0B?STj7aj%;9j-}FK*3~<}!|XM3 z<6^AhfA;;;S?muw!mv?sMK<>u*mRz?E+^Uq@9l0SAfd@zBWXC4u!SgN8;s8q?66Ng zt9ElUQO#1!+_6MO=XDMDXJs_#+vP=3VFyYFsC#Y&ukws?7isnk^>)m@%|u-;BxzP^ zhe)<5u}O~0cKvC(s0A()vr(38^J_AgZitWQ^YizzqfTM_I9&B&*}Ja#^tg0pvZT~x zjb-y;<3yWil|x>lv2pigAW8mLsCxpE;XtE9?|37QrTh2E!E7Ht!zDxtS2`a-;xkVH z?6IDu9`{u(35kKk9XfJyGns2lG&Hti@94-#Ts1*S2?<2HTyk>qa7<-1G&J#OKxAYj z*`WG=Pxm0VRvJ>*E<s`xLqo$*v4SxntfcxsdQ}5MLp$#LERPy{g0#8lGoROo<B2HP z*pf<1OUHzWlIr2VKr}Qr?_PUjPQ#YWHKEV6wYA~0nT7Vzp;6G&N25_lO)o5Xv;xQv zzh3{0#D)*UREpwNRaI>pN8SEUCKk-W=K6110GaDp*4_txEfMYrxQty~9_AD}pRqDK z6o_d(wlF>dA=GLL(1qATSodGpjWfTdP>1uwHbL|!8^6xv9Z3{czXyBEDTaJAD}oP} zHa;gv&4h7FPbIQOkkLrL{HYQ!wI+TiqTKqKTqGnHyD9ZDp9wLPz*`vbBb>tlQ1$}` zIx{5FfHT^P^0>O_sbHUwurm><=G=6OU`?03?bjd43LE5y_B^VbA8}B{dR>1>rET7; zvP5zo%!HP281i2C&2lResOwFz2)OOH25E-At=$q%f&30})=e;wmXwM*%70#evr^-{ zL@s363-#l-b|X1{Z@0kYo7!VNL3n4}S1YfsKDgTM2QZ2FKe0M>Js<qeOEl1Up@Ksj z=&X+pXdbsZ+%rD|Tb9jj3?+XBFNAo%z^w+@YZ0xq%j02qs^2QWWo7w)sbzB&1DCg? z`Q}7x+BXGJY_t}4c%&MS+!^x&aA*?bz}L;|4HhluEZiJg+0s>KeA4_z!`vjaZ@MY} zT)g_dX+go5KXi5-8N2c(iI9L)1{DajOfpW78-w0Js-QEfLzv17t|?Br^%mBpVD42q zwaI^IB*l}rO!f@8BtN}B-V;RhdqrLlBf7UYcw|@HAgdip`6%*HkPlx4<Q7sLK#LiP zd{6}L)RT~1o;qmQJNCQ|W9RDIP&wHX4AxMqH1R^N^=?$^8p`V@{A$VC<rQ1M1~1ib z4qpZOz@f$286dY}U>v<YlhX^JyWF|&i1>c#;*OKHqM7~En!0#FYB##;&B+p9mB5ww zVMc7zMcq`rAt!E?sG<xjJ*seXP(nl^$y{`DVq0EO;d48|<8w1YgD33IJ54aTP@#SM zsgH$pKFskl8qysNE2fx=L>t&R>HXy|m_5vg$H5jN@!Dh8_)(dd*#gnjp*As@CMWAi zYwja{X=>X8`7L^}*3yuQPu#fBHUVWR0O5ICk>c6(`f&S;l2PEPka<F>)l$%dBj7pB zuZ5cd0SEQyPm*fG4-1w`6QeV3xu%xh_Lx67g6sAEGz&Fn=I`&|<++>Thh}Fb>O`J% zrm~dRKUMUdlxQgUkdL<ZvGlKj?_U@gwA4~h3oR6}?uAL$qaH6yR5f%YRua^Ymb<dJ zrTKk0)SM|5%&z<K-uo85p0DcJr;O?7*2QLrqHZetIk`C1<X;5s)mEBOG2~74FKG0Z zlx$8tc|1_E*3?GM0^FRr&hxALzrMs?Qw|lgIn8+Qyb`&z0m}#98wE%6SgiJM`PQc! zLSEuy+F$q9*L@Ci5J`ly`y)_p_fnKypRTr3`uO~xoWpMEizudK%)md){``m-^4e(W z3{wEkH6KlZn~9wK$O6}<2fBkTM|l~=Tq<|Dtve#(0ZC*RE`0rgP3pHzDV2>%PpE~- z95P;D%Q8v?vcp4`ZBCBG*N<^6L4slQU=V~zQrpNOw`O_qZ^=k~y}eG4P16VRX0y#i zU=kp4Y%ha6<*^9VDOU6@VxV95whOPX9&~s0kwd{?*1*%6ukzMVf+Eef2hX&u`Yjp% z8`fU;oEI>cv|q3jV6oek8VT8hFP$wE`G||?iM-5Z%Ekxtp#EqJNfe=HBZNIL<95+5 z#+<v|AMww$b72otl!avt4A!HZtMwXBT$NRsSqC}HmT{jwa4t$L{dr%B0(_SryzzQT zsz~n}JRrXGd$JQ(Sjy^MCg(S2^-SYhLok}026=_Bu3i46Zv7M9X2_k1QLl>&>F<4V z6}-vT*Tcl8p{4YHwD*=#aRqIcC<(!W1_&;}gNNYmZXpodHMrAwCqR(k?vM~PxVyV{ zaCg_HacE>t-tV1zzkBDM`7vu|eKWJB*J@bZaO%{ls&i^TyY_yH4*AW|uSHVnk-UZ| z!rd;5dBsC<xyIY`-k<r(EPm`?TLv>WZwVLYp%dA?Gxaj4q4f3+{EdTtX7H95A$)sF z{zG$COO#X#JiF>JHQZFQc)7+!Bzw|vXknvE2FV|w8<{h-I5O)Z-;4o%(GhqtqgN5J z5fLhAvY{;Kw<cj2FGfvp_;|gHT6-(9D$yx-mG6mNQoBzu4F|&W3k=U>|KX7QIKSrf z>BLf6u;_!-2%>Ux;o(u{V%cn3*pmm-)s{w_Xo<;Wxlgp6a5=uq(`NEsO)2-c5hYUh zL!R+dc}JW3<4X<uC3M~<r#U>iP=}u<<m?DP1Kp1vdZYt}oycTy<)a9$+Gd+G$Bd5i zxL@F?PRNnMjmm#<V%+G>X(vGPlQ_*G&x0g0?rHXNxBW!XL{oZioULgWmTD-lItzl& z6T%NX#X_(t%bX0i-L{D3$%yeE+C`GuKXh#kvH6;i!s=Hf#cw4(hDaPD6w~gCD2{RV zZmE<!SHv%avj4pBAmzf~krP@aBBy=o+{e&q{>G0M$XXwJ7hNRPYEL6rx~i7CENjq- z|GULlHC=VzoMEntk;z3}TObq7q$74Hhr!aRs7<xY9csFx);m)`@uQ2fhv1lc8zuT| z0OV7yy#n8q*Z4$mb_)4Q1<MtE7ma~3MG}b%xw-AHH|upi$HX{<ms@xFMLR(k6uzH( zAZTuIo((8gPIwu|C}S$W8LUjq^U@?pX2_iLzr&V+-M6#l{OJ2S?SbeF6BlTfc()aP z^tw^|4n?EPaQ5cm5rW)AMTP~)3@#G+--o^_J#=BTMAO^3BG2beh-sutJ$^lparT&p ze$slqvRSw2&@A;UOrOvD#Bd%!4Ia(p;TCI$i~{z|J6)wE*@^NeZR0F0kc<2LP`Ry1 zYdsDtdGoP8;Sqj!uLWF>0)r^Duuy$nfwO-PiRf~4N;RK<?U;wYbwNUV@m#xfW>^@L zANzZORL$I+_QK-g>knZ>U&X#$U3r+Anf(K`>|sjcj%TPa0M4128BGbQhc@!YGoTpY z@0T1Ms0E%1J+1<o-c-1;iV6kx|C2x$^gJPS7v{Nla4?Z<qA2wz*~Ew(Up4GAP${$i zsMwSgISq}e?X0K)9i->afXje+3cdXN0%(=6&j1{YAmWf%PY8YW`gL#UD;l7XrX}^* z^B<lHQt00#UKU7Y6c)ZUuncwjuOLKx9T*00weQB~K4HTUsQwAKBy~Evynr8rD<LLT z%hll!lh1?v7DQOZZrAqP%8F{(@Q{4ofIpL;bjiq#AX$tTp2cdS2XADH##!g#*;cmj z@IriaZ3L^L>kWhc5Fn>9c^O+TH<t3Q&0z{<tBZFNdY-tQ6#Y0TU5Q<0fBoS`p`S3b z_uG0gfS{Ok+d9l%HKs{v2n9lCJ`i#SZ{^z@2tZ7}iBhiBm&&q_tR=a<<ROT$;V`P* z473z@-U<dOm#@tamZ5VNgm1hpZFfH}m^36q(W4-i7<K33`{-1AoZki~)7;ztG3vL~ zh2cOfJ;uo&arYdOITpRJx96jte~1$9b<qqXv{Z5F{G_Gj3QRwfNt7bKDZHz`=M$=O zY`;+fL?v<nn!$UDPBd@NR-7(&sIt12DXcYpX%Y3hBzybCQc3nFIaL!RaJv^LiqNwm z3ed+iOt)h*uM<m>W!d%OYqh%(vbf_lFI#SlM4!`peuOcM9pSbZjDnC+SHeF;w!32L zUygQN`5G#sj+W2&y?@8pqrh$U+$U{PhwhHgE1@iAI_{hXoe0l{<?;!V-Up7B%ujx6 zHhDH|rWx}fPC=)ZSCV~9V!q^1x4lyZ*$)uC**kdo?o2k1<#vUUGXILZy0Ia`p7JjA zPJyY+Y?KeIJ1Sv>hBpIu@&X46I=|BMH?^AN{B(3XC%XnruZ~7?`)0&^qzWmg)d)qi z9-I8$3BM$vIcxj;b4Ge&>{t|Ea&R*stW?ZpAiitGhoq;T-ns&z5g*2E4bne#Y5R&H zAJP^(13rnwnIXPF+kh{!oS|1c&;MvP;qGPjSij{wO>}difH2th(0L!MSv~2&opXi7 zcS2MvH%GDFLyse4e9ozVml-JS2l4#~BoAjs6=_Hz_196f1;uc0I|~V5?b+n~{*1z! z{i1R?MS3Mio>oe7S1`TMyXb2aj<(rE3J;jxx?8Y_RaarMmItd=yib*39HxZ0&#uzl zo*%I6$Bp5QJ#I3ZQ{ziA?x<P0Q>%HO9gOP7m^HE_OH5g2F)1j!XJ@F8XvRTI$(A>i zB)i{pBi?uU5k93})3YQtaM%y-QT4r|*@}!5^a5Qp%(s?(2LNX$u5wq8&{-d5oiZx< z(rDUrZOx^2STlbV&4oStl=yNJ_tfK<V(4f6O_zbp6tRoEyGlcG09Y0QSbmgub!(6z z+Z-5zVvc-T<%XkhXghXCCTPWr*ou+x;})5h_hF&+jZa^sYpg#=z(CN}TeTmHDYyiQ zlW4ysB%q*R;|})^@f#S7^=RqAaZ8&=t+pQ}bMdWLej>+H)wct!x@x<U^r{VePlPUV zH$Qi@+H!l7$Jr>yS$Pkbcb(l*SJK&S#C=tGQS^3<Z%Ni@xeSMeJNNV==p+B2?DULx zz-mf&i*qF{M7Oi6@(NM_%6ER6`0Cgrq*<cdodlNO?SRx}UTLR;HM<VMS{+GR9#z{6 zFt=J1m8Biq`q)+nUcvC{J#Tl?Qbxr>|Dv=^J#?p5gWj^h)@yW6W2JGDnUZ2!t35@H zW7v9=Bb8{2HLWPiqfkc2;ILzB$kD6IqdSN(RNucb#J0N1>DgB{QetUl?W?YrJG~CS z%)?1rn&0|#b_)-KK0XYo%m9)0!G2r6H{%USp>5P=lw&_9lW-W#Q)ThB+tClQTnLe> zsS?RKK_tVYVh7~|?HK!RFJUsr;K$+Nr~QzVWeo=>&I5++l9IQ(<Bp9*x~=IdUz`~H zwJ3^b5;%^b(T==P5h03<db%<?om{vfJ+b^CTr^>L(E~QolR5wPHZ$YUY4v99Ly(M$ z=}vv{`jDa0nEgYXTat4tm^U(~i+adEL!$p`xj<MGq)b<v1WPE>9UNkKkM>ZN6nxcL zH~;?!-|)KX)_|s`*zT(Di6y=w*jnmH>1mGwqRpN_0P-Hd%vh8nBLI#Z1W={w%UvKi zd_7jI)YDT9k2K(7h}taUtMC&Hyy!TzTO{OKl-nRWAu&NTB}yd<haA$(-Ls*@LDxfg z=MhND!sdO?yFu0>$l9j2JD&;bzN*wCE!+9GM3l+rZ57OSW_8Bx=Hpym@$hJ|SzVSr z(c_T$`dg7*JDL)wPJnE9$K;PVRtINox*ZNyYdPozi3N=n2k7CX*-tqLdZtW8SRK#H zbRZv{!qd}|XR2F#Nk`k>2=^CkD{QA@Aqc|8Y+(=k`sJwoF|dbNu9LEULc81@dj=iQ zH3hLOomI>ULPcx_Ph1ozecz$10D$xw51ud8Ptv<GK1Gr}bc-(~@8MU}w`J9lLf^0= zmkfg5F->)pU1Jz+IV#W`do!<fxW^+tsj>g8BUs|Kih1RTRMHbY{06q>zu=7!9D91{ zS%N4Gl5num(ip#8mK~Ij3f|*#0jGtPTJzSjf2zDZRtXRegh2KCkc>UaqOxcT^ouVi z&f2mQDy{htOy7HM-RjGblbEN2KLgQ%)PntZ>u`U5yfM;VZqY3cIh<(#Z2l^o!Y8VA zSzJJva8Cmi1ca0~VKif#&yFd_J{4;koG=a{5l|J_FHaraV}8AQ+IfV2x@5ak=WR1; zQMsNwiieV5O`Io-^O%Kod2CSfprRl*ZaS;Y*&(t*Emt{jry|}OKlu5@9+psnPb}?L z(<Sa$iAN$XE6Uf&C>D%2vtV~~MQS1zap3dYhcxDV^&4>}^>*Gg4m`Z|hixa{Cv6oS z*NO)o1UUITt=7zk`e7MYQyz4)FW9P$>TmuC$`Ccsg$xZ9^&kZKx%vDGy?s85FzWu{ zT9dg`6)RR0YJni(&)x}M4ZqeSZUymEz?@=?J!76u=SsBU51cPf_lbjRwNY+~y{)yi z#FsDm6ILb(8&3q12&PR*)1?=*pl__T8I+Z+=B`rvguNyZSp*+ox1#&BZe^lA;iZnn zRBzI18n?ITuUGKTywI?mGY+@P1o{ydo(9y{TfC+FVs}x>&zl%BCo-GoA9Po<G0&Y_ z^|}v_jX`t!H>U_9Z*Im#R)nXnpPPhr5nMRuOD)97a#r2C`;V_QN9|6N_`9mVACyGV zqmeu8lNb`3XMyh%sbqxuwa50Q*zN49(AnAvk&mLF1|h!q@)rGtD*sDv%6iK1@F{#s zxCb}hivSecxy!|`6c#AZHlKtkIN^IU3FOrXo4nf(#$S>l>PRA@0_1kLe>AEbLDfxF ziCfTu>^bbO`39|yJ1;@ihqaa)?U~<&`4hLWZRaiyS7`Jg%-@!tZs-K;;p2u^9#N(% zLxf(=%Ft{~Rn^Vl)#=J>ywIq+@%i;W?4SkG&v&_w!ss|yna)N!vh?eBpV{60X5R^} zE+`grh6PatmcHt0J?H0fw>;g;NPv`M`cFxu(*&koTg3V6k!9LD1Y{$lNjQE9WT5<( z7QJXhCXc46>pwl=BXv+(P?h`rffu7{w463}>$3^eXJuu(eL>L{^-Wf8EYDTDp{y0r zX?;cI+iYc4aAtvh^db1gZzscL>a_t*xy|BcRC6|})>S%5hcU~8J!QL<`Jo7F&W2)R z{z(Rw{>NzynU#raQ3tqO8*zQweb}&eooCEwzp}-kD%QuU&+n%Zj0S(Kg%R_i_*jc- z%6_t-X`5psSgJ3*F6`xr%h<JgH)$ww;^*G8@GG=+T-r{H*Felyfr(sI)$*$aIyyw) zq_O4ol#Go7BS=`SYb(r{GFHw`3BT?w-|c-_c**|$eT}ccB=15P$w8Nw>lZdvoTEUy zG|x5;0lCM(N#ZIGbXQztJqMagO7$@BMHvs>b{ewg=%xTh4X@5Dp6rQ1l?n@Wjjj9& zHvzn^FEokLsv~P3eY2&8v%Do%2kA<mnzd!jV{U78ZC0H)A5r4vd`$3=u<g6NB}u|~ zFNKqSqS5l#RdGRvHuwYWgy`d!bV3S!9bVO%RP^P=oNvN<I0p1OeMsi+K9WA#wzBw{ zgP^Yc8>SOxo5WAsFTYb-cX0IdRgmgq2yXPE_e3iFIPaClSMM9Cjye=B@Y_hskeyjj zczG6}UD_?zB$|w&MJA8vfY%4fmilBth5fYjeRGGDXy)4JBnVEizEYcrzk7^O(qpw1 zn`|mP`3L;^U&ee|g)^DO{HN26=BuWGCju{bPpJJi-&Y|=ZhRxibvIz^Wic6EYEz%Z zq%(JrxAc*|TC;EKSn+`hJ2W5HDRQL^eEYR~k~q2vGp=8()JAS=!+$25^&V$8QnHXK zb8P3G7)w#3BAWqUHxM3X4$}|gCw<Z%%+`7Rs*%rai}986lTWf-9#F!uoorStEST!m zt61jHe-h^jp_a9}XfMXDybNNQ72n`=*h~w?)1+=NzGCI!Ndm$_Bn@hrH#k39TQle8 z<^Xb=AKSaWREn!$GOT~|XKiibhrMz*;vFt=i6(PpWu-T#WstfM-3EWAN-Iv-0A1c> z-TxdSe&X6oq37IF$j#k-f4G8<dT+R*9193%xuW|6SE2p;Za{hnwre-3F(&+%uzu0U zSJC~yzR0}mRj1a~(jw$t@uZmrk_2sMeIMYj)@Jd&hxLEAdI8it=bHpoS@VfNS>sOH zUn<|#W7&On|Gi|;Bx$hJzg}24f4R2U_+`<NQr6OvK6Q1NmeZ9i6>qol2s3)zBP~0# zqvd${KeO!+kmWzwb|c>hdu(NsTSo|pf|^>3pmp&|fg}IRf>kx&qrCL09-R`n={tv6 z^1Hr^&G5gWUA}331~fNn;!clO={D!E|FnBU;rZ2hX2{Ph?<A3$><GehIYA4`zX9Y- zoaETtXFl!WiKm9Iy`l;nbDk)I3bxL|X^KNQpBFzUNqWEHe0tWQIvgzFq5t$k`kD^` zwRW~L-vz~9qytxWAwtM<#WJt#$mDlgAVVF=%WrZim2nej&J59Y1##TvHEWo9VUPhT z=n8F)YGz#dw^HWf!Y4n}ocNS0CpJKUsto2;G~(;IvEZl8yt+qS_pc2YT6PG~4pV7M zZkgZj(Rp*B#`@Y8w0R|Yq17KR$c{G~!x?#;XCjHDmo9)de%;W%P2@yr$hD4~1^)5% z;#n3-f98cRf<*go<9&wfk6g|b<a}$Ig{->YL6-cSPa2(w&2evyGMw)q@?}f{XD!r7 zFfYFsIXM+NUw)*gZSX6l@_j?YD~NCZNlb~bd{ms$pMZv!@}4<+loH8X3wu_br#PbT zU5%!a$(`z1>SL?YPV3e)Rq|nqw635*^%SSo=bG))%gtkzGi@C7?@Ttx6g--o+iNn6 z$b>rBzbnj5&x8Fbcp#ZwXV`a-`=utGK^BM+*COq2hw^^E-dpKE3(4o)ZevtR5$eAw zyp!0!FN}=Z%*MI#Beh>yBZh+0y5ZJgD%2*ZI3Cqco1PyZDNbuEeJrRT?c;;HdPnB7 z5D%O?L&m@4blaP-8Fy0ay)gP;#)x`#b|%9~toKklod$;?^92Kjka$%aopXg4STD2A ztuI9xh<Sd&_n@Ctg3G=vmyRm>?0aIuV+Ul$y?K92gGyj^UIEr_m)pxbAWJJUFFk$o zIiJ-)-s|>Pu3t-RDKzNKk>%lm0GTM0+4k@E`STz(olTL=xt02viv4&lg5+{@3vrSY zaB0p8Z-+=r3d^qnFZvKQs~goVc?gO`4VRC+90yv#eeFc-Zlb9XaVjWToKNhHF~j2` zh~p{MD_8eNZ52TNQ`fb`g|hBhx~@D=TCK|b)<47uH^yMDgLJr;#9Fu>!Z0hWAx1LQ zc^<?nLUuldFD=WhtkTGZxVJ<#`pUK}8tMqDQ^}Z&2H)gY2j5dV_O&`tiTc*Q_?{f| znzthu1$OJXZ0A$#IDf))wX838`P-aNySu7%)bqr{Wn6^%(VF3s8GYXENBmlh)QLfi ztfeYz`U<~<)%nNY!Tywsbp-da*lPC^?=1bBg1-l{ZJbK}iDtDRXvD(}#o|4^Um~Yv zVKuwIT(h_7Ov{+{(O9?6VWkiAVO{gLC3MijYLsM}_cO)i!G#W*Ozo}Eu-UeRH#niM zP^@|KN!Z&x%=_c+u+Z!$NWOM-#mh9xmP$kHknRJK0UF`ptsn>#9~cxM=UioX6C|?L zu{YMPtgLNH@9!vK*GE>@SvbV!1)T>`{)g8-z6eyJj}#raJ(P7Oq!VyHJvkbXIrN2k zU!>_~6TJX^2r{u?;NJC8L!XF322!Xh!)9Od{8KZ|{%Vzf8+JOAmuGW!Zu{lr?Jf;j zWnOIcXA=4GKeuD_zYT4E(7c#Q;mRM)mS1pMb3*)7Tt6{I{@j+#`g)h+oTm`XmYrM< zPO#v_<va7{=!NE1dR7LOMqg5R(Lc;$F+IAoX9`nENaCh&fhmX|{UIp*rY;W0#!n1v zCq&PDKdMeIB>VIeZ&06i3iYjGkUtsrrp@ZYz$DZcd7kfGDJ||rGiEtIQ73G8<q@fO zoLE&?-0IHXYg3f3X7mcz&ppGAgf=<*pr^JKK-<7rIB+#W6oIrb%6imf%!<4?VV<mQ zM7xF5xbKtCZ%F26k2)W^$VlK0=r8P+rdCGBjo@;b>Eak-`aHviL&!@dPtUQpdpsX{ zR??#E$5m4w;mr+)R~$-SpGSJp8N@2^4)-id^w)lDCQ7`ty^60i#^r4P)Q>f5>e7aX z*`mBGxR1Y_`~|+dhRIOX@Gic=2}Ll=JBl1_dxVVt`_e9MB|pM_O$)X>r#H>S{E~#5 z3%zE?<%5vR*E-AxhV?h=g8iy!Ax===R01vJNwVzK?q(}ypUo4C`PtF};UF5>dyH^$ zkB!xC(;QemIn|4dhU<b%Prq^6%(;BT{tu#=%VJNSSn_6;;v_c*g@>Y{d8?b%<{Y+m z!m8V&`9lOf0`Eg6@R_+l8<abybFl29Zt;t8m8c+O(nUzcFCMa}&FYB0bp*$H$_ijq zV2httBqsEKm#c;su^UMpgQFPsXS|ts8|0Z;tel11TG|o{G@*T$QZF%!K^;9GHR)x$ z*9DT2G}N+BQTJV4KYO-(Pj*!LwVkX<FPtjhlK0cmS5%q1IW()``<bH-?9m2~R1YiG zS+#w)+kX6X++Scr-APAazSV;{b6>iog&FHfFcIHjb(kDmRvk|H?s3p%;^@z<4HZ)P zF@x<_+m3=C`pftb=1AdK;%G>LvnJKBEI^L1ioLUA*sQck%Dr5n+dHOz#}$moo>5iS z+b5PR`-?@b6^w?cRy49&5mEB9HQaYuB*)a(4n911cq2q{Go-WQeUB;MiSVH6pG8zo zxyb4|S0x<2Q-4^rTA#Lr>5gx2(3SGjig+`;iY{&j6v_ISPg+57>RN|aQ?@t}idfMT zf5u(t+m0yHhetlC6_Q181;=Xy@qJ0oCtzm8&Kgc+6}2xHQtfRH8-W`UXYhq$H_z8L z$Jg*%^C9aVPpVtl1-iCI`HjssmoiWo?LCxg)Y4WqxX{Z>3KZ&9CVbBfOIYdAxnAav zv|r!y^F(dnh?No)<8to)1%p2XE$BK`ncP_XXDz^LM(4)O%1s{-#5*q<E<AqD%PWnK z0<)T{t<p<Uk%ovod!&k7BF1F-UD$gm#!uXRdw(m>RklSs*T9yY2hHO+m~pf8yS`X+ zzi+~nJ!{qWA^CheC*3!<lC5!3@51<`3H5UUlgO%1I@o~0s32E{PTNbpfJfc^wB5vk z)X}be5C2M2yiyawUyhME{yvyl@78S1Z<pp-z`O$kHRb}3j+iHJ)@*k!{Z2UB2l(UU zTZ>}Tjse=MuGFgYfo{qxGnZa={6@wMYQ1`5kg%1cwu^-N$8|yGc&_}o>%jzi`4OTA z=fmMUm$Wh94dEyCix|lmy6@kKj|zzr^^47PjcPL&F1G@`9k4zO@t+O4-of8^cF$T9 z6n97Nj!Au#XJXIs8s(#Bt`j}uLo~85CfB!#2D7bw*-V6P)8KVcm#v=mXg6SCk0T=f z<be(atC<oSc7AEt(w`G1xY&a1RA?<qYZYUTQUWuNQ2w#`pn2cDLF93P%3!&1xzX+c zm|p`U$s;!x-WWn*6*n;N&h=0cFUsfIB@7ghUD?S%rBij(`M8uTH~wwN^&`NV^|bmG zC<}`22mRC(z^2%k%I#fM@A;i~KF~nCRL)M<-a-MDzuq%{g60rUgOJy@3R<$qu-JbB z9D#_Y_%hUFP77*$|Kx^wll|%T@Q0`2PyK7wtq|~U%UfoOIM-~t-QmT7OunyZ)k)*A zwL#zN7b~KfjR*HuW5BEU`9j*#vlVmI_K8KR_-nql&*B+WjL)8IXm2Z@SCPf=)h95v zZ7XF9ePcYg4IO2EBS@d+7=iWiVa;u({&F=cGXieal{BxE!uJgqBs6H{C2POYLl!6c z*8FUXQ7N^RWOd7H$;^rTQexi^QFB$X7zy>8O3R<7)%ivE&MPB4_x9l2EzdVW?Td<U zF;0vqsYUXxL+LIa{qK!Pds8VTLX#p@EBEI-zn}GQ3S6e`Ko|JpVuFTU&IJ1VU1<x} zK9~LKM89Y#vOSG+(_6hUbX=_+l#=l)jc@f4Kz+9Iw4^wE$=Ler!Uu+Rwe#ayUwAVN zIf{#S?c~|%<kVE~COzT!7;Rja@)YGMqQCCdQOFkl1`7G2!bU0E(xuNn24eP<`n5Cl zuQp3zYf7v>f%j;$vOYzJJ`Ae6dOp_f^szYUaub)VN8ZcIr~uF1IdQK*;@b)?U&50e zoAjdP)~c+WUj?gH`|MNy#_Ef|TDIOAX$jn0rQDEfSUvAtlnUoAx^6m;c@b9zQh!o$ zG<;A_e`{zUyDQ6D!~Z_T%ZPrzzIS|nhyKR;&rzX%8Hs?s4^oAycIP6ViVh!F;(ex% z%Dt-%j37U&jlQUnxo}L_-3xhmWnk~Y*6>n(merouqSD-8W<Q=NZD6WEm6C?LJWbdO zam4F_Ow%sg^$wFElCXQ~)3hI$vb*b(nYQO0NwUV$zzYE*eJKtL{5;y&({U1tw;|bv z*yHO2GeVjnyyi6PSwb!j$X)OvUpExAyv7^Rwy$cO2ml6jk8`@#SxMY4HR~rOcdH7P zuAaGIt8PfCD@lOOO721hi3un{XR+p<qKV-iYr499xi>6355}0UrLn8gn4QgBylCfN zdKT&3;$W_N#pX2U$ln%_>1qn~=Ll1Usu)jnZ-yZXc(CT3{-Nbzkt6ku>Ze?tc;6R2 zYO>P*T7%^`jrq5|AlR0&+gBv|2Un8UqReaSI8v)A@+%Q52v|HkX09nSn&QqYSQY$o zx8uKZvE8dLdqji+Q<Pk+$aBsbrKf^BNs=YTqJ8QGAJ@qQv_v5}!WaG*Rt^;Vl1p|5 zda_G?dASAvC8tRZWNRMR@V@u14!j5hRXkZC3AN0BpB9LyA@*uuSv&R-5rU128q23G zg6@Q(04uy&;}s*t3f9a|Hu{Hi&y8{r=i`&chkH51C@P-d<u%i}S5QXTinG}blJ?c7 zc`@FgZWCXT?6&u^WuKv~*A#$~Tmb6$$@$6mRiY$C-pJm$AGOhlRkmN>;{&`A*hhu! zTEiTNA{wQ6IjZ;lskCoq-Rr+fIUp+ka+p2D*t`^v6C3)I=D)3z8=5XC$jak?&6NR7 z{u)B+`6yv0D^(`&Z>y$$W9nrp*&ZLX)H*clDaquQ1VxiC4m+QWYBe6A0$AkeuU^W; zzhjJUY2NtRKYHT+30UGmPx_2YzZ1)Qbv}BFZD+A>!wb5<P<t<naUZ@Uy;U9(CA58j zFBEXB7tUC&J-7jNt8$hkH*Dd$uL`?K9e&GhG&q!<yH++Ytw*%Poj9f2r`}`($ZZB_ zFNSqV?)>XL_u4&9?{&YaONkVC4t*N*IDY%SZTZ@^tv`SBuHxHFMPNjW&_vXU#Wk~D zk#`=NqbjPXq3+6c{QEkn54YJr2<nNPuOIL}S?KNG-+8{rS9!Jn%X4UYU-6-ha$*8b zH&N$=Qmgv<<82n9tv)sA{!J;=hfekBBjXc8=1n((v-!?;?4O#jWYGyi%5XXMUiC5s zaYtrl6d$u)zw)n9>G4iTy4E()46ZY@@B4Ds_CDv);J?_AQ>d6YD<-%{Sn+03{Qdd# z*^H<D;QmMxdsLG<`{s<Gqx6eb6S=aFb+-0o-WH6Cl50;NpYvl|u2sN^!B*s-!qE#| zwJUpj*Q>nuFT{togsNsVKgJOD*pSOiJT#i~Epc7iLpVI>C$C<AY&^&Evri4(gA37B z85Ba#@f#B^{Iaw}fb18cp#w<y{~=<^|H?xAe~hy8|5oPz=yu=2^*-%HVZX-Tdj0{> z470_eQ2NHy+RhrDSPvlm0EWN*^Ow8R(txzMaR7g;I2s^KU0!99s2H5L2)qU4#e{=m z>r=1xrvIvQ0S<M4f4^~Q5{G{@wTl?_MTKr@vaOBFGyTnLxG3pfuoGVdWVd_Y&G3Lf zEQ2$;zq`_`9}xS#0Q9`cJ_6KMuTfuoc67Ab`}5P=uQlMGGUsrMj>V@PT!`#efQvw; zrw|3;%R&1l@s9&6YIVaRBR}!cWB^nxWZ&)Jo17FIqrv&VSN;4C)a0~5)Ty%o!ne3s z(I}YpmAFx`pZf=ZEd^?N?=K1@Jx(9>Xe^*{(fs)#RP+%R4OAA1D}WIYJ_D-e23Q|y z<4MyYFd0dWrS_VxZ$^u13elweC7JPGIs*i&^C<VM{z*}2M)hG?vq~~Cb;im&+J7nR zmR1qMF0=Ap`22SxFb7H>!TM-&*_{%Jx>}_6j=rY(Ydf^X1yzF+lk+7;mZ!DnBiH&h z_Vn$ht4NFb=hb*A^{u2`?pvz{18t|b4$c)1ZYPJ1KA&F>gMHf0ab7i6NvQ}uGtI9u zg)X){%=~?;F!gzBSG$o?Y+{n<UDi81x~(|#t5Wh#-h^~~TP^#if_V%ix!as=dSl{D z#~-#Uhz&68iZ8$#y($~a`+UXA4c$14ZQ<Jx&G9`noSUj}O18JAD!LeLs}A{F^CU}O z5ipc3WlUZTLfLi+QLx3-(8hL?0Osn?l^>VGTa(*jlv5-s9@Y>#(a}B&L<s5dTm4U} z$-QF(dZzrR1sHWLG0Ib&Uw3ef&zF$g9&*#Z)g!Es5RBMZ^d)f|!F6cb9C*)XdaEoQ z&UPi+4Z4CZMf6uz*HrX-k{ED{eDZ@vl|`P<5%K^0LRTGMIXT+)T_UM{{8?0NLojj& zQqUn|`X|||laHoa%0F*@pkv(!+k$Jpa)Zc>=O=9opAe0m<1RnEI`8l6sMNH{Vm5PC zCwyiqpV=Bj45qLX^+NVnD&KNFmfvjcXsFNXiPsyI*cC}4EVVS!iV>Sr@pfRm1s!ra zP~)aj^Jc9(q=BY4Z*2%@SRHr>OLz7zFg2|D*5Jn6=oGBow~mU&oAh?ownY?Hzu=ME z3B2wxeybAxH#TVa*aRx4t!grZdc3fsU<+ETV@s`ZyO*=k=EJ#&38e?rKGP~ynafkJ zA^DH&2!W#}>m7&~<lS9hl{`z;nZb1-5&!1MqhybGlaQIA{i&YUmD`C@cH+G0=L8k^ zNyjWgnkw@sX4;j0$Yo}cEa`gDj$eKXfr&?x7A*K+6~{lBQd+YztvybsVxB$_H^(Kf zFX)a~x)l1-1*VLVoPk@2ubZl&1z&wd40#t<4)p4pzx(kLerRPP#+s?Hv*pZc&U~9q zjT=VIoVC2D{qBSiK}zSSXT^mODO#B>--k4E#cgMa9P!IT5`3Tu=1(G8!AWCJyA4sY z>RfS-XxyYAP<DHEQjO)lwrsgkO%VGTC6vn6RG^rd^!BN3LCMvW03zU%Y^5Z};352H znQws18yi`99#>u79l`UnFR*2s-oS;hAMYc-*paLO+1_V*|0QaDo{l7it2xgk><U&Z z1tt}AAZ9nJM!80J{e0BSic{gN?!zk!KBjSZK;}Sjo@eo4Bz3BOL?3zhm&{p27bEp6 z=Bf>1&cxTXw*^MShZMfAXSZHYMcL;+mU>^cCdf|PhTqg%K{lzE#ActyEZ8ZG*ooFM zRyN#B)1L*H2ogtss-`V<#9MKAE@{Sh+QoRNZHIBOXaJBo*$^tG#9Pv&Sne87Z26+B zMntqdnBR*$yC2brf0zu#ONpWHu-{{ttj-RSb9?N4a9v6dk|dYbOMYj$r_%@A_|K(h zNQ03#<>)RQ4~ym}^~R0$Fq=gl>5Iaa?S|7;=0n)t(?bf@e)=@jZMN^fM0~NwFWD;- zrINq-H1o$B0~MrRU!7H$pz)aP7-6}E((Jj6xMsFmeChVEt>ju^)?SBx9nv~1?0CG{ z0lP-kvBSa2qRVHKStXHvxFQiU>6jfjYpm9Jbcl(yZgq7-d1k~}4t34;(u*?x=xCE` z=>vTTus_ss0;9PbG87G}akY)l`7G4?5YjbTOnIR!s#<v}1%Cc7sgF!Aa^O+)$(-B^ zt8c?pMjp!|uTiNm=xutu^EYYp-k!B|S3-D_idEP5w>c~-eZu-uIlW`!<KkzO8DlZE zdMWWvcl5^~cGJO-UVN2o1Su2B%<iiBuy<Usbd9C>B3xfbjiTsaecCxWIiVzOSRTID zkld5hd8Iib>HFg%+C@0>tqs#4B#GnEdG67T;A*nqnV8E-^_Gyj(n$|fh1B}pBd&sD zy-C3ry3%i3tGq~UN3_n`M>-w};eV+IHiM2WEd)g&;^@S<ZG@eq_uKoX`KnmB7GZSU zj%f27m0Va2*+=FWS1HJ`d>kQLITG}$!EeJcXSbf8a5O|QQM=GJ_Cc?~Jby6Portm_ z1*>=9-zR={D`>>keQPx@tPPSCO@4ei2)Xcz7+weDw`Nm?YInV-xN0KsBhD-6>fenb zfr*Ve@YGXi?%0DR>8t2D$eql~Pyvgu68zS@b8FieF`YS`E}A)mni{N(uc}VKpc7rn zaOeV&l#*ggFW$tI1Xe(k|Kp8H{};~w9}Q>!SMb99j~-%dKLJdB>Y^&nh5*?2ws5@w zz^>EmalD-std9VkY=G{Elc51%hKmhXT5q}g2Pz*gTA$*E6bJr=>#-R4D&$`c|DhPy zEKbbI$^umHv6C5$8DGT^il@CB^XUmYxc%Fos=qp*hI9i7{4-Q`^RnUx_yJ`BnV?4m zV8X+B{TeXg0m$~>)bYVs<iBTUnN#{VJ00rH05wL-gCiTvSBmZJ|Eq|2>-LWWfHMNx z`fr?F01Sp@s1z86N^QkWaR0Ztmj7-G<313`Zvb!{z}puVbGWa7A6h7Y+yO|Ezdl!K zMoUkR`~STXA&ThdB%Oeb@*j!_8kvX>d~~K#($XBJJ*Yq-09gMw=c2^V7{HRqz{I5R z7nlE2Hu7JF8Cu~^oWl%JmP?!NH+QTKr72w*ky+>!4+3XUHghAQ&x=a_y=^PofW~EK zI)iKHHQkcTwB)4D#W~MiyYMgTQAGSF%JTWTrt~>A!;bFi0#3o*X9U8Z22T(l@pwTe zEL&f>l@Yi~QpTphX=4}rtzb)04Y}S!`y06Z*kViI_nZ)~H=t`zxB_T(p7?2jMV zoBruc^lfCIcDZHD(h6H^=AP<}plU2iYZ?oG_vN49{Y!MZ^}Xa8HHHh!t*tG%^vRt9 z>TtoWcB}E4l~?80=TQP4jna*AsBV$Z&lqJyhgq4fl21=y9}p$sr_4R)drr=gFVw+j zF<)$z2|lOY#vE#D%iHVPrY$&>CR)@qry1(iFYEGf*&a9L8|Z0ve{?8Uo@Y^1pA9)+ zYgE9tu_jK_CUtm85K1lAF_OW59WDMGY3t$*2HdaR@SJ2<=y42#(+J#t4$qYI@Zg!B zpJ%x@c)8LJVb~lMGxoVQhkdrBSawVu%_-USWTS~p`2K0c%Th1!i@o}9^m!wvl3K-a z%ToQuIN&vzpPZ0T;dQ=KZS>~WaGx6OSOTfh)>ZxnW-0?sm72MGE~Dy}U8gbrBrJZH zqaVdbz31-C1y{+9c@X`DCR`C#uwzmAt1vhX%fX=yOn;3LMlHHoAmq`32^Yyd&uo7~ zeTXPi-u9JT(c^=fZpT*MxVhnTGOzFgdR8l0aejSI=ISRm><0;6*jg!&G3O}fVwi@E zgU#Y1+T^+!37>+cU^drlgvdV$IaIJI{w!Xuj?J($Ms!&|r+VGa(S!}c?)S|g&(MiJ z#&_1-;gE3NICX>9$iyldz|0N^>j-@E4MK@13KL#?+oci8iq>_g>$?T-tnb<K9p)5Z z6+gF1$LM^gp7&)fsd_$VyjJP1MOTU381gF~xH;7PUfk;0jZ5saYW~sRDZ-s|_k+jo z^OF|T!h!Y5Rj8F+pFO@%+F03J-Z2~O6$Lpm&eTO@sgI{!Ztt-g!t!v15*v_}j3)}E zx{7EWFHMtLb;R<X#Xj`Czs<tO6>1!jAMSkpNy&e{SKs~7@JtcN9~rt`Kj}!_31bzB z_psa^N-P07X*8Xe({_f%)YSBgn3KrE#U!vP2Fe%Pvc~+^ij1pt(?W}ZRb(@E&0kEq zmuQWWM`CMj=lpCYbzvksYyw?-(zZHq7i1cVmwX$DZ;fw93K%JCQxc13<1(|PPJX%0 zYb@sIY&A!C9U9WO>d$rrMQNQK8e!0z4wwVJ0<Gp_k~oSn%VS$G&2(i%39l`wb2|&| zUBUT){vBC7WO<;<FPVdeCjO`&x!Ok>YgOFL1)V=kIcp@Sq13#s;VHq1X)95*{Kr|7 ztaeIeOk@1gmW5^P;t%o##m@GZVaJ);q!~ux$MI5IyGZyoKNvaheR(VCknp{+)SUR@ za(jZGDVtLdcH>&})y5gv^1Kq+K_g(%9yn4n7#J3G3l=8-RRy=t6;9|IfrorJT{^cb zE*(oEme0-4i%k+CzV=BiSwKmIve0j~zWBJyVs6rBC!<kr`uIDb<7Hm2)JoMCtKHRp za?rBW$0;0oW<FI{fOf#a!;_SkN2|6R&B@Kp6?f|E>3PeyNc_!8@WUNAeJ+zL!q%+w z@)v%bEzjA6UKaAOr$>A5N$#s99Bdrvp+fVVX9}Q-Uo5HPv(nFSoMELAdoU`&E99AQ z%6BQAD1VlsEhZB6;fRDd(&f3InTBlQ8EOb0*gT%G<@};<_yNh2LXeWdQ&U&zofiV_ zgwlF#yl84q9Q>|&z#WmaUD=+)&M5j)O7ljVhG>B`yG50>?1Wp$7{py=Vns8Vwua&~ zEz(k34xuFjwV!OU`o-|4GSA7p$14+U{q*yhEC~7j9n~qc@ask7lIlulhvM}Qs-EY3 zxI}+Q&#v%4Rup_W8KjZJU^o>4vOa)RDS8o19Pa1^(x2|XrKF@Zk?am4lu~Ad6R_GG zQxAcZ)`>4(YFA%L+FV(3Joc?)wBK^Ls3ydnAIK;ivd?ILsQoc6GBqAhe9b3uE1;=w zJe7s*-hgVaDB7epnETiws7F}-rrp0kgVP^9gZ9czy@df<a~|6ZKYM$)-nSgES;8_< z(4hAmt?Wgdw?<t1Q$qZtMI%dAYe0*-04CkiyLf3=`uGXFmpo(Zok)!tGt+~v3PiJh zD_MP_J{^baBmE~%8x!SH;Ta@9UXEQU8-*L!gJ?*@<5F9%xD6LSz&%eymY}63o1Nu^ zt_Ci|tyd1FX0ODM97`dXT#GVTmQDrwGcClSx{yb1ySw|3$PC=osv+nY*jbhY*2UE2 z3KkMeJTvy?k)5GZxZURi^M;GDyJA>+?N0HY3fpW~P0kqi_g-20nX;*b9g46k3-gj9 zT8oiClWDH1pS<3Mlq93?3HrXR4C34hHXoW{v7A1s6617{epb$E&^d<6ds1;{n*>|P z*f%GaGak6CkM-XD(1lI8k@8B#Gvqy%AH`b`?8@GVmz*?YE|K(e*3~0($Wm1shLGsH zVq>`G%T}>oHW!gEZ4bCk*UqkQ>+1^2)%xmKz4(Yv5e5+uu)*?D;u?5xWShcEeuQq) zglzcyM^UlY8OP}Y7)adMf-{aemNyu2GzZEj{At++brIXP=fu7)y7!P24#w;Y#$Vbi zRB5>s<3Yi}xCc(I{gmpdf<Cyv2qX)cvrP6miC-bzDj#2UbzY?NCZ|&bC(HVkqudCG z(%!D1*)3)h7fK5(MZn7|%~@5f4$#H4pAWWYC=D?d(1a0n<jT@l+uXjKD8T<{%@=mW z5t9I?T+cU*<X-lkd%DMWFrx6>{_etQZcY+kj$t=9&z_iymmGUJi=w0TF|2?5TP}0| z*#Nnu3fhKTTt9}zZd^!8(^A;v{5L9)Qui?`gU6JUOL9J^du06FGe75$Y@u{gEEKI6 zk=7IiA7CO-HWMSlM_8AkNNtDw966+h1zYD{y|994(1Ww&`w-P(0vS?Q6vH)oOUC#- zLb*|47m@m%8P+Nj??3J@Ch5Ljh`D>lbevpTRW)H}7i>bLp`o#t#D6Tkug7A9&WKk= zyRuFN-lv_gr7+!2*!i7ezY3U_AV0(db(@Lo<%kb0^VNm?sft=j#uz<K1j4)hhnG0e zO4Yfn#lX$>dTMGw8jDwaWRXwoYTS?~{8)#acBS1%y{zYi4NJdLVNOmc1)*}bYNP4_ zX^+=zoeID~i>WqQ2mjzuW4r!lzM%eA@PfNwQeIiv$-qi0H$NY_V6qE|CwElMQ{h8+ zRMgH|XW+{ZXe8X+++;$YF?)M^Gi={|(GTpI#aTr}vT|~$&(6<b-tD~x>QZC4KtXP9 zH3DYH{6Mj-H#eRSfY?_j&r%PlXE^@f+#3XpfAes#eEhqNq@8;T;KfS0^}o3~{-5dq zNjaon{sqJTiyV*>GsE^}p#GDFMo%n_?5THasX;<@*S;GrIk~d0@4fZ8W!oJZ<nbKG zC~3AvVo>*FY3%3y*};jB$GLQ=0e-Bb)H<X1FqiU6baX~mRysz;us?tP%&<-9sHv+< z`umIcw`T_hy|CjU&9{EPdHKMy0!K2yZJwAOpVC@<VK-{`a5QpFB9gV@Lj*<<1-FR2 zN-u46ms+3zT9|c`>u-@j@{m7&J}6C=pvPU^AGtKjDJW2jie}&3+%$P9))C@PRHA27 zTz=h0H=i41u?>XmdPEox)=5ynVL(Hp2>@^1eCv(#Tc<{Sz?Ix&{e%VqnEPzVtg$!D z8Jj9)&2fBj_CSGI*>^ju$qWaL9yr4L4pzF2l09p8Nn)Xx?dOjd0R_~|w}y;DbbGfC zk38cU-gVikW;nhk__Vd=vFeOMc?O@4EvII@9o`UibIuJhe^dn81-f;s@?$Vf*Czu^ z!o0pO{L&(%a}y~YQNrx!aHAD?tG-J;AfZcrA<@$IbDznI;S~$mZ_^Je!Rv<^Szz!} zToE2S!`OJh<+I6a9x%zJ;G^Mo7t)vT!nW?zG@i`jP2CFrz<pV-+u2DzU#Ls|mG$~# zdwg0!NJS_rfePtR!wW(a|G<EIzk84QWV?4wHRgz`<UqqR{vEz|+>rcC<6*o>Z8u*s zuy)MY+RcZz@X+mQtFw?@3WmchbiEq(bhNrZiYZC8L9pd#zXFz1)o(^Q(}~}o|6UNY zCr%$NDc-9+A{y(!zEQavSU<!Pa2I@`gm+|;TW|$9NjR5?KPB*Y6N#2F%;O18z~{|; zO*A@S)bH+%Q*a2dV>8$13{ej;$t4ij$(FIneFD$<$OJL`@tnkWY{FCGt*As?*R z(Nz9+2<yJRtzbji?yCHT7JEbiSQat5ACWwsNc5q;Vp;K<^~8P;L&@X0ddZ5`_x?Sp zY&{CoySebaDJLuAfc47_Sc<VHhjM7t_fG3HjU%sEr0|us_;Ml7-v$?re#o2-0@%Mr zO#J%PTW(AkDRPKB`vZk8;>X+<xfEB|i&lk|dv6*?8hB39TVElsiB5XTlk#<i9eqCQ z{koh$UiXzbpuN>D(4Xq-rC>t}cy)A%h*TnFGS}`JWvPuiA_2wG>?q1!zquNZh=M{6 zPON436uNlrVY-eeO0!MYq1PKsSkK8V`a6t%O??Q|cLr=>@sCSJgADmmI{(;xcLzE( zAu^ghYZ*^=K9my-R_h%rA#PdVkF?U#myw!>3-P-nEBXjQ9s)BiLC7o7+Hu~jib@@u zovvfs_mAv;??z4QN_>-piWEJMJ`(z#VSt41nY?(FXfD1rwwO1kOS2dJsr6*FZubuZ z8zzzzrX;ovPlCPDh6J;=Vr?P4@7hfC^2@KB`?i@pU6kqOuOAb->yyr}9&uefPH248 zWrqZ0oo_1?iBIPZksphK4zOa)A^VaxO9dnT<pCGsna=Hp1wRyNH%*6ApT2e?US9?z z&pLP3F?D(CkKEO?DS2(Y3s<SB7@-yi9+%?f(wjoI;9SXjf2GN-9@6O3b0p}-vo$6P zLlDZ-!g5&g1Pbl}Yz-6IKUYk1(_IGPKr#FPwpwcmGGg_rE1UOtO4CtN1qWv-syABC zu$<L9Z1wJ<^QW{&vbQPZ1pM;78%IM1?7o>cHm5RCYwgL=J-f`7k!nM$*`6tQm7waQ zG%+XtP3K~IR?@q5L4{7yD3@M1$Hj>|7B9W~cHg2S?Za<_ToxE??CetMIK3w!9lmz5 zk~-W?ZiiZ{er3YXPqRrx`p8I%eDPy(Wz!CIzY8Dqf^bzht`!f#F6i6c{_=z3++sji z;k4%82S9+KVxO1CfgjqgFq{XPY@q5Als(q{G;m0hIj7Rn2?)D&W__l)Ffh71EUVRy zu}h4=a1T5sC5-Zr-%N<<qhUTb?iYGBS<wnF=Wabo^qQj0x3-gA{Mw`L1)=X!xIO*W zczthdyWyTDR6Q@oVy+Z2yQdvR#{v?y-huU_9Yw~f^SB|D8M0S@evLe>njPK!S=(Hu z6S}oQEgv}?oMX$bupL3IVmphuT+!OPKVJ=^(<l0j%f`+C-{hlcn37B$7N{_pruw}v zfdVy|csg~gx;I&a$2q8oPWRNZ5PdpNc$23WC*SOp4~uy`mvOybd$_+HXQ`v$`wH3J z9QY~p*myumR?+PAs{h9$kl5nUPX)oUSH*DEB|zlCo2zYk0p|k98sh86*$m;&rk(Bg z{jg@aRny!6JD|k9op-qMQTjm|co$sddalBxgctP~=+%D9sTUQvAG*=iL-~GZ7SyRo zTq1J8+UIpwBn<Tm-u;C|#20h>>-eH<4lOU++3U#x<22RRH-}T}0(1NL)6<&=N5^>e z31Ek&FRR%8qWp_dc%WsmzUHC9vFq+iCYLBTv!PQL>hNov`BDbbEQOt`F@hl)+#rqf zgdel|s?+h@+>;n)dr(g2)|!NNVS(6wY%SyP&;n?5*)A{SK0}<xNl9s!;XG?Y3(uxw zeIst1vk~ls`siIRP@PWic{b2u#ivM4&m*y)@GeHr@;+cCmZC3ep(FlU#oU09&;$mf zZNgIb9D!`(O%QWBvb8rg2mgtAi@coN7inNZ{*|PN;fg*#+A9!F=&%(Mja{(Fxa}C5 zg!PGEvo8UI;T-%(vqIs+9ZTfvyvKJ==r{Iz6PdF8@pZWpS@%jdiH6oE*PCIi1~anL zO7Y8;%`HC2_ID80xe8{7)`+2Q5IhTCqz(BLuAS}oMVgM4xvK0PZRe1tj?VlZ@8mlS zCMG75R;wR!dd5V^0GauWP~WLDm5?4sii(OVC@5%(+lM3===$z=7(iw-U^;FV{__S% z6+)u#OWxs7k4HkHTd(vcMH$#yIM`&(O~9Ft%>5A9CDX>{`Snam`*Hkmvp$<|o0x0a z<bDsZQ!cFL$aFv3?~3>Ve%-(B1e}zrzU7$-$0b&ZOz`)b%2}htHJ|N%KC&p8Xtms0 z{MUdU?A7?zYQ9Yt?nOyNELX&W3De~i_wn5Wrgsw$fpG!b<Qz|0+U#6iad9!}NWI3S zP-1ikALMRV@({DQWZ8K!4XPeZ^>1^NW_Nz$wq8}KW0Swxd0JDzAKnch+mF!Vgs=Lh z5FOuN(BN0xVXcbsycUAY-OBT*+iFiI_xEc?A=}KOP7<Yn(iZAk{4Crl2?+^c%>_VA zj|u5b=<+^3$LF^PQMb8ssD!;n#9y0pUm#At;prNh{vy4A6$uPAi|YBkg<57H%Ubc_ zxOcv5=<&L{vN9&{`Iqf%nR{!nJrC*i3XW=M0lqJH?XKwJX}6YSXhDLHW2obO05-LK z2G9gG06ueec1}o6j?c~p>#{Fg4Q;!`#>9x}>+1)e`nqj=OCcW`7@Ajw!45RpEY3|K zXf)h(v9gZMAArZTJaCbP04i!)r&+xTI1uk!2dol&6^qC2n=(`lE8+Q3;<=@N_*AWD zfxhNR$828qU*PY!8nUdO7Cu1Fzs@NFP#1wcoRogz`Q59>v=#K&YRO}||1IG(5ODl! zgm1$m1oQ+zy4xiJ5kl_t?SsD^k6!gK7ipG9bLQ{gzo9iBfGf7w`Sj_vhtD}?HI?!x zknt9{S3VdTVu^)dk&iWS02W5$?Q3|^@83BsEo8~V({{&`z%6IZ!_&>B3``DySCR<X zznyZ@&P@&Y-yc@h^FTN7kzwhx5n_e>8bq+K+p(@+1Twb&4MD804?+r-9USX+=FL5y zyE88ydmsU|ukqnYFFhI0u<WjW{A>QB6%+mf{QCDnjrFtgS-^j#_m-XA(Yp|cbwYiC vATTZ^g-EkZzbFkTL{2fIMc@AE@!=LMb<WB2`+%z&0eHzvt4Nhe7zg|(4WWtA diff --git a/docs/_images/application-register-client-credential.png b/docs/_images/application-register-client-credential.png index 8d200d87f6a45db2ede4a5a3f3961fcb6bc08e64..510ed4f8430d50de682e00b08971a035e59e8b2f 100644 GIT binary patch literal 133348 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ⅆ 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 Z5u#E@CS;^2JA{HX83{%4AEHLV{{=AGzgYkP literal 33986 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 b4UJVCt|sIkO)Y!}^H7vklPP~|^5y>kUSPrL diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 5462a32fb..5e1fe3b51 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -91,9 +91,10 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `https://www.getpostman.com/oauth2/callback` - * `Allowed origins`: Web applications use Cross-Origin Resource Sharing (CORS) to request resources from origins other than their own. - You can provide list of origins of web applications that will have access to the token endpoint of :term:`Authorization Server`. - This setting controls only token endpoint and it is not related with Django CORS Headers settings. + * `Allowed origins`: Web applications use Cross-Origin Resource Sharing (CORS) to request resources from origins other + than their own. You can provide list of origins of web applications that will have access to the token endpoint + of :term:`Authorization Server`. This setting controls only token endpoint and it is not related + with Django CORS Headers settings. * `Client type`: this value affects the security level at which some communications between the client application and the authorization server are performed. For this tutorial choose *Confidential*. diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 271eb7649..440518903 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -40,6 +40,11 @@ <h3 class="block-center-heading">{{ application.name }}</h3> <p><b>{% trans "Post Logout Redirect Uris" %}</b></p> <textarea class="input-block-level" readonly>{{ application.post_logout_redirect_uris }}</textarea> </li> + + <li> + <p><b>{% trans "Allowed Origins" %}</b></p> + <textarea class="input-block-level" readonly>{{ application.allowed_origins }}</textarea> + </li> </ul> <div class="btn-toolbar"> From 7282fdee0bd8e71cc008bffdf88f407f65c88b2b Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 1 Oct 2023 09:43:01 +0300 Subject: [PATCH 543/722] Code and docs cleanup --- docs/tutorial/tutorial_01.rst | 4 ++-- oauth2_provider/oauth2_backends.py | 3 ++- oauth2_provider/oauth2_validators.py | 15 ++++++++------- tests/test_cors.py | 24 +++++++++++++++++++++++- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 5e1fe3b51..a930ca399 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -91,8 +91,8 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati specifies one of the verified redirection uris. For this tutorial, paste verbatim the value `https://www.getpostman.com/oauth2/callback` - * `Allowed origins`: Web applications use Cross-Origin Resource Sharing (CORS) to request resources from origins other - than their own. You can provide list of origins of web applications that will have access to the token endpoint + * `Allowed origins`: Browser-based clients use Cross-Origin Resource Sharing (CORS) to request resources from origins other + than their own. You can provide list of origins that will have access to the token endpoint of :term:`Authorization Server`. This setting controls only token endpoint and it is not related with Django CORS Headers settings. diff --git a/oauth2_provider/oauth2_backends.py b/oauth2_provider/oauth2_backends.py index 401e9fc5c..3ddb9c90b 100644 --- a/oauth2_provider/oauth2_backends.py +++ b/oauth2_provider/oauth2_backends.py @@ -75,7 +75,8 @@ def extract_headers(self, request): del headers["wsgi.errors"] if "HTTP_AUTHORIZATION" in headers: headers["Authorization"] = headers["HTTP_AUTHORIZATION"] - # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, if the origin is allowed by RequestValidator.is_origin_allowed. + # Add Access-Control-Allow-Origin header to the token endpoint response for authentication code grant, + # if the origin is allowed by RequestValidator.is_origin_allowed. # https://github.com/oauthlib/oauthlib/pull/791 if "HTTP_ORIGIN" in headers: headers["Origin"] = headers["HTTP_ORIGIN"] diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 6a4acc8e3..00497db9a 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -960,10 +960,11 @@ def get_additional_claims(self, request): return {} def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): - if request.client is None or not request.client.client_id: - return False - application = Application.objects.filter(client_id=request.client.client_id).first() - if application: - return application.origin_allowed(origin) - else: - return False + """Indicate if the given origin is allowed to access the token endpoint + via Cross-Origin Resource Sharing (CORS). CORS is used by browser-based + clients, such as Single-Page Applications, to perform the Authorization + Code Grant. + + Verifies if request's origin is within Application's allowed origins list. + """ + return request.client.origin_allowed(origin) diff --git a/tests/test_cors.py b/tests/test_cors.py index 64f2a5fec..e8eff07a1 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -20,6 +20,8 @@ # CORS is allowed for https only CLIENT_URI = "https://example.org" +CLIENT_URI_HTTP = "http://example.org" + @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) @@ -39,7 +41,7 @@ def setUp(self): self.application = Application.objects.create( name="Test Application", - redirect_uris=(CLIENT_URI), + redirect_uris=CLIENT_URI, user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -85,6 +87,26 @@ def test_cors_header(self): self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + def test_cors_header_no_https(self): + """ + Test that CORS is not allowed if origin uri does not have https:// schema + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["HTTP_ORIGIN"] = CLIENT_URI_HTTP + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + def test_no_cors_header_origin_not_allowed(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin From 09d853a95e93b378e83acf7de0affcf193c9c255 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 1 Oct 2023 10:13:19 +0300 Subject: [PATCH 544/722] Code cleanup --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2cc3c3901..d620c3f59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,7 +108,6 @@ def application(): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, - allowed_origins="https://example.com", ) From 8dc3ff10f2390a0ed6d2968d939cd90d0bcf706a Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Tue, 3 Oct 2023 21:18:04 +0300 Subject: [PATCH 545/722] Code review: update docs and test names --- docs/advanced_topics.rst | 1 + tests/{test_cors.py => test_token_endpoint_cors.py} | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) rename tests/{test_cors.py => test_token_endpoint_cors.py} (96%) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index be0e3faab..d92d71b12 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -21,6 +21,7 @@ logo, acceptance of some user agreement and so on. * :attr:`user` ref to a Django user * :attr:`redirect_uris` The list of allowed redirect uri. The string consists of valid URLs separated by space * :attr:`post_logout_redirect_uris` The list of allowed redirect uris after an RP initiated logout. The string consists of valid URLs separated by space + * :attr:`allowed_origins` The list of origin URIs to enable CORS for token endpoint. The string consists of valid URLs separated by space * :attr:`client_type` Client type as described in :rfc:`2.1` * :attr:`authorization_grant_type` Authorization flows available to the Application * :attr:`client_secret` Confidential secret issued to the client during the registration process as described in :rfc:`2.2` diff --git a/tests/test_cors.py b/tests/test_token_endpoint_cors.py similarity index 96% rename from tests/test_cors.py rename to tests/test_token_endpoint_cors.py index e8eff07a1..d0eecb463 100644 --- a/tests/test_cors.py +++ b/tests/test_token_endpoint_cors.py @@ -25,7 +25,7 @@ @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) -class TestCors(TestCase): +class TestTokenEndpointCors(TestCase): """ Test that CORS headers can be managed by OAuthLib. The objective is: http request 'Origin' header should be passed to OAuthLib @@ -56,7 +56,7 @@ def tearDown(self): self.test_user.delete() self.dev_user.delete() - def test_cors_header(self): + def test_valid_origin_with_https(self): """ Test that /token endpoint has Access-Control-Allow-Origin """ @@ -87,7 +87,7 @@ def test_cors_header(self): self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) - def test_cors_header_no_https(self): + def test_valid_origin_no_https(self): """ Test that CORS is not allowed if origin uri does not have https:// schema """ @@ -107,7 +107,7 @@ def test_cors_header_no_https(self): self.assertEqual(response.status_code, 200) self.assertFalse(response.has_header("Access-Control-Allow-Origin")) - def test_no_cors_header_origin_not_allowed(self): + def test_origin_not_from_allowed_origins(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin when request origin is not in Application.allowed_origins @@ -127,7 +127,7 @@ def test_no_cors_header_origin_not_allowed(self): self.assertEqual(response.status_code, 200) self.assertFalse(response.has_header("Access-Control-Allow-Origin")) - def test_no_cors_header_no_origin(self): + def test_no_origin(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin """ From f52833892b6583287011b9b96af4e38a11e365c2 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Tue, 3 Oct 2023 21:32:13 +0300 Subject: [PATCH 546/722] Code review: update allowed_origins documentation --- docs/tutorial/tutorial_01.rst | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index a930ca399..a7bf20466 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -92,9 +92,11 @@ point your browser to http://localhost:8000/o/applications/ and add an Applicati `https://www.getpostman.com/oauth2/callback` * `Allowed origins`: Browser-based clients use Cross-Origin Resource Sharing (CORS) to request resources from origins other - than their own. You can provide list of origins that will have access to the token endpoint - of :term:`Authorization Server`. This setting controls only token endpoint and it is not related - with Django CORS Headers settings. + than their own. Provide space-separated list of allowed origins for the token endpoint. + The origin must be in the form of `"://" [ ":" ]`, such as `https://login.mydomain.com` or `http://localhost:3000`. + Query strings and hash information are not taken into account when validating these URLs. + This does not include the 'Redirect URIs' or 'Post Logout Redirect URIs', if those domains will also use the token + endpoint, they must be included in this list. * `Client type`: this value affects the security level at which some communications between the client application and the authorization server are performed. For this tutorial choose *Confidential*. From aa33708e6e03a397a2879264f41737267b44d2b1 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Wed, 4 Oct 2023 20:30:19 +0300 Subject: [PATCH 547/722] Added more tests --- tests/test_models.py | 10 ++++++++++ tests/test_token_endpoint_cors.py | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/test_models.py b/tests/test_models.py index 5ebb1f0f9..4de823b8d 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -584,3 +584,13 @@ def test_application_clean(oauth2_settings, application): with pytest.raises(ValidationError) as exc: application.clean() assert "You cannot use HS256" in str(exc.value) + + application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE + + # allowed_origins can be only https:// + application.allowed_origins = "http://example.com" + with pytest.raises(ValidationError) as exc: + application.clean() + assert "Enter a valid URL" in str(exc.value) + application.allowed_origins = "https://example.com" + application.clean() diff --git a/tests/test_token_endpoint_cors.py b/tests/test_token_endpoint_cors.py index d0eecb463..af5696c58 100644 --- a/tests/test_token_endpoint_cors.py +++ b/tests/test_token_endpoint_cors.py @@ -122,7 +122,7 @@ def test_origin_not_from_allowed_origins(self): } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["HTTP_ORIGIN"] = "another_example.org" + auth_headers["HTTP_ORIGIN"] = "https://another_example.org" response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertFalse(response.has_header("Access-Control-Allow-Origin")) From 641ab0b748be9c0a1e07dca89ba7c32b5058cafb Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Fri, 6 Oct 2023 17:23:37 +0300 Subject: [PATCH 548/722] Added default value for allowed_origins --- .../migrations/0010_application_allowed_origins.py | 6 +++++- oauth2_provider/models.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/oauth2_provider/migrations/0010_application_allowed_origins.py b/oauth2_provider/migrations/0010_application_allowed_origins.py index 39ca9af8e..a22a8f7c0 100644 --- a/oauth2_provider/migrations/0010_application_allowed_origins.py +++ b/oauth2_provider/migrations/0010_application_allowed_origins.py @@ -13,6 +13,10 @@ class Migration(migrations.Migration): migrations.AddField( model_name="application", name="allowed_origins", - field=models.TextField(blank=True, help_text="Allowed origins list to enable CORS, space separated"), + field=models.TextField( + blank=True, + help_text="Allowed origins list to enable CORS, space separated", + default="", + ), ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index d003d99e6..c37057e49 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -135,6 +135,7 @@ class AbstractApplication(models.Model): allowed_origins = models.TextField( blank=True, help_text=_("Allowed origins list to enable CORS, space separated"), + default="", ) class Meta: From f9fcaff4c2ccd6d6531b7c3f5260d12ecdbd6b74 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <darrel.opry@spry-group.com> Date: Mon, 1 May 2023 16:43:11 -0400 Subject: [PATCH 549/722] feat: idp/rp test apps Implement a minimal testing IDP and RP for maintainers. There is a single Application configured in the IDP for the RP sample application it used the OIDC Authorization + PKCE flow. This is a meant to be a starting point for building out further test scenarios. --- .gitignore | 3 + tests/app/README.md | 48 + tests/app/idp/README.md | 16 + tests/app/idp/fixtures/seed.json | 37 + tests/app/idp/idp/__init__.py | 0 tests/app/idp/idp/asgi.py | 17 + tests/app/idp/idp/settings.py | 214 ++ tests/app/idp/idp/urls.py | 25 + tests/app/idp/idp/wsgi.py | 17 + tests/app/idp/manage.py | 22 + tests/app/idp/requirements.txt | 4 + .../app/idp/templates/registration/login.html | 7 + tests/app/rp/.gitignore | 10 + tests/app/rp/.npmrc | 2 + tests/app/rp/.prettierignore | 13 + tests/app/rp/.prettierrc | 9 + tests/app/rp/README.md | 40 + tests/app/rp/package-lock.json | 1717 +++++++++++++++++ tests/app/rp/package.json | 29 + tests/app/rp/src/app.d.ts | 12 + tests/app/rp/src/app.html | 12 + tests/app/rp/src/routes/+page.svelte | 44 + tests/app/rp/static/favicon.png | Bin 0 -> 1571 bytes tests/app/rp/svelte.config.js | 18 + tests/app/rp/tsconfig.jsonc | 17 + tests/app/rp/vite.config.ts | 6 + 26 files changed, 2339 insertions(+) create mode 100644 tests/app/README.md create mode 100644 tests/app/idp/README.md create mode 100644 tests/app/idp/fixtures/seed.json create mode 100644 tests/app/idp/idp/__init__.py create mode 100644 tests/app/idp/idp/asgi.py create mode 100644 tests/app/idp/idp/settings.py create mode 100644 tests/app/idp/idp/urls.py create mode 100644 tests/app/idp/idp/wsgi.py create mode 100644 tests/app/idp/manage.py create mode 100644 tests/app/idp/requirements.txt create mode 100644 tests/app/idp/templates/registration/login.html create mode 100644 tests/app/rp/.gitignore create mode 100644 tests/app/rp/.npmrc create mode 100644 tests/app/rp/.prettierignore create mode 100644 tests/app/rp/.prettierrc create mode 100644 tests/app/rp/README.md create mode 100644 tests/app/rp/package-lock.json create mode 100644 tests/app/rp/package.json create mode 100644 tests/app/rp/src/app.d.ts create mode 100644 tests/app/rp/src/app.html create mode 100644 tests/app/rp/src/routes/+page.svelte create mode 100644 tests/app/rp/static/favicon.png create mode 100644 tests/app/rp/svelte.config.js create mode 100644 tests/app/rp/tsconfig.jsonc create mode 100644 tests/app/rp/vite.config.ts diff --git a/.gitignore b/.gitignore index 4d15af97f..c4436f57d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ _build /venv/ /coverage.xml + +db.sqlite3 +venv/ diff --git a/tests/app/README.md b/tests/app/README.md new file mode 100644 index 000000000..904af273c --- /dev/null +++ b/tests/app/README.md @@ -0,0 +1,48 @@ +# Test Apps + +These apps are for local end to end testing of DOT features. They were implemented to save maintainers the trouble of setting up +local test environments. + +## /tests/app/idp + +This is an example IDP implementation for end to end testing. There are pre-configured fixtures which will work with the sample RP. + +username: superuser +password: password + +### Development Tasks + +* starting up the idp + + ```bash + cd tests/app/idp + # create a virtual env if that is something you do + python manage.py migrate + python manage.py loaddata fixtures/seed.json + python manage.py runserver + # open http://localhost:8000/admin + + ``` + +* update fixtures + + You can update data in the IDP and then dump the data to a new seed file as follows. + + ``` + python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype --natural-foreign --natural-primary --indent 2 > fixtures/seed.json + ``` + +## /test/app/rp + +This is an example RP. It is a SPA built with Svelte. + +### Development Tasks + +* starting the RP + + ```bash + cd test/apps/rp + npm install + npm run dev + # open http://localhost:5173 + ``` \ No newline at end of file diff --git a/tests/app/idp/README.md b/tests/app/idp/README.md new file mode 100644 index 000000000..699b821d2 --- /dev/null +++ b/tests/app/idp/README.md @@ -0,0 +1,16 @@ +# TEST IDP + +This is an example IDP implementation for end to end testing. + +username: superuser +password: password + +## Development Tasks + +* update fixtures + + ``` + python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.grant -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json + ``` + + *check seeds as you produce them to makre sure any unrequired models are excluded to keep our seeds as small as possible.* diff --git a/tests/app/idp/fixtures/seed.json b/tests/app/idp/fixtures/seed.json new file mode 100644 index 000000000..270c62625 --- /dev/null +++ b/tests/app/idp/fixtures/seed.json @@ -0,0 +1,37 @@ +[ +{ + "model": "auth.user", + "fields": { + "password": "pbkdf2_sha256$390000$29LoVHfFRlvEOJ9clv73Wx$fx5ejfUJ+nYsnBXFf21jZvDsq4o3p5io3TrAGKAVTq4=", + "last_login": "2023-10-05T14:39:15.980Z", + "is_superuser": true, + "username": "superuser", + "first_name": "", + "last_name": "", + "email": "", + "is_staff": true, + "is_active": true, + "date_joined": "2023-05-01T19:53:59.622Z", + "groups": [], + "user_permissions": [] + } +}, +{ + "model": "oauth2_provider.application", + "fields": { + "client_id": "2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm", + "user": null, + "redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", + "post_logout_redirect_uris": "http://localhost:5173\r\nhttp://127.0.0.1:5173", + "client_type": "public", + "authorization_grant_type": "authorization-code", + "client_secret": "pbkdf2_sha256$600000$HEYByn6WXiQUI1D6ezTnAf$qPLekt0t3ZssnzEOvQkeOSfxx7tbs/gcC3O0CthtP2A=", + "hash_client_secret": true, + "name": "OIDC - Authorization Code", + "skip_authorization": true, + "created": "2023-05-01T20:27:46.167Z", + "updated": "2023-05-11T16:37:21.669Z", + "algorithm": "RS256" + } +} +] diff --git a/tests/app/idp/idp/__init__.py b/tests/app/idp/idp/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/app/idp/idp/asgi.py b/tests/app/idp/idp/asgi.py new file mode 100644 index 000000000..00e738adf --- /dev/null +++ b/tests/app/idp/idp/asgi.py @@ -0,0 +1,17 @@ +""" +ASGI config for idp project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") + +application = get_asgi_application() diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py new file mode 100644 index 000000000..2331cddb7 --- /dev/null +++ b/tests/app/idp/idp/settings.py @@ -0,0 +1,214 @@ +""" +Django settings for idp project. + +Generated by 'django-admin startproject' using Django 4.2. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/4.2/ref/settings/ +""" + +from pathlib import Path + + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = "django-insecure-vri27@j_q62e2it4$xiy9ca!7@qgjkhhan(*zs&lz0k@yukbb3" + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + + +# Application definition + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "oauth2_provider", + "corsheaders", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "idp.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "idp.wsgi.application" + + +# Database +# https://docs.djangoproject.com/en/4.2/ref/settings/#databases + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": BASE_DIR / "db.sqlite3", + } +} + + +# Password validation +# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + + +# Internationalization +# https://docs.djangoproject.com/en/4.2/topics/i18n/ + +LANGUAGE_CODE = "en-us" + +TIME_ZONE = "UTC" + +USE_I18N = True + +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/4.2/howto/static-files/ + +STATIC_URL = "static/" + +# Default primary key field type +# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +OAUTH2_PROVIDER = { + "OIDC_ENABLED": True, + "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, + # this key is just for out test app, you should never store a key like this in a production environment. + "OIDC_RSA_PRIVATE_KEY": """ +-----BEGIN RSA PRIVATE KEY----- +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 +-----END RSA PRIVATE KEY----- +""", + "SCOPES": { + "openid": "OpenID Connect scope", + }, +} + +# just for this example +CORS_ORIGIN_ALLOW_ALL = True + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "handlers": { + "console": { + "class": "logging.StreamHandler", + }, + }, + "root": { + "handlers": ["console"], + "level": "WARNING", + }, + "loggers": { + # log oauth2_provider issues to facilitate troubleshooting + "oauth2_provider": { + "handlers": ["console"], + "level": "DEBUG", + "propagate": False, + }, + }, +} diff --git a/tests/app/idp/idp/urls.py b/tests/app/idp/idp/urls.py new file mode 100644 index 000000000..2ebc27295 --- /dev/null +++ b/tests/app/idp/idp/urls.py @@ -0,0 +1,25 @@ +""" +URL configuration for idp project. + +The `urlpatterns` list routes URLs to views. For more information please see: + https://docs.djangoproject.com/en/4.2/topics/http/urls/ +Examples: +Function views + 1. Add an import: from my_app import views + 2. Add a URL to urlpatterns: path('', views.home, name='home') +Class-based views + 1. Add an import: from other_app.views import Home + 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') +Including another URLconf + 1. Import the include() function: from django.urls import include, path + 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) +""" +from django.contrib import admin +from django.urls import include, path + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("accounts/", include("django.contrib.auth.urls")), +] diff --git a/tests/app/idp/idp/wsgi.py b/tests/app/idp/idp/wsgi.py new file mode 100644 index 000000000..41a8c45e1 --- /dev/null +++ b/tests/app/idp/idp/wsgi.py @@ -0,0 +1,17 @@ +""" +WSGI config for idp project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") + +application = get_wsgi_application() diff --git a/tests/app/idp/manage.py b/tests/app/idp/manage.py new file mode 100644 index 000000000..abee496ad --- /dev/null +++ b/tests/app/idp/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "idp.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt new file mode 100644 index 000000000..d17f9bd45 --- /dev/null +++ b/tests/app/idp/requirements.txt @@ -0,0 +1,4 @@ +Django>=3.2,<4.2 +django-cors-headers==3.14.0 + +-e ../../../ \ No newline at end of file diff --git a/tests/app/idp/templates/registration/login.html b/tests/app/idp/templates/registration/login.html new file mode 100644 index 000000000..4622bdfbf --- /dev/null +++ b/tests/app/idp/templates/registration/login.html @@ -0,0 +1,7 @@ +<!-- templates/registration/login.html --> +<h2>Log In</h2> +<form method="post"> + {% csrf_token %} + {{ form.as_p }} + <button type="submit">Log In</button> +</form> \ No newline at end of file diff --git a/tests/app/rp/.gitignore b/tests/app/rp/.gitignore new file mode 100644 index 000000000..6635cf554 --- /dev/null +++ b/tests/app/rp/.gitignore @@ -0,0 +1,10 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example +vite.config.js.timestamp-* +vite.config.ts.timestamp-* diff --git a/tests/app/rp/.npmrc b/tests/app/rp/.npmrc new file mode 100644 index 000000000..0c05da457 --- /dev/null +++ b/tests/app/rp/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +resolution-mode=highest diff --git a/tests/app/rp/.prettierignore b/tests/app/rp/.prettierignore new file mode 100644 index 000000000..38972655f --- /dev/null +++ b/tests/app/rp/.prettierignore @@ -0,0 +1,13 @@ +.DS_Store +node_modules +/build +/.svelte-kit +/package +.env +.env.* +!.env.example + +# Ignore files for PNPM, NPM and YARN +pnpm-lock.yaml +package-lock.json +yarn.lock diff --git a/tests/app/rp/.prettierrc b/tests/app/rp/.prettierrc new file mode 100644 index 000000000..a77fddea9 --- /dev/null +++ b/tests/app/rp/.prettierrc @@ -0,0 +1,9 @@ +{ + "useTabs": true, + "singleQuote": true, + "trailingComma": "none", + "printWidth": 100, + "plugins": ["prettier-plugin-svelte"], + "pluginSearchDirs": ["."], + "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] +} diff --git a/tests/app/rp/README.md b/tests/app/rp/README.md new file mode 100644 index 000000000..4c6cfd3bc --- /dev/null +++ b/tests/app/rp/README.md @@ -0,0 +1,40 @@ +# create-svelte + +**Please Read ../README.md First** + +Everything you need to build a Svelte project, powered by [`create-svelte`](https://github.com/sveltejs/kit/tree/master/packages/create-svelte). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```bash +# create a new project in the current directory +npm create svelte@latest + +# create a new project in my-app +npm create svelte@latest my-app +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```bash +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```bash +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://kit.svelte.dev/docs/adapters) for your target environment. diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json new file mode 100644 index 000000000..72001d25f --- /dev/null +++ b/tests/app/rp/package-lock.json @@ -0,0 +1,1717 @@ +{ + "name": "rp", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rp", + "version": "0.0.1", + "dependencies": { + "@dopry/svelte-oidc": "^1.1.0" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.8.1", + "svelte": "^3.54.0", + "svelte-check": "^3.0.1", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.3.0" + } + }, + "node_modules/@dopry/svelte-oidc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@dopry/svelte-oidc/-/svelte-oidc-1.1.0.tgz", + "integrity": "sha512-FfXm/f2vRNxFsYxKs8hal1Huf94dqKrRIppDzjDIH9cNy683b9sN9NUY0mZtrHc1yJL+jyfNNsB+bY9/9fCErA==", + "dependencies": { + "oidc-client": "1.11.5" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", + "integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz", + "integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz", + "integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz", + "integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz", + "integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz", + "integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz", + "integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz", + "integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz", + "integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz", + "integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz", + "integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz", + "integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz", + "integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz", + "integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz", + "integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz", + "integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz", + "integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz", + "integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz", + "integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz", + "integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz", + "integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz", + "integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", + "integrity": "sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, + "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", + "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", + "dev": true + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.21", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", + "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "dev": true + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.0.1.tgz", + "integrity": "sha512-anxxYMcQy7HWSKxN4YNaVcgNzCHtNFwygq72EA1Xv7c+5gSECOJ1ez1PYoLciPiFa7A3XBvMDQXUFJ2eqLDtAA==", + "dev": true, + "dependencies": { + "import-meta-resolve": "^3.0.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^1.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.15.10.tgz", + "integrity": "sha512-qRZxODfsixjgY+7OOxhAQB8viVaxjyDUz2lM6cE22kObzF5mNke81FIxB2wdaOX42LyfVwIYULZQSr7duxLZ7w==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte": "^2.1.1", + "@types/cookie": "^0.5.1", + "cookie": "^0.5.0", + "devalue": "^4.3.0", + "esm-env": "^1.0.0", + "kleur": "^4.1.5", + "magic-string": "^0.30.0", + "mime": "^3.0.0", + "sade": "^1.8.1", + "set-cookie-parser": "^2.6.0", + "sirv": "^2.0.2", + "tiny-glob": "^0.2.9", + "undici": "~5.22.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": "^16.14 || >=18" + }, + "peerDependencies": { + "svelte": "^3.54.0", + "vite": "^4.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.1.1.tgz", + "integrity": "sha512-7YeBDt4us0FiIMNsVXxyaP4Hwyn2/v9x3oqStkHU3ZdIc5O22pGwUwH33wUqYo+7Itdmo8zxJ45Qvfm3H7UUjQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.4", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.0", + "svelte-hmr": "^0.15.1", + "vitefu": "^0.2.4" + }, + "engines": { + "node": "^14.18.0 || >= 16" + }, + "peerDependencies": { + "svelte": "^3.54.0", + "vite": "^4.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", + "integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==", + "dev": true + }, + "node_modules/@types/pug": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz", + "integrity": "sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==", + "dev": true + }, + "node_modules/acorn": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", + "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/binary-extensions": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", + "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", + "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "dev": true, + "dependencies": { + "fill-range": "^7.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "engines": { + "node": "*" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dev": true, + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", + "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/cookie": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", + "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/core-js": { + "version": "3.30.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz", + "integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==", + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/crypto-js": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", + "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + }, + "node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.0.tgz", + "integrity": "sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==", + "dev": true + }, + "node_modules/es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.17.18", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz", + "integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/android-arm": "0.17.18", + "@esbuild/android-arm64": "0.17.18", + "@esbuild/android-x64": "0.17.18", + "@esbuild/darwin-arm64": "0.17.18", + "@esbuild/darwin-x64": "0.17.18", + "@esbuild/freebsd-arm64": "0.17.18", + "@esbuild/freebsd-x64": "0.17.18", + "@esbuild/linux-arm": "0.17.18", + "@esbuild/linux-arm64": "0.17.18", + "@esbuild/linux-ia32": "0.17.18", + "@esbuild/linux-loong64": "0.17.18", + "@esbuild/linux-mips64el": "0.17.18", + "@esbuild/linux-ppc64": "0.17.18", + "@esbuild/linux-riscv64": "0.17.18", + "@esbuild/linux-s390x": "0.17.18", + "@esbuild/linux-x64": "0.17.18", + "@esbuild/netbsd-x64": "0.17.18", + "@esbuild/openbsd-x64": "0.17.18", + "@esbuild/sunos-x64": "0.17.18", + "@esbuild/win32-arm64": "0.17.18", + "@esbuild/win32-ia32": "0.17.18", + "@esbuild/win32-x64": "0.17.18" + } + }, + "node_modules/esm-env": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", + "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", + "dev": true + }, + "node_modules/fast-glob": { + "version": "3.2.12", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", + "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "dev": true, + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", + "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", + "dev": true, + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", + "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globalyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", + "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", + "dev": true + }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/import-fresh": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", + "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", + "dev": true, + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-meta-resolve": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-3.0.0.tgz", + "integrity": "sha512-4IwhLhNNA8yy445rPjD/lWh++7hMDOml2eHtd58eG7h+qK3EryMuuRbsHGPikCoAgIkkDnckKfWSk2iDla/ejg==", + "dev": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/magic-string": { + "version": "0.30.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", + "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", + "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "dev": true, + "dependencies": { + "braces": "^3.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "dev": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/mrmime": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", + "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "dev": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", + "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/oidc-client": { + "version": "1.11.5", + "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", + "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", + "dependencies": { + "acorn": "^7.4.1", + "base64-js": "^1.5.1", + "core-js": "^3.8.3", + "crypto-js": "^4.0.0", + "serialize-javascript": "^4.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/picocolors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", + "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "dev": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", + "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-svelte": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-2.10.0.tgz", + "integrity": "sha512-GXMY6t86thctyCvQq+jqElO+MKdB09BkL3hexyGP3Oi8XLKRFaJP1ud/xlWCZ9ZIa2BxHka32zhHfcuU+XsRQg==", + "dev": true, + "peerDependencies": { + "prettier": "^1.16.4 || ^2.0.0", + "svelte": "^3.2.0" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "dev": true, + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/rollup": { + "version": "3.21.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.4.tgz", + "integrity": "sha512-N5LxpvDolOm9ueiCp4NfB80omMDqb45ShtsQw2+OT3f11uJ197dv703NZvznYHP6RWR85wfxanXurXKG3ux2GQ==", + "dev": true, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=14.18.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/sander": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", + "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", + "dev": true, + "dependencies": { + "es6-promise": "^3.1.2", + "graceful-fs": "^4.1.3", + "mkdirp": "^0.5.1", + "rimraf": "^2.5.2" + } + }, + "node_modules/serialize-javascript": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", + "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", + "integrity": "sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==", + "dev": true + }, + "node_modules/sirv": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", + "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "dev": true, + "dependencies": { + "@polka/url": "^1.0.0-next.20", + "mrmime": "^1.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sorcery": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", + "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.14", + "buffer-crc32": "^0.2.5", + "minimist": "^1.2.0", + "sander": "^0.5.0" + }, + "bin": { + "sorcery": "bin/sorcery" + } + }, + "node_modules/source-map-js": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", + "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "dev": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svelte": { + "version": "3.58.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.58.0.tgz", + "integrity": "sha512-brIBNNB76mXFmU/Kerm4wFnkskBbluBDCjx/8TcpYRb298Yh2dztS2kQ6bhtjMcvUhd5ynClfwpz5h2gnzdQ1A==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/svelte-check": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.2.0.tgz", + "integrity": "sha512-6ZnscN8dHEN5Eq5LgIzjj07W9nc9myyBH+diXsUAuiY/3rt0l65/LCIQYlIuoFEjp2F1NhXqZiJwV9omPj9tMw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "chokidar": "^3.4.1", + "fast-glob": "^3.2.7", + "import-fresh": "^3.2.1", + "picocolors": "^1.0.0", + "sade": "^1.7.4", + "svelte-preprocess": "^5.0.3", + "typescript": "^5.0.3" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "peerDependencies": { + "svelte": "^3.55.0" + } + }, + "node_modules/svelte-hmr": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", + "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "dev": true, + "engines": { + "node": "^12.20 || ^14.13.1 || >= 16" + }, + "peerDependencies": { + "svelte": ">=3.19.0" + } + }, + "node_modules/svelte-preprocess": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.3.tgz", + "integrity": "sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@types/pug": "^2.0.6", + "detect-indent": "^6.1.0", + "magic-string": "^0.27.0", + "sorcery": "^0.11.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">= 14.10.0" + }, + "peerDependencies": { + "@babel/core": "^7.10.2", + "coffeescript": "^2.5.1", + "less": "^3.11.3 || ^4.0.0", + "postcss": "^7 || ^8", + "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0", + "pug": "^3.0.0", + "sass": "^1.26.8", + "stylus": "^0.55.0", + "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", + "svelte": "^3.23.0", + "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "coffeescript": { + "optional": true + }, + "less": { + "optional": true + }, + "postcss": { + "optional": true + }, + "postcss-load-config": { + "optional": true + }, + "pug": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/svelte-preprocess/node_modules/magic-string": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", + "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.13" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/tiny-glob": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", + "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", + "dev": true, + "dependencies": { + "globalyzer": "0.1.0", + "globrex": "^0.1.2" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "dev": true + }, + "node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } + }, + "node_modules/undici": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.0.tgz", + "integrity": "sha512-fR9RXCc+6Dxav4P9VV/sp5w3eFiSdOjJYsbtWfd4s5L5C4ogyuVpdKIVHeW0vV1MloM65/f7W45nR9ZxwVdyiA==", + "dev": true, + "dependencies": { + "busboy": "^1.6.0" + }, + "engines": { + "node": ">=14.0" + } + }, + "node_modules/vite": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.4.tgz", + "integrity": "sha512-f90aqGBoxSFxWph2b39ae2uHAxm5jFBBdnfueNxZAT1FTpM13ccFQExCaKbR2xFW5atowjleRniQ7onjJ22QEg==", + "dev": true, + "dependencies": { + "esbuild": "^0.17.5", + "postcss": "^8.4.23", + "rollup": "^3.21.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + }, + "peerDependencies": { + "@types/node": ">= 14", + "less": "*", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", + "integrity": "sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==", + "dev": true, + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + } + } +} diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json new file mode 100644 index 000000000..50c2d5eac --- /dev/null +++ b/tests/app/rp/package.json @@ -0,0 +1,29 @@ +{ + "name": "rp", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "lint": "prettier --plugin-search-dir . --check .", + "format": "prettier --plugin-search-dir . --write ." + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^2.0.0", + "@sveltejs/kit": "^1.5.0", + "prettier": "^2.8.0", + "prettier-plugin-svelte": "^2.8.1", + "svelte": "^3.54.0", + "svelte-check": "^3.0.1", + "tslib": "^2.4.1", + "typescript": "^5.0.0", + "vite": "^4.3.0" + }, + "type": "module", + "dependencies": { + "@dopry/svelte-oidc": "^1.1.0" + } +} diff --git a/tests/app/rp/src/app.d.ts b/tests/app/rp/src/app.d.ts new file mode 100644 index 000000000..f59b884c5 --- /dev/null +++ b/tests/app/rp/src/app.d.ts @@ -0,0 +1,12 @@ +// See https://kit.svelte.dev/docs/types#app +// for information about these interfaces +declare global { + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface Platform {} + } +} + +export {}; diff --git a/tests/app/rp/src/app.html b/tests/app/rp/src/app.html new file mode 100644 index 000000000..effe0d0d2 --- /dev/null +++ b/tests/app/rp/src/app.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <link rel="icon" href="%sveltekit.assets%/favicon.png" /> + <meta name="viewport" content="width=device-width" /> + %sveltekit.head% + </head> + <body data-sveltekit-preload-data="hover"> + <div style="display: contents">%sveltekit.body%</div> + </body> +</html> diff --git a/tests/app/rp/src/routes/+page.svelte b/tests/app/rp/src/routes/+page.svelte new file mode 100644 index 000000000..1aeb32372 --- /dev/null +++ b/tests/app/rp/src/routes/+page.svelte @@ -0,0 +1,44 @@ +<script> +import { browser } from '$app/environment'; +import { + OidcContext, + LoginButton, + LogoutButton, + RefreshTokenButton, + authError, + accessToken, + idToken, + isAuthenticated, + isLoading, + login, + logout, + userInfo, +} from '@dopry/svelte-oidc'; + +const metadata = {}; +</script> + +{#if browser} +<OidcContext + issuer="http://127.0.0.1:8000/o" + client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm" + redirect_uri="http://localhost:5173" + post_logout_redirect_uri="http://localhost:5173" + metadata={metadata} + scope="openid" + extraOptions={{ + mergeClaims: true, + }} + > + + <LoginButton>Login</LoginButton> + <LogoutButton>Logout</LogoutButton> + <RefreshTokenButton>RefreshToken</RefreshTokenButton><br /> + <pre>isLoading: {$isLoading}</pre> + <pre>isAuthenticated: {$isAuthenticated}</pre> + <pre>authToken: {$accessToken}</pre> + <pre>idToken: {$idToken}</pre> + <pre>userInfo: {JSON.stringify($userInfo, null, 2)}</pre> + <pre>authError: {$authError}</pre> +</OidcContext> +{/if} diff --git a/tests/app/rp/static/favicon.png b/tests/app/rp/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..825b9e65af7c104cfb07089bb28659393b4f2097 GIT binary patch literal 1571 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 VHuiSbmk|H}002ovPDHLkV1f%e^4kCa literal 0 HcmV?d00001 diff --git a/tests/app/rp/svelte.config.js b/tests/app/rp/svelte.config.js new file mode 100644 index 000000000..1cf26a00d --- /dev/null +++ b/tests/app/rp/svelte.config.js @@ -0,0 +1,18 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/kit/vite'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + // Consult https://kit.svelte.dev/docs/integrations#preprocessors + // for more information about preprocessors + preprocess: vitePreprocess(), + + kit: { + // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. + // If your environment is not supported or you settled on a specific environment, switch out the adapter. + // See https://kit.svelte.dev/docs/adapters for more information about adapters. + adapter: adapter() + } +}; + +export default config; diff --git a/tests/app/rp/tsconfig.jsonc b/tests/app/rp/tsconfig.jsonc new file mode 100644 index 000000000..6ae0c8c44 --- /dev/null +++ b/tests/app/rp/tsconfig.jsonc @@ -0,0 +1,17 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true + } + // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias + // + // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes + // from the referenced tsconfig.json - TypeScript does not merge them in +} diff --git a/tests/app/rp/vite.config.ts b/tests/app/rp/vite.config.ts new file mode 100644 index 000000000..bbf8c7da4 --- /dev/null +++ b/tests/app/rp/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()] +}); From 0986719d3b29841d82f61f33fb9a8ec137867eb3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 12:42:45 -0400 Subject: [PATCH 550/722] Bump postcss from 8.4.23 to 8.4.31 in /tests/app/rp (#1340) Bumps [postcss](https://github.com/postcss/postcss) from 8.4.23 to 8.4.31. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.4.23...8.4.31) --- updated-dependencies: - dependency-name: postcss dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 72001d25f..c1152babf 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -1188,9 +1188,9 @@ } }, "node_modules/postcss": { - "version": "8.4.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.23.tgz", - "integrity": "sha512-bQ3qMcpF6A/YjR55xtoTr0jGOlnPOKAIMdOWiv0EIT6HVPEaJiJB4NLljSbiHoC2RX7DN5Uvjtpbg1NPdwv1oA==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { From efdf89758d04c1ca290d3477e15f4638feb719e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 17 Oct 2023 13:17:49 -0400 Subject: [PATCH 551/722] Bump vite from 4.3.4 to 4.3.9 in /tests/app/rp (#1341) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.3.4 to 4.3.9. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v4.3.9/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 8 ++++---- tests/app/rp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index c1152babf..8db37063d 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -19,7 +19,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.3.0" + "vite": "^4.3.9" } }, "node_modules/@dopry/svelte-oidc": { @@ -1646,9 +1646,9 @@ } }, "node_modules/vite": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.4.tgz", - "integrity": "sha512-f90aqGBoxSFxWph2b39ae2uHAxm5jFBBdnfueNxZAT1FTpM13ccFQExCaKbR2xFW5atowjleRniQ7onjJ22QEg==", + "version": "4.3.9", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz", + "integrity": "sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==", "dev": true, "dependencies": { "esbuild": "^0.17.5", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 50c2d5eac..0001775e7 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -20,7 +20,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.3.0" + "vite": "^4.3.9" }, "type": "module", "dependencies": { From db6942f70206c43911efe83925bc640f261630f7 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 11 Dec 2022 13:43:32 +0400 Subject: [PATCH 552/722] Fix CORS by passing 'Origin' header to OAuthLib It is possible to control CORS by overriding is_origin_allowed method of RequestValidator class. OAuthLib allows origin if: - is_origin_allowed returns True for particular request - Request connection is secure - Request has 'Origin' header --- tests/test_cors.py | 117 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 tests/test_cors.py diff --git a/tests/test_cors.py b/tests/test_cors.py new file mode 100644 index 000000000..4ddc0e141 --- /dev/null +++ b/tests/test_cors.py @@ -0,0 +1,117 @@ +from urllib.parse import parse_qs, urlparse + +import pytest +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase +from django.urls import reverse + +from oauth2_provider.models import get_application_model +from oauth2_provider.oauth2_validators import OAuth2Validator + +from . import presets +from .utils import get_basic_auth_header + + +class CorsOAuth2Validator(OAuth2Validator): + def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): + """Enable CORS in OAuthLib""" + return True + + +Application = get_application_model() +UserModel = get_user_model() + +CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" + +# CORS is allowed for https only +CLIENT_URI = "https://example.org" + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class CorsTest(TestCase): + """ + Test that CORS headers can be managed by OAuthLib. + The objective is: http request 'Origin' header should be passed to OAuthLib + """ + + def setUp(self): + self.factory = RequestFactory() + self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + self.oauth2_settings.PKCE_REQUIRED = False + + self.application = Application.objects.create( + name="Test Application", + redirect_uris=(CLIENT_URI), + user=self.dev_user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, + ) + + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] + self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CorsOAuth2Validator + + def tearDown(self): + self.application.delete() + self.test_user.delete() + self.dev_user.delete() + + def test_cors_header(self): + """ + Test that /token endpoint has Access-Control-Allow-Origin + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["origin"] = CLIENT_URI + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + + def test_no_cors_header(self): + """ + Test that /token endpoint does not have Access-Control-Allow-Origin + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + # No CORS headers, because request did not have Origin + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + + def _get_authorization_code(self): + self.client.login(username="test_user", password="123456") + + # retrieve a valid authorization code + authcode_data = { + "client_id": self.application.client_id, + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "https://example.org", + "response_type": "code", + "allow": True, + } + response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) + query_dict = parse_qs(urlparse(response["Location"]).query) + return query_dict["code"].pop() From 16cb6134325aa96c1a37b8d5c6331cd29b663fda Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 19 Feb 2023 15:34:51 +0300 Subject: [PATCH 553/722] Fixed tests for Access-Control-Allow-Origin header returned by oauthlib --- tests/test_cors.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_cors.py b/tests/test_cors.py index 4ddc0e141..9d7260bc9 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -29,7 +29,7 @@ def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) -class CorsTest(TestCase): +class TestCors(TestCase): """ Test that CORS headers can be managed by OAuthLib. The objective is: http request 'Origin' header should be passed to OAuthLib @@ -74,8 +74,7 @@ def test_cors_header(self): } auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["origin"] = CLIENT_URI - + auth_headers["HTTP_ORIGIN"] = CLIENT_URI response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) From ff2dfa9ec9746979aa0c57059a28967fa1ffa636 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Fri, 29 Sep 2023 22:12:25 +0300 Subject: [PATCH 554/722] Added Allowed Origins application setting --- oauth2_provider/models.py | 2 -- tests/conftest.py | 1 + tests/test_cors.py | 44 +++++++++++++++++++++++++++++++-------- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index c37057e49..a1e7fda52 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -22,7 +22,6 @@ from .utils import jwk_from_pem from .validators import RedirectURIValidator, URIValidator, WildcardSet - logger = logging.getLogger(__name__) @@ -137,7 +136,6 @@ class AbstractApplication(models.Model): help_text=_("Allowed origins list to enable CORS, space separated"), default="", ) - class Meta: abstract = True diff --git a/tests/conftest.py b/tests/conftest.py index d620c3f59..2cc3c3901 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,6 +108,7 @@ def application(): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, + allowed_origins="https://example.com", ) diff --git a/tests/test_cors.py b/tests/test_cors.py index 9d7260bc9..64f2a5fec 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -1,3 +1,4 @@ +import json from urllib.parse import parse_qs, urlparse import pytest @@ -6,18 +7,11 @@ from django.urls import reverse from oauth2_provider.models import get_application_model -from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets from .utils import get_basic_auth_header -class CorsOAuth2Validator(OAuth2Validator): - def is_origin_allowed(self, client_id, origin, request, *args, **kwargs): - """Enable CORS in OAuthLib""" - return True - - Application = get_application_model() UserModel = get_user_model() @@ -50,10 +44,10 @@ def setUp(self): client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, + allowed_origins=CLIENT_URI, ) self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - self.oauth2_settings.OAUTH2_VALIDATOR_CLASS = CorsOAuth2Validator def tearDown(self): self.application.delete() @@ -76,10 +70,42 @@ def test_cors_header(self): auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) auth_headers["HTTP_ORIGIN"] = CLIENT_URI response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + content = json.loads(response.content.decode("utf-8")) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) - def test_no_cors_header(self): + def test_no_cors_header_origin_not_allowed(self): + """ + Test that /token endpoint does not have Access-Control-Allow-Origin + when request origin is not in Application.allowed_origins + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["HTTP_ORIGIN"] = "another_example.org" + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + + def test_no_cors_header_no_origin(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin """ From 0550d93c5ae20a73c3fe8e2be76ec291c3a0c4e3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 29 Sep 2023 19:22:20 +0000 Subject: [PATCH 555/722] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauth2_provider/models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a1e7fda52..c37057e49 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -22,6 +22,7 @@ from .utils import jwk_from_pem from .validators import RedirectURIValidator, URIValidator, WildcardSet + logger = logging.getLogger(__name__) @@ -136,6 +137,7 @@ class AbstractApplication(models.Model): help_text=_("Allowed origins list to enable CORS, space separated"), default="", ) + class Meta: abstract = True From b6de483a9d15214620d86d8d4afdd240b95e3240 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 1 Oct 2023 09:43:01 +0300 Subject: [PATCH 556/722] Code and docs cleanup --- tests/test_cors.py | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/test_cors.py b/tests/test_cors.py index 64f2a5fec..e8eff07a1 100644 --- a/tests/test_cors.py +++ b/tests/test_cors.py @@ -20,6 +20,8 @@ # CORS is allowed for https only CLIENT_URI = "https://example.org" +CLIENT_URI_HTTP = "http://example.org" + @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) @@ -39,7 +41,7 @@ def setUp(self): self.application = Application.objects.create( name="Test Application", - redirect_uris=(CLIENT_URI), + redirect_uris=CLIENT_URI, user=self.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, @@ -85,6 +87,26 @@ def test_cors_header(self): self.assertEqual(response.status_code, 200) self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) + def test_cors_header_no_https(self): + """ + Test that CORS is not allowed if origin uri does not have https:// schema + """ + authorization_code = self._get_authorization_code() + + # exchange authorization code for a valid access token + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": CLIENT_URI, + } + + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + auth_headers["HTTP_ORIGIN"] = CLIENT_URI_HTTP + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.has_header("Access-Control-Allow-Origin")) + def test_no_cors_header_origin_not_allowed(self): """ Test that /token endpoint does not have Access-Control-Allow-Origin From d6c5c5840ef5afc31f3e679c1168c8cb23c85b94 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Sun, 1 Oct 2023 10:13:19 +0300 Subject: [PATCH 557/722] Code cleanup --- tests/conftest.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2cc3c3901..d620c3f59 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -108,7 +108,6 @@ def application(): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, - allowed_origins="https://example.com", ) From 0fc16f7192b264d4662c8a546c4a087b75a9574a Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Tue, 3 Oct 2023 21:18:04 +0300 Subject: [PATCH 558/722] Code review: update docs and test names --- tests/test_cors.py | 164 --------------------------------------------- 1 file changed, 164 deletions(-) delete mode 100644 tests/test_cors.py diff --git a/tests/test_cors.py b/tests/test_cors.py deleted file mode 100644 index e8eff07a1..000000000 --- a/tests/test_cors.py +++ /dev/null @@ -1,164 +0,0 @@ -import json -from urllib.parse import parse_qs, urlparse - -import pytest -from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase -from django.urls import reverse - -from oauth2_provider.models import get_application_model - -from . import presets -from .utils import get_basic_auth_header - - -Application = get_application_model() -UserModel = get_user_model() - -CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" - -# CORS is allowed for https only -CLIENT_URI = "https://example.org" - -CLIENT_URI_HTTP = "http://example.org" - - -@pytest.mark.usefixtures("oauth2_settings") -@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) -class TestCors(TestCase): - """ - Test that CORS headers can be managed by OAuthLib. - The objective is: http request 'Origin' header should be passed to OAuthLib - """ - - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - self.oauth2_settings.PKCE_REQUIRED = False - - self.application = Application.objects.create( - name="Test Application", - redirect_uris=CLIENT_URI, - user=self.dev_user, - client_type=Application.CLIENT_CONFIDENTIAL, - authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, - client_secret=CLEARTEXT_SECRET, - allowed_origins=CLIENT_URI, - ) - - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - - def test_cors_header(self): - """ - Test that /token endpoint has Access-Control-Allow-Origin - """ - authorization_code = self._get_authorization_code() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": CLIENT_URI, - } - - auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["HTTP_ORIGIN"] = CLIENT_URI - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - - content = json.loads(response.content.decode("utf-8")) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) - - token_request_data = { - "grant_type": "refresh_token", - "refresh_token": content["refresh_token"], - "scope": content["scope"], - } - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - self.assertEqual(response["Access-Control-Allow-Origin"], CLIENT_URI) - - def test_cors_header_no_https(self): - """ - Test that CORS is not allowed if origin uri does not have https:// schema - """ - authorization_code = self._get_authorization_code() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": CLIENT_URI, - } - - auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["HTTP_ORIGIN"] = CLIENT_URI_HTTP - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - - self.assertEqual(response.status_code, 200) - self.assertFalse(response.has_header("Access-Control-Allow-Origin")) - - def test_no_cors_header_origin_not_allowed(self): - """ - Test that /token endpoint does not have Access-Control-Allow-Origin - when request origin is not in Application.allowed_origins - """ - authorization_code = self._get_authorization_code() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": CLIENT_URI, - } - - auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - auth_headers["HTTP_ORIGIN"] = "another_example.org" - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - self.assertFalse(response.has_header("Access-Control-Allow-Origin")) - - def test_no_cors_header_no_origin(self): - """ - Test that /token endpoint does not have Access-Control-Allow-Origin - """ - authorization_code = self._get_authorization_code() - - # exchange authorization code for a valid access token - token_request_data = { - "grant_type": "authorization_code", - "code": authorization_code, - "redirect_uri": CLIENT_URI, - } - - auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) - - response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) - self.assertEqual(response.status_code, 200) - # No CORS headers, because request did not have Origin - self.assertFalse(response.has_header("Access-Control-Allow-Origin")) - - def _get_authorization_code(self): - self.client.login(username="test_user", password="123456") - - # retrieve a valid authorization code - authcode_data = { - "client_id": self.application.client_id, - "state": "random_state_string", - "scope": "read write", - "redirect_uri": "https://example.org", - "response_type": "code", - "allow": True, - } - response = self.client.post(reverse("oauth2_provider:authorize"), data=authcode_data) - query_dict = parse_qs(urlparse(response["Location"]).query) - return query_dict["code"].pop() From 94213eafd5170af29c31f6461d7968674ad1de7e Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Wed, 18 Oct 2023 12:50:26 +0300 Subject: [PATCH 559/722] Added ALLOWED_SCHEMES setting for Allowed Orgins validation --- docs/settings.rst | 11 ++++++ oauth2_provider/models.py | 9 +++-- oauth2_provider/settings.py | 1 + oauth2_provider/validators.py | 26 +++++++++++++ tests/test_validators.py | 71 ++++++++++++++++++++++++++++++++++- 5 files changed, 114 insertions(+), 4 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index f31aff533..a7cac94a1 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -63,6 +63,17 @@ assigned ports. Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. +ALLOWED_SCHEMES +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Default: ``["https"]`` + +A list of schemes that the ``allowed_origins`` field will be validated against. +Setting this to ``["https"]`` only in production is strongly recommended. +Adding ``"http"`` to the list is considered to be safe only for local development and testing. +Note that `OAUTHLIB_INSECURE_TRANSPORT <https://oauthlib.readthedocs.io/en/latest/oauth2/security.html#envvar-OAUTHLIB_INSECURE_TRANSPORT>`_ +environment variable should be also set to allow http origins. + APPLICATION_MODEL ~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index c37057e49..e09b41664 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -20,8 +20,7 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings from .utils import jwk_from_pem -from .validators import RedirectURIValidator, URIValidator, WildcardSet - +from .validators import RedirectURIValidator, URIValidator, WildcardSet, AllowedURIValidator logger = logging.getLogger(__name__) @@ -218,7 +217,7 @@ def clean(self): allowed_origins = self.allowed_origins.strip().split() if allowed_origins: # oauthlib allows only https scheme for CORS - validator = URIValidator({"https"}) + validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "Origin") for uri in allowed_origins: validator(uri) @@ -808,6 +807,10 @@ def is_origin_allowed(origin, allowed_origins): """ parsed_origin = urlparse(origin) + + if parsed_origin.scheme not in oauth2_settings.ALLOWED_SCHEMES: + return False + for allowed_origin in allowed_origins: parsed_allowed_origin = urlparse(allowed_origin) if ( diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index aa7de7351..c5af9ebae 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -68,6 +68,7 @@ "REFRESH_TOKEN_ADMIN_CLASS": "oauth2_provider.admin.RefreshTokenAdmin", "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], + "ALLOWED_SCHEMES": ["https"], "OIDC_ENABLED": False, "OIDC_ISS_ENDPOINT": "", "OIDC_USERINFO_ENDPOINT": "", diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 6c8fa3839..9ecced631 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -31,6 +31,32 @@ def __call__(self, value): raise ValidationError("Redirect URIs must not contain fragments") +class AllowedURIValidator(URIValidator): + def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False): + """ + :params schemes: List of allowed schemes. E.g.: ["https"] + :params name: Name of the validater URI required for validation message. E.g.: "Origin" + :params allow_path: If URI can contain path part + :params allow_query: If URI can contain query part + :params allow_fragments: If URI can contain fragments part + """ + super().__init__(schemes=schemes) + self.name = name + self.allow_path = allow_path + self.allow_query = allow_query + self.allow_fragments = allow_fragments + + def __call__(self, value): + super().__call__(value) + value = force_str(value) + scheme, netloc, path, query, fragment = urlsplit(value) + if path and not self.allow_path: + raise ValidationError("{} URIs must not contain path".format(self.name)) + if query and not self.allow_query: + raise ValidationError("{} URIs must not contain query".format(self.name)) + if fragment and not self.allow_fragments: + raise ValidationError("{} URIs must not contain fragments".format(self.name)) + ## # WildcardSet is a special set that contains everything. # This is required in order to move validation of the scheme from diff --git a/tests/test_validators.py b/tests/test_validators.py index 0760e0290..d77e128a3 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,7 +2,7 @@ from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.validators import RedirectURIValidator +from oauth2_provider.validators import RedirectURIValidator, AllowedURIValidator @pytest.mark.usefixtures("oauth2_settings") @@ -36,6 +36,11 @@ def test_validate_custom_uri_scheme(self): # Check ValidationError not thrown validator(uri) + validator = AllowedURIValidator(["my-scheme", "https", "git+ssh"], "Origin") + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] @@ -61,3 +66,67 @@ def test_validate_bad_uris(self): for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) + + def test_validate_good_origin_uris(self): + """ + Test AllowedURIValidator validates origin URIs if they match requirements + """ + validator = AllowedURIValidator( + ["https"], + "Origin", + allow_path=False, + allow_query=False, + allow_fragments=False, + ) + good_uris = [ + "https://example.com", + "https://example.com:8080", + "https://example", + "https://localhost", + "https://1.1.1.1", + "https://127.0.0.1", + "https://255.255.255.255", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + def test_validate_bad_origin_uris(self): + """ + Test AllowedURIValidator rejects origin URIs if they do not match requirements + """ + validator = AllowedURIValidator( + ["https"], + "Origin", + allow_path=False, + allow_query=False, + allow_fragments=False, + ) + bad_uris = [ + "http:/example.com", + "HTTP://localhost", + "HTTP://example.com", + "HTTP://example.com.", + "http://example.com/#fragment", + "123://example.com", + "http://fe80::1", + "git+ssh://example.com", + "my-scheme://example.com", + "uri-without-a-scheme", + "https://example.com/#fragment", + "good://example.com/#fragment", + " ", + "", + # Bad IPv6 URL, urlparse behaves differently for these + 'https://["><script>alert()</script>', + # Origin uri should not contain path, query of fragment parts + # https://www.rfc-editor.org/rfc/rfc6454#section-7.1 + "https:/example.com/", + "https:/example.com/test", + "https:/example.com/?q=test", + "https:/example.com/#test", + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) From 45ea9625e4e3c3b678256c7f0d8dc9615df73c00 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Wed, 18 Oct 2023 22:13:34 +0300 Subject: [PATCH 560/722] Code cleanup --- oauth2_provider/models.py | 2 +- oauth2_provider/validators.py | 1 + tests/test_validators.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index e09b41664..a50972728 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -20,7 +20,7 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings from .utils import jwk_from_pem -from .validators import RedirectURIValidator, URIValidator, WildcardSet, AllowedURIValidator +from .validators import AllowedURIValidator, RedirectURIValidator, WildcardSet logger = logging.getLogger(__name__) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 9ecced631..e69bb27b2 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -57,6 +57,7 @@ def __call__(self, value): if fragment and not self.allow_fragments: raise ValidationError("{} URIs must not contain fragments".format(self.name)) + ## # WildcardSet is a special set that contains everything. # This is required in order to move validation of the scheme from diff --git a/tests/test_validators.py b/tests/test_validators.py index d77e128a3..247e97baa 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,7 +2,7 @@ from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.validators import RedirectURIValidator, AllowedURIValidator +from oauth2_provider.validators import AllowedURIValidator, RedirectURIValidator @pytest.mark.usefixtures("oauth2_settings") From ec61ec27a10b63c8fac747c7dfc9692c657d473d Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Wed, 18 Oct 2023 23:01:34 +0300 Subject: [PATCH 561/722] Add more tests for origin validators --- oauth2_provider/validators.py | 14 +++++++------- tests/conftest.py | 12 ++++++++++++ tests/presets.py | 8 ++++++++ tests/test_models.py | 16 ++++++++++++++++ 4 files changed, 43 insertions(+), 7 deletions(-) diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index e69bb27b2..df3d9e753 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -34,11 +34,11 @@ def __call__(self, value): class AllowedURIValidator(URIValidator): def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False): """ - :params schemes: List of allowed schemes. E.g.: ["https"] - :params name: Name of the validater URI required for validation message. E.g.: "Origin" - :params allow_path: If URI can contain path part - :params allow_query: If URI can contain query part - :params allow_fragments: If URI can contain fragments part + :param schemes: List of allowed schemes. E.g.: ["https"] + :param name: Name of the validated URI. It is required for validation message. E.g.: "Origin" + :param allow_path: If URI can contain path part + :param allow_query: If URI can contain query part + :param allow_fragments: If URI can contain fragments part """ super().__init__(schemes=schemes) self.name = name @@ -50,12 +50,12 @@ def __call__(self, value): super().__call__(value) value = force_str(value) scheme, netloc, path, query, fragment = urlsplit(value) - if path and not self.allow_path: - raise ValidationError("{} URIs must not contain path".format(self.name)) if query and not self.allow_query: raise ValidationError("{} URIs must not contain query".format(self.name)) if fragment and not self.allow_fragments: raise ValidationError("{} URIs must not contain fragments".format(self.name)) + if path and not self.allow_path: + raise ValidationError("{} URIs must not contain path".format(self.name)) ## diff --git a/tests/conftest.py b/tests/conftest.py index d620c3f59..eff48f7fb 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -124,6 +124,18 @@ def public_application(): ) +@pytest.fixture +def cors_application(): + return Application.objects.create( + name="Test CORS Application", + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + algorithm=Application.RS256_ALGORITHM, + client_secret=CLEARTEXT_SECRET, + allowed_origins="https://example.com http://example.com", + ) + + @pytest.fixture def logged_in_client(test_user): from django.test.client import Client diff --git a/tests/presets.py b/tests/presets.py index 1ac8d3279..4538c64eb 100644 --- a/tests/presets.py +++ b/tests/presets.py @@ -57,3 +57,11 @@ "READ_SCOPE": "read", "WRITE_SCOPE": "write", } + +ALLOWED_SCHEMES_DEFAULT = { + "ALLOWED_SCHEMES": ["https"], +} + +ALLOWED_SCHEMES_HTTP = { + "ALLOWED_SCHEMES": ["https", "http"], +} diff --git a/tests/test_models.py b/tests/test_models.py index 4de823b8d..8c62e2c99 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -594,3 +594,19 @@ def test_application_clean(oauth2_settings, application): assert "Enter a valid URL" in str(exc.value) application.allowed_origins = "https://example.com" application.clean() + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_DEFAULT) +def test_application_origin_allowed_default_https(oauth2_settings, cors_application): + """Test that http schemes are not allowed because ALLOWED_SCHEMES allows only https""" + assert cors_application.origin_allowed("https://example.com") + assert not cors_application.origin_allowed("http://example.com") + + +@pytest.mark.django_db +@pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_HTTP) +def test_application_origin_allowed_http(oauth2_settings, cors_application): + """Test that http schemes are allowed because http was added to ALLOWED_SCHEMES""" + assert cors_application.origin_allowed("https://example.com") + assert cors_application.origin_allowed("http://example.com") From 0cf46cd5902e98caeb765ee9dbc6eaf985736e02 Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau <alekseikonstantinov@gmail.com> Date: Wed, 18 Oct 2023 23:18:10 +0300 Subject: [PATCH 562/722] fix coverage --- tests/test_validators.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_validators.py b/tests/test_validators.py index 247e97baa..6cbc23172 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -121,10 +121,10 @@ def test_validate_bad_origin_uris(self): 'https://["><script>alert()</script>', # Origin uri should not contain path, query of fragment parts # https://www.rfc-editor.org/rfc/rfc6454#section-7.1 - "https:/example.com/", - "https:/example.com/test", - "https:/example.com/?q=test", - "https:/example.com/#test", + "https://example.com/", + "https://example.com/test", + "https://example.com/?q=test", + "https://example.com/#test", ] for uri in bad_uris: From 2c83e6cb8e279dafbbd8307ca944af02893a8187 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 19 Oct 2023 18:52:23 +0000 Subject: [PATCH 563/722] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- oauth2_provider/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a50972728..80d8f3487 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -22,6 +22,7 @@ from .utils import jwk_from_pem from .validators import AllowedURIValidator, RedirectURIValidator, WildcardSet + logger = logging.getLogger(__name__) From 584627dfa3853d913288831b3c395793cf80c1e6 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 20 Oct 2023 11:32:24 -0400 Subject: [PATCH 564/722] feat: update test idp to use new cors (#1346) --- tests/app/idp/idp/apps.py | 14 ++++++++++++++ tests/app/idp/idp/settings.py | 14 +++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) create mode 100644 tests/app/idp/idp/apps.py diff --git a/tests/app/idp/idp/apps.py b/tests/app/idp/idp/apps.py new file mode 100644 index 000000000..a9d8e3071 --- /dev/null +++ b/tests/app/idp/idp/apps.py @@ -0,0 +1,14 @@ +from corsheaders.signals import check_request_enabled +from django.apps import AppConfig + + +def cors_allow_origin(sender, request, **kwargs): + return request.path == "/o/userinfo/" or request.path == "/o/userinfo" + + +class IDPAppConfig(AppConfig): + name = "idp" + default = True + + def ready(self): + check_request_enabled.connect(cors_allow_origin) diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index 2331cddb7..9ef6c15a6 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -10,6 +10,7 @@ https://docs.djangoproject.com/en/4.2/ref/settings/ """ +import os from pathlib import Path @@ -32,6 +33,7 @@ # Application definition INSTALLED_APPS = [ + "idp.apps.IDPAppConfig", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", @@ -186,10 +188,10 @@ "SCOPES": { "openid": "OpenID Connect scope", }, + "ALLOWED_SCHEMES": ["https", "http"], } - -# just for this example -CORS_ORIGIN_ALLOW_ALL = True +# needs to be set to allow cors requests from the test app, along with ALLOWED_SCHEMES=["http"] +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" LOGGING = { "version": 1, @@ -210,5 +212,11 @@ "level": "DEBUG", "propagate": False, }, + # occasionally you may want to see what's going on in upstream in oauthlib + # "oauthlib": { + # "handlers": ["console"], + # "level": "DEBUG", + # "propagate": False, + # }, }, } From 4c1367942fcce5a8cb36d7e5fa4e39d481a361cc Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 20 Oct 2023 14:53:34 -0400 Subject: [PATCH 565/722] fix: RedirectURIValidator Encapsulation (#1345) --- CHANGELOG.md | 1 + oauth2_provider/models.py | 11 +- oauth2_provider/oauth2_validators.py | 1 - oauth2_provider/validators.py | 48 +++++- tests/test_models.py | 2 +- tests/test_validators.py | 216 ++++++++++++++++++++++----- 6 files changed, 225 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a61a3ebdb..f516b64c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1322 Instructions in documentation on how to create a code challenge and code verifier * #1284 Allow to logout with no id_token_hint even if the browser session already expired * #1296 Added reverse function in migration 0006_alter_application_client_secret +* #1336 Fix encapsulation for Redirect URI scheme validation ## [2.3.0] 2023-05-31 diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 80d8f3487..661bd7dfc 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -20,7 +20,7 @@ from .scopes import get_scopes_backend from .settings import oauth2_settings from .utils import jwk_from_pem -from .validators import AllowedURIValidator, RedirectURIValidator, WildcardSet +from .validators import AllowedURIValidator logger = logging.getLogger(__name__) @@ -202,12 +202,11 @@ def clean(self): allowed_schemes = set(s.lower() for s in self.get_allowed_schemes()) if redirect_uris: - validator = RedirectURIValidator(WildcardSet()) + validator = AllowedURIValidator( + allowed_schemes, name="redirect uri", allow_path=True, allow_query=True + ) for uri in redirect_uris: validator(uri) - scheme = urlparse(uri).scheme - if scheme not in allowed_schemes: - raise ValidationError(_("Unauthorized redirect scheme: {scheme}").format(scheme=scheme)) elif self.authorization_grant_type in grant_types: raise ValidationError( @@ -218,7 +217,7 @@ def clean(self): allowed_origins = self.allowed_origins.strip().split() if allowed_origins: # oauthlib allows only https scheme for CORS - validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "Origin") + validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin") for uri in allowed_origins: validator(uri) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 00497db9a..61238aef5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -305,7 +305,6 @@ def authenticate_client_id(self, client_id, request, *args, **kwargs): proceed only if the client exists and is not of type "Confidential". """ if self._load_application(client_id, request) is not None: - log.debug("Application %r has type %r" % (client_id, request.client.client_type)) return request.client.client_type != AbstractApplication.CLIENT_CONFIDENTIAL return False diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index df3d9e753..1654dccd7 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -1,4 +1,5 @@ import re +import warnings from urllib.parse import urlsplit from django.core.exceptions import ValidationError @@ -20,6 +21,7 @@ class URIValidator(URLValidator): class RedirectURIValidator(URIValidator): def __init__(self, allowed_schemes, allow_fragments=False): + warnings.warn("This class is deprecated and will be removed in version 2.5.0.", DeprecationWarning) super().__init__(schemes=allowed_schemes) self.allow_fragments = allow_fragments @@ -32,6 +34,8 @@ def __call__(self, value): class AllowedURIValidator(URIValidator): + # TODO: find a way to get these associated with their form fields in place of passing name + # TODO: submit PR to get `cause` included in the parent class ValidationError params` def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False): """ :param schemes: List of allowed schemes. E.g.: ["https"] @@ -47,15 +51,45 @@ def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fra self.allow_fragments = allow_fragments def __call__(self, value): - super().__call__(value) value = force_str(value) - scheme, netloc, path, query, fragment = urlsplit(value) + try: + scheme, netloc, path, query, fragment = urlsplit(value) + except ValueError as e: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": e}, + ) + + # send better validation errors + if scheme not in self.schemes: + raise ValidationError( + "%(name)s URI Validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "invalid_scheme"}, + ) + if query and not self.allow_query: - raise ValidationError("{} URIs must not contain query".format(self.name)) + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "query string not allowed"}, + ) if fragment and not self.allow_fragments: - raise ValidationError("{} URIs must not contain fragments".format(self.name)) + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "fragment not allowed"}, + ) if path and not self.allow_path: - raise ValidationError("{} URIs must not contain path".format(self.name)) + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": "path not allowed"}, + ) + + try: + super().__call__(value) + except ValidationError as e: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={"name": self.name, "value": value, "cause": e}, + ) ## @@ -69,5 +103,9 @@ class WildcardSet(set): A set that always returns True on `in`. """ + def __init__(self, *args, **kwargs): + warnings.warn("This class is deprecated and will be removed in version 2.5.0.", DeprecationWarning) + super().__init__(*args, **kwargs) + def __contains__(self, item): return True diff --git a/tests/test_models.py b/tests/test_models.py index 8c62e2c99..5bcd7d6ba 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -591,7 +591,7 @@ def test_application_clean(oauth2_settings, application): application.allowed_origins = "http://example.com" with pytest.raises(ValidationError) as exc: application.clean() - assert "Enter a valid URL" in str(exc.value) + assert "allowed origin URI Validation error. invalid_scheme: http://example.com" in str(exc.value) application.allowed_origins = "https://example.com" application.clean() diff --git a/tests/test_validators.py b/tests/test_validators.py index 6cbc23172..b2bbb2970 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,7 +2,7 @@ from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.validators import AllowedURIValidator, RedirectURIValidator +from oauth2_provider.validators import AllowedURIValidator, RedirectURIValidator, WildcardSet @pytest.mark.usefixtures("oauth2_settings") @@ -36,11 +36,6 @@ def test_validate_custom_uri_scheme(self): # Check ValidationError not thrown validator(uri) - validator = AllowedURIValidator(["my-scheme", "https", "git+ssh"], "Origin") - for uri in good_uris: - # Check ValidationError not thrown - validator(uri) - def test_validate_bad_uris(self): validator = RedirectURIValidator(allowed_schemes=["https"]) self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] @@ -67,47 +62,73 @@ def test_validate_bad_uris(self): with self.assertRaises(ValidationError): validator(uri) - def test_validate_good_origin_uris(self): - """ - Test AllowedURIValidator validates origin URIs if they match requirements - """ - validator = AllowedURIValidator( - ["https"], - "Origin", - allow_path=False, - allow_query=False, - allow_fragments=False, - ) + def test_validate_wildcard_scheme__bad_uris(self): + validator = RedirectURIValidator(allowed_schemes=WildcardSet()) + bad_uris = [ + "http:/example.com#fragment", + "HTTP://localhost#fragment", + "http://example.com/#fragment", + "good://example.com/#fragment", + " ", + "", + # Bad IPv6 URL, urlparse behaves differently for these + 'https://["><script>alert()</script>', + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError, msg=uri): + validator(uri) + + def test_validate_wildcard_scheme_good_uris(self): + validator = RedirectURIValidator(allowed_schemes=WildcardSet()) good_uris = [ + "my-scheme://example.com", + "my-scheme://example", + "my-scheme://localhost", "https://example.com", - "https://example.com:8080", - "https://example", - "https://localhost", - "https://1.1.1.1", - "https://127.0.0.1", - "https://255.255.255.255", + "HTTPS://example.com", + "HTTPS://example.com.", + "git+ssh://example.com", + "ANY://localhost", + "scheme://example.com", + "at://example.com", + "all://example.com", ] for uri in good_uris: # Check ValidationError not thrown validator(uri) - def test_validate_bad_origin_uris(self): - """ - Test AllowedURIValidator rejects origin URIs if they do not match requirements - """ - validator = AllowedURIValidator( - ["https"], - "Origin", - allow_path=False, - allow_query=False, - allow_fragments=False, - ) + +@pytest.mark.usefixtures("oauth2_settings") +class TestAllowedURIValidator(TestCase): + # TODO: verify the specifics of the ValidationErrors + def test_valid_schemes(self): + validator = AllowedURIValidator(["my-scheme", "https", "git+ssh"], "test") + good_uris = [ + "my-scheme://example.com", + "my-scheme://example", + "my-scheme://localhost", + "https://example.com", + "HTTPS://example.com", + "git+ssh://example.com", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + def test_invalid_schemes(self): + validator = AllowedURIValidator(["https"], "test") bad_uris = [ "http:/example.com", "HTTP://localhost", "HTTP://example.com", + "https://-exa", # triggers an exception in the upstream validators + "HTTP://example.com/path", + "HTTP://example.com/path?query=string", + "HTTP://example.com/path?query=string#fragmemt", "HTTP://example.com.", - "http://example.com/#fragment", + "http://example.com/path/#fragment", + "http://example.com?query=string#fragment", "123://example.com", "http://fe80::1", "git+ssh://example.com", @@ -119,12 +140,125 @@ def test_validate_bad_origin_uris(self): "", # Bad IPv6 URL, urlparse behaves differently for these 'https://["><script>alert()</script>', - # Origin uri should not contain path, query of fragment parts - # https://www.rfc-editor.org/rfc/rfc6454#section-7.1 - "https://example.com/", - "https://example.com/test", - "https://example.com/?q=test", - "https://example.com/#test", + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) + + def test_allow_paths_valid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_path=True) + good_uris = [ + "https://example.com", + "https://example.com:8080", + "https://example", + "https://example.com/path", + "https://example.com:8080/path", + "https://example/path", + "https://localhost/path", + "myapp://host/path", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + def test_allow_paths_invalid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_path=True) + bad_uris = [ + "https://example.com?query=string", + "https://example.com#fragment", + "https://example.com/path?query=string", + "https://example.com/path#fragment", + "https://example.com/path?query=string#fragment", + "myapp://example.com/path?query=string", + "myapp://example.com/path#fragment", + "myapp://example.com/path?query=string#fragment", + "bad://example.com/path", + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) + + def test_allow_query_valid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_query=True) + good_uris = [ + "https://example.com", + "https://example.com:8080", + "https://example.com?query=string", + "https://example", + "myapp://example.com?query=string", + "myapp://example?query=string", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + def test_allow_query_invalid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_query=True) + bad_uris = [ + "https://example.com/path", + "https://example.com#fragment", + "https://example.com/path?query=string", + "https://example.com/path#fragment", + "https://example.com/path?query=string#fragment", + "https://example.com:8080/path", + "https://example/path", + "https://localhost/path", + "myapp://example.com/path?query=string", + "myapp://example.com/path#fragment", + "myapp://example.com/path?query=string#fragment", + "bad://example.com/path", + ] + + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) + + def test_allow_fragment_valid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_fragments=True) + good_uris = [ + "https://example.com", + "https://example.com#fragment", + "https://example.com:8080", + "https://example.com:8080#fragment", + "https://example", + "https://example#fragment", + "myapp://example", + "myapp://example#fragment", + "myapp://example.com", + "myapp://example.com#fragment", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + def test_allow_fragment_invalid_urls(self): + validator = AllowedURIValidator(["https", "myapp"], "test", allow_fragments=True) + bad_uris = [ + "https://example.com?query=string", + "https://example.com?query=string#fragment", + "https://example.com/path", + "https://example.com/path?query=string", + "https://example.com/path#fragment", + "https://example.com/path?query=string#fragment", + "https://example.com:8080/path", + "https://example?query=string", + "https://example?query=string#fragment", + "https://example/path", + "https://example/path?query=string", + "https://example/path#fragment", + "https://example/path?query=string#fragment", + "myapp://example?query=string", + "myapp://example?query=string#fragment", + "myapp://example/path", + "myapp://example/path?query=string", + "myapp://example/path#fragment", + "myapp://example.com/path?query=string", + "myapp://example.com/path#fragment", + "myapp://example.com/path?query=string#fragment", + "myapp://example.com?query=string", + "bad://example.com", ] for uri in bad_uris: From 9b91d79d26447d59efec5ee335da91c12069fdb0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Oct 2023 10:13:43 -0400 Subject: [PATCH 566/722] Bump crypto-js from 4.1.1 to 4.2.0 in /tests/app/rp (#1349) Bumps [crypto-js](https://github.com/brix/crypto-js) from 4.1.1 to 4.2.0. - [Commits](https://github.com/brix/crypto-js/compare/4.1.1...4.2.0) --- updated-dependencies: - dependency-name: crypto-js dependency-type: indirect ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 8db37063d..220d2dd44 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -694,9 +694,9 @@ } }, "node_modules/crypto-js": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.1.1.tgz", - "integrity": "sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==" + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, "node_modules/debug": { "version": "4.3.4", From 854204bfa944a06483ddaaffe1105776a6728237 Mon Sep 17 00:00:00 2001 From: Asaf Klibansky <discobeta@gmail.com> Date: Sat, 28 Oct 2023 02:25:14 -0400 Subject: [PATCH 567/722] Fix access token 500 (#1337) * try/except when looking for an access token to avoid 500 * try/except when looking for an access token to avoid 500 * adding additionnal tests * adding a test for using a deleted token * returning an empty array, updating tests * updating CHANGELOG and AUTHORS --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/oauth2_validators.py | 6 +++-- tests/test_authorization_code.py | 33 ++++++++++++++++++++++++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/AUTHORS b/AUTHORS index bbceaadb0..ef4e773f5 100644 --- a/AUTHORS +++ b/AUTHORS @@ -26,6 +26,7 @@ Antoine Laurent Anvesh Agarwal Aristóbulo Meneses Aryan Iyappan +Asaf Klibansky Ash Christopher Asif Saif Uddin Bart Merenda diff --git a/CHANGELOG.md b/CHANGELOG.md index f516b64c7..dddfe00e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1273 Add caching of loading of OIDC private key. * #1285 Add post_logout_redirect_uris field in application views. * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. +* #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. - ### Fixed * #1322 Instructions in documentation on how to create a code challenge and code verifier diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 61238aef5..4b7fccaea 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -725,8 +725,10 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs): # validate_refresh_token. rt = request.refresh_token_instance if not rt.access_token_id: - return AccessToken.objects.get(source_refresh_token_id=rt.id).scope - + try: + return AccessToken.objects.get(source_refresh_token_id=rt.id).scope + except AccessToken.DoesNotExist: + return [] return rt.access_token.scope def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs): diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index b27eb8b67..087627fba 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1002,6 +1002,39 @@ def test_refresh_repeating_requests_non_rotating_tokens(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 200) + def test_refresh_with_deleted_token(self): + """ + Ensure that using a deleted refresh token returns 400 + """ + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "scope": "read write", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + + # get a refresh token + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + + content = json.loads(response.content.decode("utf-8")) + rt = content["refresh_token"] + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": rt, + "scope": "read write", + } + + # delete the access token + AccessToken.objects.filter(token=content["access_token"]).delete() + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + def test_basic_auth_bad_authcode(self): """ Request an access token using a bad authorization code From d66608bb72ddcf42b69a324306388a698dee4545 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Oct 2023 15:09:52 -0400 Subject: [PATCH 568/722] [pre-commit.ci] pre-commit autoupdate (#1338) --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d746cd662..44b9733aa 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.9.1 + rev: 23.10.1 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.6.8 + rev: v0.8.1 hooks: - id: sphinx-lint From f580e2e46bcafda6c1c0373701c05cde3b3da722 Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Fri, 10 Nov 2023 16:38:18 +0000 Subject: [PATCH 569/722] Upgrade GitHub Actions (#1351) --- .github/workflows/release.yml | 6 +++--- .github/workflows/test.yml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 25051eaff..8d4683cfd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,14 +11,14 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: '3.12' - name: Install dependencies run: | diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 00707f35b..84bfe64ed 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,7 +35,7 @@ jobs: django-version: 'main' steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 From fa4bcdf3030fc2ef0db97a3652450b86d668b087 Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Fri, 10 Nov 2023 17:26:27 +0000 Subject: [PATCH 570/722] Update supported versions, remove eol dj22 and py37, add dj5 and py12 (#1350) * Update tested versions * chore: remove remaining assertEquals --- .github/workflows/test.yml | 43 +++++++++++++++++++++------------ CHANGELOG.md | 6 ++++- README.rst | 4 +-- oauth2_provider/__init__.py | 6 ----- setup.cfg | 7 +++--- tests/test_application_views.py | 26 ++++++++++---------- tox.ini | 14 ++++++----- 7 files changed, 59 insertions(+), 47 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 84bfe64ed..86a21bc27 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,31 +9,42 @@ jobs: strategy: fail-fast: false matrix: - python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] - django-version: ['2.2', '3.2', '4.0', '4.1', '4.2', 'main'] + python-version: + - '3.8' + - '3.9' + - '3.10' + - '3.11' + - '3.12' + django-version: + - '3.2' + - '4.0' + - '4.1' + - '4.2' + - '5.0' + - 'main' exclude: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django - # Python 3.10+ is not supported by Django 2.2 - - python-version: '3.10' - django-version: '2.2' - - # Python 3.7 is not supported by Django 4.0+ - - python-version: '3.7' - django-version: '4.0' - - python-version: '3.7' - django-version: '4.1' - - python-version: '3.7' - django-version: '4.2' - - python-version: '3.7' - django-version: 'main' - # < Python 3.10 is not supported by Django 5.0+ + - python-version: '3.8' + django-version: '5.0' + - python-version: '3.9' + django-version: '5.0' - python-version: '3.8' django-version: 'main' - python-version: '3.9' django-version: 'main' + # Python 3.12 is not supported by Django < 5.0 + - python-version: '3.12' + django-version: '3.2' + - python-version: '3.12' + django-version: '4.0' + - python-version: '3.12' + django-version: '4.1' + - python-version: '3.12' + django-version: '4.2' + steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index dddfe00e5..6b4275a6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,13 +25,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1285 Add post_logout_redirect_uris field in application views. * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. +* #1350 Support Python 3.12 and Django 5.0 -- ### Fixed +### Fixed * #1322 Instructions in documentation on how to create a code challenge and code verifier * #1284 Allow to logout with no id_token_hint even if the browser session already expired * #1296 Added reverse function in migration 0006_alter_application_client_secret * #1336 Fix encapsulation for Redirect URI scheme validation +### Removed +* #1350 Remove support for Python 3.7 and Django 2.2 + ## [2.3.0] 2023-05-31 ### WARNING diff --git a/README.rst b/README.rst index 15ff04f7b..cbeedf1b4 100644 --- a/README.rst +++ b/README.rst @@ -43,8 +43,8 @@ Please report any security issues to the JazzBand security team at <security@jaz Requirements ------------ -* Python 3.7+ -* Django 2.2, 3.2, 4.0 (4.0.1+ due to a regression), 4.1, or 4.2 +* Python 3.8+ +* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0 * oauthlib 3.1+ Installation diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index ebd93203d..55e470907 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1,7 +1 @@ -import django - - __version__ = "2.3.0" - -if django.VERSION < (3, 2): - default_app_config = "oauth2_provider.apps.DOTConfig" diff --git a/setup.cfg b/setup.cfg index 8acc93c9a..453126c28 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,30 +12,31 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 2.2 Framework :: Django :: 3.2 Framework :: Django :: 4.0 Framework :: Django :: 4.1 Framework :: Django :: 4.2 + Framework :: Django :: 5.0 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent Programming Language :: Python :: 3 - Programming Language :: Python :: 3.7 Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 Programming Language :: Python :: 3.11 + Programming Language :: Python :: 3.12 Topic :: Internet :: WWW/HTTP [options] packages = find: include_package_data = True zip_safe = False +python_requires = >=3.8 # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 2.2, != 4.0.0 + django >= 3.2, != 4.0.0 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 560c68cdb..9b277bc71 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -57,13 +57,13 @@ def test_application_registration_user(self): app = get_application_model().objects.get(name="Foo app") self.assertEqual(app.user.username, "foo_user") app = Application.objects.get() - self.assertEquals(app.name, form_data["name"]) - self.assertEquals(app.client_id, form_data["client_id"]) - self.assertEquals(app.redirect_uris, form_data["redirect_uris"]) - self.assertEquals(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) - self.assertEquals(app.client_type, form_data["client_type"]) - self.assertEquals(app.authorization_grant_type, form_data["authorization_grant_type"]) - self.assertEquals(app.algorithm, form_data["algorithm"]) + self.assertEqual(app.name, form_data["name"]) + self.assertEqual(app.client_id, form_data["client_id"]) + self.assertEqual(app.redirect_uris, form_data["redirect_uris"]) + self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEqual(app.client_type, form_data["client_type"]) + self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"]) + self.assertEqual(app.algorithm, form_data["algorithm"]) class TestApplicationViews(BaseTest): @@ -115,7 +115,7 @@ def test_application_detail_not_owner(self): response = self.client.get(reverse("oauth2_provider:detail", args=(self.app_bar_1.pk,))) self.assertEqual(response.status_code, 404) - def test_application_udpate(self): + def test_application_update(self): self.client.login(username="foo_user", password="123456") form_data = { @@ -132,8 +132,8 @@ def test_application_udpate(self): self.assertRedirects(response, reverse("oauth2_provider:detail", args=(self.app_foo_1.pk,))) self.app_foo_1.refresh_from_db() - self.assertEquals(self.app_foo_1.client_id, form_data["client_id"]) - self.assertEquals(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) - self.assertEquals(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) - self.assertEquals(self.app_foo_1.client_type, form_data["client_type"]) - self.assertEquals(self.app_foo_1.authorization_grant_type, form_data["authorization_grant_type"]) + self.assertEqual(self.app_foo_1.client_id, form_data["client_id"]) + self.assertEqual(self.app_foo_1.redirect_uris, form_data["redirect_uris"]) + self.assertEqual(self.app_foo_1.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEqual(self.app_foo_1.client_type, form_data["client_type"]) + self.assertEqual(self.app_foo_1.authorization_grant_type, form_data["authorization_grant_type"]) diff --git a/tox.ini b/tox.ini index cf9390b32..61b983b5b 100644 --- a/tox.ini +++ b/tox.ini @@ -5,20 +5,20 @@ envlist = migrate_swapped, docs, sphinxlint, - py{37,38,39}-dj22, - py{37,38,39,310}-dj32, + py{38,39,310}-dj32, py{38,39,310}-dj40, py{38,39,310,311}-dj41, - py{38,39,310,311}-dj42, - py{310,311}-djmain, + py{38,39,310,311,312}-dj42, + py{310,311,312}-dj50, + py{310,311,312}-djmain, [gh-actions] python = - 3.7: py37 3.8: py38, docs, flake8, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 3.11: py311 + 3.12: py312 [gh-actions:env] DJANGO = @@ -27,6 +27,7 @@ DJANGO = 4.0: dj40 4.1: dj41 4.2: dj42 + 5.0: dj50 main: djmain [pytest] @@ -54,6 +55,7 @@ deps = dj40: Django>=4.0.0,<4.1 dj41: Django>=4.1,<4.2 dj42: Django>=4.2,<4.3 + dj50: Django>=5.0b1,<5.1 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 @@ -68,7 +70,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{38,39,310}-djmain] +[testenv:py{310,311,312}-djmain] ignore_errors = true ignore_outcome = true From e15e245174528706f9c3a4edfa908c2cd6d8b92d Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Fri, 10 Nov 2023 17:40:57 +0000 Subject: [PATCH 571/722] Speed up tests ~2x (#1352) --- tests/test_application_views.py | 36 ++++++++----------- tests/test_auth_backends.py | 31 ++++++---------- tests/test_authorization_code.py | 48 +++++++++++++------------ tests/test_client_credential.py | 27 +++++++------- tests/test_decorators.py | 20 +++++------ tests/test_hybrid.py | 25 +++++++------ tests/test_implicit.py | 28 +++++++-------- tests/test_introspection_auth.py | 32 ++++++++--------- tests/test_introspection_view.py | 42 ++++++++++------------ tests/test_models.py | 60 +++++++++++++++---------------- tests/test_oauth2_backends.py | 39 ++++++++++---------- tests/test_oauth2_validators.py | 11 +++--- tests/test_password.py | 19 +++++----- tests/test_rest_framework.py | 17 ++++----- tests/test_scopes.py | 19 +++++----- tests/test_token_endpoint_cors.py | 22 +++++------- tests/test_token_revocation.py | 19 +++++----- tests/test_token_view.py | 15 ++++---- 18 files changed, 230 insertions(+), 280 deletions(-) diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 9b277bc71..c8c145d9b 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -14,13 +14,10 @@ class BaseTest(TestCase): - def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") - - def tearDown(self): - self.foo_user.delete() - self.bar_user.delete() + @classmethod + def setUpTestData(cls): + cls.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") + cls.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") @pytest.mark.usefixtures("oauth2_settings") @@ -67,8 +64,9 @@ def test_application_registration_user(self): class TestApplicationViews(BaseTest): - def _create_application(self, name, user): - app = Application.objects.create( + @classmethod + def _create_application(cls, name, user): + return Application.objects.create( name=name, redirect_uris="http://example.com", post_logout_redirect_uris="http://other_example.com", @@ -76,20 +74,16 @@ def _create_application(self, name, user): authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, user=user, ) - return app - - def setUp(self): - super().setUp() - self.app_foo_1 = self._create_application("app foo_user 1", self.foo_user) - self.app_foo_2 = self._create_application("app foo_user 2", self.foo_user) - self.app_foo_3 = self._create_application("app foo_user 3", self.foo_user) - self.app_bar_1 = self._create_application("app bar_user 1", self.bar_user) - self.app_bar_2 = self._create_application("app bar_user 2", self.bar_user) + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.app_foo_1 = cls._create_application("app foo_user 1", cls.foo_user) + cls.app_foo_2 = cls._create_application("app foo_user 2", cls.foo_user) + cls.app_foo_3 = cls._create_application("app foo_user 3", cls.foo_user) - def tearDown(self): - super().tearDown() - get_application_model().objects.all().delete() + cls.app_bar_1 = cls._create_application("app bar_user 1", cls.bar_user) + cls.app_bar_2 = cls._create_application("app bar_user 2", cls.bar_user) def test_application_list(self): self.client.login(username="foo_user", password="123456") diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index 6b958ecb0..b0ff145ab 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -24,23 +24,20 @@ class BaseTest(TestCase): Base class for cases in this module """ - def setUp(self): - self.user = UserModel.objects.create_user("user", "test@example.com", "123456") - self.app = ApplicationModel.objects.create( + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.user = UserModel.objects.create_user("user", "test@example.com", "123456") + cls.app = ApplicationModel.objects.create( name="app", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_CLIENT_CREDENTIALS, - user=self.user, + user=cls.user, ) - self.token = AccessTokenModel.objects.create( - user=self.user, token="tokstr", application=self.app, expires=now() + timedelta(days=365) + cls.token = AccessTokenModel.objects.create( + user=cls.user, token="tokstr", application=cls.app, expires=now() + timedelta(days=365) ) - self.factory = RequestFactory() - - def tearDown(self): - self.user.delete() - self.app.delete() - self.token.delete() class TestOAuth2Backend(BaseTest): @@ -103,10 +100,6 @@ def test_get_user(self): } ) class TestOAuth2Middleware(BaseTest): - def setUp(self): - super().setUp() - self.anon_user = AnonymousUser() - def dummy_get_response(self, request): return HttpResponse() @@ -131,7 +124,7 @@ def test_middleware_user_is_set(self): request.user = self.user m(request) self.assertIs(request.user, self.user) - request.user = self.anon_user + request.user = AnonymousUser() m(request) self.assertEqual(request.user.pk, self.user.pk) @@ -176,10 +169,6 @@ def test_middleware_response_header(self): } ) class TestOAuth2ExtraTokenMiddleware(BaseTest): - def setUp(self): - super().setUp() - self.anon_user = AnonymousUser() - def dummy_get_response(self, request): return HttpResponse() diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 087627fba..9d71016d3 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -43,29 +43,27 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] - self.oauth2_settings.PKCE_REQUIRED = False + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() + def setUp(self): + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + self.oauth2_settings.PKCE_REQUIRED = False class TestRegressionIssue315(BaseTest): @@ -1592,10 +1590,11 @@ def test_code_exchange_succeed_when_redirect_uri_match_with_multiple_query_param @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeTokenView(BaseAuthorizationCodeTokenView): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.RS256_ALGORITHM + cls.application.save() def test_id_token_public(self): """ @@ -1669,11 +1668,15 @@ def test_id_token_code_exchange_succeed_when_redirect_uri_match_with_multiple_qu @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeHSAlgorithm(BaseAuthorizationCodeTokenView): + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.HS256_ALGORITHM + cls.application.save() + def setUp(self): super().setUp() self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None - self.application.algorithm = Application.HS256_ALGORITHM - self.application.save() def test_id_token(self): """ @@ -1765,10 +1768,11 @@ def test_resource_access_deny(self): @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeProtectedResource(BaseTest): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.RS256_ALGORITHM + cls.application.save() def test_id_token_resource_access_allowed(self): self.client.login(username="test_user", password="123456") diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 38265c3d9..4c6e384d0 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -35,24 +35,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( name="test_client_credentials_app", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestClientCredential(BaseTest): def test_client_credential_access_allowed(self): @@ -98,7 +95,7 @@ def test_client_credential_user_is_none_on_access_token(self): self.assertIsNone(access_token.user) -class TestView(OAuthLibMixin, View): +class ExampleView(OAuthLibMixin, View): server_class = BackendApplicationServer validator_class = OAuth2Validator oauthlib_backend_class = OAuthLibCore @@ -132,7 +129,7 @@ def test_extended_request(self): request = self.request_factory.get("/fake-req", **auth_headers) request.user = "fake" - test_view = TestView() + test_view = ExampleView() self.assertIsInstance(test_view.get_server(), BackendApplicationServer) valid, r = test_view.verify_request(request) @@ -145,7 +142,7 @@ def test_raises_error_with_invalid_hex_in_query_params(self): request = self.request_factory.get("/fake-req?auth_token=%%7A") with pytest.raises(SuspiciousOperation): - TestView().verify_request(request) + ExampleView().verify_request(request) @patch("oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core") def test_reraises_value_errors_as_is(self, patched_core): @@ -154,7 +151,7 @@ def test_reraises_value_errors_as_is(self, patched_core): request = self.request_factory.get("/fake-req") with pytest.raises(ValueError): - TestView().verify_request(request) + ExampleView().verify_request(request) class TestClientResourcePasswordBased(BaseTest): diff --git a/tests/test_decorators.py b/tests/test_decorators.py index ce17a891a..a8ee788b5 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -14,26 +14,24 @@ class TestProtectedResourceDecorator(TestCase): - @classmethod - def setUpClass(cls): - cls.request_factory = RequestFactory() - super().setUpClass() + request_factory = RequestFactory() - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.application = Application.objects.create( name="test_client_credentials_app", - user=self.user, + user=cls.user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, ) - self.access_token = AccessToken.objects.create( - user=self.user, + cls.access_token = AccessToken.objects.create( + user=cls.user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application, + application=cls.application, ) def test_access_denied(self): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index be631d09c..40cd8c56f 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -48,30 +48,29 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") - self.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - self.oauth2_settings.PKCE_REQUIRED = False - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.hy_test_user = UserModel.objects.create_user("hy_test_user", "test_hy@example.com", "123456") + cls.hy_dev_user = UserModel.objects.create_user("hy_dev_user", "dev_hy@example.com", "123456") - self.application = Application( + cls.application = Application( name="Hybrid Test Application", redirect_uris=( "http://localhost http://example.com http://example.org custom-scheme://example.com" ), - user=self.hy_dev_user, + user=cls.hy_dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_OPENID_HYBRID, algorithm=Application.RS256_ALGORITHM, client_secret=CLEARTEXT_SECRET, ) - self.application.save() + cls.application.save() - def tearDown(self): - self.application.delete() - self.hy_test_user.delete() - self.hy_dev_user.delete() + def setUp(self): + self.oauth2_settings.PKCE_REQUIRED = False + self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["http", "custom-scheme"] @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) diff --git a/tests/test_implicit.py b/tests/test_implicit.py index e4340a18f..7d710e9a1 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -25,24 +25,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( name="Test Implicit Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_IMPLICIT, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - @pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RO) class TestImplicitAuthorizationCodeView(BaseTest): @@ -276,10 +273,11 @@ def test_resource_access_allowed(self): @pytest.mark.usefixtures("oidc_key") @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOpenIDConnectImplicitFlow(BaseTest): - def setUp(self): - super().setUp() - self.application.algorithm = Application.RS256_ALGORITHM - self.application.save() + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application.algorithm = Application.RS256_ALGORITHM + cls.application.save() def test_id_token_post_auth_allow(self): """ diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 8b2a6daf0..c4f8231d5 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -89,45 +89,41 @@ class TestTokenIntrospectionAuth(TestCase): Tests for Authorization through token introspection """ - def setUp(self): - self.validator = OAuth2Validator() - self.request = mock.MagicMock(wraps=Request) - self.resource_server_user = UserModel.objects.create_user( + @classmethod + def setUpTestData(cls): + cls.validator = OAuth2Validator() + cls.request = mock.MagicMock(wraps=Request) + cls.resource_server_user = UserModel.objects.create_user( "resource_server", "test@example.com", "123456" ) - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.resource_server_user, + user=cls.resource_server_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.resource_server_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678900", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) - self.invalid_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.invalid_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678901", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) + def setUp(self): self.oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN = self.resource_server_token.token - def tearDown(self): - self.resource_server_token.delete() - self.application.delete() - AccessToken.objects.all().delete() - UserModel.objects.all().delete() - @mock.patch("requests.post", side_effect=mocked_requests_post) def test_get_token_from_authentication_server_not_existing_token(self, mock_get): """ diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index b19c521d5..b82e922be 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -27,64 +27,60 @@ class TestTokenIntrospectionViews(TestCase): Tests for Authorized Token Introspection Views """ - def setUp(self): - self.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") - self.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") + @classmethod + def setUpTestData(cls): + cls.resource_server_user = UserModel.objects.create_user("resource_server", "test@example.com") + cls.test_user = UserModel.objects.create_user("bar_user", "dev@example.com") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.test_user, + user=cls.test_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - self.resource_server_token = AccessToken.objects.create( - user=self.resource_server_user, + cls.resource_server_token = AccessToken.objects.create( + user=cls.resource_server_user, token="12345678900", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="introspection", ) - self.valid_token = AccessToken.objects.create( - user=self.test_user, + cls.valid_token = AccessToken.objects.create( + user=cls.test_user, token="12345678901", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) - self.invalid_token = AccessToken.objects.create( - user=self.test_user, + cls.invalid_token = AccessToken.objects.create( + user=cls.test_user, token="12345678902", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=-1), scope="read write dolphin", ) - self.token_without_user = AccessToken.objects.create( + cls.token_without_user = AccessToken.objects.create( user=None, token="12345678903", - application=self.application, + application=cls.application, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) - self.token_without_app = AccessToken.objects.create( - user=self.test_user, + cls.token_without_app = AccessToken.objects.create( + user=cls.test_user, token="12345678904", application=None, expires=timezone.now() + datetime.timedelta(days=1), scope="read write dolphin", ) - def tearDown(self): - AccessToken.objects.all().delete() - Application.objects.all().delete() - UserModel.objects.all().delete() - def test_view_forbidden(self): """ Test that the view is restricted for logged-in users. diff --git a/tests/test_models.py b/tests/test_models.py index 5bcd7d6ba..586bef124 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -31,11 +31,9 @@ class BaseTestModels(TestCase): - def setUp(self): - self.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - - def tearDown(self): - self.user.delete() + @classmethod + def setUpTestData(cls): + cls.user = UserModel.objects.create_user("test_user", "test@example.com", "123456") class TestModels(BaseTestModels): @@ -252,20 +250,17 @@ def test_custom_grant_model_not_installed(self): class TestGrantModel(BaseTestModels): - def setUp(self): - super().setUp() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.application = Application.objects.create( name="Test Application", redirect_uris="", - user=self.user, + user=cls.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - def tearDown(self): - self.application.delete() - super().tearDown() - def test_str(self): grant = Grant(code="test_code") self.assertEqual("%s" % grant, grant.code) @@ -324,32 +319,33 @@ def test_str(self): @pytest.mark.usefixtures("oauth2_settings") class TestClearExpired(BaseTestModels): - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): + super().setUpTestData() # Insert many tokens, both expired and not, and grants. - self.num_tokens = 100 - self.delta_secs = 1000 - self.now = timezone.now() - self.earlier = self.now - timedelta(seconds=self.delta_secs) - self.later = self.now + timedelta(seconds=self.delta_secs) + cls.num_tokens = 100 + cls.delta_secs = 1000 + cls.now = timezone.now() + cls.earlier = cls.now - timedelta(seconds=cls.delta_secs) + cls.later = cls.now + timedelta(seconds=cls.delta_secs) app = Application.objects.create( name="test_app", redirect_uris="http://localhost http://example.com http://example.org", - user=self.user, + user=cls.user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) # make 200 access tokens, half current and half expired. expired_access_tokens = [ - AccessToken(token="expired AccessToken {}".format(i), expires=self.earlier) - for i in range(self.num_tokens) + AccessToken(token="expired AccessToken {}".format(i), expires=cls.earlier) + for i in range(cls.num_tokens) ] for a in expired_access_tokens: a.save() current_access_tokens = [ - AccessToken(token=f"current AccessToken {i}", expires=self.later) for i in range(self.num_tokens) + AccessToken(token=f"current AccessToken {i}", expires=cls.later) for i in range(cls.num_tokens) ] for a in current_access_tokens: a.save() @@ -361,7 +357,7 @@ def setUp(self): token=f"expired AT's refresh token {i}", application=app, access_token=expired_access_tokens[i], - user=self.user, + user=cls.user, ).save() for i in range(1, len(current_access_tokens) // 2, 2): @@ -369,24 +365,24 @@ def setUp(self): token=f"current AT's refresh token {i}", application=app, access_token=current_access_tokens[i], - user=self.user, + user=cls.user, ).save() # Make some grants, half of which are expired. - for i in range(self.num_tokens): + for i in range(cls.num_tokens): Grant( - user=self.user, + user=cls.user, code=f"old grant code {i}", application=app, - expires=self.earlier, + expires=cls.earlier, redirect_uri="https://localhost/redirect", ).save() - for i in range(self.num_tokens): + for i in range(cls.num_tokens): Grant( - user=self.user, + user=cls.user, code=f"new grant code {i}", application=app, - expires=self.later, + expires=cls.later, redirect_uri="https://localhost/redirect", ).save() diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 03f288e9b..21dd7a0c3 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -19,9 +19,11 @@ @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackend(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.oauthlib_core = OAuthLibCore() + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.oauthlib_core = OAuthLibCore() def test_swappable_server_class(self): self.oauth2_settings.OAUTH2_SERVER_CLASS = mock.MagicMock @@ -60,23 +62,21 @@ def test_application_json_extract_params(self): @pytest.mark.usefixtures("oauth2_settings") class TestOAuthLibCoreBackendErrorHandling(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.oauthlib_core = OAuthLibCore() - self.user = UserModel.objects.create_user("john", "test@example.com", "123456") - self.app = ApplicationModel.objects.create( + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.oauthlib_core = OAuthLibCore() + cls.user = UserModel.objects.create_user("john", "test@example.com", "123456") + cls.app = ApplicationModel.objects.create( name="app", client_id="app_id", client_secret="app_secret", client_type=ApplicationModel.CLIENT_CONFIDENTIAL, authorization_grant_type=ApplicationModel.GRANT_PASSWORD, - user=self.user, + user=cls.user, ) - def tearDown(self): - self.user.delete() - self.app.delete() - def test_create_token_response_valid(self): payload = ( "grant_type=password&username=john&password=123456&client_id=app_id&client_secret=app_secret" @@ -153,8 +153,7 @@ class MyOAuthLibCore(OAuthLibCore): def _get_extra_credentials(self, request): return 1 - def setUp(self): - self.factory = RequestFactory() + factory = RequestFactory() def test_create_token_response_gets_extra_credentials(self): """ @@ -172,9 +171,7 @@ def test_create_token_response_gets_extra_credentials(self): class TestJSONOAuthLibCoreBackend(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.oauthlib_core = JSONOAuthLibCore() + factory = RequestFactory() def test_application_json_extract_params(self): payload = json.dumps( @@ -185,16 +182,16 @@ def test_application_json_extract_params(self): } ) request = self.factory.post("/o/token/", payload, content_type="application/json") + oauthlib_core = JSONOAuthLibCore() - uri, http_method, body, headers = self.oauthlib_core._extract_params(request) + uri, http_method, body, headers = oauthlib_core._extract_params(request) self.assertIn("grant_type=password", body) self.assertIn("username=john", body) self.assertIn("password=123456", body) class TestOAuthLibCore(TestCase): - def setUp(self): - self.factory = RequestFactory() + factory = RequestFactory() def test_validate_authorization_request_unsafe_query(self): auth_headers = { diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 5694982b0..cb734a9b2 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -477,11 +477,12 @@ class TestOAuth2ValidatorErrorResourceToken(TestCase): is unsuccessful. """ - def setUp(self): - self.token = "test_token" - self.introspection_url = "http://example.com/token/introspection/" - self.introspection_token = "test_introspection_token" - self.validator = OAuth2Validator() + @classmethod + def setUpTestData(cls): + cls.token = "test_token" + cls.introspection_url = "http://example.com/token/introspection/" + cls.introspection_token = "test_introspection_token" + cls.validator = OAuth2Validator() def test_response_when_auth_server_response_return_404(self): with self.assertLogs(logger="oauth2_provider") as mock_log: diff --git a/tests/test_password.py b/tests/test_password.py index ab0f49228..ec9f17f54 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -25,24 +25,21 @@ def get(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( name="Test Password Application", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_PASSWORD, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestPasswordTokenView(BaseTest): def test_get_token(self): diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index a25611b93..0061f8d3a 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -130,24 +130,25 @@ class AuthenticationNoneOAuth2View(MockView): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): - def setUp(self): - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - self.access_token = AccessToken.objects.create( - user=self.test_user, + cls.access_token = AccessToken.objects.create( + user=cls.test_user, scope="read write", expires=timezone.now() + timedelta(seconds=300), token="secret-access-token-key", - application=self.application, + application=cls.application, ) def _create_authorization_header(self, token): diff --git a/tests/test_scopes.py b/tests/test_scopes.py index 548cc060c..ec36da418 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -58,25 +58,22 @@ def post(self, request, *args, **kwargs): @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(SCOPE_SETTINGS) class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestScopesSave(BaseTest): def test_scopes_saved_in_grant(self): diff --git a/tests/test_token_endpoint_cors.py b/tests/test_token_endpoint_cors.py index af5696c58..791237b4a 100644 --- a/tests/test_token_endpoint_cors.py +++ b/tests/test_token_endpoint_cors.py @@ -31,30 +31,26 @@ class TestTokenEndpointCors(TestCase): The objective is: http request 'Origin' header should be passed to OAuthLib """ - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - self.oauth2_settings.PKCE_REQUIRED = False + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris=CLIENT_URI, - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, allowed_origins=CLIENT_URI, ) + def setUp(self): self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https"] - - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() + self.oauth2_settings.PKCE_REQUIRED = False def test_valid_origin_with_https(self): """ diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index b4f5af7dd..8655a5b3e 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -17,25 +17,22 @@ class BaseTest(TestCase): - def setUp(self): - self.factory = RequestFactory() - self.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") - self.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + factory = RequestFactory() - self.application = Application.objects.create( + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.dev_user, + user=cls.dev_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, client_secret=CLEARTEXT_SECRET, ) - def tearDown(self): - self.application.delete() - self.test_user.delete() - self.dev_user.delete() - class TestRevocationView(BaseTest): def test_revoke_access_token(self): diff --git a/tests/test_token_view.py b/tests/test_token_view.py index 784ea3b84..fc73c2a66 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -18,22 +18,19 @@ class TestAuthorizedTokenViews(TestCase): TestCase superclass for Authorized Token Views" Test Cases """ - def setUp(self): - self.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") - self.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") + @classmethod + def setUpTestData(cls): + cls.foo_user = UserModel.objects.create_user("foo_user", "test@example.com", "123456") + cls.bar_user = UserModel.objects.create_user("bar_user", "dev@example.com", "123456") - self.application = Application.objects.create( + cls.application = Application.objects.create( name="Test Application", redirect_uris="http://localhost http://example.com http://example.org", - user=self.bar_user, + user=cls.bar_user, client_type=Application.CLIENT_CONFIDENTIAL, authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, ) - def tearDown(self): - self.foo_user.delete() - self.bar_user.delete() - class TestAuthorizedTokenListView(TestAuthorizedTokenViews): """ From 5d5246a86f91104e4cfe9629432c25056eeb09ab Mon Sep 17 00:00:00 2001 From: Adam Johnson <me@adamj.eu> Date: Fri, 10 Nov 2023 18:25:03 +0000 Subject: [PATCH 572/722] Add changelog entry for Django 4.2 support (#1353) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b4275a6c..67bf633cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,7 @@ These issues both result in `{"error": "invalid_client"}`: * Add Japanese(日本語) Language Support * #1244 implement [OIDC RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) * #1092 Allow Authorization Code flow without a client_secret per [RFC 6749 2.3.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-2.3.1) +* #1264 Support Django 4.2. ### Changed * #1222 Remove expired ID tokens alongside access tokens in `cleartokens` management command From 4f59b067191f4dbabaccae6acbb1c37a87409920 Mon Sep 17 00:00:00 2001 From: Muminur Rahman <r.muminur@gmail.com> Date: Sat, 11 Nov 2023 02:41:48 +0600 Subject: [PATCH 573/722] Add LOGIN_URL settings in getting_started.rst (#1162) Co-authored-by: Mumin <muminur.rahman@ucb.com.bd> --- docs/rest-framework/getting_started.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 531077eab..bff2b9017 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -112,6 +112,8 @@ Also add the following to your `settings.py` module: ) } + LOGIN_URL = '/admin/login/' + `OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, so we can use them for permission check. From a30001ff2a2e90bfd2b31152f0c513deeb1e2cc1 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Sat, 11 Nov 2023 13:55:08 -0500 Subject: [PATCH 574/722] Fix/test app rp openid configuration (#1362) * fix: cors on .well-know redirect in test app (cherry picked from commit a592988d1c61635c7ef6b568b0f1c51a3912a06f) * fix: mismatched issuer origin for idp --- tests/app/README.md | 5 +++-- tests/app/idp/README.md | 15 +-------------- tests/app/idp/fixtures/seed.json | 7 ++++--- tests/app/idp/idp/apps.py | 7 ++++++- tests/app/rp/src/routes/+page.svelte | 2 +- 5 files changed, 15 insertions(+), 21 deletions(-) diff --git a/tests/app/README.md b/tests/app/README.md index 904af273c..a2632b262 100644 --- a/tests/app/README.md +++ b/tests/app/README.md @@ -1,7 +1,8 @@ # Test Apps These apps are for local end to end testing of DOT features. They were implemented to save maintainers the trouble of setting up -local test environments. +local test environments. You should be able to start both and instance of the IDP and RP using the directions below, then test the +functionality of the IDP using the RP. ## /tests/app/idp @@ -29,7 +30,7 @@ password: password You can update data in the IDP and then dump the data to a new seed file as follows. ``` - python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype --natural-foreign --natural-primary --indent 2 > fixtures/seed.json +python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json ``` ## /test/app/rp diff --git a/tests/app/idp/README.md b/tests/app/idp/README.md index 699b821d2..54245073d 100644 --- a/tests/app/idp/README.md +++ b/tests/app/idp/README.md @@ -1,16 +1,3 @@ # TEST IDP -This is an example IDP implementation for end to end testing. - -username: superuser -password: password - -## Development Tasks - -* update fixtures - - ``` - python -Xutf8 ./manage.py dumpdata -e sessions -e admin.logentry -e auth.permission -e contenttypes.contenttype -e oauth2_provider.grant -e oauth2_provider.accesstoken -e oauth2_provider.refreshtoken -e oauth2_provider.idtoken --natural-foreign --natural-primary --indent 2 > fixtures/seed.json - ``` - - *check seeds as you produce them to makre sure any unrequired models are excluded to keep our seeds as small as possible.* +see ../README.md diff --git a/tests/app/idp/fixtures/seed.json b/tests/app/idp/fixtures/seed.json index 270c62625..b77d1f4e2 100644 --- a/tests/app/idp/fixtures/seed.json +++ b/tests/app/idp/fixtures/seed.json @@ -3,7 +3,7 @@ "model": "auth.user", "fields": { "password": "pbkdf2_sha256$390000$29LoVHfFRlvEOJ9clv73Wx$fx5ejfUJ+nYsnBXFf21jZvDsq4o3p5io3TrAGKAVTq4=", - "last_login": "2023-10-05T14:39:15.980Z", + "last_login": "2023-11-11T17:24:19.359Z", "is_superuser": true, "username": "superuser", "first_name": "", @@ -30,8 +30,9 @@ "name": "OIDC - Authorization Code", "skip_authorization": true, "created": "2023-05-01T20:27:46.167Z", - "updated": "2023-05-11T16:37:21.669Z", - "algorithm": "RS256" + "updated": "2023-11-11T17:23:44.643Z", + "algorithm": "RS256", + "allowed_origins": "http://localhost:5173\r\nhttp://127.0.0.1:5173" } } ] diff --git a/tests/app/idp/idp/apps.py b/tests/app/idp/idp/apps.py index a9d8e3071..f40a9f644 100644 --- a/tests/app/idp/idp/apps.py +++ b/tests/app/idp/idp/apps.py @@ -3,7 +3,12 @@ def cors_allow_origin(sender, request, **kwargs): - return request.path == "/o/userinfo/" or request.path == "/o/userinfo" + return ( + request.path == "/o/userinfo/" + or request.path == "/o/userinfo" + or request.path == "/o/.well-known/openid-configuration" + or request.path == "/o/.well-known/openid-configuration/" + ) class IDPAppConfig(AppConfig): diff --git a/tests/app/rp/src/routes/+page.svelte b/tests/app/rp/src/routes/+page.svelte index 1aeb32372..5853d61f1 100644 --- a/tests/app/rp/src/routes/+page.svelte +++ b/tests/app/rp/src/routes/+page.svelte @@ -20,7 +20,7 @@ const metadata = {}; {#if browser} <OidcContext - issuer="http://127.0.0.1:8000/o" + issuer="http://localhost:8000/o" client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm" redirect_uri="http://localhost:5173" post_logout_redirect_uri="http://localhost:5173" From 862cb7adad01a48f63cfbad17ca2da019a5a2d1e Mon Sep 17 00:00:00 2001 From: Alan Rominger <arominge@redhat.com> Date: Sat, 11 Nov 2023 15:57:15 -0500 Subject: [PATCH 575/722] Move signal import to django.core (#1357) * Move signal import to django.core * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Update CHANGELOG.md and AUTHORS --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/settings.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index ef4e773f5..84fc2a7aa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -12,6 +12,7 @@ Adam Johnson Adam Zahradník Adheeth P Praveen Alan Crosswell +Alan Rominger Alejandro Mantecon Guillen Aleksander Vaskevich Alessandro De Angelis diff --git a/CHANGELOG.md b/CHANGELOG.md index 67bf633cf..0a7185824 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1284 Allow to logout with no id_token_hint even if the browser session already expired * #1296 Added reverse function in migration 0006_alter_application_client_secret * #1336 Fix encapsulation for Redirect URI scheme validation +* #1357 Move import of setting_changed signal from test to django core modules ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index c5af9ebae..1672b40df 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -18,8 +18,8 @@ from django.conf import settings from django.core.exceptions import ImproperlyConfigured +from django.core.signals import setting_changed from django.http import HttpRequest -from django.test.signals import setting_changed from django.urls import reverse from django.utils.module_loading import import_string from oauthlib.common import Request From e038f420b13922dfb9c56b1cb8e447edceedff24 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Mon, 13 Nov 2023 10:49:54 -0500 Subject: [PATCH 576/722] Fix/wellknown openid configuration no trailing slash (#1364) * chore: tests showing configuration error * fix: Connect Discovery Endpoint redirects --- docs/oidc.rst | 2 +- docs/settings.rst | 2 +- oauth2_provider/settings.py | 2 +- oauth2_provider/urls.py | 6 +++++- tests/test_oidc_views.py | 34 ++++++++++++++++++++++++++++++---- 5 files changed, 38 insertions(+), 8 deletions(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 7a758ed65..88c3b6ffc 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -407,7 +407,7 @@ the URLs accordingly. ConnectDiscoveryInfoView ~~~~~~~~~~~~~~~~~~~~~~~~ -Available at ``/o/.well-known/openid-configuration/``, this view provides auto +Available at ``/o/.well-known/openid-configuration``, this view provides auto discovery information to OIDC clients, telling them the JWT issuer to use, the location of the JWKs to verify JWTs with, the token and userinfo endpoints to query, and other details. diff --git a/docs/settings.rst b/docs/settings.rst index a7cac94a1..c64c24954 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -366,7 +366,7 @@ Default: ``""`` The URL of the issuer that is used in the ID token JWT and advertised in the OIDC discovery metadata. Clients use this location to retrieve the OIDC discovery metadata from ``OIDC_ISS_ENDPOINT`` + -``/.well-known/openid-configuration/``. +``/.well-known/openid-configuration``. If unset, the default location is used, eg if ``django-oauth-toolkit`` is mounted at ``/o``, it will be ``<server-address>/o``. diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 1672b40df..e608799e1 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -295,7 +295,7 @@ def oidc_issuer(self, request): else: raise TypeError("request must be a django or oauthlib request: got %r" % request) abs_url = django_request.build_absolute_uri(reverse("oauth2_provider:oidc-connect-discovery-info")) - return abs_url[: -len("/.well-known/openid-configuration/")] + return abs_url[: -len("/.well-known/openid-configuration")] oauth2_settings = OAuth2ProviderSettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS, MANDATORY) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 4d23a3a5f..038a7eaf9 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -31,8 +31,12 @@ ] oidc_urlpatterns = [ + # .well-known/openid-configuration/ is deprecated + # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig + # does not specify a trailing slash + # Support for trailing slash should shall be removed in a future release. re_path( - r"^\.well-known/openid-configuration/$", + r"^\.well-known/openid-configuration/?$", views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info", ), diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 201ff0436..98939e02d 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -50,11 +50,37 @@ def test_get_connect_discovery_info(self): "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], "claims_supported": ["sub"], } - response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) + response = self.client.get("/o/.well-known/openid-configuration") + self.assertEqual(response.status_code, 200) + assert response.json() == expected_response + + def test_get_connect_discovery_info_deprecated(self): + expected_response = { + "issuer": "http://localhost/o", + "authorization_endpoint": "http://localhost/o/authorize/", + "token_endpoint": "http://localhost/o/token/", + "userinfo_endpoint": "http://localhost/o/userinfo/", + "jwks_uri": "http://localhost/o/.well-known/jwks.json", + "scopes_supported": ["read", "write", "openid"], + "response_types_supported": [ + "code", + "token", + "id_token", + "id_token token", + "code token", + "code id_token", + "code id_token token", + ], + "subject_types_supported": ["public"], + "id_token_signing_alg_values_supported": ["RS256", "HS256"], + "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "claims_supported": ["sub"], + } + response = self.client.get("/o/.well-known/openid-configuration/") self.assertEqual(response.status_code, 200) assert response.json() == expected_response - def expect_json_response_with_rp(self, base): + def expect_json_response_with_rp_logout(self, base): expected_response = { "issuer": f"{base}", "authorization_endpoint": f"{base}/authorize/", @@ -83,7 +109,7 @@ def expect_json_response_with_rp(self, base): def test_get_connect_discovery_info_with_rp_logout(self): self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True - self.expect_json_response_with_rp(self.oauth2_settings.OIDC_ISS_ENDPOINT) + self.expect_json_response_with_rp_logout(self.oauth2_settings.OIDC_ISS_ENDPOINT) def test_get_connect_discovery_info_without_issuer_url(self): self.oauth2_settings.OIDC_ISS_ENDPOINT = None @@ -117,7 +143,7 @@ def test_get_connect_discovery_info_without_issuer_url_with_rp_logout(self): self.oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED = True self.oauth2_settings.OIDC_ISS_ENDPOINT = None self.oauth2_settings.OIDC_USERINFO_ENDPOINT = None - self.expect_json_response_with_rp("http://testserver/o") + self.expect_json_response_with_rp_logout("http://testserver/o") def test_get_connect_discovery_info_without_rsa_key(self): self.oauth2_settings.OIDC_RSA_PRIVATE_KEY = None From 2d641f2b458b64d74b0fe5492f93946b37f3f6d2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Nov 2023 13:37:52 -0500 Subject: [PATCH 577/722] [pre-commit.ci] pre-commit autoupdate (#1368) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0) - [github.com/sphinx-contrib/sphinx-lint: v0.8.1 → v0.8.2](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.8.1...v0.8.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 44b9733aa..c749f69cd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.10.1 + rev: 23.11.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.8.1 + rev: v0.8.2 hooks: - id: sphinx-lint From a4b26b17ccbbe1ac24255927a6d8ec74f6f443e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20UTARD?= <gael.utard@kisis.fr> Date: Thu, 16 Nov 2023 20:53:28 +0100 Subject: [PATCH 578/722] Add code_challenge_methods_supported property to OIDC auto discovery (#1367) Fix #1249 --- AUTHORS | 1 + CHANGELOG.md | 2 ++ oauth2_provider/views/oidc.py | 2 ++ tests/test_oidc_views.py | 4 ++++ 4 files changed, 9 insertions(+) diff --git a/AUTHORS b/AUTHORS index 84fc2a7aa..689ab48de 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,6 +52,7 @@ Egor Poderiagin Emanuele Palazzetti Federico Dolce Frederico Vieira +Gaël Utard Hasan Ramezani Hiroki Kiyohara Hossein Shakiba diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7185824..28125afa3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. * #1350 Support Python 3.12 and Django 5.0 +* #1249 Add code_challenge_methods_supported property to auto discovery informations + per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) ### Fixed * #1322 Instructions in documentation on how to create a code challenge and code verifier diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 26bc977f2..584b0c895 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -26,6 +26,7 @@ from ..forms import ConfirmLogoutForm from ..http import OAuth2ResponseRedirect from ..models import ( + AbstractGrant, get_access_token_model, get_application_model, get_id_token_model, @@ -96,6 +97,7 @@ def get(self, request, *args, **kwargs): "token_endpoint_auth_methods_supported": ( oauth2_settings.OIDC_TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED ), + "code_challenge_methods_supported": [key for key, _ in AbstractGrant.CODE_CHALLENGE_METHODS], "claims_supported": oidc_claims, } if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ENABLED: diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 98939e02d..4bcf839ef 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -48,6 +48,7 @@ def test_get_connect_discovery_info(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } response = self.client.get("/o/.well-known/openid-configuration") @@ -74,6 +75,7 @@ def test_get_connect_discovery_info_deprecated(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } response = self.client.get("/o/.well-known/openid-configuration/") @@ -100,6 +102,7 @@ def expect_json_response_with_rp_logout(self, base): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], "end_session_endpoint": f"{base}/logout/", } @@ -133,6 +136,7 @@ def test_get_connect_discovery_info_without_issuer_url(self): "subject_types_supported": ["public"], "id_token_signing_alg_values_supported": ["RS256", "HS256"], "token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"], + "code_challenge_methods_supported": ["plain", "S256"], "claims_supported": ["sub"], } response = self.client.get(reverse("oauth2_provider:oidc-connect-discovery-info")) From 322154bcd4fdd39e4d652f8ca8dd9b7d14b2a7f1 Mon Sep 17 00:00:00 2001 From: Andy Zickler <andyzickler@users.noreply.github.com> Date: Sun, 26 Nov 2023 08:21:13 -0500 Subject: [PATCH 579/722] fix: prompt=none shows a login screen (#1361) --- AUTHORS | 1 + CHANGELOG.md | 5 +-- oauth2_provider/views/base.py | 27 ++++++++++++++++ tests/app/idp/idp/oauth.py | 40 +++++++++++++++++++++++ tests/app/idp/idp/settings.py | 1 + tests/test_authorization_code.py | 54 ++++++++++++++++++++++++++++++++ 6 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 tests/app/idp/idp/oauth.py diff --git a/AUTHORS b/AUTHORS index 689ab48de..8596063b9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -23,6 +23,7 @@ Allisson Azevedo Andrea Greco Andrej Zbín Andrew Chen Wang +Andrew Zickler Antoine Laurent Anvesh Agarwal Aristóbulo Meneses diff --git a/CHANGELOG.md b/CHANGELOG.md index 28125afa3..d1e9704d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,8 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. * #1350 Support Python 3.12 and Django 5.0 -* #1249 Add code_challenge_methods_supported property to auto discovery informations - per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) +* #1249 Add code_challenge_methods_supported property to auto discovery informations, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) + ### Fixed * #1322 Instructions in documentation on how to create a code challenge and code verifier @@ -35,6 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1296 Added reverse function in migration 0006_alter_application_client_secret * #1336 Fix encapsulation for Redirect URI scheme validation * #1357 Move import of setting_changed signal from test to django core modules +* #1268 fix prompt=none redirects to login screen ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index abaa81f59..846be3e73 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -244,6 +244,33 @@ def handle_prompt_login(self): self.get_redirect_field_name(), ) + def handle_no_permission(self): + """ + Generate response for unauthorized users. + + If prompt is set to none, then we redirect with an error code + as defined by OIDC 3.1.2.6 + + Some code copied from OAuthLibMixin.error_response, but that is designed + to operated on OAuth1Error from oauthlib wrapped in a OAuthToolkitError + """ + prompt = self.request.GET.get("prompt") + redirect_uri = self.request.GET.get("redirect_uri") + if prompt == "none" and redirect_uri: + response_parameters = {"error": "login_required"} + + # REQUIRED if the Authorization Request included the state parameter. + # Set to the value received from the Client + state = self.request.GET.get("state") + if state: + response_parameters["state"] = state + + separator = "&" if "?" in redirect_uri else "?" + redirect_to = redirect_uri + separator + urlencode(response_parameters) + return self.redirect(redirect_to, application=None) + else: + return super().handle_no_permission() + @method_decorator(csrf_exempt, name="dispatch") class TokenView(OAuthLibMixin, View): diff --git a/tests/app/idp/idp/oauth.py b/tests/app/idp/idp/oauth.py new file mode 100644 index 000000000..3e8a4645e --- /dev/null +++ b/tests/app/idp/idp/oauth.py @@ -0,0 +1,40 @@ +from django.conf import settings +from django.contrib.auth.middleware import AuthenticationMiddleware +from django.contrib.sessions.middleware import SessionMiddleware + +from oauth2_provider.oauth2_validators import OAuth2Validator + + +# get_response is required for middlware, it doesn't need to do anything +# the way we're using it, so we just use a lambda that returns None +def get_response(): + None + + +class CustomOAuth2Validator(OAuth2Validator): + def validate_silent_login(self, request) -> None: + # request is an OAuthLib.common.Request and doesn't have the session + # or user of the django request. We will emulate the session and auth + # middleware here, since that is what the idp is using for auth. You + # may need to modify this if you are using a different session + # middleware or auth backend. + + session_cookie_name = settings.SESSION_COOKIE_NAME + HTTP_COOKIE = request.headers.get("HTTP_COOKIE") + COOKIES = HTTP_COOKIE.split("; ") + for cookie in COOKIES: + cookie_name, cookie_value = cookie.split("=") + if cookie.startswith(session_cookie_name): + break + session_middleware = SessionMiddleware(get_response) + session = session_middleware.SessionStore(cookie_value) + # add session to request for compatibility with django.contrib.auth + request.session = session + + # call the auth middleware to set request.user + auth_middleware = AuthenticationMiddleware(get_response) + auth_middleware.process_request(request) + return request.user.is_authenticated + + def validate_silent_authorization(self, request) -> None: + return True diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index 9ef6c15a6..375cdcc9b 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -129,6 +129,7 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" OAUTH2_PROVIDER = { + "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", "OIDC_ENABLED": True, "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, # this key is just for out test app, you should never store a key like this in a production environment. diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 9d71016d3..b77f4f9ba 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -545,6 +545,33 @@ def test_code_post_auth_fails_when_redirect_uri_path_is_invalid(self): @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) class TestOIDCAuthorizationCodeView(BaseTest): + def test_login(self): + """ + Test login page is rendered if user is not authenticated + """ + self.oauth2_settings.PKCE_REQUIRED = False + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "openid", + "redirect_uri": "http://example.org", + } + path = reverse("oauth2_provider:authorize") + response = self.client.get(path, data=query_data) + # The authorization view redirects to the login page with the + self.assertEqual(response.status_code, 302) + scheme, netloc, path, params, query, fragment = urlparse(response["Location"]) + self.assertEqual(path, settings.LOGIN_URL) + parsed_query = parse_qs(query) + next = parsed_query["next"][0] + self.assertIn(f"client_id={self.application.client_id}", next) + self.assertIn("response_type=code", next) + self.assertIn("state=random_state_string", next) + self.assertIn("scope=openid", next) + self.assertIn("redirect_uri=http%3A%2F%2Fexample.org", next) + def test_id_token_skip_authorization_completely(self): """ If application.skip_authorization = True, should skip the authorization page. @@ -645,6 +672,33 @@ def test_prompt_login(self): self.assertNotIn("prompt=login", next) + def test_prompt_none_unauthorized(self): + """ + Test response for redirect when supplied with prompt: none + + Should redirect to redirect_uri with an error of login_required + """ + self.oauth2_settings.PKCE_REQUIRED = False + + query_data = { + "client_id": self.application.client_id, + "response_type": "code", + "state": "random_state_string", + "scope": "read write", + "redirect_uri": "http://example.org", + "prompt": "none", + } + + response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) + + self.assertEqual(response.status_code, 302) + + scheme, netloc, path, params, query, fragment = urlparse(response["Location"]) + parsed_query = parse_qs(query) + + self.assertIn("login_required", parsed_query["error"]) + self.assertIn("random_state_string", parsed_query["state"]) + class BaseAuthorizationCodeTokenView(BaseTest): def get_auth(self, scope="read write"): From cbe601c9674f5ae17665da8c2349d70fde2b63a4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Nov 2023 13:00:00 -0500 Subject: [PATCH 580/722] [pre-commit.ci] pre-commit autoupdate (#1370) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/sphinx-contrib/sphinx-lint: v0.8.2 → v0.9.0](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.8.2...v0.9.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c749f69cd..73a5d4a5d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.8.2 + rev: v0.9.0 hooks: - id: sphinx-lint From a1883bb22c54f8228650c7b61a59f343a1e91de5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Dec 2023 13:28:56 -0500 Subject: [PATCH 581/722] [pre-commit.ci] pre-commit autoupdate (#1372) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/isort: 5.12.0 → 5.13.0](https://github.com/PyCQA/isort/compare/5.12.0...5.13.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 73a5d4a5d..c643ee821 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + rev: 5.13.0 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) From d4a4d4d74109426aa5e6459b48aad05581996048 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Dec 2023 13:32:15 -0500 Subject: [PATCH 582/722] [pre-commit.ci] pre-commit autoupdate (#1374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 23.11.0 → 23.12.0](https://github.com/psf/black/compare/23.11.0...23.12.0) - [github.com/PyCQA/isort: 5.13.0 → 5.13.2](https://github.com/PyCQA/isort/compare/5.13.0...5.13.2) - [github.com/sphinx-contrib/sphinx-lint: v0.9.0 → v0.9.1](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.9.0...v0.9.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c643ee821..40f6509d0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.11.0 + rev: 23.12.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -16,7 +16,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/PyCQA/isort - rev: 5.13.0 + rev: 5.13.2 hooks: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -26,6 +26,6 @@ repos: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.9.0 + rev: v0.9.1 hooks: - id: sphinx-lint From 25c63049841ae6d39a8e545227136867c1af2527 Mon Sep 17 00:00:00 2001 From: suspiciousRaccoon <127566947+suspiciousRaccoon@users.noreply.github.com> Date: Mon, 18 Dec 2023 16:42:06 -0300 Subject: [PATCH 583/722] Update index.rst requirements (#1375) I simply copy pasted the ones from the README.rst Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index caada02e4..e0df769cd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -21,8 +21,8 @@ If you need help please submit a `question <https://github.com/jazzband/django-o Requirements ------------ -* Python 3.7+ -* Django 2.2, 3.2, 4.0.1+ +* Python 3.8+ +* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0 * oauthlib 3.1+ Index From 3e117031945157511ebc1d522640f70187c369e5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 25 Dec 2023 15:02:03 -0500 Subject: [PATCH 584/722] [pre-commit.ci] pre-commit autoupdate (#1378) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40f6509d0..bec824a29 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.12.0 + rev: 23.12.1 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From fda64f97974aac78d4ac9c9f0f36e137dbe4fb8c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 8 Jan 2024 13:41:24 -0500 Subject: [PATCH 585/722] [pre-commit.ci] pre-commit autoupdate (#1386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/flake8: 6.1.0 → 7.0.0](https://github.com/PyCQA/flake8/compare/6.1.0...7.0.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index bec824a29..83b5c6f62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 6.1.0 + rev: 7.0.0 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From e98d959eca3d6e3cea59f5a185c6400b5e2f0e07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jan 2024 08:49:30 -0500 Subject: [PATCH 586/722] Bump vite from 4.3.9 to 4.5.2 in /tests/app/rp (#1389) --- tests/app/rp/package-lock.json | 209 +++++++++++++++++---------------- tests/app/rp/package.json | 2 +- 2 files changed, 109 insertions(+), 102 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 220d2dd44..cd2c4a471 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -19,7 +19,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.3.9" + "vite": "^4.5.2" } }, "node_modules/@dopry/svelte-oidc": { @@ -31,9 +31,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.17.18.tgz", - "integrity": "sha512-EmwL+vUBZJ7mhFCs5lA4ZimpUH3WMAoqvOIYhVQwdIgSpHC8ImHdsRyhHAVxpDYUSm0lWvd63z0XH1IlImS2Qw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", "cpu": [ "arm" ], @@ -47,9 +47,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.17.18.tgz", - "integrity": "sha512-/iq0aK0eeHgSC3z55ucMAHO05OIqmQehiGay8eP5l/5l+iEr4EIbh4/MI8xD9qRFjqzgkc0JkX0LculNC9mXBw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", "cpu": [ "arm64" ], @@ -63,9 +63,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.17.18.tgz", - "integrity": "sha512-x+0efYNBF3NPW2Xc5bFOSFW7tTXdAcpfEg2nXmxegm4mJuVeS+i109m/7HMiOQ6M12aVGGFlqJX3RhNdYM2lWg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", "cpu": [ "x64" ], @@ -79,9 +79,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.17.18.tgz", - "integrity": "sha512-6tY+djEAdF48M1ONWnQb1C+6LiXrKjmqjzPNPWXhu/GzOHTHX2nh8Mo2ZAmBFg0kIodHhciEgUBtcYCAIjGbjQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", "cpu": [ "arm64" ], @@ -95,9 +95,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.17.18.tgz", - "integrity": "sha512-Qq84ykvLvya3dO49wVC9FFCNUfSrQJLbxhoQk/TE1r6MjHo3sFF2tlJCwMjhkBVq3/ahUisj7+EpRSz0/+8+9A==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", "cpu": [ "x64" ], @@ -111,9 +111,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.18.tgz", - "integrity": "sha512-fw/ZfxfAzuHfaQeMDhbzxp9mc+mHn1Y94VDHFHjGvt2Uxl10mT4CDavHm+/L9KG441t1QdABqkVYwakMUeyLRA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", "cpu": [ "arm64" ], @@ -127,9 +127,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.17.18.tgz", - "integrity": "sha512-FQFbRtTaEi8ZBi/A6kxOC0V0E9B/97vPdYjY9NdawyLd4Qk5VD5g2pbWN2VR1c0xhzcJm74HWpObPszWC+qTew==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", "cpu": [ "x64" ], @@ -143,9 +143,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.17.18.tgz", - "integrity": "sha512-jW+UCM40LzHcouIaqv3e/oRs0JM76JfhHjCavPxMUti7VAPh8CaGSlS7cmyrdpzSk7A+8f0hiedHqr/LMnfijg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", "cpu": [ "arm" ], @@ -159,9 +159,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.17.18.tgz", - "integrity": "sha512-R7pZvQZFOY2sxUG8P6A21eq6q+eBv7JPQYIybHVf1XkQYC+lT7nDBdC7wWKTrbvMXKRaGudp/dzZCwL/863mZQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", "cpu": [ "arm64" ], @@ -175,9 +175,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.17.18.tgz", - "integrity": "sha512-ygIMc3I7wxgXIxk6j3V00VlABIjq260i967Cp9BNAk5pOOpIXmd1RFQJQX9Io7KRsthDrQYrtcx7QCof4o3ZoQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", "cpu": [ "ia32" ], @@ -191,9 +191,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.17.18.tgz", - "integrity": "sha512-bvPG+MyFs5ZlwYclCG1D744oHk1Pv7j8psF5TfYx7otCVmcJsEXgFEhQkbhNW8otDHL1a2KDINW20cfCgnzgMQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", "cpu": [ "loong64" ], @@ -207,9 +207,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.17.18.tgz", - "integrity": "sha512-oVqckATOAGuiUOa6wr8TXaVPSa+6IwVJrGidmNZS1cZVx0HqkTMkqFGD2HIx9H1RvOwFeWYdaYbdY6B89KUMxA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", "cpu": [ "mips64el" ], @@ -223,9 +223,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.17.18.tgz", - "integrity": "sha512-3dLlQO+b/LnQNxgH4l9rqa2/IwRJVN9u/bK63FhOPB4xqiRqlQAU0qDU3JJuf0BmaH0yytTBdoSBHrb2jqc5qQ==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", "cpu": [ "ppc64" ], @@ -239,9 +239,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.17.18.tgz", - "integrity": "sha512-/x7leOyDPjZV3TcsdfrSI107zItVnsX1q2nho7hbbQoKnmoeUWjs+08rKKt4AUXju7+3aRZSsKrJtaRmsdL1xA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", "cpu": [ "riscv64" ], @@ -255,9 +255,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.17.18.tgz", - "integrity": "sha512-cX0I8Q9xQkL/6F5zWdYmVf5JSQt+ZfZD2bJudZrWD+4mnUvoZ3TDDXtDX2mUaq6upMFv9FlfIh4Gfun0tbGzuw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", "cpu": [ "s390x" ], @@ -271,9 +271,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.17.18.tgz", - "integrity": "sha512-66RmRsPlYy4jFl0vG80GcNRdirx4nVWAzJmXkevgphP1qf4dsLQCpSKGM3DUQCojwU1hnepI63gNZdrr02wHUA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", "cpu": [ "x64" ], @@ -287,9 +287,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.17.18.tgz", - "integrity": "sha512-95IRY7mI2yrkLlTLb1gpDxdC5WLC5mZDi+kA9dmM5XAGxCME0F8i4bYH4jZreaJ6lIZ0B8hTrweqG1fUyW7jbg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", "cpu": [ "x64" ], @@ -303,9 +303,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.17.18.tgz", - "integrity": "sha512-WevVOgcng+8hSZ4Q3BKL3n1xTv5H6Nb53cBrtzzEjDbbnOmucEVcZeGCsCOi9bAOcDYEeBZbD2SJNBxlfP3qiA==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", "cpu": [ "x64" ], @@ -319,9 +319,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.17.18.tgz", - "integrity": "sha512-Rzf4QfQagnwhQXVBS3BYUlxmEbcV7MY+BH5vfDZekU5eYpcffHSyjU8T0xucKVuOcdCsMo+Ur5wmgQJH2GfNrg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", "cpu": [ "x64" ], @@ -335,9 +335,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.17.18.tgz", - "integrity": "sha512-Kb3Ko/KKaWhjeAm2YoT/cNZaHaD1Yk/pa3FTsmqo9uFh1D1Rfco7BBLIPdDOozrObj2sahslFuAQGvWbgWldAg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", "cpu": [ "arm64" ], @@ -351,9 +351,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.17.18.tgz", - "integrity": "sha512-0/xUMIdkVHwkvxfbd5+lfG7mHOf2FRrxNbPiKWg9C4fFrB8H0guClmaM3BFiRUYrznVoyxTIyC/Ou2B7QQSwmw==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", "cpu": [ "ia32" ], @@ -367,9 +367,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.17.18.tgz", - "integrity": "sha512-qU25Ma1I3NqTSHJUOKi9sAH1/Mzuvlke0ioMJRthLXKm7JiSKVwFghlGbDLOO2sARECGhja4xYfRAZNPAkooYg==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", "cpu": [ "x64" ], @@ -746,9 +746,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.17.18", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.18.tgz", - "integrity": "sha512-z1lix43jBs6UKjcZVKOw2xx69ffE2aG0PygLL5qJ9OS/gy0Ewd1gW/PUQIOIQGXBHWNywSc0floSKoMFF8aK2w==", + "version": "0.18.20", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", "dev": true, "hasInstallScript": true, "bin": { @@ -758,28 +758,28 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.17.18", - "@esbuild/android-arm64": "0.17.18", - "@esbuild/android-x64": "0.17.18", - "@esbuild/darwin-arm64": "0.17.18", - "@esbuild/darwin-x64": "0.17.18", - "@esbuild/freebsd-arm64": "0.17.18", - "@esbuild/freebsd-x64": "0.17.18", - "@esbuild/linux-arm": "0.17.18", - "@esbuild/linux-arm64": "0.17.18", - "@esbuild/linux-ia32": "0.17.18", - "@esbuild/linux-loong64": "0.17.18", - "@esbuild/linux-mips64el": "0.17.18", - "@esbuild/linux-ppc64": "0.17.18", - "@esbuild/linux-riscv64": "0.17.18", - "@esbuild/linux-s390x": "0.17.18", - "@esbuild/linux-x64": "0.17.18", - "@esbuild/netbsd-x64": "0.17.18", - "@esbuild/openbsd-x64": "0.17.18", - "@esbuild/sunos-x64": "0.17.18", - "@esbuild/win32-arm64": "0.17.18", - "@esbuild/win32-ia32": "0.17.18", - "@esbuild/win32-x64": "0.17.18" + "@esbuild/android-arm": "0.18.20", + "@esbuild/android-arm64": "0.18.20", + "@esbuild/android-x64": "0.18.20", + "@esbuild/darwin-arm64": "0.18.20", + "@esbuild/darwin-x64": "0.18.20", + "@esbuild/freebsd-arm64": "0.18.20", + "@esbuild/freebsd-x64": "0.18.20", + "@esbuild/linux-arm": "0.18.20", + "@esbuild/linux-arm64": "0.18.20", + "@esbuild/linux-ia32": "0.18.20", + "@esbuild/linux-loong64": "0.18.20", + "@esbuild/linux-mips64el": "0.18.20", + "@esbuild/linux-ppc64": "0.18.20", + "@esbuild/linux-riscv64": "0.18.20", + "@esbuild/linux-s390x": "0.18.20", + "@esbuild/linux-x64": "0.18.20", + "@esbuild/netbsd-x64": "0.18.20", + "@esbuild/openbsd-x64": "0.18.20", + "@esbuild/sunos-x64": "0.18.20", + "@esbuild/win32-arm64": "0.18.20", + "@esbuild/win32-ia32": "0.18.20", + "@esbuild/win32-x64": "0.18.20" } }, "node_modules/esm-env": { @@ -1312,9 +1312,9 @@ } }, "node_modules/rollup": { - "version": "3.21.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.21.4.tgz", - "integrity": "sha512-N5LxpvDolOm9ueiCp4NfB80omMDqb45ShtsQw2+OT3f11uJ197dv703NZvznYHP6RWR85wfxanXurXKG3ux2GQ==", + "version": "3.29.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", + "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", "dev": true, "bin": { "rollup": "dist/bin/rollup" @@ -1646,14 +1646,14 @@ } }, "node_modules/vite": { - "version": "4.3.9", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.3.9.tgz", - "integrity": "sha512-qsTNZjO9NoJNW7KnOrgYwczm0WctJ8m/yqYAMAK9Lxt4SoySUfS5S8ia9K7JHpa3KEeMfyF8LoJ3c5NeBJy6pg==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", + "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", "dev": true, "dependencies": { - "esbuild": "^0.17.5", - "postcss": "^8.4.23", - "rollup": "^3.21.0" + "esbuild": "^0.18.10", + "postcss": "^8.4.27", + "rollup": "^3.27.1" }, "bin": { "vite": "bin/vite.js" @@ -1661,12 +1661,16 @@ "engines": { "node": "^14.18.0 || >=16.0.0" }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@types/node": ">= 14", "less": "*", + "lightningcss": "^1.21.0", "sass": "*", "stylus": "*", "sugarss": "*", @@ -1679,6 +1683,9 @@ "less": { "optional": true }, + "lightningcss": { + "optional": true + }, "sass": { "optional": true }, diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 0001775e7..c36ba94df 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -20,7 +20,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.3.9" + "vite": "^4.5.2" }, "type": "module", "dependencies": { From 07d271531beff8425757e2aea50d8148722b0de6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jan 2024 10:49:49 -0500 Subject: [PATCH 587/722] Bump undici and @sveltejs/kit in /tests/app/rp (#1390) --- tests/app/rp/package-lock.json | 128 ++++++++++++++++----------------- tests/app/rp/package.json | 2 +- 2 files changed, 62 insertions(+), 68 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index cd2c4a471..4b4fa6710 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -12,7 +12,7 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.5.0", + "@sveltejs/kit": "^1.30.3", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "svelte": "^3.54.0", @@ -382,6 +382,15 @@ "node": ">=12" } }, + "node_modules/@fastify/busboy": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", + "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", + "dev": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", @@ -467,25 +476,25 @@ } }, "node_modules/@sveltejs/kit": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.15.10.tgz", - "integrity": "sha512-qRZxODfsixjgY+7OOxhAQB8viVaxjyDUz2lM6cE22kObzF5mNke81FIxB2wdaOX42LyfVwIYULZQSr7duxLZ7w==", + "version": "1.30.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.30.3.tgz", + "integrity": "sha512-0DzVXfU4h+tChFvoc8C61IqErCyskD4ydSIDjpKS2lYlEzIYrtYrY7juSqACFxqcvZAnOEXvSY+zZ8br0+ZMMg==", "dev": true, "hasInstallScript": true, "dependencies": { - "@sveltejs/vite-plugin-svelte": "^2.1.1", + "@sveltejs/vite-plugin-svelte": "^2.5.0", "@types/cookie": "^0.5.1", "cookie": "^0.5.0", - "devalue": "^4.3.0", + "devalue": "^4.3.1", "esm-env": "^1.0.0", "kleur": "^4.1.5", "magic-string": "^0.30.0", - "mime": "^3.0.0", + "mrmime": "^1.0.1", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^2.0.2", "tiny-glob": "^0.2.9", - "undici": "~5.22.0" + "undici": "~5.26.2" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -494,28 +503,46 @@ "node": "^16.14 || >=18" }, "peerDependencies": { - "svelte": "^3.54.0", + "svelte": "^3.54.0 || ^4.0.0-next.0 || ^5.0.0-next.0", "vite": "^4.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.1.1.tgz", - "integrity": "sha512-7YeBDt4us0FiIMNsVXxyaP4Hwyn2/v9x3oqStkHU3ZdIc5O22pGwUwH33wUqYo+7Itdmo8zxJ45Qvfm3H7UUjQ==", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz", + "integrity": "sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==", "dev": true, "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^1.0.4", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.0", - "svelte-hmr": "^0.15.1", + "magic-string": "^0.30.3", + "svelte-hmr": "^0.15.3", "vitefu": "^0.2.4" }, "engines": { "node": "^14.18.0 || >= 16" }, "peerDependencies": { - "svelte": "^3.54.0", + "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-next.0", + "vite": "^4.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz", + "integrity": "sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.4" + }, + "engines": { + "node": "^14.18.0 || >= 16" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^2.2.0", + "svelte": "^3.54.0 || ^4.0.0", "vite": "^4.0.0" } }, @@ -620,18 +647,6 @@ "node": "*" } }, - "node_modules/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dev": true, - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -734,9 +749,9 @@ } }, "node_modules/devalue": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.0.tgz", - "integrity": "sha512-n94yQo4LI3w7erwf84mhRUkUJfhLoCZiLyoOZ/QFsDbcWNZePrLwbQpvZBUG2TNxwV3VjCKPxkiiQA6pe3TrTA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", + "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", "dev": true }, "node_modules/es6-promise": { @@ -989,12 +1004,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.0.tgz", - "integrity": "sha512-LA+31JYDJLs82r2ScLrlz1GjSgu66ZV518eyWT+S8VhyQn/JL0u9MeBOvQMGYiPk1DBiSN9DDMOcXvigJZaViQ==", + "version": "0.30.5", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", + "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", "dev": true, "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" + "@jridgewell/sourcemap-codec": "^1.4.15" }, "engines": { "node": ">=12" @@ -1022,18 +1037,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", - "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", - "dev": true, - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -1445,15 +1448,6 @@ "node": ">=0.10.0" } }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "dev": true, - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/strip-indent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -1498,15 +1492,15 @@ } }, "node_modules/svelte-hmr": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.1.tgz", - "integrity": "sha512-BiKB4RZ8YSwRKCNVdNxK/GfY+r4Kjgp9jCLEy0DuqAKfmQtpL38cQK3afdpjw4sqSs4PLi3jIPJIFp259NkZtA==", + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", + "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, "peerDependencies": { - "svelte": ">=3.19.0" + "svelte": "^3.19.0 || ^4.0.0" } }, "node_modules/svelte-preprocess": { @@ -1634,12 +1628,12 @@ } }, "node_modules/undici": { - "version": "5.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.0.tgz", - "integrity": "sha512-fR9RXCc+6Dxav4P9VV/sp5w3eFiSdOjJYsbtWfd4s5L5C4ogyuVpdKIVHeW0vV1MloM65/f7W45nR9ZxwVdyiA==", + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz", + "integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==", "dev": true, "dependencies": { - "busboy": "^1.6.0" + "@fastify/busboy": "^2.0.0" }, "engines": { "node": ">=14.0" @@ -1701,12 +1695,12 @@ } }, "node_modules/vitefu": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.4.tgz", - "integrity": "sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", + "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { "vite": { diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index c36ba94df..26417101a 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -13,7 +13,7 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.5.0", + "@sveltejs/kit": "^1.30.3", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "svelte": "^3.54.0", From 843a1c1049c57c37ce2cc65c83eb353bbfd50035 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 29 Jan 2024 13:44:38 -0500 Subject: [PATCH 588/722] [pre-commit.ci] pre-commit autoupdate (#1395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/psf/black: 23.12.1 → 24.1.1](https://github.com/psf/black/compare/23.12.1...24.1.1) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- docs/rfc.py | 1 + oauth2_provider/views/generic.py | 2 -- oauth2_provider/views/mixins.py | 1 - tests/app/idp/idp/urls.py | 1 + 5 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83b5c6f62..dbce7fd50 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.1.1 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) diff --git a/docs/rfc.py b/docs/rfc.py index ac929f7cd..da5e6ecde 100644 --- a/docs/rfc.py +++ b/docs/rfc.py @@ -1,6 +1,7 @@ """ Custom Sphinx documentation module to link to parts of the OAuth2 RFC. """ + from docutils import nodes diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index da675eac4..123848043 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -36,7 +36,6 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): - """View for protecting a resource with client-credentials method. This involves allowing access tokens, Basic Auth and plain credentials in request body. """ @@ -45,7 +44,6 @@ class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, V class ClientProtectedScopedResourceView(ScopedResourceMixin, ClientProtectedResourceView): - """Impose scope restrictions if client protection fallsback to access token.""" pass diff --git a/oauth2_provider/views/mixins.py b/oauth2_provider/views/mixins.py index b3d9ab2f2..203d0103b 100644 --- a/oauth2_provider/views/mixins.py +++ b/oauth2_provider/views/mixins.py @@ -279,7 +279,6 @@ def get_scopes(self, *args, **kwargs): class ClientProtectedResourceMixin(OAuthLibMixin): - """Mixin for protecting resources with client authentication as mentioned in rfc:`3.2.1` This involves authenticating with any of: HTTP Basic Auth, Client Credentials and Access token in that order. Breaks off after first validation. diff --git a/tests/app/idp/idp/urls.py b/tests/app/idp/idp/urls.py index 2ebc27295..90e8abd48 100644 --- a/tests/app/idp/idp/urls.py +++ b/tests/app/idp/idp/urls.py @@ -14,6 +14,7 @@ 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import include, path From 40003dddda5b13f91b841ca267fd730487338c89 Mon Sep 17 00:00:00 2001 From: Enno Richter <elo-gthb@nerd-works.de> Date: Wed, 31 Jan 2024 13:34:27 +0100 Subject: [PATCH 589/722] Update oidc.rst: match example file name to import (#1396) --- docs/oidc.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 88c3b6ffc..d998dac9b 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -239,7 +239,7 @@ just return the same claims as the ID token. To configure all of these things we need to customize the ``OAUTH2_VALIDATOR_CLASS`` in ``django-oauth-toolkit``. Create a new file in -our project, eg ``my_project/oauth_validator.py``:: +our project, eg ``my_project/oauth_validators.py``:: from oauth2_provider.oauth2_validators import OAuth2Validator From ea05db409d19890caca95bc6b4771306ecc10557 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 16 Feb 2024 13:30:25 -0500 Subject: [PATCH 590/722] Bump undici, @sveltejs/adapter-auto and @sveltejs/kit in /tests/app/rp (#1398) --- tests/app/rp/package-lock.json | 154 +++++++++++++++------------------ tests/app/rp/package.json | 4 +- 2 files changed, 72 insertions(+), 86 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 4b4fa6710..f74485d70 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -11,8 +11,8 @@ "@dopry/svelte-oidc": "^1.1.0" }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.30.3", + "@sveltejs/adapter-auto": "^3.1.1", + "@sveltejs/kit": "^2.5.0", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "svelte": "^3.54.0", @@ -382,15 +382,6 @@ "node": ">=12" } }, - "node_modules/@fastify/busboy": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.0.tgz", - "integrity": "sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==", - "dev": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", @@ -458,98 +449,100 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.21", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.21.tgz", - "integrity": "sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==", + "version": "1.0.0-next.24", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", + "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, "node_modules/@sveltejs/adapter-auto": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-2.0.1.tgz", - "integrity": "sha512-anxxYMcQy7HWSKxN4YNaVcgNzCHtNFwygq72EA1Xv7c+5gSECOJ1ez1PYoLciPiFa7A3XBvMDQXUFJ2eqLDtAA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", + "integrity": "sha512-6LeZft2Fo/4HfmLBi5CucMYmgRxgcETweQl/yQoZo/895K3S9YWYN4Sfm/IhwlIpbJp3QNvhKmwCHbsqQNYQpw==", "dev": true, "dependencies": { - "import-meta-resolve": "^3.0.0" + "import-meta-resolve": "^4.0.0" }, "peerDependencies": { - "@sveltejs/kit": "^1.0.0" + "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/kit": { - "version": "1.30.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-1.30.3.tgz", - "integrity": "sha512-0DzVXfU4h+tChFvoc8C61IqErCyskD4ydSIDjpKS2lYlEzIYrtYrY7juSqACFxqcvZAnOEXvSY+zZ8br0+ZMMg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", + "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", "dev": true, "hasInstallScript": true, "dependencies": { - "@sveltejs/vite-plugin-svelte": "^2.5.0", - "@types/cookie": "^0.5.1", - "cookie": "^0.5.0", - "devalue": "^4.3.1", + "@types/cookie": "^0.6.0", + "cookie": "^0.6.0", + "devalue": "^4.3.2", "esm-env": "^1.0.0", + "import-meta-resolve": "^4.0.0", "kleur": "^4.1.5", - "magic-string": "^0.30.0", - "mrmime": "^1.0.1", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.2", - "tiny-glob": "^0.2.9", - "undici": "~5.26.2" + "sirv": "^2.0.4", + "tiny-glob": "^0.2.9" }, "bin": { "svelte-kit": "svelte-kit.js" }, "engines": { - "node": "^16.14 || >=18" + "node": ">=18.13" }, "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0-next.0 || ^5.0.0-next.0", - "vite": "^4.0.0" + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.3" } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-2.5.3.tgz", - "integrity": "sha512-erhNtXxE5/6xGZz/M9eXsmI7Pxa6MS7jyTy06zN3Ck++ldrppOnOlJwHHTsMC7DHDQdgUp4NAc4cDNQ9eGdB/w==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz", + "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==", "dev": true, + "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^1.0.4", + "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.3", + "magic-string": "^0.30.5", "svelte-hmr": "^0.15.3", - "vitefu": "^0.2.4" + "vitefu": "^0.2.5" }, "engines": { - "node": "^14.18.0 || >= 16" + "node": "^18.0.0 || >=20" }, "peerDependencies": { - "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0-next.0", - "vite": "^4.0.0" + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-1.0.4.tgz", - "integrity": "sha512-zjiuZ3yydBtwpF3bj0kQNV0YXe+iKE545QGZVTaylW3eAzFr+pJ/cwK8lZEaRp4JtaJXhD5DyWAV4AxLh6DgaQ==", + "node_modules/@sveltejs/vite-plugin-svelte/node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", + "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", "dev": true, + "peer": true, "dependencies": { "debug": "^4.3.4" }, "engines": { - "node": "^14.18.0 || >= 16" + "node": "^18.0.0 || >=20" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^2.2.0", - "svelte": "^3.54.0 || ^4.0.0", - "vite": "^4.0.0" + "@sveltejs/vite-plugin-svelte": "^3.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "vite": "^5.0.0" } }, "node_modules/@types/cookie": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.5.1.tgz", - "integrity": "sha512-COUnqfB2+ckwXXSFInsFdOAWQzCCx+a5hq2ruyj+Vjund94RJQd4LG2u9hnvJrTgunKAaax7ancBYlDrNYxA0g==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, "node_modules/@types/pug": { @@ -690,9 +683,9 @@ "dev": true }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", "dev": true, "engines": { "node": ">= 0.6" @@ -718,6 +711,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", "dev": true, + "peer": true, "dependencies": { "ms": "2.1.2" }, @@ -735,6 +729,7 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -927,9 +922,9 @@ } }, "node_modules/import-meta-resolve": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-3.0.0.tgz", - "integrity": "sha512-4IwhLhNNA8yy445rPjD/lWh++7hMDOml2eHtd58eG7h+qK3EryMuuRbsHGPikCoAgIkkDnckKfWSk2iDla/ejg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", + "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", "dev": true, "funding": { "type": "github", @@ -1004,9 +999,9 @@ } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", + "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1089,9 +1084,9 @@ } }, "node_modules/mrmime": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-1.0.1.tgz", - "integrity": "sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", + "integrity": "sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==", "dev": true, "engines": { "node": ">=10" @@ -1101,7 +1096,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true + "dev": true, + "peer": true }, "node_modules/nanoid": { "version": "3.3.6", @@ -1411,13 +1407,13 @@ "dev": true }, "node_modules/sirv": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.3.tgz", - "integrity": "sha512-O9jm9BsID1P+0HOi81VpXPoDxYP374pkOLzACAoyUQ/3OUVndNpsz6wMnY2z+yOxzbllCKZrM+9QrWsv4THnyA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", "dev": true, "dependencies": { - "@polka/url": "^1.0.0-next.20", - "mrmime": "^1.0.0", + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", "totalist": "^3.0.0" }, "engines": { @@ -1496,6 +1492,7 @@ "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", "dev": true, + "peer": true, "engines": { "node": "^12.20 || ^14.13.1 || >= 16" }, @@ -1627,18 +1624,6 @@ "node": ">=12.20" } }, - "node_modules/undici": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.26.5.tgz", - "integrity": "sha512-cSb4bPFd5qgR7qr2jYAi0hlX9n5YKK2ONKkLFkxl+v/9BvC0sOpZjBHDBSXc5lWAf5ty9oZdRXytBIHzgUcerw==", - "dev": true, - "dependencies": { - "@fastify/busboy": "^2.0.0" - }, - "engines": { - "node": ">=14.0" - } - }, "node_modules/vite": { "version": "4.5.2", "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", @@ -1699,6 +1684,7 @@ "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", "dev": true, + "peer": true, "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" }, diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 26417101a..fe1872941 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -12,8 +12,8 @@ "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^2.0.0", - "@sveltejs/kit": "^1.30.3", + "@sveltejs/adapter-auto": "^3.1.1", + "@sveltejs/kit": "^2.5.0", "prettier": "^2.8.0", "prettier-plugin-svelte": "^2.8.1", "svelte": "^3.54.0", From 817eb40052d2c3d7c9070c3e7db885c5a5141633 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 Feb 2024 15:28:04 -0500 Subject: [PATCH 591/722] [pre-commit.ci] pre-commit autoupdate (#1400) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dbce7fd50..b33a5a356 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 24.1.1 + rev: 24.2.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) From fdd05941c661ada288898002f94d1e894d46d4b4 Mon Sep 17 00:00:00 2001 From: TAKAHASHI Shuuji <shuuji3@gmail.com> Date: Wed, 28 Feb 2024 02:41:27 +0900 Subject: [PATCH 592/722] docs: clean up and improve documentation (#1401) --- docs/advanced_topics.rst | 22 ++++----- docs/contributing.rst | 55 ++++++++++++----------- docs/getting_started.rst | 10 ++--- docs/install.rst | 13 +++--- docs/management_commands.rst | 2 +- docs/rest-framework/getting_started.rst | 60 ++++++++++--------------- docs/settings.rst | 47 +++++++++---------- docs/signals.rst | 4 +- docs/tutorial/tutorial_01.rst | 6 +-- docs/tutorial/tutorial_02.rst | 10 ++--- docs/tutorial/tutorial_03.rst | 26 +++++------ docs/tutorial/tutorial_04.rst | 8 ++-- docs/tutorial/tutorial_05.rst | 18 ++++---- docs/views/application.rst | 6 +-- docs/views/class_based.rst | 2 +- docs/views/function_based.rst | 6 +-- docs/views/token.rst | 4 +- 17 files changed, 139 insertions(+), 160 deletions(-) diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index d92d71b12..0b2ee20b0 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -31,7 +31,7 @@ Django OAuth Toolkit lets you extend the AbstractApplication model in a fashion custom user models. If you need, let's say, application logo and user agreement acceptance field, you can do this in -your Django app (provided that your app is in the list of the INSTALLED_APPS in your settings +your Django app (provided that your app is in the list of the ``INSTALLED_APPS`` in your settings module):: from django.db import models @@ -44,11 +44,11 @@ module):: Then you need to tell Django OAuth Toolkit which model you want to use to represent applications. Write something like this in your settings module:: - OAUTH2_PROVIDER_APPLICATION_MODEL='your_app_name.MyApplication' + OAUTH2_PROVIDER_APPLICATION_MODEL = 'your_app_name.MyApplication' Be aware that, when you intend to swap the application model, you should create and run the -migration defining the swapped application model prior to setting OAUTH2_PROVIDER_APPLICATION_MODEL. -You'll run into models.E022 in Core system checks if you don't get the order right. +migration defining the swapped application model prior to setting ``OAUTH2_PROVIDER_APPLICATION_MODEL``. +You'll run into ``models.E022`` in Core system checks if you don't get the order right. You can force your migration providing the custom model to run in the right order by adding:: @@ -61,15 +61,15 @@ to the migration class. That's all, now Django OAuth Toolkit will use your model wherever an Application instance is needed. - **Notice:** `OAUTH2_PROVIDER_APPLICATION_MODEL` is the only setting variable that is not namespaced, this +.. note:: ``OAUTH2_PROVIDER_APPLICATION_MODEL`` is the only setting variable that is not namespaced, this is because of the way Django currently implements swappable models. - See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details + See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. Multiple Grants ~~~~~~~~~~~~~~~ The default application model supports a single OAuth grant (e.g. authorization code, client credentials). If you need -applications to support multiple grants, override the `allows_grant_type` method. For example, if you want applications +applications to support multiple grants, override the ``allows_grant_type`` method. For example, if you want applications to support the authorization code *and* client credentials grants, you might do the following:: from oauth2_provider.models import AbstractApplication @@ -86,12 +86,12 @@ Skip authorization form Depending on the OAuth2 flow in use and the access token policy, users might be prompted for the same authorization multiple times: sometimes this is acceptable or even desirable but other times it isn't. -To control DOT behaviour you can use the `approval_prompt` parameter when hitting the authorization endpoint. +To control DOT behaviour you can use the ``approval_prompt`` parameter when hitting the authorization endpoint. Possible values are: -* `force` - users are always prompted for authorization. +* ``force`` - users are always prompted for authorization. -* `auto` - users are prompted only the first time, subsequent authorizations for the same application +* ``auto`` - users are prompted only the first time, subsequent authorizations for the same application and scopes will be automatically accepted. Skip authorization completely for trusted applications @@ -109,7 +109,7 @@ Overriding views ================ You may want to override whole views from Django OAuth Toolkit, for instance if you want to -change the login view for unregistred users depending on some query params. +change the login view for unregistered users depending on some query params. In order to do that, you need to write a custom urlpatterns diff --git a/docs/contributing.rst b/docs/contributing.rst index 1d88bc4b0..c31e72990 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -12,7 +12,7 @@ This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree t Setup ===== -Fork `django-oauth-toolkit` repository on `GitHub <https://github.com/jazzband/django-oauth-toolkit>`_ and follow these steps: +Fork ``django-oauth-toolkit`` repository on `GitHub <https://github.com/jazzband/django-oauth-toolkit>`_ and follow these steps: * Create a virtualenv and activate it * Clone your repository locally @@ -55,14 +55,14 @@ is a better way to structure the code so that it is more readable. Documentation ============= -You can edit the documentation by editing files in ``docs/``. This project +You can edit the documentation by editing files in :file:`docs/`. This project uses sphinx to turn ``ReStructuredText`` into the HTML docs you are reading. In order to build the docs in to HTML, you can run:: tox -e docs -This will build the docs, and place the result in ``docs/_build/html``. +This will build the docs, and place the result in :file:`docs/_build/html`. Alternatively, you can run:: tox -e livedocs @@ -89,7 +89,7 @@ For example, to add Deutsch:: cd oauth2_provider django-admin makemessages --locale de -Then edit ``locale/de/LC_MESSAGES/django.po`` to add your translations. +Then edit :file:`locale/de/LC_MESSAGES/django.po` to add your translations. When deploying your app, don't forget to compile the messages with:: @@ -108,8 +108,8 @@ And, if a new migration is needed, use:: django-admin makemigrations --settings tests.mig_settings -Auto migrations frequently have ugly names like `0004_auto_20200902_2022`. You can make your migration -name "better" by adding the `-n name` option:: +Auto migrations frequently have ugly names like ``0004_auto_20200902_2022``. You can make your migration +name "better" by adding the ``-n name`` option:: django-admin makemigrations --settings tests.mig_settings -n widget @@ -117,7 +117,7 @@ name "better" by adding the `-n name` option:: Pull requests ============= -Please avoid providing a pull request from your `master` and use **topic branches** instead; you can add as many commits +Please avoid providing a pull request from your ``master`` and use **topic branches** instead; you can add as many commits as you want but please keep them in one branch which aims to solve one single issue. Then submit your pull request. To create a topic branch, simply do:: @@ -129,7 +129,7 @@ When you're ready to submit your pull request, first push the topic branch to yo git push origin fix-that-issue Now you can go to your repository dashboard on GitHub and open a pull request starting from your topic branch. You can -apply your pull request to the `master` branch of django-oauth-toolkit (this should be the default behaviour of GitHub +apply your pull request to the ``master`` branch of django-oauth-toolkit (this should be the default behaviour of GitHub user interface). When you begin your PR, you'll be asked to provide the following: @@ -150,29 +150,29 @@ When you begin your PR, you'll be asked to provide the following: * Update the documentation (in `docs/`) to describe the new or changed functionality. -* Update `CHANGELOG.md` (only for user relevant changes). We use `Keep A Changelog <https://keepachangelog.com/en/1.0.0/>`_ +* Update ``CHANGELOG.md`` (only for user relevant changes). We use `Keep A Changelog <https://keepachangelog.com/en/1.0.0/>`_ format which categorizes the changes as: - * `Added` for new features. + * ``Added`` for new features. - * `Changed` for changes in existing functionality. + * ``Changed`` for changes in existing functionality. - * `Deprecated` for soon-to-be removed features. + * ``Deprecated`` for soon-to-be removed features. - * `Removed` for now removed features. + * ``Removed`` for now removed features. - * `Fixed` for any bug fixes. + * ``Fixed`` for any bug fixes. - * `Security` in case of vulnerabilities. (Please report any security issues to the - JazzBand security team `<security@jazzband.co>`. Do not file an issue on the tracker + * ``Security`` in case of vulnerabilities. (Please report any security issues to the + JazzBand security team ``<security@jazzband.co>``. Do not file an issue on the tracker or submit a PR until directed to do so.) -* Make sure your name is in `AUTHORS`. We want to give credit to all contributors! +* Make sure your name is in :file:`AUTHORS`. We want to give credit to all contributors! If your PR is not yet ready to be merged mark it as a Work-in-Progress -By prepending `WIP:` to the PR title so that it doesn't get inadvertently approved and merged. +By prepending ``WIP:`` to the PR title so that it doesn't get inadvertently approved and merged. -Make sure to request a review by assigning Reviewer `jazzband/django-oauth-toolkit`. +Make sure to request a review by assigning Reviewer ``jazzband/django-oauth-toolkit``. This will assign the review to the project team and a member will review it. In the meantime you can continue to add commits to your topic branch (and push them up to GitHub) either if you see something that needs changing, or in response to a reviewer's comments. If a reviewer asks for changes, you do not need to close the pull and reissue it @@ -194,7 +194,7 @@ Then merge the changes that you fetched:: git merge upstream/master -For more info, see http://help.github.com/fork-a-repo/ +For more information, see the `GitHub Docs on forking the repository <https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo>`_. .. note:: Please be sure to rebase your commits on the master when possible, so your commits can be fast-forwarded: we try to avoid *merge commits* when they are not necessary. @@ -209,7 +209,7 @@ The Checklist A checklist template is automatically added to your PR when you create it. Make sure you've done all the applicable steps and check them off to indicate you have done so. This is -what you'll see when creating your PR: +what you'll see when creating your PR:: Fixes # @@ -251,7 +251,7 @@ You can check your coverage locally with the `coverage <https://pypi.org/project pip install coverage coverage html -d mycoverage -Open mycoverage/index.html in your browser and you can see a coverage summary and coverage details for each file. +Open :file:`mycoverage/index.html` in your browser and you can see a coverage summary and coverage details for each file. There's no need to wait for Codecov to complain after you submit your PR. @@ -263,8 +263,9 @@ Try reading our code and grasp the overall philosophy regarding method and varia the sake of readability, keep in mind that *simple is better than complex*. If you feel the code is not straightforward, add a comment. If you think a function is not trivial, add a docstrings. -To see if your code formatting will pass muster use: `tox -e flake8` +To see if your code formatting will pass muster use:: + tox -e flake8 The contents of this page are heavily based on the docs from `django-admin2 <https://github.com/twoscoops/django-admin2>`_ @@ -301,14 +302,14 @@ and rtfd.io. This checklist is a reminder of the required steps. to make them meaningful to users. - Make a final PR for the release that updates: - - CHANGELOG to show the release date. - - `oauth2_provider/__init__.py` to set `__version__ = "..."` + - :file:`CHANGELOG.md` to show the release date. + - :file:`oauth2_provider/__init__.py` to set ``__version__ = "..."`` - Once the final PR is merged, create and push a tag for the release. You'll shortly get a notification from Jazzband of the availability of two pypi packages (source tgz and wheel). Download these locally before releasing them. -- Do a `tox -e build` and extract the downloaded and bullt wheel zip and tgz files into - temp directories and do a `diff -r` to make sure they have the same content. +- Do a ``tox -e build`` and extract the downloaded and built wheel zip and tgz files into + temp directories and do a ``diff -r`` to make sure they have the same content. (Unfortunately the checksums do not match due to timestamps in the metadata so you need to compare all the files.) - Once happy that the above comparison checks out, approve the releases to Pypi.org. diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 388afa300..2d7ebe269 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -42,7 +42,7 @@ Create a Django project:: django-admin startproject iam -This will create a mysite directory in your current directory. With the following estructure:: +This will create a mysite directory in your current directory. With the following structure:: . └── iam @@ -109,7 +109,7 @@ Configure ``users.User`` to be the model used for the ``auth`` application by ad .. code-block:: python - AUTH_USER_MODEL='users.User' + AUTH_USER_MODEL = 'users.User' Create inital migration for ``users`` application ``User`` model:: @@ -203,7 +203,7 @@ Last change, add ``LOGIN_URL`` to :file:`iam/settings.py`: .. code-block:: python - LOGIN_URL='/admin/login/' + LOGIN_URL = '/admin/login/' We will use Django Admin login to make our life easy. @@ -332,7 +332,7 @@ To be more easy to visualize:: The OAuth2 provider will return the follow response: -.. code-block:: javascript +.. code-block:: json { "access_token": "jooqrnOrNa0BrNWlg68u9sl6SkdFZg", @@ -402,7 +402,7 @@ To be easier to visualize:: The OAuth2 provider will return the following response: -.. code-block:: javascript +.. code-block:: json { "access_token": "PaZDOD5UwzbGOFsQr34LQ7JUYOj3yK", diff --git a/docs/install.rst b/docs/install.rst index 65dcb1d17..7186a94c0 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,11 +1,11 @@ Installation ============ -Install with pip -:: +Install with pip:: + pip install django-oauth-toolkit -Add `oauth2_provider` to your `INSTALLED_APPS` +Add ``oauth2_provider`` to your ``INSTALLED_APPS`` .. code-block:: python @@ -15,7 +15,7 @@ Add `oauth2_provider` to your `INSTALLED_APPS` ) -If you need an OAuth2 provider you'll want to add the following to your urls.py +If you need an OAuth2 provider you'll want to add the following to your :file:`urls.py` .. code-block:: python @@ -26,7 +26,7 @@ If you need an OAuth2 provider you'll want to add the following to your urls.py path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] -Or using `re_path()` +Or using ``re_path()`` .. code-block:: python @@ -34,7 +34,6 @@ Or using `re_path()` urlpatterns = [ ... - re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] @@ -43,7 +42,7 @@ Sync your database .. sourcecode:: sh - $ python manage.py migrate oauth2_provider + python manage.py migrate oauth2_provider Next step is :doc:`getting started <getting_started>` or :doc:`first tutorial <tutorial/tutorial_01>`. diff --git a/docs/management_commands.rst b/docs/management_commands.rst index aa36e2ebf..83770041e 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -92,5 +92,5 @@ The ``createapplication`` management command provides a shortcut to create a new --force-color Force colorization of the command output. --skip-checks Skip system checks. -If you let `createapplication` auto-generate the secret then it displays the value before hashing it. +If you let ``createapplication`` auto-generate the secret then it displays the value before hashing it. diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index bff2b9017..4e6b037b0 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -4,20 +4,16 @@ Getting started Django OAuth Toolkit provide a support layer for `Django REST Framework <http://django-rest-framework.org/>`_. This tutorial is based on the Django REST Framework example and shows you how to easily integrate with it. -**NOTE** - -The following code has been tested with Django 2.0.3 and Django REST Framework 3.7.7 +.. note:: The following code has been tested with Django 2.0.3 and Django REST Framework 3.7.7 Step 1: Minimal setup --------------------- -Create a virtualenv and install following packages using `pip`... - -:: +Create a virtualenv and install following packages using ``pip``:: pip install django-oauth-toolkit djangorestframework -Start a new Django project and add `'rest_framework'` and `'oauth2_provider'` to your `INSTALLED_APPS` setting. +Start a new Django project and add ``'rest_framework'`` and ``'oauth2_provider'`` to your ``INSTALLED_APPS`` setting. .. code-block:: python @@ -29,7 +25,7 @@ Start a new Django project and add `'rest_framework'` and `'oauth2_provider'` to ) Now we need to tell Django REST Framework to use the new authentication backend. -To do so add the following lines at the end of your `settings.py` module: +To do so add the following lines at the end of your :file:`settings.py` module: .. code-block:: python @@ -44,7 +40,7 @@ Step 2: Create a simple API Let's create a simple API for accessing users and groups. -Here's our project's root `urls.py` module: +Here's our project's root :file:`urls.py` module: .. code-block:: python @@ -95,7 +91,7 @@ Here's our project's root `urls.py` module: # ... ] -Also add the following to your `settings.py` module: +Also add the following to your :file:`settings.py` module: .. code-block:: python @@ -114,7 +110,7 @@ Also add the following to your `settings.py` module: LOGIN_URL = '/admin/login/' -`OAUTH2_PROVIDER.SCOPES` setting parameter contains the scopes that the application will be aware of, +``OAUTH2_PROVIDER.SCOPES`` setting parameter contains the scopes that the application will be aware of, so we can use them for permission check. Now run the following commands: @@ -149,25 +145,23 @@ views you can use to CRUD application instances, just point your browser at: Click on the link to create a new application and fill the form with the following data: -* Name: *just a name of your choice* -* Client Type: *confidential* -* Authorization Grant Type: *Resource owner password-based* +* **Name:** *just a name of your choice* +* **Client Type:** *confidential* +* **Authorization Grant Type:** *Resource owner password-based* Save your app! Step 4: Get your token and use your API --------------------------------------- -At this point we're ready to request an access_token. Open your shell - -:: +At this point we're ready to request an access_token. Open your shell:: curl -X POST -d "grant_type=password&username=<user_name>&password=<password>" -u"<client_id>:<client_secret>" http://localhost:8000/o/token/ The *user_name* and *password* are the credential of the users registered in your :term:`Authorization Server`, like any user created in Step 2. Response should be something like: -.. code-block:: javascript +.. code-block:: json { "access_token": "<your_access_token>", @@ -177,9 +171,7 @@ Response should be something like: "scope": "read write groups" } -Grab your access_token and start using your new OAuth2 API: - -:: +Grab your access_token and start using your new OAuth2 API:: # Retrieve users curl -H "Authorization: Bearer <your_access_token>" http://localhost:8000/users/ @@ -191,15 +183,13 @@ Grab your access_token and start using your new OAuth2 API: # Insert a new user curl -H "Authorization: Bearer <your_access_token>" -X POST -d"username=foo&password=bar&scope=write" http://localhost:8000/users/ -Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`: - -:: +Some time has passed and your access token is about to expire, you can get renew the access token issued using the `refresh token`:: curl -X POST -d "grant_type=refresh_token&refresh_token=<your_refresh_token>&client_id=<your_client_id>&client_secret=<your_client_secret>" http://localhost:8000/o/token/ -Your response should be similar to your first access_token request, containing a new access_token and refresh_token: +Your response should be similar to your first ``access_token`` request, containing a new access_token and refresh_token: -.. code-block:: javascript +.. code-block:: json { "access_token": "<your_new_access_token>", @@ -214,15 +204,13 @@ Your response should be similar to your first access_token request, containing a Step 5: Testing Restricted Access --------------------------------- -Let's try to access resources using a token with a restricted scope adding a `scope` parameter to the token request - -:: +Let's try to access resources using a token with a restricted scope adding a ``scope`` parameter to the token request:: curl -X POST -d "grant_type=password&username=<user_name>&password=<password>&scope=read" -u"<client_id>:<client_secret>" http://localhost:8000/o/token/ -As you can see the only scope provided is `read`: +As you can see the only scope provided is ``read``: -.. code-block:: javascript +.. code-block:: json { "access_token": "<your_access_token>", @@ -232,15 +220,13 @@ As you can see the only scope provided is `read`: "scope": "read" } -We now try to access our resources: - -:: +We now try to access our resources:: # Retrieve users curl -H "Authorization: Bearer <your_access_token>" http://localhost:8000/users/ curl -H "Authorization: Bearer <your_access_token>" http://localhost:8000/users/1/ -Ok, this one works since users read only requires `read` scope. +OK, this one works since users read only requires ``read`` scope. :: @@ -250,5 +236,5 @@ Ok, this one works since users read only requires `read` scope. # 'write' scope needed curl -H "Authorization: Bearer <your_access_token>" -X POST -d"username=foo&password=bar" http://localhost:8000/users/ -You'll get a `"You do not have permission to perform this action"` error because your access_token does not provide the -required scopes `groups` and `write`. +You'll get a ``"You do not have permission to perform this action"`` error because your access_token does not provide the +required scopes ``groups`` and ``write``. diff --git a/docs/settings.rst b/docs/settings.rst index c64c24954..db5ef110b 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -1,10 +1,10 @@ Settings ======== -Our configurations are all namespaced under the `OAUTH2_PROVIDER` settings with the exception of -`OAUTH2_PROVIDER_APPLICATION_MODEL, OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL, OAUTH2_PROVIDER_GRANT_MODEL, -OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL`: this is because of the way Django currently implements -swappable models. See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) for details. +Our configurations are all namespaced under the ``OAUTH2_PROVIDER`` settings with the exception of +``OAUTH2_PROVIDER_APPLICATION_MODEL``, ``OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL``, ``OAUTH2_PROVIDER_GRANT_MODEL``, +``OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL``: this is because of the way Django currently implements +swappable models. See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. For example: @@ -45,7 +45,7 @@ this value if you wrote your own implementation (subclass of ACCESS_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~ Import path of a callable used to generate access tokens. -oauthlib.oauth2.rfc6749.tokens.random_token_generator is (normally) used if not provided. +``oauthlib.oauth2.rfc6749.tokens.random_token_generator`` is (normally) used if not provided. ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -72,7 +72,7 @@ A list of schemes that the ``allowed_origins`` field will be validated against. Setting this to ``["https"]`` only in production is strongly recommended. Adding ``"http"`` to the list is considered to be safe only for local development and testing. Note that `OAUTHLIB_INSECURE_TRANSPORT <https://oauthlib.readthedocs.io/en/latest/oauth2/security.html#envvar-OAUTHLIB_INSECURE_TRANSPORT>`_ -environment variable should be also set to allow http origins. +environment variable should be also set to allow HTTP origins. APPLICATION_MODEL @@ -187,15 +187,15 @@ this value if you wrote your own implementation (subclass of ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ -When is set to `True` (default) a new refresh token is issued to the client when the client refreshes an access token. -If `False`, it will reuse the same refresh token and only update the access token with a new token value. +When is set to ``True`` (default) a new refresh token is issued to the client when the client refreshes an access token. +If ``False``, it will reuse the same refresh token and only update the access token with a new token value. See also: validator's rotate_refresh_token method can be overridden to make this variable (could be usable with expiring refresh tokens, in particular, so that they are rotated when close to expiration, theoretically). REFRESH_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~~ -See `ACCESS_TOKEN_GENERATOR`. This is the same but for refresh tokens. +See `ACCESS_TOKEN_GENERATOR`_. This is the same but for refresh tokens. Defaults to access token generator if not provided. REQUEST_APPROVAL_PROMPT @@ -210,7 +210,7 @@ Defaults to ``oauth2_provider.scopes.SettingsScopes``, which reads scopes throug SCOPES ~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. A dictionary mapping each scope name to its human description. @@ -218,11 +218,11 @@ A dictionary mapping each scope name to its human description. DEFAULT_SCOPES ~~~~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. A list of scopes that should be returned by default. -This is a subset of the keys of the SCOPES setting. -By default this is set to '__all__' meaning that the whole set of SCOPES will be returned. +This is a subset of the keys of the ``SCOPES`` setting. +By default this is set to ``'__all__'`` meaning that the whole set of ``SCOPES`` will be returned. .. code-block:: python @@ -230,13 +230,13 @@ By default this is set to '__all__' meaning that the whole set of SCOPES will be READ_SCOPE ~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if `SCOPES_BACKEND_CLASS` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. The name of the *write* scope. @@ -248,8 +248,8 @@ Only applicable when used with `Django REST Framework <http://django-rest-framew RESOURCE_SERVER_INTROSPECTION_URL ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The introspection endpoint for validating token remotely (RFC7662). This URL requires either an authorization -token (RESOURCE_SERVER_AUTH_TOKEN) -or HTTP Basic Auth client credentials (RESOURCE_SERVER_INTROSPECTION_CREDENTIALS): +token (``RESOURCE_SERVER_AUTH_TOKEN``) +or HTTP Basic Auth client credentials (``RESOURCE_SERVER_INTROSPECTION_CREDENTIALS``). RESOURCE_SERVER_AUTH_TOKEN ~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -258,7 +258,7 @@ The bearer token to authenticate the introspection request towards the introspec RESOURCE_SERVER_INTROSPECTION_CREDENTIALS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ The HTTP Basic Auth Client_ID and Client_Secret to authenticate the introspection request -towards the introspect endpoint (RFC7662) as a tuple: (client_id,client_secret). +towards the introspect endpoint (RFC7662) as a tuple: ``(client_id, client_secret)``. RESOURCE_SERVER_TOKEN_CACHING_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -281,11 +281,6 @@ According to `OAuth 2.0 Security Best Current Practice <https://oauth.net/2/oaut - Public clients MUST use PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ - For confidential clients, the use of PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ is RECOMMENDED. - - - - - OIDC_RSA_PRIVATE_KEY ~~~~~~~~~~~~~~~~~~~~ Default: ``""`` @@ -328,7 +323,7 @@ OIDC_RP_INITIATED_LOGOUT_ENABLED ~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``False`` -When is set to `False` (default) the `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_ +When is set to ``False`` (default) the `OpenID Connect RP-Initiated Logout <https://openid.net/specs/openid-connect-rpinitiated-1_0.html>`_ endpoint is not enabled. OpenID Connect RP-Initiated Logout enables an :term:`Client` (Relying Party) to request that a :term:`Resource Owner` (End User) is logged out at the :term:`Authorization Server` (OpenID Provider). @@ -356,7 +351,7 @@ OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS Default: ``True`` Whether to delete the access, refresh and ID tokens of the user that is being logged out. -The types of applications for which tokens are deleted can be customized with `RPInitiatedLogoutView.token_types_to_delete`. +The types of applications for which tokens are deleted can be customized with ``RPInitiatedLogoutView.token_types_to_delete``. The default is to delete the tokens of all applications if this flag is enabled. OIDC_ISS_ENDPOINT @@ -412,7 +407,7 @@ Default: ``0`` Time of sleep in seconds used by ``cleartokens`` management command between batch deletions. -Set this to a non-zero value (e.g. `0.1`) to add a pause between batch sizes to reduce system +Set this to a non-zero value (e.g. ``0.1``) to add a pause between batch sizes to reduce system load when clearing large batches of expired tokens. diff --git a/docs/signals.rst b/docs/signals.rst index fe696ae2c..f35832af5 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -4,7 +4,7 @@ Signals Django-oauth-toolkit sends messages to various signals, depending on the action that has been triggered. -You can easily import signals from `oauth2_provider.signals` and attach your +You can easily import signals from ``oauth2_provider.signals`` and attach your own listeners. For example: @@ -20,5 +20,5 @@ For example: Currently supported signals are: -* `oauth2_provider.signals.app_authorized` - fired once an oauth code has been +* ``oauth2_provider.signals.app_authorized`` - fired once an oauth code has been authorized and an access token has been granted diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index a7bf20466..9f1ace1bd 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -117,9 +117,9 @@ process we'll explain shortly) Test Your Authorization Server ------------------------------ Your authorization server is ready and can begin issuing access tokens. To test the process you need an OAuth2 -consumer; if you are familiar enough with OAuth2, you can use curl, requests, or anything that speaks http. +consumer; if you are familiar enough with OAuth2, you can use curl, requests, or anything that speaks HTTP. -For this tutorial, we suggest using [Postman](https://www.postman.com/downloads/) : +For this tutorial, we suggest using `Postman <https://www.postman.com/downloads/>`_. Open up the Authorization tab under a request and, for this tutorial, set the fields as follows: @@ -150,7 +150,7 @@ again to the consumer service. Possible errors: -* loginTemplate: If you are not redirected to the correct page after logging in successfully, you probably need to `setup your login template correctly`__. +* loginTemplate: If you are not redirected to the correct page after logging in successfully, you probably need to `setup your login template correctly <loginTemplate_>`_. * invalid client: client id and client secret needs to be correct. Secret cannot be copied from Django admin after creation. (but you can reset it by pasting the same random string into Django admin and into Postman, to avoid recreating the app) * invalid callback url: Add the postman link into your app in Django admin. diff --git a/docs/tutorial/tutorial_02.rst b/docs/tutorial/tutorial_02.rst index cdc94540c..556eb6356 100644 --- a/docs/tutorial/tutorial_02.rst +++ b/docs/tutorial/tutorial_02.rst @@ -14,7 +14,7 @@ to provide an API to access some kind of resources. We don't need an actual reso endpoint protected with OAuth2: let's do it in a *class based view* fashion! Django OAuth Toolkit provides a set of generic class based view you can use to add OAuth behaviour to your views. Open -your `views.py` module and import the view: +your :file:`views.py` module and import the view: .. code-block:: python @@ -29,7 +29,7 @@ Then create the view which will respond to the API endpoint: def get(self, request, *args, **kwargs): return HttpResponse('Hello, OAuth2!') -That's it, our API will expose only one method, responding to `GET` requests. Now open your `urls.py` and specify the +That's it, our API will expose only one method, responding to ``GET`` requests. Now open your :file:`urls.py` and specify the URL this view will respond to: .. code-block:: python @@ -73,15 +73,15 @@ URL this view will respond to: You will probably want to write your own application views to deal with permissions and access control but the ones packaged with the library can get you started when developing the app. -Since we inherit from `ProtectedResourceView`, we're done and our API is OAuth2 protected - for the sake of the lazy +Since we inherit from ``ProtectedResourceView``, we're done and our API is OAuth2 protected - for the sake of the lazy programmer. Testing your API ---------------- Time to make requests to your API. -For a quick test, try accessing your app at the url `/api/hello` with your browser -and verify that it responds with a `403` (in fact no `HTTP_AUTHORIZATION` header was provided). +For a quick test, try accessing your app at the url ``/api/hello`` with your browser +and verify that it responds with a ``403`` (in fact no ``HTTP_AUTHORIZATION`` header was provided). You can test your API with anything that can perform HTTP requests, but for this tutorial you can use the online `consumer client <http://django-oauth-toolkit.herokuapp.com/consumer/client>`_. Just fill the form with the URL of the API endpoint (i.e. http://localhost:8000/api/hello if you're on localhost) and diff --git a/docs/tutorial/tutorial_03.rst b/docs/tutorial/tutorial_03.rst index ef5d57969..a9e063785 100644 --- a/docs/tutorial/tutorial_03.rst +++ b/docs/tutorial/tutorial_03.rst @@ -31,28 +31,28 @@ which takes care of token verification. In your settings.py: '...', ] -You will likely use the `django.contrib.auth.backends.ModelBackend` along with the OAuth2 backend +You will likely use the ``django.contrib.auth.backends.ModelBackend`` along with the OAuth2 backend (or you might not be able to log in into the admin), only pay attention to the order in which Django processes authentication backends. -If you put the OAuth2 backend *after* the AuthenticationMiddleware and `request.user` is valid, -the backend will do nothing; if `request.user` is the Anonymous user it will try to authenticate +If you put the OAuth2 backend *after* the ``AuthenticationMiddleware`` and ``request.user`` is valid, +the backend will do nothing; if ``request.user`` is the Anonymous user it will try to authenticate the user using the OAuth2 access token. -If you put the OAuth2 backend *before* AuthenticationMiddleware, or AuthenticationMiddleware is +If you put the OAuth2 backend *before* ``AuthenticationMiddleware``, or AuthenticationMiddleware is not used at all, it will try to authenticate user with the OAuth2 access token and set -`request.user` and `request._cached_user` fields so that AuthenticationMiddleware (when active) +``request.user`` and ``request._cached_user`` fields so that AuthenticationMiddleware (when active) will not try to get user from the session. -If you use AuthenticationMiddleware, be sure it appears before OAuth2TokenMiddleware. -However AuthenticationMiddleware is NOT required for using django-oauth-toolkit. +If you use ``AuthenticationMiddleware``, be sure it appears before ``OAuth2TokenMiddleware``. +However ``AuthenticationMiddleware`` is NOT required for using ``django-oauth-toolkit``. -Note, `OAuth2TokenMiddleware` adds the user to the request object. There is also an optional `OAuth2ExtraTokenMiddleware` that adds the `Token` to the request. This makes it convenient to access the `Application` object within your views. To use it just add `oauth2_provider.middleware.OAuth2ExtraTokenMiddleware` to the `MIDDLEWARE` setting. +Note, ``OAuth2TokenMiddleware`` adds the user to the request object. There is also an optional ``OAuth2ExtraTokenMiddleware`` that adds the ``Token`` to the request. This makes it convenient to access the ``Application`` object within your views. To use it just add ``oauth2_provider.middleware.OAuth2ExtraTokenMiddleware`` to the ``MIDDLEWARE`` setting. Protect your view ----------------- -The authentication backend will run smoothly with, for example, `login_required` decorators, so -that you can have a view like this in your `views.py` module: +The authentication backend will run smoothly with, for example, ``login_required`` decorators, so +that you can have a view like this in your :file:`views.py` module: .. code-block:: python @@ -75,7 +75,7 @@ To check everything works properly, mount the view above to some url: You should have an :term:`Application` registered at this point, if you don't, follow the steps in the previous tutorials to create one. Obtain an :term:`Access Token`, either following the OAuth2 flow of your application or manually creating in the Django admin. -Now supposing your access token value is `123456` you can try to access your authenticated view: +Now supposing your access token value is ``123456`` you can try to access your authenticated view: :: @@ -92,7 +92,7 @@ It would be nice to reuse those views **and** support token handling. Instead of those classes to be ProtectedResourceView based, the solution is much simpler than that. Assume you have already modified the settings as was already shown. -The key is setting a class attribute to override the default *permissions_classes* with something that will use our :term:`Access Token` properly. +The key is setting a class attribute to override the default ``permissions_classes`` with something that will use our :term:`Access Token` properly. .. code-block:: python @@ -107,7 +107,7 @@ The key is setting a class attribute to override the default *permissions_classe permission_classes = [TokenHasReadWriteScope] Note that this example overrides the Django default permission class setting. There are several other -ways this can be solved. Overriding the class function *get_permission_classes* is another way +ways this can be solved. Overriding the class function ``get_permission_classes`` is another way to solve the problem. A detailed dive into the `Django REST framework permissions is here. <https://www.django-rest-framework.org/api-guide/permissions/>`_ diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index 07759d1e7..089f2ac25 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -7,12 +7,12 @@ You've granted a user an :term:`Access Token`, following :doc:`part 1 <tutorial_ Revoking a Token ---------------- -Be sure that you've granted a valid token. If you've hooked in `oauth-toolkit` into your `urls.py` as specified in :doc:`part 1 <tutorial_01>`, you'll have a URL at `/o/revoke_token`. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. +Be sure that you've granted a valid token. If you've hooked in ``oauth-toolkit`` into your :file:`urls.py` as specified in :doc:`part 1 <tutorial_01>`, you'll have a URL at ``/o/revoke_token``. By submitting the appropriate request to that URL, you can revoke a user's :term:`Access Token`. `Oauthlib <https://github.com/idan/oauthlib>`_ is compliant with https://rfc-editor.org/rfc/rfc7009.html, so as specified, the revocation request requires: -- token: REQUIRED, this is the :term:`Access Token` you want to revoke -- token_type_hint: OPTIONAL, designating either 'access_token' or 'refresh_token'. +- ``token``: REQUIRED, this is the :term:`Access Token` you want to revoke +- ``token_type_hint``: OPTIONAL, designating either 'access_token' or 'refresh_token'. Note that these revocation-specific parameters are in addition to the authentication parameters already specified by your particular client type. @@ -36,7 +36,7 @@ obtained in :doc:`part 1 <tutorial_01>`. If your application type is `Confidenti token=XXXX&client_id=XXXX&client_secret=XXXX -The server will respond wih a `200` status code on successful revocation. You can use `curl` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. +The server will respond wih a ``200`` status code on successful revocation. You can use ``curl`` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. :: diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index 1be656b88..e75f3e23e 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -38,7 +38,7 @@ See the `RabbitMQ Installing on Windows <https://www.rabbitmq.com/install-window Add Celery ---------- -Make sure you virtualenv is active and install `celery` and +Make sure you virtualenv is active and install ``celery`` and `django-celery-beat <https://django-celery-beat.readthedocs.io/>`_. :: @@ -58,7 +58,7 @@ in the database and adds a Django Admin interface for configuring them. } -Now add a new file to your app to add Celery: ``tutorial/celery.py``: +Now add a new file to your app to add Celery: :file:`tutorial/celery.py`: .. code-block:: python @@ -74,8 +74,8 @@ Now add a new file to your app to add Celery: ``tutorial/celery.py``: # Load task modules from all registered Django apps. app.autodiscover_tasks() -This will autodiscover any ``tasks.py`` files in the list of installed apps. -We'll add ours now in ``tutorial/tasks.py``: +This will autodiscover any :file:`tasks.py` files in the list of installed apps. +We'll add ours now in :file:`tutorial/tasks.py`: .. code-block:: python @@ -87,7 +87,7 @@ We'll add ours now in ``tutorial/tasks.py``: clear_expired() -Finally, update ``tutorial/__init__.py`` to make sure Celery gets loaded when the app starts up: +Finally, update :file:`tutorial/__init__.py` to make sure Celery gets loaded when the app starts up: .. code-block:: python @@ -162,8 +162,6 @@ References The preceding is based on these references: -https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html - -https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers - -https://django-celery-beat.readthedocs.io/en/latest/index.html +* https://docs.celeryq.dev/en/stable/django/first-steps-with-django.html +* https://docs.celeryq.dev/en/stable/userguide/periodic-tasks.html#beat-custom-schedulers +* https://django-celery-beat.readthedocs.io/en/latest/index.html diff --git a/docs/views/application.rst b/docs/views/application.rst index a9f04bcd3..c5ec70d3b 100644 --- a/docs/views/application.rst +++ b/docs/views/application.rst @@ -2,9 +2,9 @@ Application Views ================= A set of views is provided to let users handle application instances without accessing Django Admin -Site. Application views are listed at the url `applications/` and you can register a new one at the -url `applications/register`. You can override default templates located in -`templates/oauth2_provider` folder and provide a custom layout. Every view provides access only to +Site. Application views are listed at the url ``applications/`` and you can register a new one at the +url ``applications/register``. You can override default templates located in +:file:`templates/oauth2_provider` folder and provide a custom layout. Every view provides access only to data belonging to the logged in user who performs the request. diff --git a/docs/views/class_based.rst b/docs/views/class_based.rst index 543ed58bb..d5573a600 100644 --- a/docs/views/class_based.rst +++ b/docs/views/class_based.rst @@ -38,7 +38,7 @@ using the *Class Based View* approach. .. class:: ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourceView): A view that provides OAuth2 authentication and read/write default scopes. - ``GET``, ``HEAD``, ``OPTIONS`` http methods require ``read`` scope, others methods + ``GET``, ``HEAD``, ``OPTIONS`` HTTP methods require ``read`` scope, others methods need the ``write`` scope. If you need, you can always specify an additional list of scopes in the ``required_scopes`` field:: diff --git a/docs/views/function_based.rst b/docs/views/function_based.rst index cc0650bd9..57884b2b9 100644 --- a/docs/views/function_based.rst +++ b/docs/views/function_based.rst @@ -43,8 +43,8 @@ Django OAuth Toolkit provides decorators to help you in protecting your function .. function:: rw_protected_resource(scopes=None, validator_cls=OAuth2Validator, server_cls=Server) Decorator to protect views by providing OAuth2 authentication and read/write scopes out of the - box. GET, HEAD, OPTIONS http methods require "read" scope. - Otherwise "write" scope is required:: + box. ``GET``, ``HEAD``, ``OPTIONS`` HTTP methods require ``'read'`` scope. + Otherwise ``'write'`` scope is required:: from oauth2_provider.decorators import rw_protected_resource @@ -54,7 +54,7 @@ Django OAuth Toolkit provides decorators to help you in protecting your function # ... pass - If you need, you can ask for other scopes over "read" and "write":: + If you need, you can ask for other scopes over ``'read'`` and ``'write'``:: from oauth2_provider.decorators import rw_protected_resource diff --git a/docs/views/token.rst b/docs/views/token.rst index ead0d023d..6c6d2b6ae 100644 --- a/docs/views/token.rst +++ b/docs/views/token.rst @@ -5,10 +5,10 @@ A set of views is provided to let users handle tokens that have been granted to Every view provides access only to the tokens that have been granted to the user performing the request. -Granted Token views are listed at the url `authorized_tokens/`. +Granted Token views are listed at the url ``authorized_tokens/``. -For each granted token there is a delete view that allows you to delete such token. You can override default templates `authorized-tokens.html` for the list view and `authorized-token-delete.html` for the delete view; they are located inside `templates/oauth2_provider` folder. +For each granted token there is a delete view that allows you to delete such token. You can override default templates :file:`authorized-tokens.html` for the list view and :file:`authorized-token-delete.html` for the delete view; they are located inside :file:`templates/oauth2_provider` folder. .. automodule:: oauth2_provider.views.token From 9e66f39c9f319fb0eaaacd8f623ccd4273131a94 Mon Sep 17 00:00:00 2001 From: TAKAHASHI Shuuji <shuuji3@gmail.com> Date: Wed, 28 Feb 2024 02:50:13 +0900 Subject: [PATCH 593/722] docs: fix a tiny typo in method docstring (#1399) --- oauth2_provider/views/token.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/views/token.py b/oauth2_provider/views/token.py index 53fcf3544..91dd1a345 100644 --- a/oauth2_provider/views/token.py +++ b/oauth2_provider/views/token.py @@ -16,7 +16,7 @@ class AuthorizedTokensListView(LoginRequiredMixin, ListView): def get_queryset(self): """ - Show only user"s tokens + Show only user's tokens """ return super().get_queryset().select_related("application").filter(user=self.request.user) From 560f84d9e20a499d10c29bce94efda7898c2939d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Apr 2024 07:01:40 -0400 Subject: [PATCH 594/722] Bump vite from 4.5.2 to 4.5.3 in /tests/app/rp (#1414) --- tests/app/rp/package-lock.json | 8 ++++---- tests/app/rp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index f74485d70..80b168437 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -19,7 +19,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.5.2" + "vite": "^4.5.3" } }, "node_modules/@dopry/svelte-oidc": { @@ -1625,9 +1625,9 @@ } }, "node_modules/vite": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.2.tgz", - "integrity": "sha512-tBCZBNSBbHQkaGyhGCDUGqeo2ph8Fstyp6FMSvTtsXeZSPpSMGlviAOav2hxVTqFcx8Hj/twtWKsMJXNY0xI8w==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", + "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", "dev": true, "dependencies": { "esbuild": "^0.18.10", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index fe1872941..4a3851d97 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -20,7 +20,7 @@ "svelte-check": "^3.0.1", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.5.2" + "vite": "^4.5.3" }, "type": "module", "dependencies": { From 2b56a480573a526d1b264ee117e29e8ebbaaaebc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 30 Apr 2024 09:11:29 -0400 Subject: [PATCH 595/722] [pre-commit.ci] pre-commit autoupdate (#1409) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.2.0 → 24.4.2](https://github.com/psf/black/compare/24.2.0...24.4.2) - [github.com/pre-commit/pre-commit-hooks: v4.5.0 → v4.6.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.5.0...v4.6.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b33a5a356..9e4922f94 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,11 +1,11 @@ repos: - repo: https://github.com/psf/black - rev: 24.2.0 + rev: 24.4.2 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 + rev: v4.6.0 hooks: - id: check-ast - id: trailing-whitespace From ea51411a74bb4f879d7127d9bface449708955ed Mon Sep 17 00:00:00 2001 From: Lazaros Toumanidis <lazToum@users.noreply.github.com> Date: Mon, 6 May 2024 21:32:54 +0300 Subject: [PATCH 596/722] Update middleware.py (#1380) * Update middleware.py Use `get_access_token_model` instead of `AccessToken` * Update CHANGELOG.md * Update AUTHORS --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/middleware.py | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 8596063b9..fb19362b6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -75,6 +75,7 @@ Julien Palard Jun Zhou Kaleb Porter Kristian Rune Larsen +Lazaros Toumanidis Ludwig Hähne Łukasz Skarżyński Marcus Sonestedt diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e9704d7..216ea20f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1336 Fix encapsulation for Redirect URI scheme validation * #1357 Move import of setting_changed signal from test to django core modules * #1268 fix prompt=none redirects to login screen +* #1381 fix AttributeError in OAuth2ExtraTokenMiddleware when a custom AccessToken model is used ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 28bd968f8..de1689894 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -3,7 +3,7 @@ from django.contrib.auth import authenticate from django.utils.cache import patch_vary_headers -from oauth2_provider.models import AccessToken +from oauth2_provider.models import get_access_token_model log = logging.getLogger(__name__) @@ -53,6 +53,7 @@ def __call__(self, request): authheader = request.META.get("HTTP_AUTHORIZATION", "") if authheader.startswith("Bearer"): tokenstring = authheader.split()[1] + AccessToken = get_access_token_model() try: token = AccessToken.objects.get(token=tokenstring) request.access_token = token From 0aa27a0ce872cb7f4c5c05b6fbe9d8774986d12e Mon Sep 17 00:00:00 2001 From: Florian Demmer <fdemmer@gmail.com> Date: Tue, 7 May 2024 15:39:14 +0200 Subject: [PATCH 597/722] Remove duplicate OAuthLibMixin from base classes (#1191) Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + oauth2_provider/views/generic.py | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index fb19362b6..4afedcbfa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -52,6 +52,7 @@ Eduardo Oliveira Egor Poderiagin Emanuele Palazzetti Federico Dolce +Florian Demmer Frederico Vieira Gaël Utard Hasan Ramezani diff --git a/oauth2_provider/views/generic.py b/oauth2_provider/views/generic.py index 123848043..232afff76 100644 --- a/oauth2_provider/views/generic.py +++ b/oauth2_provider/views/generic.py @@ -2,14 +2,13 @@ from .mixins import ( ClientProtectedResourceMixin, - OAuthLibMixin, ProtectedResourceMixin, ReadWriteScopedResourceMixin, ScopedResourceMixin, ) -class ProtectedResourceView(ProtectedResourceMixin, OAuthLibMixin, View): +class ProtectedResourceView(ProtectedResourceMixin, View): """ Generic view protecting resources by providing OAuth2 authentication out of the box """ @@ -35,7 +34,7 @@ class ReadWriteScopedResourceView(ReadWriteScopedResourceMixin, ProtectedResourc pass -class ClientProtectedResourceView(ClientProtectedResourceMixin, OAuthLibMixin, View): +class ClientProtectedResourceView(ClientProtectedResourceMixin, View): """View for protecting a resource with client-credentials method. This involves allowing access tokens, Basic Auth and plain credentials in request body. """ From 6ae81979c6991c9152d36dfb7f4f271419beb2ca Mon Sep 17 00:00:00 2001 From: Glauco Junior <glaucojunior22@gmail.com> Date: Tue, 7 May 2024 11:44:43 -0300 Subject: [PATCH 598/722] Fix the invalid_client error when request token without the client_secret field (#1288) * Fix the invalid_client error when request token without the client_secret field. * add a CHANGELOG entry since this is a user-visible change. --------- Co-authored-by: Glauco Junior <gjunior@clb.santillana.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/oauth2_validators.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index 4afedcbfa..048085a11 100644 --- a/AUTHORS +++ b/AUTHORS @@ -55,6 +55,7 @@ Federico Dolce Florian Demmer Frederico Vieira Gaël Utard +Glauco Junior Hasan Ramezani Hiroki Kiyohara Hossein Shakiba diff --git a/CHANGELOG.md b/CHANGELOG.md index 216ea20f6..dfe72e91d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1357 Move import of setting_changed signal from test to django core modules * #1268 fix prompt=none redirects to login screen * #1381 fix AttributeError in OAuth2ExtraTokenMiddleware when a custom AccessToken model is used +* #1288 fixes #1276 which attempt to resolve #1092 for requests that don't have a client_secret per [RFC 6749 4.1.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1) ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4b7fccaea..9c1e02887 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -183,7 +183,7 @@ def _authenticate_request_body(self, request): # TODO: check if oauthlib has already unquoted client_id and client_secret try: client_id = request.client_id - client_secret = getattr(request, "client_secret", "") + client_secret = getattr(request, "client_secret", "") or "" except AttributeError: return False From 30efd79bf7aa69247d07d6c7d9a529d389415d3d Mon Sep 17 00:00:00 2001 From: Wouter Klein Heerenbrink <wouter@fluxility.com> Date: Tue, 7 May 2024 18:54:37 +0200 Subject: [PATCH 599/722] =?UTF-8?q?Expect=20the=20remote=20exp=20to=20be?= =?UTF-8?q?=20defined=20in=20time=20zone=20UTC=20conform=20rfc=20(Fix?= =?UTF-8?q?=E2=80=A6=20(#1292)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Expect the remote exp to be defined in time zone UTC conform rfc (Fixes #1291) * deal with zoneinfo for python < 3.9 --------- Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 3 + CHANGELOG.md | 5 ++ docs/settings.rst | 6 ++ oauth2_provider/oauth2_validators.py | 7 ++- oauth2_provider/settings.py | 2 + oauth2_provider/utils.py | 22 +++++++ setup.cfg | 1 + tests/test_introspection_auth.py | 94 ++++++++++++++++++++++++---- 8 files changed, 126 insertions(+), 14 deletions(-) diff --git a/AUTHORS b/AUTHORS index 048085a11..6a4ef9df8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -107,4 +107,7 @@ Tom Evans Vinay Karanam Víðir Valberg Guðmundsson Will Beaufoy +pySilver +Łukasz Skarżyński +Wouter Klein Heerenbrink Yuri Savin diff --git a/CHANGELOG.md b/CHANGELOG.md index dfe72e91d..b7ddbabb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] +### Fixed +* #1292 Interpret `EXP` in AccessToken always as UTC instead of own key +* #1292 Introduce setting `AUTHENTICATION_SERVER_EXP_TIME_ZONE` to enable different time zone in case remote + authentication server doe snot provide EXP in UTC + ### WARNING * If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted diff --git a/docs/settings.rst b/docs/settings.rst index db5ef110b..f7ee76267 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -266,6 +266,12 @@ The number of seconds an authorization token received from the introspection end If the expire time of the received token is less than ``RESOURCE_SERVER_TOKEN_CACHING_SECONDS`` the expire time will be used. +AUTHENTICATION_SERVER_EXP_TIME_ZONE +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The exp (expiration date) of Access Tokens must be defined in UTC (Unix Timestamp). Although its wrong, sometimes +a remote Authentication Server does not use UTC (eg. no timezone support and configured in local time other than UTC). +Prior to fix #1292 this could be fixed by changing your own time zone. With the introduction of this fix, this workaround +would not be possible anymore. This setting re-enables this workaround. PKCE_REQUIRED ~~~~~~~~~~~~~ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 9c1e02887..37adf4181 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -38,6 +38,7 @@ ) from .scopes import get_scopes_backend from .settings import oauth2_settings +from .utils import get_timezone log = logging.getLogger("oauth2_provider") @@ -400,7 +401,11 @@ def _get_token_from_authentication_server( expires = max_caching_time scope = content.get("scope", "") - expires = make_aware(expires) if settings.USE_TZ else expires + + if settings.USE_TZ: + expires = make_aware( + expires, timezone=get_timezone(oauth2_settings.AUTHENTICATION_SERVER_EXP_TIME_ZONE) + ) access_token, _created = AccessToken.objects.update_or_create( token=token, diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index e608799e1..950ab5643 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -102,6 +102,8 @@ "RESOURCE_SERVER_AUTH_TOKEN": None, "RESOURCE_SERVER_INTROSPECTION_CREDENTIALS": None, "RESOURCE_SERVER_TOKEN_CACHING_SECONDS": 36000, + # Authentication Server Exp Timezone: the time zone use dby Auth Server for generate EXP + "AUTHENTICATION_SERVER_EXP_TIME_ZONE": "UTC", # Whether or not PKCE is required "PKCE_REQUIRED": True, # Whether to re-create OAuthlibCore on every request. diff --git a/oauth2_provider/utils.py b/oauth2_provider/utils.py index de641f74f..3f48723c5 100644 --- a/oauth2_provider/utils.py +++ b/oauth2_provider/utils.py @@ -1,5 +1,6 @@ import functools +from django.conf import settings from jwcrypto import jwk @@ -10,3 +11,24 @@ def jwk_from_pem(pem_string): Converting from PEM is expensive for large keys such as those using RSA. """ return jwk.JWK.from_pem(pem_string.encode("utf-8")) + + +# @functools.lru_cache +def get_timezone(time_zone): + """ + Return the default time zone as a tzinfo instance. + + This is the time zone defined by settings.TIME_ZONE. + """ + try: + import zoneinfo + except ImportError: + import pytz + + return pytz.timezone(time_zone) + else: + if getattr(settings, "USE_DEPRECATED_PYTZ", False): + import pytz + + return pytz.timezone(time_zone) + return zoneinfo.ZoneInfo(time_zone) diff --git a/setup.cfg b/setup.cfg index 453126c28..d015d1238 100644 --- a/setup.cfg +++ b/setup.cfg @@ -40,6 +40,7 @@ install_requires = requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 + pytz >= 2024.1 [options.packages.find] exclude = diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index c4f8231d5..100ef064e 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -29,7 +29,7 @@ AccessToken = get_access_token_model() UserModel = get_user_model() -exp = datetime.datetime.now() + datetime.timedelta(days=1) +default_exp = datetime.datetime.now() + datetime.timedelta(days=1) class ScopeResourceView(ScopedProtectedResourceView): @@ -42,19 +42,20 @@ def post(self, request, *args, **kwargs): return HttpResponse("This is a protected resource", 200) +class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + def mocked_requests_post(url, data, *args, **kwargs): """ Mock the response from the authentication server """ - class MockResponse: - def __init__(self, json_data, status_code): - self.json_data = json_data - self.status_code = status_code - - def json(self): - return self.json_data - if "token" in data and data["token"] and data["token"] != "12345678900": return MockResponse( { @@ -62,7 +63,7 @@ def json(self): "scope": "read write dolphin", "client_id": "client_id_{}".format(data["token"]), "username": "{}_user".format(data["token"]), - "exp": int(calendar.timegm(exp.timetuple())), + "exp": int(calendar.timegm(default_exp.timetuple())), }, 200, ) @@ -75,6 +76,21 @@ def json(self): ) +def mocked_introspect_request_short_living_token(url, data, *args, **kwargs): + exp = datetime.datetime.now() + datetime.timedelta(minutes=30) + + return MockResponse( + { + "active": True, + "scope": "read write dolphin", + "client_id": "client_id_{}".format(data["token"]), + "username": "{}_user".format(data["token"]), + "exp": int(calendar.timegm(exp.timetuple())), + }, + 200, + ) + + urlpatterns = [ path("oauth2/", include("oauth2_provider.urls")), path("oauth2-test-resource/", ScopeResourceView.as_view()), @@ -152,24 +168,76 @@ def test_get_token_from_authentication_server_existing_token(self, mock_get): self.assertEqual(token.user.username, "foo_user") self.assertEqual(token.scope, "read write dolphin") - @mock.patch("requests.post", side_effect=mocked_requests_post) - def test_get_token_from_authentication_server_expires_timezone(self, mock_get): + @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token) + def test_get_token_from_authentication_server_expires_no_timezone(self, mock_get): """ Test method _get_token_from_authentication_server for projects with USE_TZ False """ settings_use_tz_backup = settings.USE_TZ settings.USE_TZ = False try: - self.validator._get_token_from_authentication_server( + access_token = self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + ) + + self.assertFalse(access_token.is_expired()) + except ValueError as exception: + self.fail(str(exception)) + finally: + settings.USE_TZ = settings_use_tz_backup + + @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token) + def test_get_token_from_authentication_server_expires_utc_timezone(self, mock_get): + """ + Test method _get_token_from_authentication_server for projects with USE_TZ True and a UTC Timezone + """ + settings_use_tz_backup = settings.USE_TZ + settings_time_zone_backup = settings.TIME_ZONE + settings.USE_TZ = True + settings.TIME_ZONE = "UTC" + try: + access_token = self.validator._get_token_from_authentication_server( "foo", oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, ) + + self.assertFalse(access_token.is_expired()) + except ValueError as exception: + self.fail(str(exception)) + finally: + settings.USE_TZ = settings_use_tz_backup + settings.TIME_ZONE = settings_time_zone_backup + + @mock.patch("requests.post", side_effect=mocked_introspect_request_short_living_token) + def test_get_token_from_authentication_server_expires_non_utc_timezone(self, mock_get): + """ + Test method _get_token_from_authentication_server for projects with USE_TZ True and a non UTC Timezone + + This test is important to check if the UTC Exp. date gets converted correctly + """ + settings_use_tz_backup = settings.USE_TZ + settings_time_zone_backup = settings.TIME_ZONE + settings.USE_TZ = True + settings.TIME_ZONE = "Europe/Amsterdam" + try: + access_token = self.validator._get_token_from_authentication_server( + "foo", + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_URL, + oauth2_settings.RESOURCE_SERVER_AUTH_TOKEN, + oauth2_settings.RESOURCE_SERVER_INTROSPECTION_CREDENTIALS, + ) + + self.assertFalse(access_token.is_expired()) except ValueError as exception: self.fail(str(exception)) finally: settings.USE_TZ = settings_use_tz_backup + settings.TIME_ZONE = settings_time_zone_backup @mock.patch("requests.post", side_effect=mocked_requests_post) def test_validate_bearer_token(self, mock_get): From b1a2bb3b6db09e6264b7a74ffce69520d11db009 Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko <debian@onerussian.com> Date: Tue, 7 May 2024 14:18:16 -0400 Subject: [PATCH 600/722] Add codespell support: config + workflow to catch new typos, let it fix some (#1392) * Add rudimentary codespell config * Add pre-commit definition for codespell Includes also squashed - [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci - Unfortunately due to bug in codespell we need to duplicate some skipped paths for pre-commit config * Add pragma handling to ignore for codespell and ignore a line with a key * [DATALAD RUNCMD] run codespell throughout fixing typos automagically === Do not change lines below === { "chain": [], "cmd": "codespell -w", "exit": 0, "extra_inputs": [], "inputs": [], "outputs": [], "pwd": "." } ^^^ Do not change lines above ^^^ * Added author --------- Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 8 ++++++++ AUTHORS | 1 + CHANGELOG.md | 8 ++++---- docs/getting_started.rst | 2 +- docs/oidc.rst | 4 ++-- docs/tutorial/tutorial_01.rst | 2 +- docs/tutorial/tutorial_04.rst | 2 +- oauth2_provider/contrib/rest_framework/permissions.py | 2 +- oauth2_provider/oauth2_validators.py | 8 ++++---- oauth2_provider/views/base.py | 4 ++-- pyproject.toml | 7 +++++++ tests/app/idp/idp/oauth.py | 2 +- tests/mig_settings.py | 2 +- tests/test_implicit.py | 2 +- 14 files changed, 35 insertions(+), 19 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9e4922f94..eea3dd1af 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,3 +29,11 @@ repos: rev: v0.9.1 hooks: - id: sphinx-lint + # Configuration for codespell is in pyproject.toml + - repo: https://github.com/codespell-project/codespell + rev: v2.2.6 + hooks: + - id: codespell + exclude: (package-lock.json|/locale/) + additional_dependencies: + - tomli diff --git a/AUTHORS b/AUTHORS index 6a4ef9df8..3443635b6 100644 --- a/AUTHORS +++ b/AUTHORS @@ -110,4 +110,5 @@ Will Beaufoy pySilver Łukasz Skarżyński Wouter Klein Heerenbrink +Yaroslav Halchenko Yuri Savin diff --git a/CHANGELOG.md b/CHANGELOG.md index b7ddbabb0..45414b083 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. * #1350 Support Python 3.12 and Django 5.0 -* #1249 Add code_challenge_methods_supported property to auto discovery informations, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) +* #1249 Add code_challenge_methods_supported property to auto discovery information, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) ### Fixed @@ -144,7 +144,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Added * #969 Add batching of expired token deletions in `cleartokens` management command and `models.clear_expired()` - to improve performance for removal of large numers of expired tokens. Configure with + to improve performance for removal of large numbers of expired tokens. Configure with [`CLEAR_EXPIRED_TOKENS_BATCH_SIZE`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-size) and [`CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#clear-expired-tokens-batch-interval). * #1070 Add a Celery task for clearing expired tokens, e.g. to be scheduled as a [periodic task](https://docs.celeryproject.org/en/stable/userguide/periodic-tasks.html). @@ -229,7 +229,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Added * #917 Documentation improvement for Access Token expiration. -* #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `locahost:8000` +* #916 (for DOT contributors) Added `tox -e livedocs` which launches a local web server on `localhost:8000` to display Sphinx documentation with live updates as you edit. * #891 (for DOT contributors) Added [details](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) on how best to contribute to this project. @@ -434,7 +434,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th * #185: fixed vulnerabilities on Basic authentication * #173: ProtectResourceMixin now allows OPTIONS requests * Fixed `client_id` and `client_secret` characters set -* #169: hide sensitive informations in error emails +* #169: hide sensitive information in error emails * #161: extend search to all token types when revoking a token * #160: return empty response on successful token revocation * #157: skip authorization form with ``skip_authorization_completely`` class field diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 2d7ebe269..2a0ff500d 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -111,7 +111,7 @@ Configure ``users.User`` to be the model used for the ``auth`` application by ad AUTH_USER_MODEL = 'users.User' -Create inital migration for ``users`` application ``User`` model:: +Create initial migration for ``users`` application ``User`` model:: python manage.py makemigrations diff --git a/docs/oidc.rst b/docs/oidc.rst index d998dac9b..59242f461 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -34,7 +34,7 @@ that must be provided. ``django-oauth-toolkit`` supports two different algorithms for signing JWT tokens, ``RS256``, which uses asymmetric RSA keys (a public key and a private key), and ``HS256``, which uses a symmetric key. -It is preferrable to use ``RS256``, because this produces a token that can be +It is preferable to use ``RS256``, because this produces a token that can be verified by anyone using the public key (which is made available and discoverable by OIDC service auto-discovery, included with ``django-oauth-toolkit``). ``HS256`` on the other hand uses the @@ -372,7 +372,7 @@ for a POST request. Again, to modify the content delivered, we need to add a function to our custom validator. The default implementation adds the claims from the ID -token, so you will probably want to re-use that:: +token, so you will probably want to reuse that:: class CustomOAuth2Validator(OAuth2Validator): diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index 9f1ace1bd..efd1265f7 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -82,7 +82,7 @@ Let's register your application. You need to be logged in before registration. So, go to http://localhost:8000/admin and log in. After that point your browser to http://localhost:8000/o/applications/ and add an Application instance. -`Client id` and `Client Secret` are automatically generated; you have to provide the rest of the informations: +`Client id` and `Client Secret` are automatically generated; you have to provide the rest of the information: * `User`: the owner of the Application (e.g. a developer, or the currently logged in user.) diff --git a/docs/tutorial/tutorial_04.rst b/docs/tutorial/tutorial_04.rst index 089f2ac25..9585582bb 100644 --- a/docs/tutorial/tutorial_04.rst +++ b/docs/tutorial/tutorial_04.rst @@ -36,7 +36,7 @@ obtained in :doc:`part 1 <tutorial_01>`. If your application type is `Confidenti token=XXXX&client_id=XXXX&client_secret=XXXX -The server will respond wih a ``200`` status code on successful revocation. You can use ``curl`` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. +The server will respond with a ``200`` status code on successful revocation. You can use ``curl`` to make a revoke request on your server. If you have access to a local installation of your authorization server, you can test revoking a token with a request like that shown below, for a `Confidential` client. :: diff --git a/oauth2_provider/contrib/rest_framework/permissions.py b/oauth2_provider/contrib/rest_framework/permissions.py index 1050bf751..bab3c776d 100644 --- a/oauth2_provider/contrib/rest_framework/permissions.py +++ b/oauth2_provider/contrib/rest_framework/permissions.py @@ -107,7 +107,7 @@ class IsAuthenticatedOrTokenHasScope(BasePermission): This only returns True if the user is authenticated, but not using a token or using a token, and the token has the correct scope. - This is usefull when combined with the DjangoModelPermissions to allow people browse + This is useful when combined with the DjangoModelPermissions to allow people browse the browsable api's if they log in using the a non token bassed middleware, and let them access the api's using a rest client with a token """ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 37adf4181..cecb843c5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -104,10 +104,10 @@ def _extract_basic_auth(self, request): if not auth: return None - splitted = auth.split(" ", 1) - if len(splitted) != 2: + split = auth.split(" ", 1) + if len(split) != 2: return None - auth_type, auth_string = splitted + auth_type, auth_string = split if auth_type != "Basic": return None @@ -927,7 +927,7 @@ def _get_client_by_audience(self, audience): return Application.objects.filter(client_id__in=audience).first() def validate_user_match(self, id_token_hint, scopes, claims, request): - # TODO: Fix to validate when necessary acording + # TODO: Fix to validate when necessary according # https://github.com/idan/oauthlib/blob/master/oauthlib/oauth2/rfc6749/request_validator.py#L556 # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest id_token_hint section return True diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 846be3e73..cad36c757 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -77,10 +77,10 @@ class AuthorizationView(BaseAuthorizationView, FormView): * then receive a ``POST`` request possibly after user authorized the access - Some informations contained in the ``GET`` request and needed to create a Grant token during + Some information contained in the ``GET`` request and needed to create a Grant token during the ``POST`` request would be lost between the two steps above, so they are temporarily stored in hidden fields on the form. - A possible alternative could be keeping such informations in the session. + A possible alternative could be keeping such information in the session. The endpoint is used in the following flows: * Authorization code diff --git a/pyproject.toml b/pyproject.toml index a4b95794e..900f4d3dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,3 +8,10 @@ exclude = ''' | .tox ) ''' + +# Ref: https://github.com/codespell-project/codespell#using-a-config-file +[tool.codespell] +skip = '.git,package-lock.json,locale' +check-hidden = true +ignore-regex = '.*pragma: codespell-ignore.*' +# ignore-words-list = '' diff --git a/tests/app/idp/idp/oauth.py b/tests/app/idp/idp/oauth.py index 3e8a4645e..bfe44904a 100644 --- a/tests/app/idp/idp/oauth.py +++ b/tests/app/idp/idp/oauth.py @@ -5,7 +5,7 @@ from oauth2_provider.oauth2_validators import OAuth2Validator -# get_response is required for middlware, it doesn't need to do anything +# get_response is required for middleware, it doesn't need to do anything # the way we're using it, so we just use a lambda that returns None def get_response(): None diff --git a/tests/mig_settings.py b/tests/mig_settings.py index 8f77d1190..a3462bcdc 100644 --- a/tests/mig_settings.py +++ b/tests/mig_settings.py @@ -21,7 +21,7 @@ # See https://docs.djangoproject.com/en/4.0/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-9$j0^ot%41l5r(nj9hg02up-n+$59kld!0%l6pvqbd()u%z2as" +SECRET_KEY = "django-insecure-9$j0^ot%41l5r(nj9hg02up-n+$59kld!0%l6pvqbd()u%z2as" # pragma: codespell-ignore # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 7d710e9a1..3f16cf71f 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -361,7 +361,7 @@ def test_id_token_skip_authorization_completely_missing_nonce(self): response = self.client.get(reverse("oauth2_provider:authorize"), data=query_data) self.assertEqual(response.status_code, 302) self.assertIn("error=invalid_request", response["Location"]) - self.assertIn("error_description=Request+is+missing+mandatory+nonce+paramete", response["Location"]) + self.assertIn("error_description=Request+is+missing+mandatory+nonce+parameter", response["Location"]) def test_id_token_post_auth_deny(self): """ From bdc578f582c32f7f2e92ccb990263fdf91957d8c Mon Sep 17 00:00:00 2001 From: Charles Chan <charleswhchan@users.noreply.github.com> Date: Tue, 7 May 2024 11:37:34 -0700 Subject: [PATCH 601/722] Update url for RP initiated logout (#1405) According to [urls.py](https://github.com/jazzband/django-oauth-toolkit/blob/master/oauth2_provider/urls.py#L45), the url should be /logout --- docs/oidc.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 59242f461..37f5f90e2 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -430,5 +430,5 @@ customize the details included in the response as described above. RPInitiatedLogoutView ~~~~~~~~~~~~~~~~~~~~~ -Available at ``/o/rp-initiated-logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner` +Available at ``/o/logout/``, this view allows a :term:`Client` (Relying Party) to request that a :term:`Resource Owner` is logged out at the :term:`Authorization Server` (OpenID Provider). From 1c33bfcdd434c9b5fb22fa7a3249b1613a14827b Mon Sep 17 00:00:00 2001 From: Charles Chan <charleswhchan@users.noreply.github.com> Date: Tue, 7 May 2024 12:04:26 -0700 Subject: [PATCH 602/722] Document OIDC_ENABLED in settings.rst (#1408) * Document OIDC_ENABLED in settings.rst * change settings to ref oidc.rst and from there ref the openid.net site. --------- Co-authored-by: Alan Crosswell <alan@columbia.edu> --- docs/oidc.rst | 4 ++-- docs/settings.rst | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/oidc.rst b/docs/oidc.rst index 37f5f90e2..bbb4651bd 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -4,8 +4,8 @@ OpenID Connect OpenID Connect support ====================== -``django-oauth-toolkit`` supports OpenID Connect (OIDC), which standardizes -authentication flows and provides a plug and play integration with other +``django-oauth-toolkit`` supports `OpenID Connect <https://openid.net/specs/openid-connect-core-1_0.html>`_ +(OIDC), which standardizes authentication flows and provides a plug and play integration with other systems. OIDC is built on top of OAuth 2.0 to provide: * Generating ID tokens as part of the login process. These are JWT that diff --git a/docs/settings.rst b/docs/settings.rst index f7ee76267..901fe8575 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -146,7 +146,7 @@ OAUTH2_SERVER_CLASS ~~~~~~~~~~~~~~~~~~~ The import string for the ``server_class`` (or ``oauthlib.oauth2.Server`` subclass) used in the ``OAuthLibMixin`` that implements OAuth2 grant types. It defaults -to ``oauthlib.oauth2.Server``, except when OIDC support is enabled, when the +to ``oauthlib.oauth2.Server``, except when :doc:`oidc` is enabled, when the default is ``oauthlib.openid.Server``. OAUTH2_VALIDATOR_CLASS @@ -287,6 +287,13 @@ According to `OAuth 2.0 Security Best Current Practice <https://oauth.net/2/oaut - Public clients MUST use PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ - For confidential clients, the use of PKCE `RFC7636 <https://datatracker.ietf.org/doc/html/rfc7636>`_ is RECOMMENDED. +OIDC_ENABLED +~~~~~~~~~~~~ +Default: ``False`` + +Whether or not :doc:`oidc` support is enabled. + + OIDC_RSA_PRIVATE_KEY ~~~~~~~~~~~~~~~~~~~~ Default: ``""`` From 2ef14c5d1443b607314c2061a8244d0de120848a Mon Sep 17 00:00:00 2001 From: Charles Chan <charleswhchan@users.noreply.github.com> Date: Tue, 7 May 2024 12:12:04 -0700 Subject: [PATCH 603/722] Update urls.py (#1410) Fix typo --- oauth2_provider/urls.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 038a7eaf9..18972612c 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -34,7 +34,7 @@ # .well-known/openid-configuration/ is deprecated # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig # does not specify a trailing slash - # Support for trailing slash should shall be removed in a future release. + # Support for trailing slash shall be removed in a future release. re_path( r"^\.well-known/openid-configuration/?$", views.ConnectDiscoveryInfoView.as_view(), From a34be997c42957fbedc41914f608418e491dc3bd Mon Sep 17 00:00:00 2001 From: Ivan Lukyanets <lukyanets.ivan@gmail.com> Date: Mon, 13 May 2024 17:20:16 +0300 Subject: [PATCH 604/722] Adds the ability to define how to store a user (#1328) * Update oauth2_validators.py * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * add docs & tests * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/oidc.rst | 11 +++++++++++ oauth2_provider/oauth2_validators.py | 15 ++++++++++++--- tests/test_oauth2_validators.py | 8 ++++++++ 5 files changed, 33 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index 3443635b6..52a3693af 100644 --- a/AUTHORS +++ b/AUTHORS @@ -60,6 +60,7 @@ Hasan Ramezani Hiroki Kiyohara Hossein Shakiba Islam Kamel +Ivan Lukyanets Jadiel Teófilo Jens Timmerman Jerome Leclanche diff --git a/CHANGELOG.md b/CHANGELOG.md index 45414b083..d9fe0ac91 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. * #1350 Support Python 3.12 and Django 5.0 * #1249 Add code_challenge_methods_supported property to auto discovery information, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) +* #1328 Adds the ability to define how to store a user profile ### Fixed diff --git a/docs/oidc.rst b/docs/oidc.rst index bbb4651bd..ac9c97161 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -404,6 +404,17 @@ In the docs below, it assumes that you have mounted the the URLs accordingly. +Define where to store the profile +================================= + +.. py:function:: OAuth2Validator.get_or_create_user_from_content(content) + +An optional layer to define where to store the profile in ``UserModel`` or a separate model. For example ``UserOAuth``, where ``user = models.OneToOneField(UserModel)``. + +The function is called after checking that the username is present in the content. + +:return: An instance of the ``UserModel`` representing the user fetched or created. + ConnectDiscoveryInfoView ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index cecb843c5..829cde25f 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -333,6 +333,17 @@ def validate_client_id(self, client_id, request, *args, **kwargs): def get_default_redirect_uri(self, client_id, request, *args, **kwargs): return request.client.default_redirect_uri + def get_or_create_user_from_content(self, content): + """ + An optional layer to define where to store the profile in `UserModel` or a separate model. For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` . + + The function is called after checking that username is in the content. + + Returns an UserModel instance; + """ + user, _ = UserModel.objects.get_or_create(**{UserModel.USERNAME_FIELD: content["username"]}) + return user + def _get_token_from_authentication_server( self, token, introspection_url, introspection_token, introspection_credentials ): @@ -383,9 +394,7 @@ def _get_token_from_authentication_server( if "active" in content and content["active"] is True: if "username" in content: - user, _created = UserModel.objects.get_or_create( - **{UserModel.USERNAME_FIELD: content["username"]} - ) + user = self.get_or_create_user_from_content(content) else: user = None diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index cb734a9b2..ca80aedb0 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -335,6 +335,14 @@ def test_save_bearer_token__with_new_token__calls_methods_to_create_access_and_r assert create_access_token_mock.call_count == 1 assert create_refresh_token_mock.call_count == 1 + def test_get_or_create_user_from_content(self): + content = {"username": "test_user"} + UserModel.objects.filter(username=content["username"]).delete() + user = self.validator.get_or_create_user_from_content(content) + + self.assertIsNotNone(user) + self.assertEqual(content["username"], user.username) + class TestOAuth2ValidatorProvidesErrorData(TransactionTestCase): """These test cases check that the recommended error codes are returned From f34ba7ca6a675a8c860e80cf7d6c8264cf946ae1 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sun, 19 May 2024 01:11:32 -0400 Subject: [PATCH 605/722] Release 2 4 0 (#1420) * in-process release 2.4.0 pending some late PR merges. * Update #1311 documentation to recommend using RS256 rather than HS256. * editorial changes to CHANGELOG * fix line too long --- CHANGELOG.md | 61 ++++++++++++++++++---------- docs/getting_started.rst | 7 +++- docs/oidc.rst | 4 +- oauth2_provider/__init__.py | 2 +- oauth2_provider/oauth2_validators.py | 3 +- 5 files changed, 51 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9fe0ac91..c965bc21b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,35 +15,54 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ## [unreleased] - +### Added +### Changed +### Deprecated +### Removed ### Fixed -* #1292 Interpret `EXP` in AccessToken always as UTC instead of own key -* #1292 Introduce setting `AUTHENTICATION_SERVER_EXP_TIME_ZONE` to enable different time zone in case remote - authentication server doe snot provide EXP in UTC +### Security + +## [2.4.0] - 2024-05-13 ### WARNING -* If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted +Issues caused by **Release 2.0.0 breaking changes** continue to be logged. Please **make sure to carefully read these release notes** before +performing a MAJOR upgrade to 2.x. + +These issues both result in `{"error": "invalid_client"}`: + +1. The application client secret is now hashed upon save. You must copy it before it is saved. Using the hashed value will fail. + +2. `PKCE_REQUIRED` is now `True` by default. You should use PKCE with your client or set `PKCE_REQUIRED=False` if you are unable to fix the client. + +If you are going to revert migration 0006 make note that previously hashed client_secret cannot be reverted! ### Added -* #1185 Add middleware for adding access token to request -* #1273 Add caching of loading of OIDC private key. -* #1285 Add post_logout_redirect_uris field in application views. -* #1311 Add option to disable client_secret hashing to allow verifying JWTs' signatures. -* #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. +* #1304 Add `OAuth2ExtraTokenMiddleware` for adding access token to request. + See [Setup a provider](https://django-oauth-toolkit.readthedocs.io/en/latest/tutorial/tutorial_03.html#setup-a-provider) in the Tutorial. +* #1273 Performance improvement: Add caching of loading of OIDC private key. +* #1285 Add `post_logout_redirect_uris` field in the [Application Registration form](https://django-oauth-toolkit.readthedocs.io/en/latest/templates.html#application-registration-form-html) +* #1311,#1334 (**Security**) Add option to disable client_secret hashing to allow verifying JWTs' signatures when using + [HS256 keys](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#using-hs256-keys). + This means your client secret will be stored in cleartext but is the only way to successfully use HS256 signed JWT's. * #1350 Support Python 3.12 and Django 5.0 -* #1249 Add code_challenge_methods_supported property to auto discovery information, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) -* #1328 Adds the ability to define how to store a user profile - +* #1367 Add `code_challenge_methods_supported` property to auto discovery information, per [RFC 8414 section 2](https://www.rfc-editor.org/rfc/rfc8414.html#page-7) +* #1328 Adds the ability to [define how to store a user profile](https://django-oauth-toolkit.readthedocs.io/en/latest/oidc.html#define-where-to-store-the-profile). ### Fixed -* #1322 Instructions in documentation on how to create a code challenge and code verifier -* #1284 Allow to logout with no id_token_hint even if the browser session already expired -* #1296 Added reverse function in migration 0006_alter_application_client_secret -* #1336 Fix encapsulation for Redirect URI scheme validation -* #1357 Move import of setting_changed signal from test to django core modules -* #1268 fix prompt=none redirects to login screen -* #1381 fix AttributeError in OAuth2ExtraTokenMiddleware when a custom AccessToken model is used -* #1288 fixes #1276 which attempt to resolve #1092 for requests that don't have a client_secret per [RFC 6749 4.1.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1) +* #1292 Interpret `EXP` in AccessToken always as UTC instead of (possibly) local timezone. + Use setting `AUTHENTICATION_SERVER_EXP_TIME_ZONE` to enable different time zone in case the remote + authentication server does not provide EXP in UTC. +* #1323 Fix instructions in [documentation](https://django-oauth-toolkit.readthedocs.io/en/latest/getting_started.html#authorization-code) + on how to create a code challenge and code verifier +* #1284 Fix a 500 error when trying to logout with no id_token_hint even if the browser session already expired. +* #1296 Added reverse function in migration `0006_alter_application_client_secret`. Note that reversing this migration cannot undo a hashed `client_secret`. +* #1345 Fix encapsulation for Redirect URI scheme validation. Deprecates `RedirectURIValidator` in favor of `AllowedURIValidator`. +* #1357 Move import of setting_changed signal from test to django core modules. +* #1361 Fix prompt=none redirects to login screen +* #1380 Fix AttributeError in OAuth2ExtraTokenMiddleware when a custom AccessToken model is used. +* #1288 Fix #1276 which attempted to resolve #1092 for requests that don't have a client_secret per [RFC 6749 4.1.1](https://www.rfc-editor.org/rfc/rfc6749.html#section-4.1.1) +* #1337 Gracefully handle expired or deleted refresh tokens, in `validate_user`. +* Various documentation improvements: #1410, #1408, #1405, #1399, #1401, #1396, #1375, #1162, #1315, #1307 ### Removed * #1350 Remove support for Python 3.7 and Django 2.2 diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 2a0ff500d..80ff9ed71 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -246,7 +246,12 @@ Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret``, we will use it in a minute. -If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect <oidc>`), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's. +If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect <oidc>`), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's with ``HS256``. + +.. note:: + ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must. + Using ``RS256`` will allow you to keep your ``client_secret`` hashed. + .. image:: _images/application-register-auth-code.png :alt: Authorization code application registration diff --git a/docs/oidc.rst b/docs/oidc.rst index ac9c97161..1669a00d4 100644 --- a/docs/oidc.rst +++ b/docs/oidc.rst @@ -149,8 +149,8 @@ scopes in your ``settings.py``:: } .. note:: - If you want to enable ``RS256`` at a later date, you can do so - just add - the private key as described above. + ``RS256`` is the more secure algorithm for signing your JWTs. Only use ``HS256`` if you must. + Using ``RS256`` will allow you to keep your ``client_secret`` hashed. RP-Initiated Logout diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 55e470907..3d67cd6bb 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1 +1 @@ -__version__ = "2.3.0" +__version__ = "2.4.0" diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 829cde25f..47d65e851 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -335,7 +335,8 @@ def get_default_redirect_uri(self, client_id, request, *args, **kwargs): def get_or_create_user_from_content(self, content): """ - An optional layer to define where to store the profile in `UserModel` or a separate model. For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` . + An optional layer to define where to store the profile in `UserModel` or a separate model. + For example `UserOAuth`, where `user = models.OneToOneField(UserModel)` . The function is called after checking that username is in the content. From c5daaebde3899c376f5defeb385c0d892ad3707b Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 20 May 2024 22:20:12 -0400 Subject: [PATCH 606/722] whitelist -> allowlist (#1422) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index 61b983b5b..ba97bd113 100644 --- a/tox.ini +++ b/tox.ini @@ -127,7 +127,7 @@ commands = deps = setuptools>=39.0 wheel -whitelist_externals = rm +allowlist_externals = rm commands = rm -rf dist python setup.py sdist bdist_wheel From fd2bcec428d7f730973886211519e7c91d90e875 Mon Sep 17 00:00:00 2001 From: Giovanni <63993401+giovanni1106@users.noreply.github.com> Date: Wed, 22 May 2024 14:42:29 -0300 Subject: [PATCH 607/722] 1421 missing import in documentation (#1424) * docs: add missing import * add name in authors --- AUTHORS | 1 + docs/tutorial/tutorial_05.rst | 1 + 2 files changed, 2 insertions(+) diff --git a/AUTHORS b/AUTHORS index 52a3693af..15eec14f9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -56,6 +56,7 @@ Florian Demmer Frederico Vieira Gaël Utard Glauco Junior +Giovanni Giampauli Hasan Ramezani Hiroki Kiyohara Hossein Shakiba diff --git a/docs/tutorial/tutorial_05.rst b/docs/tutorial/tutorial_05.rst index e75f3e23e..74feec4d2 100644 --- a/docs/tutorial/tutorial_05.rst +++ b/docs/tutorial/tutorial_05.rst @@ -65,6 +65,7 @@ Now add a new file to your app to add Celery: :file:`tutorial/celery.py`: import os from celery import Celery + from django.conf import settings # Set the default Django settings module for the 'celery' program. os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tutorial.settings') From 30afee8e82c2654c7de77d0182330b632ccc9f04 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 28 May 2024 13:02:49 -0400 Subject: [PATCH 608/722] [pre-commit.ci] pre-commit autoupdate (#1429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/codespell-project/codespell: v2.2.6 → v2.3.0](https://github.com/codespell-project/codespell/compare/v2.2.6...v2.3.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eea3dd1af..ea110f065 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: - id: sphinx-lint # Configuration for codespell is in pyproject.toml - repo: https://github.com/codespell-project/codespell - rev: v2.2.6 + rev: v2.3.0 hooks: - id: codespell exclude: (package-lock.json|/locale/) From 5185d20840dc2d0e9156cced3d7ddb6be4d4c65c Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 12 Jun 2024 09:44:53 -0400 Subject: [PATCH 609/722] Remove stuff that was deprecated for 2.5.0 (#1425) * Remove stuff that was deprecated for 2.5.0 * add PR# * temporarily remove codespell which is incorrectly causing commit failures until we can better tune it. --- .pre-commit-config.yaml | 14 ++--- CHANGELOG.md | 2 + oauth2_provider/validators.py | 34 ----------- oauth2_provider/views/oidc.py | 71 ----------------------- pyproject.toml | 8 +-- tests/test_oidc_views.py | 105 +--------------------------------- tests/test_validators.py | 96 +------------------------------ 7 files changed, 15 insertions(+), 315 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ea110f065..c1628c521 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,10 +30,10 @@ repos: hooks: - id: sphinx-lint # Configuration for codespell is in pyproject.toml - - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 - hooks: - - id: codespell - exclude: (package-lock.json|/locale/) - additional_dependencies: - - tomli + # - repo: https://github.com/codespell-project/codespell + # rev: v2.3.0 + # hooks: + # - id: codespell + # exclude: (package-lock.json|/locale/) + # additional_dependencies: + # - tomli diff --git a/CHANGELOG.md b/CHANGELOG.md index c965bc21b..8bf0ff2ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed ### Deprecated ### Removed +* #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 + ### Fixed ### Security diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index 1654dccd7..b238b12d6 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -1,5 +1,4 @@ import re -import warnings from urllib.parse import urlsplit from django.core.exceptions import ValidationError @@ -19,20 +18,6 @@ class URIValidator(URLValidator): regex = re.compile(scheme_re + host_re + port_re + path_re, re.IGNORECASE) -class RedirectURIValidator(URIValidator): - def __init__(self, allowed_schemes, allow_fragments=False): - warnings.warn("This class is deprecated and will be removed in version 2.5.0.", DeprecationWarning) - super().__init__(schemes=allowed_schemes) - self.allow_fragments = allow_fragments - - def __call__(self, value): - super().__call__(value) - value = force_str(value) - scheme, netloc, path, query, fragment = urlsplit(value) - if fragment and not self.allow_fragments: - raise ValidationError("Redirect URIs must not contain fragments") - - class AllowedURIValidator(URIValidator): # TODO: find a way to get these associated with their form fields in place of passing name # TODO: submit PR to get `cause` included in the parent class ValidationError params` @@ -90,22 +75,3 @@ def __call__(self, value): "%(name)s URI validation error. %(cause)s: %(value)s", params={"name": self.name, "value": value, "cause": e}, ) - - -## -# WildcardSet is a special set that contains everything. -# This is required in order to move validation of the scheme from -# URLValidator (the base class of URIValidator), to OAuth2Application.clean(). - - -class WildcardSet(set): - """ - A set that always returns True on `in`. - """ - - def __init__(self, *args, **kwargs): - warnings.warn("This class is deprecated and will be removed in version 2.5.0.", DeprecationWarning) - super().__init__(*args, **kwargs) - - def __contains__(self, item): - return True diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index 584b0c895..c9d10c25e 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -1,5 +1,4 @@ import json -import warnings from urllib.parse import urlparse from django.contrib.auth import logout @@ -212,76 +211,6 @@ def _validate_claims(request, claims): return True -def validate_logout_request(request, id_token_hint, client_id, post_logout_redirect_uri): - """ - Validate an OIDC RP-Initiated Logout Request. - `(prompt_logout, (post_logout_redirect_uri, application), token_user)` is returned. - - `prompt_logout` indicates whether the logout has to be confirmed by the user. This happens if the - specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. - `post_logout_redirect_uri` is the validated URI where the User should be redirected to after the - logout. Can be None. None will redirect to "/" of this app. If it is set `application` will also - be set to the Application that is requesting the logout. `token_user` is the id_token user, which will - used to revoke the tokens if found. - - The `id_token_hint` will be validated if given. If both `client_id` and `id_token_hint` are given they - will be validated against each other. - """ - - warnings.warn("This method is deprecated and will be removed in version 2.5.0.", DeprecationWarning) - - id_token = None - must_prompt_logout = True - token_user = None - if id_token_hint: - # Only basic validation has been done on the IDToken at this point. - id_token, claims = _load_id_token(id_token_hint) - - if not id_token or not _validate_claims(request, claims): - raise InvalidIDTokenError() - - token_user = id_token.user - - if id_token.user == request.user: - # A logout without user interaction (i.e. no prompt) is only allowed - # if an ID Token is provided that matches the current user. - must_prompt_logout = False - - # If both id_token_hint and client_id are given it must be verified that they match. - if client_id: - if id_token.application.client_id != client_id: - raise ClientIdMissmatch() - - # The standard states that a prompt should always be shown. - # This behaviour can be configured with OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT. - prompt_logout = must_prompt_logout or oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT - - application = None - # Determine the application that is requesting the logout. - if client_id: - application = get_application_model().objects.get(client_id=client_id) - elif id_token: - application = id_token.application - - # Validate `post_logout_redirect_uri` - if post_logout_redirect_uri: - if not application: - raise InvalidOIDCClientError() - scheme = urlparse(post_logout_redirect_uri)[0] - if not scheme: - raise InvalidOIDCRedirectURIError("A Scheme is required for the redirect URI.") - if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS and ( - scheme == "http" and application.client_type != "confidential" - ): - raise InvalidOIDCRedirectURIError("http is only allowed with confidential clients.") - if scheme not in application.get_allowed_schemes(): - raise InvalidOIDCRedirectURIError(f'Redirect to scheme "{scheme}" is not permitted.') - if not application.post_logout_redirect_uri_allowed(post_logout_redirect_uri): - raise InvalidOIDCRedirectURIError("This client does not have this redirect uri registered.") - - return prompt_logout, (post_logout_redirect_uri, application), token_user - - class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView): template_name = "oauth2_provider/logout_confirm.html" form_class = ConfirmLogoutForm diff --git a/pyproject.toml b/pyproject.toml index 900f4d3dd..4d10990b9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ exclude = ''' ''' # Ref: https://github.com/codespell-project/codespell#using-a-config-file -[tool.codespell] -skip = '.git,package-lock.json,locale' -check-hidden = true -ignore-regex = '.*pragma: codespell-ignore.*' +# [tool.codespell] +# skip = '.git,package-lock.json,locale' +# check-hidden = true +# ignore-regex = '.*pragma: codespell-ignore.*' # ignore-words-list = '' diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 4bcf839ef..f44a808e7 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -15,12 +15,7 @@ from oauth2_provider.models import get_access_token_model, get_id_token_model, get_refresh_token_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings -from oauth2_provider.views.oidc import ( - RPInitiatedLogoutView, - _load_id_token, - _validate_claims, - validate_logout_request, -) +from oauth2_provider.views.oidc import RPInitiatedLogoutView, _load_id_token, _validate_claims from . import presets @@ -225,104 +220,6 @@ def mock_request_for(user): return request -@pytest.mark.django_db -@pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) -def test_deprecated_validate_logout_request( - oidc_tokens, public_application, other_user, rp_settings, ALWAYS_PROMPT -): - rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT - oidc_tokens = oidc_tokens - application = oidc_tokens.application - client_id = application.client_id - id_token = oidc_tokens.id_token - assert validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=None, - post_logout_redirect_uri=None, - ) == (True, (None, None), None) - assert validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=client_id, - post_logout_redirect_uri=None, - ) == (True, (None, application), None) - assert validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=client_id, - post_logout_redirect_uri="http://example.org", - ) == (True, ("http://example.org", application), None) - assert validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=id_token, - client_id=None, - post_logout_redirect_uri="http://example.org", - ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) - assert validate_logout_request( - request=mock_request_for(other_user), - id_token_hint=id_token, - client_id=None, - post_logout_redirect_uri="http://example.org", - ) == (True, ("http://example.org", application), oidc_tokens.user) - assert validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=id_token, - client_id=client_id, - post_logout_redirect_uri="http://example.org", - ) == (ALWAYS_PROMPT, ("http://example.org", application), oidc_tokens.user) - with pytest.raises(InvalidIDTokenError): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint="111", - client_id=public_application.client_id, - post_logout_redirect_uri="http://other.org", - ) - with pytest.raises(ClientIdMissmatch): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=id_token, - client_id=public_application.client_id, - post_logout_redirect_uri="http://other.org", - ) - with pytest.raises(InvalidOIDCClientError): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=None, - post_logout_redirect_uri="http://example.org", - ) - with pytest.raises(InvalidOIDCRedirectURIError): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=client_id, - post_logout_redirect_uri="example.org", - ) - with pytest.raises(InvalidOIDCRedirectURIError): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=client_id, - post_logout_redirect_uri="imap://example.org", - ) - with pytest.raises(InvalidOIDCRedirectURIError): - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=client_id, - post_logout_redirect_uri="http://other.org", - ) - with pytest.raises(InvalidOIDCRedirectURIError): - rp_settings.OIDC_RP_INITIATED_LOGOUT_STRICT_REDIRECT_URIS = True - validate_logout_request( - request=mock_request_for(oidc_tokens.user), - id_token_hint=None, - client_id=public_application.client_id, - post_logout_redirect_uri="http://other.org", - ) - - @pytest.mark.django_db def test_validate_logout_request(oidc_tokens, public_application, rp_settings): oidc_tokens = oidc_tokens diff --git a/tests/test_validators.py b/tests/test_validators.py index b2bbb2970..a28e54a4d 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -2,101 +2,7 @@ from django.core.validators import ValidationError from django.test import TestCase -from oauth2_provider.validators import AllowedURIValidator, RedirectURIValidator, WildcardSet - - -@pytest.mark.usefixtures("oauth2_settings") -class TestValidators(TestCase): - def test_validate_good_uris(self): - validator = RedirectURIValidator(allowed_schemes=["https"]) - good_uris = [ - "https://example.com/", - "https://example.org/?key=val", - "https://example", - "https://localhost", - "https://1.1.1.1", - "https://127.0.0.1", - "https://255.255.255.255", - ] - for uri in good_uris: - # Check ValidationError not thrown - validator(uri) - - def test_validate_custom_uri_scheme(self): - validator = RedirectURIValidator(allowed_schemes=["my-scheme", "https", "git+ssh"]) - good_uris = [ - "my-scheme://example.com", - "my-scheme://example", - "my-scheme://localhost", - "https://example.com", - "HTTPS://example.com", - "git+ssh://example.com", - ] - for uri in good_uris: - # Check ValidationError not thrown - validator(uri) - - def test_validate_bad_uris(self): - validator = RedirectURIValidator(allowed_schemes=["https"]) - self.oauth2_settings.ALLOWED_REDIRECT_URI_SCHEMES = ["https", "good"] - bad_uris = [ - "http:/example.com", - "HTTP://localhost", - "HTTP://example.com", - "HTTP://example.com.", - "http://example.com/#fragment", - "123://example.com", - "http://fe80::1", - "git+ssh://example.com", - "my-scheme://example.com", - "uri-without-a-scheme", - "https://example.com/#fragment", - "good://example.com/#fragment", - " ", - "", - # Bad IPv6 URL, urlparse behaves differently for these - 'https://["><script>alert()</script>', - ] - - for uri in bad_uris: - with self.assertRaises(ValidationError): - validator(uri) - - def test_validate_wildcard_scheme__bad_uris(self): - validator = RedirectURIValidator(allowed_schemes=WildcardSet()) - bad_uris = [ - "http:/example.com#fragment", - "HTTP://localhost#fragment", - "http://example.com/#fragment", - "good://example.com/#fragment", - " ", - "", - # Bad IPv6 URL, urlparse behaves differently for these - 'https://["><script>alert()</script>', - ] - - for uri in bad_uris: - with self.assertRaises(ValidationError, msg=uri): - validator(uri) - - def test_validate_wildcard_scheme_good_uris(self): - validator = RedirectURIValidator(allowed_schemes=WildcardSet()) - good_uris = [ - "my-scheme://example.com", - "my-scheme://example", - "my-scheme://localhost", - "https://example.com", - "HTTPS://example.com", - "HTTPS://example.com.", - "git+ssh://example.com", - "ANY://localhost", - "scheme://example.com", - "at://example.com", - "all://example.com", - ] - for uri in good_uris: - # Check ValidationError not thrown - validator(uri) +from oauth2_provider.validators import AllowedURIValidator @pytest.mark.usefixtures("oauth2_settings") From 12236cd11d3696e17e43f4470cd6334bfb4672fe Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:09:48 -0400 Subject: [PATCH 610/722] fix: test/app/rp npm install failing (#1430) --- .github/workflows/test.yml | 38 +- tests/app/rp/package-lock.json | 761 ++++++++++++++++++++------- tests/app/rp/package.json | 10 +- tests/app/rp/src/app.html | 2 +- tests/app/rp/src/routes/+page.svelte | 75 ++- tests/app/rp/svelte.config.js | 2 +- 6 files changed, 662 insertions(+), 226 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86a21bc27..627aacf97 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,8 @@ name: Test on: [push, pull_request] jobs: - build: - name: build (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) + test-package: + name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -84,8 +84,40 @@ jobs: with: name: Python ${{ matrix.python-version }} + test-demo-rp: + name: Test Demo Relying Party + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - "18.x" + - "20.x" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up NodeJS + uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + working-directory: tests/app/rp + + - name: Run Lint + run: npm run lint + working-directory: tests/app/rp + + - name: Run build + run: npm run build + working-directory: tests/app/rp + success: - needs: build + needs: + - test-package + - test-demo-rp runs-on: ubuntu-latest name: Test successful steps: diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 80b168437..9188cf955 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -13,13 +13,26 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/kit": "^2.5.0", - "prettier": "^2.8.0", - "prettier-plugin-svelte": "^2.8.1", - "svelte": "^3.54.0", - "svelte-check": "^3.0.1", + "prettier": "^3.3.2", + "prettier-plugin-svelte": "^3.2.4", + "svelte": "^4.0.0", + "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.5.3" + "vite": "^5.0.3" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/@dopry/svelte-oidc": { @@ -30,10 +43,26 @@ "oidc-client": "1.11.5" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, "node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", "cpu": [ "arm" ], @@ -47,9 +76,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", "cpu": [ "arm64" ], @@ -63,9 +92,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", "cpu": [ "x64" ], @@ -79,9 +108,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", "cpu": [ "arm64" ], @@ -95,9 +124,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", "cpu": [ "x64" ], @@ -111,9 +140,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", "cpu": [ "arm64" ], @@ -127,9 +156,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", "cpu": [ "x64" ], @@ -143,9 +172,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", "cpu": [ "arm" ], @@ -159,9 +188,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", "cpu": [ "arm64" ], @@ -175,9 +204,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", "cpu": [ "ia32" ], @@ -191,9 +220,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", "cpu": [ "loong64" ], @@ -207,9 +236,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", "cpu": [ "mips64el" ], @@ -223,9 +252,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", "cpu": [ "ppc64" ], @@ -239,9 +268,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", "cpu": [ "riscv64" ], @@ -255,9 +284,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", "cpu": [ "s390x" ], @@ -271,9 +300,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", "cpu": [ "x64" ], @@ -287,9 +316,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", "cpu": [ "x64" ], @@ -303,9 +332,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", "cpu": [ "x64" ], @@ -319,9 +348,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", "cpu": [ "x64" ], @@ -335,9 +364,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", "cpu": [ "arm64" ], @@ -351,9 +380,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", "cpu": [ "ia32" ], @@ -367,9 +396,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", "cpu": [ "x64" ], @@ -382,6 +411,20 @@ "node": ">=12" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz", @@ -391,6 +434,15 @@ "node": ">=6.0.0" } }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.4.15", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", @@ -398,21 +450,15 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.18", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", - "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { - "@jridgewell/resolve-uri": "3.1.0", - "@jridgewell/sourcemap-codec": "1.4.14" + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@jridgewell/trace-mapping/node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.14", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz", - "integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==", - "dev": true - }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -454,6 +500,214 @@ "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", + "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", + "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", + "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", + "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", + "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", + "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", + "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", + "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", + "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", + "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", + "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", + "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", + "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", + "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", + "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@sveltejs/adapter-auto": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", @@ -545,10 +799,16 @@ "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", "dev": true }, + "node_modules/@types/estree": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", + "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", + "dev": true + }, "node_modules/@types/pug": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.6.tgz", - "integrity": "sha512-SnHmG9wN1UVmagJOnyo/qkk0Z7gejYxOYYmaAwr5u2yFYfsupN3sg10kyzN8Hep/2zbHxCnsumxOoRIRMBwKCg==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", + "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, "node_modules/acorn": { @@ -575,6 +835,24 @@ "node": ">= 8" } }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/axobject-query": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", + "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -632,12 +910,12 @@ } }, "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "dev": true, "engines": { - "node": "*" + "node": ">=8.0.0" } }, "node_modules/callsites": { @@ -676,6 +954,31 @@ "fsevents": "~2.3.2" } }, + "node_modules/code-red": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", + "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15", + "@types/estree": "^1.0.1", + "acorn": "^8.10.0", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0" + } + }, + "node_modules/code-red/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -706,6 +1009,19 @@ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" }, + "node_modules/css-tree": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "dev": true, + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -734,6 +1050,15 @@ "node": ">=0.10.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/detect-indent": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", @@ -756,9 +1081,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", "dev": true, "hasInstallScript": true, "bin": { @@ -768,28 +1093,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" } }, "node_modules/esm-env": { @@ -798,6 +1124,15 @@ "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", "dev": true }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/fast-glob": { "version": "3.2.12", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", @@ -842,9 +1177,9 @@ "dev": true }, "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, "optional": true, @@ -859,6 +1194,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -935,6 +1271,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "dependencies": { "once": "^1.3.0", @@ -989,6 +1326,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-reference": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", + "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -998,6 +1344,12 @@ "node": ">=6" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, "node_modules/magic-string": { "version": "0.30.7", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", @@ -1010,6 +1362,12 @@ "node": ">=12" } }, + "node_modules/mdn-data": { + "version": "2.0.30", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "dev": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -1100,9 +1458,9 @@ "peer": true }, "node_modules/nanoid": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz", - "integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==", + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", + "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", "dev": true, "funding": [ { @@ -1168,6 +1526,17 @@ "node": ">=0.10.0" } }, + "node_modules/periscopic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", + "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^3.0.0", + "is-reference": "^3.0.0" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -1187,9 +1556,9 @@ } }, "node_modules/postcss": { - "version": "8.4.31", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", - "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "version": "8.4.38", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", + "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", "dev": true, "funding": [ { @@ -1206,37 +1575,37 @@ } ], "dependencies": { - "nanoid": "^3.3.6", + "nanoid": "^3.3.7", "picocolors": "^1.0.0", - "source-map-js": "^1.0.2" + "source-map-js": "^1.2.0" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/prettier": { - "version": "2.8.8", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", - "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", + "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", "dev": true, "bin": { - "prettier": "bin-prettier.js" + "prettier": "bin/prettier.cjs" }, "engines": { - "node": ">=10.13.0" + "node": ">=14" }, "funding": { "url": "https://github.com/prettier/prettier?sponsor=1" } }, "node_modules/prettier-plugin-svelte": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-2.10.0.tgz", - "integrity": "sha512-GXMY6t86thctyCvQq+jqElO+MKdB09BkL3hexyGP3Oi8XLKRFaJP1ud/xlWCZ9ZIa2BxHka32zhHfcuU+XsRQg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.4.tgz", + "integrity": "sha512-tZv+ADfeOWFNQkXkRh6zUXE16w3Vla8x2Ug0B/EnSmjR4EnwdwZbGgL/liSwR1kcEALU5mAAyua98HBxheCxgg==", "dev": true, "peerDependencies": { - "prettier": "^1.16.4 || ^2.0.0", - "svelte": "^3.2.0" + "prettier": "^3.0.0", + "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, "node_modules/queue-microtask": { @@ -1302,6 +1671,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -1311,18 +1681,37 @@ } }, "node_modules/rollup": { - "version": "3.29.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz", - "integrity": "sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", + "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", "dev": true, + "dependencies": { + "@types/estree": "1.0.5" + }, "bin": { "rollup": "dist/bin/rollup" }, "engines": { - "node": ">=14.18.0", + "node": ">=18.0.0", "npm": ">=8.0.0" }, "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.18.0", + "@rollup/rollup-android-arm64": "4.18.0", + "@rollup/rollup-darwin-arm64": "4.18.0", + "@rollup/rollup-darwin-x64": "4.18.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", + "@rollup/rollup-linux-arm-musleabihf": "4.18.0", + "@rollup/rollup-linux-arm64-gnu": "4.18.0", + "@rollup/rollup-linux-arm64-musl": "4.18.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", + "@rollup/rollup-linux-riscv64-gnu": "4.18.0", + "@rollup/rollup-linux-s390x-gnu": "4.18.0", + "@rollup/rollup-linux-x64-gnu": "4.18.0", + "@rollup/rollup-linux-x64-musl": "4.18.0", + "@rollup/rollup-win32-arm64-msvc": "4.18.0", + "@rollup/rollup-win32-ia32-msvc": "4.18.0", + "@rollup/rollup-win32-x64-msvc": "4.18.0", "fsevents": "~2.3.2" } }, @@ -1421,13 +1810,13 @@ } }, "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", + "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", + "buffer-crc32": "^1.0.0", "minimist": "^1.2.0", "sander": "^0.5.0" }, @@ -1436,9 +1825,9 @@ } }, "node_modules/source-map-js": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz", - "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", + "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", "dev": true, "engines": { "node": ">=0.10.0" @@ -1457,18 +1846,34 @@ } }, "node_modules/svelte": { - "version": "3.58.0", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-3.58.0.tgz", - "integrity": "sha512-brIBNNB76mXFmU/Kerm4wFnkskBbluBDCjx/8TcpYRb298Yh2dztS2kQ6bhtjMcvUhd5ynClfwpz5h2gnzdQ1A==", + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", + "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.1", + "@jridgewell/sourcemap-codec": "^1.4.15", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/estree": "^1.0.1", + "acorn": "^8.9.0", + "aria-query": "^5.3.0", + "axobject-query": "^4.0.0", + "code-red": "^1.0.3", + "css-tree": "^2.3.1", + "estree-walker": "^3.0.3", + "is-reference": "^3.0.1", + "locate-character": "^3.0.0", + "magic-string": "^0.30.4", + "periscopic": "^3.1.0" + }, "engines": { - "node": ">= 8" + "node": ">=16" } }, "node_modules/svelte-check": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.2.0.tgz", - "integrity": "sha512-6ZnscN8dHEN5Eq5LgIzjj07W9nc9myyBH+diXsUAuiY/3rt0l65/LCIQYlIuoFEjp2F1NhXqZiJwV9omPj9tMw==", + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.0.tgz", + "integrity": "sha512-7Nxn+3X97oIvMzYJ7t27w00qUf1Y52irE2RU2dQAd5PyvfGp4E7NLhFKVhb6PV2fx7dCRMpNKDIuazmGthjpSQ==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", @@ -1477,14 +1882,14 @@ "import-fresh": "^3.2.1", "picocolors": "^1.0.0", "sade": "^1.7.4", - "svelte-preprocess": "^5.0.3", + "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "bin": { "svelte-check": "bin/svelte-check" }, "peerDependencies": { - "svelte": "^3.55.0" + "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" } }, "node_modules/svelte-hmr": { @@ -1501,32 +1906,32 @@ } }, "node_modules/svelte-preprocess": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.0.3.tgz", - "integrity": "sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", + "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/pug": "^2.0.6", "detect-indent": "^6.1.0", - "magic-string": "^0.27.0", + "magic-string": "^0.30.5", "sorcery": "^0.11.0", "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 14.10.0" + "node": ">= 16.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", "coffeescript": "^2.5.1", "less": "^3.11.3 || ^4.0.0", "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0", + "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", "pug": "^3.0.0", "sass": "^1.26.8", "stylus": "^0.55.0", "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0", + "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" }, "peerDependenciesMeta": { @@ -1562,16 +1967,16 @@ } } }, - "node_modules/svelte-preprocess/node_modules/magic-string": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.27.0.tgz", - "integrity": "sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==", + "node_modules/svelte/node_modules/acorn": { + "version": "8.11.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", + "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.13" + "bin": { + "acorn": "bin/acorn" }, "engines": { - "node": ">=12" + "node": ">=0.4.0" } }, "node_modules/tiny-glob": { @@ -1625,29 +2030,29 @@ } }, "node_modules/vite": { - "version": "4.5.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-4.5.3.tgz", - "integrity": "sha512-kQL23kMeX92v3ph7IauVkXkikdDRsYMGTVl5KY2E9OY4ONLvkHf04MDTbnfo6NKxZiDLWzVpP5oTa8hQD8U3dg==", + "version": "5.2.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", + "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", "dev": true, "dependencies": { - "esbuild": "^0.18.10", - "postcss": "^8.4.27", - "rollup": "^3.27.1" + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^18.0.0 || >=20.0.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" }, "optionalDependencies": { - "fsevents": "~2.3.2" + "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": ">= 14", + "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 4a3851d97..dd087397e 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -14,13 +14,13 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/kit": "^2.5.0", - "prettier": "^2.8.0", - "prettier-plugin-svelte": "^2.8.1", - "svelte": "^3.54.0", - "svelte-check": "^3.0.1", + "prettier": "^3.3.2", + "prettier-plugin-svelte": "^3.2.4", + "svelte": "^4.0.0", + "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^4.5.3" + "vite": "^5.0.3" }, "type": "module", "dependencies": { diff --git a/tests/app/rp/src/app.html b/tests/app/rp/src/app.html index effe0d0d2..77ec85d79 100644 --- a/tests/app/rp/src/app.html +++ b/tests/app/rp/src/app.html @@ -1,4 +1,4 @@ -<!DOCTYPE html> +<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> diff --git a/tests/app/rp/src/routes/+page.svelte b/tests/app/rp/src/routes/+page.svelte index 5853d61f1..1df1a226b 100644 --- a/tests/app/rp/src/routes/+page.svelte +++ b/tests/app/rp/src/routes/+page.svelte @@ -1,44 +1,43 @@ <script> -import { browser } from '$app/environment'; -import { - OidcContext, - LoginButton, - LogoutButton, - RefreshTokenButton, - authError, - accessToken, - idToken, - isAuthenticated, - isLoading, - login, - logout, - userInfo, -} from '@dopry/svelte-oidc'; + import { browser } from '$app/environment'; + import { + OidcContext, + LoginButton, + LogoutButton, + RefreshTokenButton, + authError, + accessToken, + idToken, + isAuthenticated, + isLoading, + login, + logout, + userInfo + } from '@dopry/svelte-oidc'; -const metadata = {}; + const metadata = {}; </script> {#if browser} -<OidcContext - issuer="http://localhost:8000/o" - client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm" - redirect_uri="http://localhost:5173" - post_logout_redirect_uri="http://localhost:5173" - metadata={metadata} - scope="openid" - extraOptions={{ - mergeClaims: true, - }} - > - - <LoginButton>Login</LoginButton> - <LogoutButton>Logout</LogoutButton> - <RefreshTokenButton>RefreshToken</RefreshTokenButton><br /> - <pre>isLoading: {$isLoading}</pre> - <pre>isAuthenticated: {$isAuthenticated}</pre> - <pre>authToken: {$accessToken}</pre> - <pre>idToken: {$idToken}</pre> - <pre>userInfo: {JSON.stringify($userInfo, null, 2)}</pre> - <pre>authError: {$authError}</pre> -</OidcContext> + <OidcContext + issuer="http://localhost:8000/o" + client_id="2EIxgjlyy5VgCp2fjhEpKLyRtSMMPK0hZ0gBpNdm" + redirect_uri="http://localhost:5173" + post_logout_redirect_uri="http://localhost:5173" + {metadata} + scope="openid" + extraOptions={{ + mergeClaims: true + }} + > + <LoginButton>Login</LoginButton> + <LogoutButton>Logout</LogoutButton> + <RefreshTokenButton>RefreshToken</RefreshTokenButton><br /> + <pre>isLoading: {$isLoading}</pre> + <pre>isAuthenticated: {$isAuthenticated}</pre> + <pre>authToken: {$accessToken}</pre> + <pre>idToken: {$idToken}</pre> + <pre>userInfo: {JSON.stringify($userInfo, null, 2)}</pre> + <pre>authError: {$authError}</pre> + </OidcContext> {/if} diff --git a/tests/app/rp/svelte.config.js b/tests/app/rp/svelte.config.js index 1cf26a00d..2b35fe1be 100644 --- a/tests/app/rp/svelte.config.js +++ b/tests/app/rp/svelte.config.js @@ -1,5 +1,5 @@ import adapter from '@sveltejs/adapter-auto'; -import { vitePreprocess } from '@sveltejs/kit/vite'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ const config = { From c09ca765d505a1d2e5ed24efed8c53b289c32f71 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 12 Jun 2024 17:20:20 -0400 Subject: [PATCH 611/722] Bump braces from 3.0.2 to 3.0.3 in /tests/app/rp (#1432) --- tests/app/rp/package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 9188cf955..a20b5654d 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -898,12 +898,12 @@ } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" @@ -1159,9 +1159,9 @@ } }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, "dependencies": { "to-regex-range": "^5.0.1" From 9a862fc749ce675628a460cc8a6c081444793937 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Thu, 13 Jun 2024 17:31:11 -0400 Subject: [PATCH 612/722] chore: fix code spell errors (#1431) --- .pre-commit-config.yaml | 16 ++++++++-------- CHANGELOG.md | 2 +- pyproject.toml | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1628c521..235de3f1a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,11 +29,11 @@ repos: rev: v0.9.1 hooks: - id: sphinx-lint - # Configuration for codespell is in pyproject.toml - # - repo: https://github.com/codespell-project/codespell - # rev: v2.3.0 - # hooks: - # - id: codespell - # exclude: (package-lock.json|/locale/) - # additional_dependencies: - # - tomli +# Configuration for codespell is in pyproject.toml + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + exclude: (package-lock.json|/locale/) + additional_dependencies: + - tomli diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bf0ff2ee..362fd74b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -206,7 +206,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ## [1.6.0] 2021-12-19 ### Added -* #949 Provide django.contrib.auth.authenticate() with a `request` for compatibiity with more backends (like django-axes). +* #949 Provide django.contrib.auth.authenticate() with a `request` for compatibility with more backends (like django-axes). * #968, #1039 Add support for Django 3.2 and 4.0. * #953 Allow loopback redirect URIs using random ports as described in [RFC8252 section 7.3](https://datatracker.ietf.org/doc/html/rfc8252#section-7.3). * #972 Add Farsi/fa language support. diff --git a/pyproject.toml b/pyproject.toml index 4d10990b9..884f7aec4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,8 +10,8 @@ exclude = ''' ''' # Ref: https://github.com/codespell-project/codespell#using-a-config-file -# [tool.codespell] -# skip = '.git,package-lock.json,locale' -# check-hidden = true -# ignore-regex = '.*pragma: codespell-ignore.*' -# ignore-words-list = '' +[tool.codespell] +skip = '.git,package-lock.json,locale,AUTHORS,tox.ini' +check-hidden = true +ignore-regex = '.*pragma: codespell-ignore.*' +ignore-words-list = 'assertIn' From 133ba8513b077729985ad5dfa7fafceb0ccbfc30 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Mon, 17 Jun 2024 09:57:26 -0400 Subject: [PATCH 613/722] feat: containerized apps (#1366) --- .dockerignore | 73 ++++++++ .gitignore | 2 + Dockerfile | 67 +++++++ docker-compose.yml | 40 +++++ tests/app/idp/idp/settings.py | 151 +++++++++------- tests/app/idp/requirements.txt | 1 + tests/app/rp/Dockerfile | 16 ++ tests/app/rp/package-lock.json | 308 +++++++++++++++++++++++++++++++-- tests/app/rp/package.json | 3 +- tests/app/rp/svelte.config.js | 6 +- 10 files changed, 582 insertions(+), 85 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 tests/app/rp/Dockerfile diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..1c1551eb3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +venv +__pycache__ +.tox +.github +.vscode +.django_oauth_toolkit.egg-info +.coverage +coverage.xml + +# every time we change this we need to do the COPY . /code and +# RUN pip install -r requirements.txt again +# so don't include the Dockerfile in the context. +Dockerfile +docker-compose.yml + + +# from .gitignore +*.py[cod] + +*.swp + +# C extensions +*.so + +# Packages +*.egg +*.egg-info +dist +build +eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ + +# Installer logs +pip-log.txt + +# Unit test / coverage reports +.cache +.pytest_cache +.coverage +.tox +.pytest_cache/ +nosetests.xml + +# Translations +*.mo + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# PyCharm stuff +.idea + +# Sphinx build dir +_build + +# Sqlite database files +*.sqlite + +/venv/ +/coverage.xml + +db.sqlite3 +venv/ diff --git a/.gitignore b/.gitignore index c4436f57d..70d81b559 100644 --- a/.gitignore +++ b/.gitignore @@ -54,3 +54,5 @@ _build db.sqlite3 venv/ + +/tests/app/idp/static diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..e501e84d2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1.6.0 +# this Dockerfile is located at the root so the build context +# includes oauth2_provider which is a requirement of the +# tests/app/idp. This way we build images with the source +# code from the repos for validation before publishing packages. + +FROM python:3.11.6-slim as builder + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +ENV DEBUG=False +ENV ALLOWED_HOSTS="*" +ENV TEMPLATES_DIRS="/data/templates" +ENV STATIC_ROOT="/data/static" +ENV DATABASE_URL="sqlite:////data/db.sqlite3" + +RUN apt-get update +# Build Deps +RUN apt-get install -y --no-install-recommends gcc libc-dev python3-dev git openssh-client libpq-dev file libev-dev +# bundle code in a virtual env to make copying to the final image without all the upstream stuff easier. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +# need to update pip and setuptools for pep517 support required by gevent. +RUN pip install --upgrade pip +RUN pip install --upgrade setuptools +COPY . /code +WORKDIR /code/tests/app/idp +RUN pip install -r requirements.txt +RUN pip install gunicorn +RUN python manage.py collectstatic --noinput + + + +FROM python:3.11.6-slim + +# allow embed sha1 at build time as release. +ARG GIT_SHA1 + +LABEL org.opencontainers.image.authors="https://jazzband.co/projects/django-oauth-toolkit" +LABEL org.opencontainers.image.source="https://github.com/jazzband/django-oauth-toolkit" +LABEL org.opencontainers.image.revision=${GIT_SHA1} + + +ENV SENTRY_RELEASE=${GIT_SHA1} + +# disable debug mode, but allow all hosts by default when running in docker +ENV DEBUG=False +ENV ALLOWED_HOSTS="*" +ENV TEMPLATES_DIRS="/data/templates" +ENV STATIC_ROOT="/data/static" +ENV DATABASE_URL="sqlite:////data/db.sqlite3" + + + + +COPY --from=builder /opt/venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY --from=builder /code /code +RUN mkdir -p /code/tests/app/idp/static /code/tests/app/idp/templates +WORKDIR /code/tests/app/idp +RUN apt-get update && apt-get install -y \ + libpq5 \ + && rm -rf /var/lib/apt/lists/* +EXPOSE 80 +VOLUME ["/data" ] +CMD ["gunicorn", "idp.wsgi:application", "-w 4 -b 0.0.0.0:80 --chdir=/code --worker-tmp-dir /dev/shm --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-'"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..3a3459fde --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,40 @@ +volumes: + idp-data: + + +x-idp: &idp + image: django-oauth-toolkit/idp + volumes: + - idp-data:/data + +services: + idp-migrate: + <<: *idp + build: . + command: python manage.py migrate + + idp-loaddata: + <<: *idp + command: python manage.py loaddata fixtures/seed.json + depends_on: + idp-migrate: + condition: service_completed_successfully + + idp: + <<: *idp + command: gunicorn idp.wsgi:application -w 4 -b 0.0.0.0:80 --chdir=/code --timeout 120 --error-logfile '-' --log-level debug --access-logfile '-' + ports: + # map to dev port. + - "8000:80" + depends_on: + idp-loaddata: + condition: service_completed_successfully + + rp: + image: django-oauth-toolkit/rp + build: ./tests/app/rp + ports: + # map to dev port. + - "5173:3000" + depends_on: + - idp \ No newline at end of file diff --git a/tests/app/idp/idp/settings.py b/tests/app/idp/idp/settings.py index 375cdcc9b..eee20982e 100644 --- a/tests/app/idp/idp/settings.py +++ b/tests/app/idp/idp/settings.py @@ -13,21 +13,93 @@ import os from pathlib import Path +import environ + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent +env = environ.FileAwareEnv( + DEBUG=(bool, True), + ALLOWED_HOSTS=(list, []), + DATABASE_URL=(str, "sqlite:///db.sqlite3"), + SECRET_KEY=(str, "django-insecure-vri27@j_q62e2it4$xiy9ca!7@qgjkhhan(*zs&lz0k@yukbb3"), + OAUTH2_PROVIDER_OIDC_ENABLED=(bool, True), + OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED=(bool, True), + OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY=( + str, + """ +-----BEGIN RSA PRIVATE KEY----- +MIIJKAIBAAKCAgEAtd8X/v8pddKt+opMJZrhV4FH86gBTMPjTGXeAfKkQVf7KDUZ +Ty90n+JMe2rvCUn+Nws9yy5vmtbkomQbj8Xs1kHJOVdCnH1L2HTkvM7BjTBmJ5vc +bA94IBmSf9jJIzfIJkepshRLcGllMvHPOYQiR+lJsj58FFDLZN4/182S21C8Ri0w ++63rT64SxiQkqt6h+E1w7V+tHQJKDZq3du1QctZVXiIr6Zs5BgTjTyRURoiqUVH0 +WJ4dT2t4+Rg9mp3PBlVwTOqzw9xTcO8ke+ZdrIWP4euZuPIr/Dya5R7S2Ki8Nwag +ANGV+LghJilucuWzJlOBO8TlIVUwgUaGOqaDxMHx9P/nRLQ6vTKP81FUJ7gNv6oj +W+6No6nMhsESQ+thizvBYOgintZZoeBwpB8lebKvGJUeqRo6qhc5BeUEjAjsAgtP +sJrRNQ4t8PT8mP+2dw4sU7J5PBAtx+ZdZ9bcH/sNuohBj77+6WhyvjmeYIKgCgjO +TdZH9O+kUIMaX9mlB+WvoVsk32qensZG/CgXXa3rWyXPvOdA9aOE4V0GCv1JfWKK +OXA8aY5aUGy0VvOWXHWpft5begr8onCjNs9UR6fCdCvcrSuiHTvNpM37E6Xh4kV4 +uMzjGaj5ZLBOAY3cYzFI6LNrK4/YJvzLi9jxI1sJG1ZMz8kCywuJISEq4LcCAwEA +AQKCAgBcnbV8l7gnVhhfA9pvNAYZJ67ad+3hh8fSefWqjEP1Orad7RxsZMBBQ16r +YvNDibi5kzHurEENWu2nfM9EUgifu3SbjMJRKsVa/3wUYj3ShpkfBpIjPWVxA1TF +YkJbeuakB8507zzTi/iLDvT2V0GV2Uk8SfGp7tMFFODyJq/om56lJhJRuGmidAT/ +fhxmH2XgKp+dYiGoKihH8UgIeiWDtX5Xp5MxLWjGleqjvN5l5ObG7rM+BZbrgNFk +GGIWwNJSaWP853CQBz0+v6mWpuOBHar945quwjSACOTgVOgOiS7/3pHQmOqEdE/9 +PRAP1sV6eP/Qzh3Y8ab3zlBAwddLmZi+8sVV/sJadEMciU6AR8ZInf2zWtmxh6Ft +TNXUrSmDjKId84wyYT+pDg8Vv04X8xMNLWAIYeBawOPasEiBiFVUqDGHciPMBbhb +XxZK7Noi8akzCLWouPkrW4pjpsd5xrllakGFAFPktLvc8ZRyz2InaQKqhaaU+is5 +ykAeHpJHVxg1xFY0hX06i8pkjXQROhc7+GUuifxKvVcouCwlUiSxcHGQLqzGKnYE +fpCs9uGI8+XolEq637LyYaZ7zpWd8Ehiw4AEfE3oOVIQd4xAQ8YDJxUG1fUYQfF8 +iD5VO2+WO7a9QfScFZK+UebHEEXQGq4+JNUlP0KSnSsp3J0XkQKCAQEA3Y0sE9sE +l8VTTW3oxKChmq18UKJchyXU3BMLFnvDAPweUTdtS0QUIsDQD2pCU7wQonWOpqUj +vMwlTZjyNo+9N0l2fqleha1phzgYFCfTsgJ6gcl82y/JUvsGqMglKOUKoCFW5UtM +kUO+P5S25GqiDc0qsO6FGKSOvJ5aJLYEpEK5ez2q9uyzSYbp5aUuKwLb11rX0HW9 +JjkB7hL4OtHpJ9E9uAsOj4VIWpysmX3d8UIv1Uez8f+bilhCMShKk4U9xz8ZY2K4 +YXdfFr83b1kQybIDzeXeOQ5NQ6myS5HiqBSYx9Iy7Y54605KVM0CzLCPS5fAAcbW +5wq1H32OtxRS4wKCAQEA0iZ24W30BIYIx65YseVbBNs4cJr9ppqCAqUGqAhW8xfe +q7Atd6KG+lXWVDj2tZzuoYeb0PLjQRsmOs8CVFUZT0ntH6YAUOpPW8l8tkrWTugp +7fCx2pR4r8aFAVb7Jkc41ojSvaYMbUClKf+JVtFPsY1ug7gNxizGjVnpAq66XX+X +76BVIpMEUivZcXos6/BrVM3seFYQg1pMZkjjO3q8lETnlT3LIYpPtRjaFSvcMaMy +1Cb4dGUz+xj8BM73bLDEJtHZEsyF6nEnurlE9rSbMui9XhckcC267e1qvIbAnKB9 +JK5oJAM4L+xOylmvk71gdrul9Q9aT+QJGUXkPxwfHQKCAQBkMIQ/UmtISyb5u/to +eA+8yDmQqWvYfiY9g6se9sbfuiPnrH4TbG0Crlkor2/hOAn5vdnNyJ5ZsaQo7EKU +o/n4d5NLgkJJh3tSd+6DpuMX/AD0km6RHJIZoYWIbEJJtRJSCeGm/Z9Zjd4KGLGA +qCwyu5ZTvvmXhEs8RwwSz/FXawlAD0oyMiZ92LILdOBk+Pz77YvtLGFmWJ9jz1ZM +G0MqC3iysuVZx/dJatKu8vmcMcc51xwsEuB+9pywaD0Za0bdxM4xYKJrCTWKLtzd +0NRDseoAgbQ17x7Hu4Tyob1zLyVML+VyAlzyZEw+/xsF/849bBmbdBUZFIGGBRy1 +9E3rAoIBAQCDs3dtb+stqpJ2Ed2kH4kbUgfdCkVM1CgGYEX7qL5VOvBhyNe10jWl +TYY04j47M06aDNKp8I5bjxg2YuWi1HI4Lqxc2Tv5ed6iN3PhCqWkbftZEy9jPQkl +n9RbMpfTNW95g+YO1LGVBp5745m+vw6ix3ArPH3lZMpKa76L39UMI5qkoma4dEqQ +9MohQ+BDPTkGvMcl40oWB9E5iRRfglwMz+IStddH/dZWOGz0N7iXox+HtaSfzYz2 +IIJQwSRvCZjkez7/eQ20D5ZGfzWpJybckN+cyAQeCYrM8a2i2RB9GFdVVbgOWbYs +0nvOdMaEYHrD7nXjTuvahZ7uJ88TfhxBAoIBAG3ClX40pxUXs6kEOGZYUXHFaYDz +Upuvj8X2h6SaepTAAokkJxGOdeg5t3ohsaXDeV2WcNb8KRFmDuVtcGSo0mUWtrtT +RXgJT9SBEMl1rEPbEh0i9uXOaI8DWdBO62Ei0efeL0Wac7kxwBbObKDn8mQCmlWK +4nvzevqUB8frm9abjRGTOZX8QlNZcPs065vHubNJ8SAqr+uoe1GTb0qL7YkWT6vb +dBCCnF8FP1yPW8UgGVGSeozmIMaJwSpl2srZUMkN1KlqHwzehrOn9Tn2grA9ue/i +ipUMvb4Se0LDJnmFuv8v6gM6V4vyXkP855mNOiRHUOHOSKdQ3SeKrLlnR6I= +-----END RSA PRIVATE KEY----- +""", + ), + OAUTH2_PROVIDER_SCOPES=(dict, {"openid": "OpenID Connect scope"}), + OAUTH2_PROVIDER_ALLOWED_SCHEMES=(list, ["https", "http"]), + OAUTHLIB_INSECURE_TRANSPORT=(bool, "1"), + STATIC_ROOT=(str, BASE_DIR / "static"), + STATIC_URL=(str, "static/"), + TEMPLATES_DIRS=(list, [BASE_DIR / "templates"]), +) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = "django-insecure-vri27@j_q62e2it4$xiy9ca!7@qgjkhhan(*zs&lz0k@yukbb3" +SECRET_KEY = env("SECRET_KEY") # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True +DEBUG = env("DEBUG") -ALLOWED_HOSTS = [] +ALLOWED_HOSTS = env("ALLOWED_HOSTS") # Application definition @@ -60,7 +132,7 @@ TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [BASE_DIR / "templates"], + "DIRS": env("TEMPLATES_DIRS"), "APP_DIRS": True, "OPTIONS": { "context_processors": [ @@ -80,10 +152,7 @@ # https://docs.djangoproject.com/en/4.2/ref/settings/#databases DATABASES = { - "default": { - "ENGINE": "django.db.backends.sqlite3", - "NAME": BASE_DIR / "db.sqlite3", - } + "default": env.db(), } @@ -120,8 +189,8 @@ # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/4.2/howto/static-files/ - -STATIC_URL = "static/" +STATIC_ROOT = env("STATIC_ROOT") +STATIC_URL = env("STATIC_URL") # Default primary key field type # https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field @@ -130,69 +199,17 @@ OAUTH2_PROVIDER = { "OAUTH2_VALIDATOR_CLASS": "idp.oauth.CustomOAuth2Validator", - "OIDC_ENABLED": True, - "OIDC_RP_INITIATED_LOGOUT_ENABLED": True, + "OIDC_ENABLED": env("OAUTH2_PROVIDER_OIDC_ENABLED"), + "OIDC_RP_INITIATED_LOGOUT_ENABLED": env("OAUTH2_PROVIDER_OIDC_RP_INITIATED_LOGOUT_ENABLED"), # this key is just for out test app, you should never store a key like this in a production environment. - "OIDC_RSA_PRIVATE_KEY": """ ------BEGIN RSA PRIVATE KEY----- -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 ------END RSA PRIVATE KEY----- -""", + "OIDC_RSA_PRIVATE_KEY": env("OAUTH2_PROVIDER_OIDC_RSA_PRIVATE_KEY"), "SCOPES": { "openid": "OpenID Connect scope", }, - "ALLOWED_SCHEMES": ["https", "http"], + "ALLOWED_SCHEMES": env("OAUTH2_PROVIDER_ALLOWED_SCHEMES"), } # needs to be set to allow cors requests from the test app, along with ALLOWED_SCHEMES=["http"] -os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = "1" +os.environ["OAUTHLIB_INSECURE_TRANSPORT"] = env("OAUTHLIB_INSECURE_TRANSPORT") LOGGING = { "version": 1, diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt index d17f9bd45..ba8e75052 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,4 +1,5 @@ Django>=3.2,<4.2 django-cors-headers==3.14.0 +django-environ==0.11.2 -e ../../../ \ No newline at end of file diff --git a/tests/app/rp/Dockerfile b/tests/app/rp/Dockerfile new file mode 100644 index 000000000..a719a1eb4 --- /dev/null +++ b/tests/app/rp/Dockerfile @@ -0,0 +1,16 @@ +FROM node:18-alpine AS builder +WORKDIR /app +COPY package*.json . +RUN npm ci +COPY . . +RUN npm run build +RUN npm prune --production + +FROM node:18-alpine +WORKDIR /app +COPY --from=builder /app/build build/ +COPY --from=builder /app/node_modules node_modules/ +COPY package.json . +EXPOSE 3000 +ENV NODE_ENV=production +CMD [ "node", "build" ] \ No newline at end of file diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index a20b5654d..80d8b1372 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -12,7 +12,8 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/adapter-node": "^5.0.1", + "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.0.0", @@ -500,6 +501,160 @@ "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", "dev": true }, + "node_modules/@rollup/plugin-commonjs": { + "version": "25.0.8", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", + "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "glob": "^8.0.3", + "is-reference": "1.2.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, + "node_modules/@rollup/plugin-commonjs/node_modules/glob": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", + "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^5.0.1", + "once": "^1.3.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "15.2.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", + "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "dev": true, + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-builtin-module": "^3.2.1", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.18.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", @@ -720,18 +875,33 @@ "@sveltejs/kit": "^2.0.0" } }, + "node_modules/@sveltejs/adapter-node": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", + "integrity": "sha512-eYdmxdUWMW+dad1JfMsWBPY2vjXz9eE+52A2AQnXPScPJlIxIVk5mmbaEEzrZivLfO2wEcLTZ5vdC03W69x+iA==", + "dev": true, + "dependencies": { + "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^15.2.3", + "rollup": "^4.9.5" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.4.0" + } + }, "node_modules/@sveltejs/kit": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz", - "integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==", + "version": "2.5.10", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.10.tgz", + "integrity": "sha512-OqoyTmFG2cYmCFAdBfW+Qxbg8m23H4dv6KqwEt7ofr/ROcfcIl3Z/VT56L22H9f0uNZyr+9Bs1eh2gedOCK9kA==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^4.3.2", + "devalue": "^5.0.0", "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", + "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", @@ -811,6 +981,12 @@ "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "dev": true }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true + }, "node_modules/acorn": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", @@ -918,6 +1094,18 @@ "node": ">=8.0.0" } }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -979,6 +1167,12 @@ "node": ">=0.4.0" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1045,7 +1239,6 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1069,9 +1262,9 @@ } }, "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", + "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", "dev": true }, "node_modules/es6-promise": { @@ -1190,6 +1383,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -1241,6 +1443,18 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -1258,9 +1472,9 @@ } }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, "funding": { "type": "github", @@ -1296,6 +1510,33 @@ "node": ">=8" } }, + "node_modules/is-builtin-module": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", + "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "dev": true, + "dependencies": { + "builtin-modules": "^3.3.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-core-module": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", + "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "dev": true, + "dependencies": { + "hasown": "^2.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1317,6 +1558,12 @@ "node": ">=0.10.0" } }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -1526,6 +1773,12 @@ "node": ">=0.10.0" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, "node_modules/periscopic": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", @@ -1648,6 +1901,23 @@ "node": ">=8.10.0" } }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "dev": true, + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -1845,6 +2115,18 @@ "node": ">=8" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svelte": { "version": "4.2.18", "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index dd087397e..d36c7b769 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -13,7 +13,8 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", - "@sveltejs/kit": "^2.5.0", + "@sveltejs/adapter-node": "^5.0.1", + "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.0.0", diff --git a/tests/app/rp/svelte.config.js b/tests/app/rp/svelte.config.js index 2b35fe1be..1023568ae 100644 --- a/tests/app/rp/svelte.config.js +++ b/tests/app/rp/svelte.config.js @@ -1,4 +1,4 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; /** @type {import('@sveltejs/kit').Config} */ @@ -8,9 +8,7 @@ const config = { preprocess: vitePreprocess(), kit: { - // adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list. - // If your environment is not supported or you settled on a specific environment, switch out the adapter. - // See https://kit.svelte.dev/docs/adapters for more information about adapters. + // build to run in containerized node.js environment adapter: adapter() } }; From 9146e2bc73943ec4f5f596c22d5f159bad3b78e2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Jun 2024 15:17:19 -0400 Subject: [PATCH 614/722] [pre-commit.ci] pre-commit autoupdate (#1433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/PyCQA/flake8: 7.0.0 → 7.1.0](https://github.com/PyCQA/flake8/compare/7.0.0...7.1.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 235de3f1a..0d6e67899 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 7.0.0 + rev: 7.1.0 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 4212987d315a69fc19a6d7550191f078218ed13f Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Thu, 20 Jun 2024 21:03:14 +0200 Subject: [PATCH 615/722] Correct rst syntax in installation section (#1434) --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index cbeedf1b4..39a2613f3 100644 --- a/README.rst +++ b/README.rst @@ -54,7 +54,7 @@ Install with pip:: pip install django-oauth-toolkit -Add `oauth2_provider` to your `INSTALLED_APPS` +Add ``oauth2_provider`` to your ``INSTALLED_APPS`` .. code-block:: python @@ -64,8 +64,8 @@ Add `oauth2_provider` to your `INSTALLED_APPS` ) -If you need an OAuth2 provider you'll want to add the following to your urls.py. -Notice that `oauth2_provider` namespace is mandatory. +If you need an OAuth2 provider you'll want to add the following to your ``urls.py``. +Notice that ``oauth2_provider`` namespace is mandatory. .. code-block:: python From 924310b8cdd1192bf86cae20baf5225b0cea1c6f Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Thu, 20 Jun 2024 23:45:18 +0200 Subject: [PATCH 616/722] Drop re_path from installation guide (#1435) --- docs/install.rst | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/docs/install.rst b/docs/install.rst index 7186a94c0..ffddc151e 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -26,17 +26,6 @@ If you need an OAuth2 provider you'll want to add the following to your :file:`u path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), ] -Or using ``re_path()`` - -.. code-block:: python - - from django.urls import include, re_path - - urlpatterns = [ - ... - re_path(r'^o/', include('oauth2_provider.urls', namespace='oauth2_provider')), - ] - Sync your database ------------------ @@ -45,4 +34,3 @@ Sync your database python manage.py migrate oauth2_provider Next step is :doc:`getting started <getting_started>` or :doc:`first tutorial <tutorial/tutorial_01>`. - From 9cb93ee37db6f0d020f29e14d6e6a42c47d00e76 Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Fri, 21 Jun 2024 18:57:27 +0200 Subject: [PATCH 617/722] Simplify how urlpatterns are loaded (#1436) --- AUTHORS | 1 + README.rst | 5 +++-- docs/getting_started.rst | 3 ++- docs/install.rst | 3 ++- docs/rest-framework/getting_started.rst | 3 ++- docs/tutorial/tutorial_01.rst | 3 ++- tests/urls.py | 4 +++- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/AUTHORS b/AUTHORS index 15eec14f9..357abc2fa 100644 --- a/AUTHORS +++ b/AUTHORS @@ -62,6 +62,7 @@ Hiroki Kiyohara Hossein Shakiba Islam Kamel Ivan Lukyanets +Jaap Roes Jadiel Teófilo Jens Timmerman Jerome Leclanche diff --git a/README.rst b/README.rst index 39a2613f3..1935c49b9 100644 --- a/README.rst +++ b/README.rst @@ -65,13 +65,14 @@ Add ``oauth2_provider`` to your ``INSTALLED_APPS`` If you need an OAuth2 provider you'll want to add the following to your ``urls.py``. -Notice that ``oauth2_provider`` namespace is mandatory. .. code-block:: python + from oauth2_provider import urls as oauth2_urls + urlpatterns = [ ... - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls)), ] Changelog diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 80ff9ed71..e95618723 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -191,10 +191,11 @@ Include ``oauth2_provider.urls`` to :file:`iam/urls.py` as follows: from django.contrib import admin from django.urls import include, path + from oauth2_provider import urls as oauth2_urls urlpatterns = [ path('admin/', admin.site.urls), - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls)), ] This will make available endpoints to authorize, generate token and create OAuth applications. diff --git a/docs/install.rst b/docs/install.rst index ffddc151e..cfa219ecd 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -20,10 +20,11 @@ If you need an OAuth2 provider you'll want to add the following to your :file:`u .. code-block:: python from django.urls import include, path + from oauth2_provider import urls as oauth2_urls urlpatterns = [ ... - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls), ] Sync your database diff --git a/docs/rest-framework/getting_started.rst b/docs/rest-framework/getting_started.rst index 4e6b037b0..8e019c44e 100644 --- a/docs/rest-framework/getting_started.rst +++ b/docs/rest-framework/getting_started.rst @@ -51,6 +51,7 @@ Here's our project's root :file:`urls.py` module: from rest_framework import generics, permissions, serializers + from oauth2_provider import urls as oauth2_urls from oauth2_provider.contrib.rest_framework import TokenHasReadWriteScope, TokenHasScope # first we define the serializers @@ -84,7 +85,7 @@ Here's our project's root :file:`urls.py` module: # Setup the URLs and include login URLs for the browsable API. urlpatterns = [ path('admin/', admin.site.urls), - path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + path('o/', include(oauth2_urls)), path('users/', UserList.as_view()), path('users/<pk>/', UserDetails.as_view()), path('groups/', GroupList.as_view()), diff --git a/docs/tutorial/tutorial_01.rst b/docs/tutorial/tutorial_01.rst index efd1265f7..0d0e6b45c 100644 --- a/docs/tutorial/tutorial_01.rst +++ b/docs/tutorial/tutorial_01.rst @@ -34,10 +34,11 @@ Include the Django OAuth Toolkit urls in your `urls.py`, choosing the urlspace y .. code-block:: python from django.urls import path, include + from oauth2_provider import urls as oauth2_urls urlpatterns = [ path("admin", admin.site.urls), - path("o/", include('oauth2_provider.urls', namespace='oauth2_provider')), + path("o/", include(oauth2_urls)), # ... ] diff --git a/tests/urls.py b/tests/urls.py index 0661a9336..6f8f56832 100644 --- a/tests/urls.py +++ b/tests/urls.py @@ -1,11 +1,13 @@ from django.contrib import admin from django.urls import include, path +from oauth2_provider import urls as oauth2_urls + admin.autodiscover() urlpatterns = [ - path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), + path("o/", include(oauth2_urls)), path("admin/", admin.site.urls), ] From 102c85141ec44549e17080c676292e79e5eb46cc Mon Sep 17 00:00:00 2001 From: Joni Bekenstein <joni@bek.io> Date: Mon, 8 Jul 2024 12:22:24 -0300 Subject: [PATCH 618/722] Add missing closing ) (#1437) --- docs/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/install.rst b/docs/install.rst index cfa219ecd..3d46c507d 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -24,7 +24,7 @@ If you need an OAuth2 provider you'll want to add the following to your :file:`u urlpatterns = [ ... - path('o/', include(oauth2_urls), + path('o/', include(oauth2_urls)), ] Sync your database From ba752975ec092dd55eed3d4dd7d6c10a3cc85f4a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 Aug 2024 16:06:02 -0400 Subject: [PATCH 619/722] [pre-commit.ci] pre-commit autoupdate (#1448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/psf/black: 24.4.2 → 24.8.0](https://github.com/psf/black/compare/24.4.2...24.8.0) - [github.com/PyCQA/flake8: 7.1.0 → 7.1.1](https://github.com/PyCQA/flake8/compare/7.1.0...7.1.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0d6e67899..8a2e65601 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/psf/black - rev: 24.4.2 + rev: 24.8.0 hooks: - id: black exclude: ^(oauth2_provider/migrations/|tests/migrations/) @@ -21,7 +21,7 @@ repos: - id: isort exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 - rev: 7.1.0 + rev: 7.1.1 hooks: - id: flake8 exclude: ^(oauth2_provider/migrations/|tests/migrations/) From 9c18de21f979bd0ddd0b5a429b79e49340d494d8 Mon Sep 17 00:00:00 2001 From: Madison Swain-Bowden <bowdenm@spu.edu> Date: Tue, 13 Aug 2024 06:16:33 -0700 Subject: [PATCH 620/722] Handle invalid hex values in query strings in DRF extension (#1444) * Handle invalid hex values in query strings in DRF extension --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> --- AUTHORS | 1 + CHANGELOG.md | 1 + .../contrib/rest_framework/authentication.py | 15 ++++++++++++--- tests/test_rest_framework.py | 6 ++++++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/AUTHORS b/AUTHORS index 357abc2fa..17447b108 100644 --- a/AUTHORS +++ b/AUTHORS @@ -83,6 +83,7 @@ Kristian Rune Larsen Lazaros Toumanidis Ludwig Hähne Łukasz Skarżyński +Madison Swain-Bowden Marcus Sonestedt Matias Seniquiel Michael Howitz diff --git a/CHANGELOG.md b/CHANGELOG.md index 362fd74b3..826ae43bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 ### Fixed +* #1443 Query strings with invalid hex values now raise a SuspiciousOperation exception (in DRF extension) instead of raising a 500 ValueError: Invalid hex encoding in query string. ### Security ## [2.4.0] - 2024-05-13 diff --git a/oauth2_provider/contrib/rest_framework/authentication.py b/oauth2_provider/contrib/rest_framework/authentication.py index 53087f756..afa75d845 100644 --- a/oauth2_provider/contrib/rest_framework/authentication.py +++ b/oauth2_provider/contrib/rest_framework/authentication.py @@ -1,5 +1,6 @@ from collections import OrderedDict +from django.core.exceptions import SuspiciousOperation from rest_framework.authentication import BaseAuthentication from ...oauth2_backends import get_oauthlib_core @@ -23,10 +24,18 @@ def authenticate(self, request): Returns two-tuple of (user, token) if authentication succeeds, or None otherwise. """ + if request is None: + return None oauthlib_core = get_oauthlib_core() - valid, r = oauthlib_core.verify_request(request, scopes=[]) - if valid: - return r.user, r.access_token + try: + valid, r = oauthlib_core.verify_request(request, scopes=[]) + except ValueError as error: + if str(error) == "Invalid hex encoding in query string.": + raise SuspiciousOperation(error) + raise + else: + if valid: + return r.user, r.access_token request.oauth2_error = getattr(r, "oauth2_error", {}) return None diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 0061f8d3a..632c62e26 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -415,3 +415,9 @@ def test_authentication_none(self): auth = self._create_authorization_header(self.access_token.token) response = self.client.get("/oauth2-authentication-none/", HTTP_AUTHORIZATION=auth) self.assertEqual(response.status_code, 401) + + def test_invalid_hex_string_in_query(self): + auth = self._create_authorization_header(self.access_token.token) + response = self.client.get("/oauth2-test/?q=73%%20of%20Arkansans", HTTP_AUTHORIZATION=auth) + # Should respond with a 400 rather than raise a ValueError + self.assertEqual(response.status_code, 400) From 51d9798a8baf03609a5cdc868e48f07a87f259da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B6ren=20Wegener?= <wegener92@gmail.com> Date: Tue, 13 Aug 2024 16:02:06 +0200 Subject: [PATCH 621/722] Refresh Token Reuse Protection (#1452) * Implement REFRESH_TOKEN_REUSE_PROTECTION (#1404) According to https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations, the authorization server needs a way to determine which refresh tokens belong to the same session, so it is able to figure out which tokens to revoke. Therefore, this commit introduces a "token_family" field to the RefreshToken table. Whenever a revoked refresh token is reused, the auth server uses the token family to revoke all related tokens. --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/settings.rst | 12 ++ .../0011_refreshtoken_token_family.py | 19 ++++ oauth2_provider/models.py | 1 + oauth2_provider/oauth2_validators.py | 36 ++++-- oauth2_provider/settings.py | 1 + .../0006_basetestapplication_token_family.py | 20 ++++ tests/test_authorization_code.py | 105 ++++++++++++++++++ 9 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 oauth2_provider/migrations/0011_refreshtoken_token_family.py create mode 100644 tests/migrations/0006_basetestapplication_token_family.py diff --git a/AUTHORS b/AUTHORS index 17447b108..ce5ec2ec8 100644 --- a/AUTHORS +++ b/AUTHORS @@ -105,6 +105,7 @@ Shaheed Haque Shaun Stanworth Silvano Cerza Sora Yanai +Sören Wegener Spencer Carroll Stéphane Raimbault Tom Evans diff --git a/CHANGELOG.md b/CHANGELOG.md index 826ae43bc..ed1ec2e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added +* #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION` ### Changed ### Deprecated ### Removed diff --git a/docs/settings.rst b/docs/settings.rst index 901fe8575..4ebe6cc47 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -185,6 +185,18 @@ The import string of the class (model) representing your refresh tokens. Overwri this value if you wrote your own implementation (subclass of ``oauth2_provider.models.RefreshToken``). +REFRESH_TOKEN_REUSE_PROTECTION +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +When this is set to ``True`` (default ``False``), and ``ROTATE_REFRESH_TOKEN`` is used, the server will check +if a previously, already revoked refresh token is used a second time. If it detects a reuse, it will automatically +revoke all related refresh tokens. +A reused refresh token indicates a breach. Since the server can't determine which request came from the legitimate +user and which from an attacker, it will end the session for both. The user is required to perform a new login. + +Can be used in combination with ``REFRESH_TOKEN_GRACE_PERIOD_SECONDS`` + +More details at https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations + ROTATE_REFRESH_TOKEN ~~~~~~~~~~~~~~~~~~~~ When is set to ``True`` (default) a new refresh token is issued to the client when the client refreshes an access token. diff --git a/oauth2_provider/migrations/0011_refreshtoken_token_family.py b/oauth2_provider/migrations/0011_refreshtoken_token_family.py new file mode 100644 index 000000000..94fb4e171 --- /dev/null +++ b/oauth2_provider/migrations/0011_refreshtoken_token_family.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2 on 2024-08-09 16:40 + +from django.db import migrations, models +from oauth2_provider.settings import oauth2_settings + +class Migration(migrations.Migration): + + dependencies = [ + ('oauth2_provider', '0010_application_allowed_origins'), + migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) + ] + + operations = [ + migrations.AddField( + model_name='refreshtoken', + name='token_family', + field=models.UUIDField(blank=True, editable=False, null=True), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 661bd7dfc..9895528de 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -490,6 +490,7 @@ class AbstractRefreshToken(models.Model): null=True, related_name="refresh_token", ) + token_family = models.UUIDField(null=True, blank=True, editable=False) created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 47d65e851..d1cb8b9b6 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -15,7 +15,6 @@ from django.contrib.auth.hashers import check_password, identify_hasher from django.core.exceptions import ObjectDoesNotExist from django.db import transaction -from django.db.models import Q from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.crypto import constant_time_compare @@ -644,7 +643,9 @@ def save_bearer_token(self, token, request, *args, **kwargs): source_refresh_token=refresh_token_instance, ) - self._create_refresh_token(request, refresh_token_code, access_token) + self._create_refresh_token( + request, refresh_token_code, access_token, refresh_token_instance + ) else: # make sure that the token data we're returning matches # the existing token @@ -688,9 +689,17 @@ def _create_authorization_code(self, request, code, expires=None): claims=json.dumps(request.claims or {}), ) - def _create_refresh_token(self, request, refresh_token_code, access_token): + def _create_refresh_token(self, request, refresh_token_code, access_token, previous_refresh_token): + if previous_refresh_token: + token_family = previous_refresh_token.token_family + else: + token_family = uuid.uuid4() return RefreshToken.objects.create( - user=request.user, token=refresh_token_code, application=request.client, access_token=access_token + user=request.user, + token=refresh_token_code, + application=request.client, + access_token=access_token, + token_family=token_family, ) def revoke_token(self, token, token_type_hint, request, *args, **kwargs): @@ -752,22 +761,25 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs Also attach User instance to the request object """ - null_or_recent = Q(revoked__isnull=True) | Q( - revoked__gt=timezone.now() - timedelta(seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS) - ) - rt = ( - RefreshToken.objects.filter(null_or_recent, token=refresh_token) - .select_related("access_token") - .first() - ) + rt = RefreshToken.objects.filter(token=refresh_token).select_related("access_token").first() if not rt: return False + if rt.revoked is not None and rt.revoked <= timezone.now() - timedelta( + seconds=oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS + ): + if oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION and rt.token_family: + rt_token_family = RefreshToken.objects.filter(token_family=rt.token_family) + for related_rt in rt_token_family.all(): + related_rt.revoke() + return False + request.user = rt.user request.refresh_token = rt.token # Temporary store RefreshToken instance to be reused by get_original_scopes and save_bearer_token. request.refresh_token_instance = rt + return rt.application == client @transaction.atomic diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 950ab5643..329a1b354 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -54,6 +54,7 @@ "ID_TOKEN_EXPIRE_SECONDS": 36000, "REFRESH_TOKEN_EXPIRE_SECONDS": None, "REFRESH_TOKEN_GRACE_PERIOD_SECONDS": 0, + "REFRESH_TOKEN_REUSE_PROTECTION": False, "ROTATE_REFRESH_TOKEN": True, "ERROR_RESPONSE_WITH_SCOPES": False, "APPLICATION_MODEL": APPLICATION_MODEL, diff --git a/tests/migrations/0006_basetestapplication_token_family.py b/tests/migrations/0006_basetestapplication_token_family.py new file mode 100644 index 000000000..6b065a242 --- /dev/null +++ b/tests/migrations/0006_basetestapplication_token_family.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2 on 2024-08-09 16:40 + +from django.db import migrations, models +from oauth2_provider.settings import oauth2_settings + + +class Migration(migrations.Migration): + + dependencies = [ + ('tests', '0005_basetestapplication_allowed_origins_and_more'), + migrations.swappable_dependency(oauth2_settings.REFRESH_TOKEN_MODEL) + ] + + operations = [ + migrations.AddField( + model_name='samplerefreshtoken', + name='token_family', + field=models.UUIDField(blank=True, editable=False, null=True), + ), + ] diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index b77f4f9ba..ae6e7e76e 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -985,6 +985,54 @@ def test_refresh_fail_repeating_requests(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) + def test_refresh_repeating_requests_revokes_old_token(self): + """ + If a refresh token is reused, the server should invalidate *all* access tokens that have a relation + to the re-used token. This forces a malicious actor to be logged out. + The server can't determine whether the first or the second client was legitimate, so it needs to + revoke both. + See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations + """ + self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("refresh_token" in content) + + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": content["refresh_token"], + "scope": content["scope"], + } + # First response works as usual + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + new_tokens = json.loads(response.content.decode("utf-8")) + + # Second request fails + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + # Previously returned tokens are now invalid as well + new_token_request_data = { + "grant_type": "refresh_token", + "refresh_token": new_tokens["refresh_token"], + "scope": new_tokens["scope"], + } + response = self.client.post( + reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers + ) + self.assertEqual(response.status_code, 400) + def test_refresh_repeating_requests(self): """ Trying to refresh an access token with the same refresh token more than @@ -1024,6 +1072,63 @@ def test_refresh_repeating_requests(self): response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) self.assertEqual(response.status_code, 400) + def test_refresh_repeating_requests_grace_period_with_reuse_protection(self): + """ + Trying to refresh an access token with the same refresh token more than + once succeeds. Should work within the grace period, but should revoke previous tokens + """ + self.oauth2_settings.REFRESH_TOKEN_GRACE_PERIOD_SECONDS = 120 + self.oauth2_settings.REFRESH_TOKEN_REUSE_PROTECTION = True + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + } + auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + content = json.loads(response.content.decode("utf-8")) + self.assertTrue("refresh_token" in content) + + refresh_token_1 = content["refresh_token"] + token_request_data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token_1, + "scope": content["scope"], + } + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + refresh_token_2 = json.loads(response.content.decode("utf-8"))["refresh_token"] + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 200) + refresh_token_3 = json.loads(response.content.decode("utf-8"))["refresh_token"] + + self.assertEqual(refresh_token_2, refresh_token_3) + + # Let the first refresh token expire + rt = RefreshToken.objects.get(token=refresh_token_1) + rt.revoked = timezone.now() - datetime.timedelta(minutes=10) + rt.save() + + # Using the expired token fails + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data, **auth_headers) + self.assertEqual(response.status_code, 400) + + # Because we used the expired token, the recently issued token is also revoked + new_token_request_data = { + "grant_type": "refresh_token", + "refresh_token": refresh_token_2, + "scope": content["scope"], + } + response = self.client.post( + reverse("oauth2_provider:token"), data=new_token_request_data, **auth_headers + ) + self.assertEqual(response.status_code, 400) + def test_refresh_repeating_requests_non_rotating_tokens(self): """ Try refreshing an access token with the same refresh token more than once when not rotating tokens. From dc3d8ff07f66d1e95f934f89d5d530e386f67c65 Mon Sep 17 00:00:00 2001 From: fazeelghafoor <33656455+fazeelghafoor@users.noreply.github.com> Date: Tue, 13 Aug 2024 11:01:17 -0400 Subject: [PATCH 622/722] change token to TextField in AbstractAccessToken model (#1447) * change token field to TextField in AbstractAccessToken model - add TokenChecksumField field - update middleware, validators, and views to use token checksums for token retrieval and validation - modified test migrations to include token_checksum field in "sampleaccesstoken" model - add test for token checksum field --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 4 +++ oauth2_provider/middleware.py | 4 ++- .../migrations/0012_add_token_checksum.py | 26 +++++++++++++++++++ oauth2_provider/models.py | 15 +++++++++-- oauth2_provider/oauth2_validators.py | 8 +++++- oauth2_provider/views/base.py | 4 ++- oauth2_provider/views/introspect.py | 6 ++++- tests/migrations/0002_swapped_models.py | 12 ++++++--- tests/test_models.py | 13 ++++++++++ 10 files changed, 83 insertions(+), 10 deletions(-) create mode 100644 oauth2_provider/migrations/0012_add_token_checksum.py diff --git a/AUTHORS b/AUTHORS index ce5ec2ec8..64986ca08 100644 --- a/AUTHORS +++ b/AUTHORS @@ -51,6 +51,7 @@ Dylan Tack Eduardo Oliveira Egor Poderiagin Emanuele Palazzetti +Fazeel Ghafoor Federico Dolce Florian Demmer Frederico Vieira diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1ec2e89..99be61e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added +* Add migration to include `token_checksum` field in AbstractAccessToken model. * #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION` ### Changed +* Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims + +* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. ### Deprecated ### Removed * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index de1689894..65c9cf03c 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -1,3 +1,4 @@ +import hashlib import logging from django.contrib.auth import authenticate @@ -55,7 +56,8 @@ def __call__(self, request): tokenstring = authheader.split()[1] AccessToken = get_access_token_model() try: - token = AccessToken.objects.get(token=tokenstring) + token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest() + token = AccessToken.objects.get(token_checksum=token_checksum) request.access_token = token except AccessToken.DoesNotExist as e: log.exception(e) diff --git a/oauth2_provider/migrations/0012_add_token_checksum.py b/oauth2_provider/migrations/0012_add_token_checksum.py new file mode 100644 index 000000000..7f62955e3 --- /dev/null +++ b/oauth2_provider/migrations/0012_add_token_checksum.py @@ -0,0 +1,26 @@ +# Generated by Django 5.0.7 on 2024-07-29 23:13 + +import oauth2_provider.models +from django.db import migrations, models +from oauth2_provider.settings import oauth2_settings + +class Migration(migrations.Migration): + dependencies = [ + ("oauth2_provider", "0011_refreshtoken_token_family"), + migrations.swappable_dependency(oauth2_settings.ACCESS_TOKEN_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="accesstoken", + name="token_checksum", + field=oauth2_provider.models.TokenChecksumField( + blank=True, db_index=True, max_length=64, unique=True + ), + ), + migrations.AlterField( + model_name="accesstoken", + name="token", + field=models.TextField(), + ), + ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 9895528de..68d30f332 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -1,3 +1,4 @@ +import hashlib import logging import time import uuid @@ -44,6 +45,14 @@ def pre_save(self, model_instance, add): return super().pre_save(model_instance, add) +class TokenChecksumField(models.CharField): + def pre_save(self, model_instance, add): + token = getattr(model_instance, "token") + checksum = hashlib.sha256(token.encode("utf-8")).hexdigest() + setattr(model_instance, self.attname, checksum) + return super().pre_save(model_instance, add) + + class AbstractApplication(models.Model): """ An Application instance represents a Client on the Authorization server. @@ -379,8 +388,10 @@ class AbstractAccessToken(models.Model): null=True, related_name="refreshed_access_token", ) - token = models.CharField( - max_length=255, + token = models.TextField() + token_checksum = TokenChecksumField( + max_length=64, + blank=True, unique=True, db_index=True, ) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index d1cb8b9b6..4ca1479d2 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -1,5 +1,6 @@ import base64 import binascii +import hashlib import http.client import inspect import json @@ -461,7 +462,12 @@ def validate_bearer_token(self, token, scopes, request): return False def _load_access_token(self, token): - return AccessToken.objects.select_related("application", "user").filter(token=token).first() + token_checksum = hashlib.sha256(token.encode("utf-8")).hexdigest() + return ( + AccessToken.objects.select_related("application", "user") + .filter(token_checksum=token_checksum) + .first() + ) def validate_code(self, client_id, code, client, request, *args, **kwargs): try: diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index cad36c757..52cb151d5 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -1,3 +1,4 @@ +import hashlib import json import logging from urllib.parse import parse_qsl, urlencode, urlparse @@ -289,7 +290,8 @@ def post(self, request, *args, **kwargs): if status == 200: access_token = json.loads(body).get("access_token") if access_token is not None: - token = get_access_token_model().objects.get(token=access_token) + token_checksum = hashlib.sha256(access_token.encode("utf-8")).hexdigest() + token = get_access_token_model().objects.get(token_checksum=token_checksum) app_authorized.send(sender=self, request=request, token=token) response = HttpResponse(content=body, status=status) diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 04ca92a38..05a77909f 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -1,4 +1,5 @@ import calendar +import hashlib from django.core.exceptions import ObjectDoesNotExist from django.http import JsonResponse @@ -24,8 +25,11 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): @staticmethod def get_token_response(token_value=None): try: + token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest() token = ( - get_access_token_model().objects.select_related("user", "application").get(token=token_value) + get_access_token_model() + .objects.select_related("user", "application") + .get(token_checksum=token_checksum) ) except ObjectDoesNotExist: return JsonResponse({"active": False}, status=200) diff --git a/tests/migrations/0002_swapped_models.py b/tests/migrations/0002_swapped_models.py index 412f19927..e168a053d 100644 --- a/tests/migrations/0002_swapped_models.py +++ b/tests/migrations/0002_swapped_models.py @@ -118,10 +118,14 @@ class Migration(migrations.Migration): field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='s_refreshed_access_token', to=settings.OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL), ), migrations.AddField( - model_name='sampleaccesstoken', - name='token', - field=models.CharField(max_length=255, unique=True), - preserve_default=False, + model_name="sampleaccesstoken", + name="token", + field=models.TextField(), + ), + migrations.AddField( + model_name="sampleaccesstoken", + name="token_checksum", + field=models.CharField(max_length=64, unique=True, db_index=True), ), migrations.AddField( model_name='sampleaccesstoken', diff --git a/tests/test_models.py b/tests/test_models.py index 586bef124..24e4ceafe 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,3 +1,5 @@ +import hashlib +import secrets from datetime import timedelta import pytest @@ -310,6 +312,17 @@ def test_expires_can_be_none(self): self.assertIsNone(access_token.expires) self.assertTrue(access_token.is_expired()) + def test_token_checksum_field(self): + token = secrets.token_urlsafe(32) + access_token = AccessToken.objects.create( + user=self.user, + token=token, + expires=timezone.now() + timedelta(hours=1), + ) + expected_checksum = hashlib.sha256(token.encode()).hexdigest() + + self.assertEqual(access_token.token_checksum, expected_checksum) + class TestRefreshTokenModel(BaseTestModels): def test_str(self): From 2a5845d398fd2112a6ac24fbe67b330123338fb0 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 15 Aug 2024 01:45:34 +0800 Subject: [PATCH 623/722] use path in urls (#1456) replaces re_path with simple, straightforward path, removing unnecessary regex. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- oauth2_provider/urls.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/oauth2_provider/urls.py b/oauth2_provider/urls.py index 18972612c..155822f45 100644 --- a/oauth2_provider/urls.py +++ b/oauth2_provider/urls.py @@ -1,4 +1,4 @@ -from django.urls import re_path +from django.urls import path, re_path from . import views @@ -7,24 +7,24 @@ base_urlpatterns = [ - re_path(r"^authorize/$", views.AuthorizationView.as_view(), name="authorize"), - re_path(r"^token/$", views.TokenView.as_view(), name="token"), - re_path(r"^revoke_token/$", views.RevokeTokenView.as_view(), name="revoke-token"), - re_path(r"^introspect/$", views.IntrospectTokenView.as_view(), name="introspect"), + path("authorize/", views.AuthorizationView.as_view(), name="authorize"), + path("token/", views.TokenView.as_view(), name="token"), + path("revoke_token/", views.RevokeTokenView.as_view(), name="revoke-token"), + path("introspect/", views.IntrospectTokenView.as_view(), name="introspect"), ] management_urlpatterns = [ # Application management views - re_path(r"^applications/$", views.ApplicationList.as_view(), name="list"), - re_path(r"^applications/register/$", views.ApplicationRegistration.as_view(), name="register"), - re_path(r"^applications/(?P<pk>[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), - re_path(r"^applications/(?P<pk>[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), - re_path(r"^applications/(?P<pk>[\w-]+)/update/$", views.ApplicationUpdate.as_view(), name="update"), + path("applications/", views.ApplicationList.as_view(), name="list"), + path("applications/register/", views.ApplicationRegistration.as_view(), name="register"), + path("applications/<slug:pk>/", views.ApplicationDetail.as_view(), name="detail"), + path("applications/<slug:pk>/delete/", views.ApplicationDelete.as_view(), name="delete"), + path("applications/<slug:pk>/update/", views.ApplicationUpdate.as_view(), name="update"), # Token management views - re_path(r"^authorized_tokens/$", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), - re_path( - r"^authorized_tokens/(?P<pk>[\w-]+)/delete/$", + path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + path( + "authorized_tokens/<slug:pk>/delete/", views.AuthorizedTokenDeleteView.as_view(), name="authorized-token-delete", ), @@ -40,9 +40,9 @@ views.ConnectDiscoveryInfoView.as_view(), name="oidc-connect-discovery-info", ), - re_path(r"^\.well-known/jwks.json$", views.JwksInfoView.as_view(), name="jwks-info"), - re_path(r"^userinfo/$", views.UserInfoView.as_view(), name="user-info"), - re_path(r"^logout/$", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), + path(".well-known/jwks.json", views.JwksInfoView.as_view(), name="jwks-info"), + path("userinfo/", views.UserInfoView.as_view(), name="user-info"), + path("logout/", views.RPInitiatedLogoutView.as_view(), name="rp-initiated-logout"), ] From 7e134136879e98b628101a9f6944fc4355d7e8d5 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 15 Aug 2024 21:03:06 +0800 Subject: [PATCH 624/722] drop support for Django versions below 4.2 (#1455) * drop support for Django below 4.2 --- .github/workflows/test.yml | 26 +++----------------------- CHANGELOG.md | 1 + README.rst | 2 +- docs/index.rst | 2 +- setup.cfg | 6 ++---- tox.ini | 16 ++++------------ 6 files changed, 12 insertions(+), 41 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 627aacf97..552e21281 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,39 +10,19 @@ jobs: fail-fast: false matrix: python-version: - - '3.8' - - '3.9' - '3.10' - '3.11' - '3.12' django-version: - - '3.2' - - '4.0' - - '4.1' - '4.2' - '5.0' + - '5.1' - 'main' - exclude: + include: # https://docs.djangoproject.com/en/dev/faq/install/#what-python-version-can-i-use-with-django - - # < Python 3.10 is not supported by Django 5.0+ - - python-version: '3.8' - django-version: '5.0' - - python-version: '3.9' - django-version: '5.0' - python-version: '3.8' - django-version: 'main' + django-version: '4.2' - python-version: '3.9' - django-version: 'main' - - # Python 3.12 is not supported by Django < 5.0 - - python-version: '3.12' - django-version: '3.2' - - python-version: '3.12' - django-version: '4.0' - - python-version: '3.12' - django-version: '4.1' - - python-version: '3.12' django-version: '4.2' steps: diff --git a/CHANGELOG.md b/CHANGELOG.md index 99be61e48..e72d9d550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Deprecated ### Removed * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 +* Remove support for Django versions below 4.2 ### Fixed * #1443 Query strings with invalid hex values now raise a SuspiciousOperation exception (in DRF extension) instead of raising a 500 ValueError: Invalid hex encoding in query string. diff --git a/README.rst b/README.rst index 1935c49b9..ff94b8c62 100644 --- a/README.rst +++ b/README.rst @@ -44,7 +44,7 @@ Requirements ------------ * Python 3.8+ -* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0 +* Django 4.2, 5.0 or 5.1 * oauthlib 3.1+ Installation diff --git a/docs/index.rst b/docs/index.rst index e0df769cd..915a4f6b8 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -22,7 +22,7 @@ Requirements ------------ * Python 3.8+ -* Django 3.2, 4.0 (4.0.1+ due to a regression), 4.1, 4.2, or 5.0 +* Django 4.2, 5.0 or 5.1 * oauthlib 3.1+ Index diff --git a/setup.cfg b/setup.cfg index d015d1238..4f25adf1d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -12,11 +12,9 @@ classifiers = Development Status :: 5 - Production/Stable Environment :: Web Environment Framework :: Django - Framework :: Django :: 3.2 - Framework :: Django :: 4.0 - Framework :: Django :: 4.1 Framework :: Django :: 4.2 Framework :: Django :: 5.0 + Framework :: Django :: 5.1 Intended Audience :: Developers License :: OSI Approved :: BSD License Operating System :: OS Independent @@ -36,7 +34,7 @@ python_requires = >=3.8 # jwcrypto has a direct dependency on six, but does not list it yet in a release # Previously, cryptography also depended on six, so this was unnoticed install_requires = - django >= 3.2, != 4.0.0 + django >= 4.2 requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 diff --git a/tox.ini b/tox.ini index ba97bd113..56d249661 100644 --- a/tox.ini +++ b/tox.ini @@ -5,11 +5,9 @@ envlist = migrate_swapped, docs, sphinxlint, - py{38,39,310}-dj32, - py{38,39,310}-dj40, - py{38,39,310,311}-dj41, py{38,39,310,311,312}-dj42, py{310,311,312}-dj50, + py{310,311,312}-dj51, py{310,311,312}-djmain, [gh-actions] @@ -22,12 +20,9 @@ python = [gh-actions:env] DJANGO = - 2.2: dj22 - 3.2: dj32 - 4.0: dj40 - 4.1: dj41 4.2: dj42 5.0: dj50 + 5.1: dj51 main: djmain [pytest] @@ -50,12 +45,9 @@ setenv = PYTHONPATH = {toxinidir} PYTHONWARNINGS = all deps = - dj22: Django>=2.2,<3 - dj32: Django>=3.2,<3.3 - dj40: Django>=4.0.0,<4.1 - dj41: Django>=4.1,<4.2 dj42: Django>=4.2,<4.3 - dj50: Django>=5.0b1,<5.1 + dj50: Django>=5.0,<5.1 + dj51: Django>=5.1,<5.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework oauthlib>=3.1.0 From 146e8bfdd12df1efc4250499fce64225a04560e0 Mon Sep 17 00:00:00 2001 From: Sayyid Hamid Mahdavi <sayyidhamidmahdavi@gmail.com> Date: Thu, 15 Aug 2024 17:11:25 +0330 Subject: [PATCH 625/722] models pk instead of models id (#1446) * use user.pk instead of user.id which allows for a custom model to have a different PK. --------- Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 3 ++- oauth2_provider/admin.py | 2 +- oauth2_provider/models.py | 4 ++-- oauth2_provider/oauth2_validators.py | 8 ++++---- .../oauth2_provider/application_detail.html | 4 ++-- .../oauth2_provider/application_form.html | 4 ++-- .../oauth2_provider/application_list.html | 2 +- tests/test_token_revocation.py | 16 ++++++++-------- 9 files changed, 23 insertions(+), 21 deletions(-) diff --git a/AUTHORS b/AUTHORS index 64986ca08..584ecf59c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -104,6 +104,7 @@ Rustem Saiargaliev Sandro Rodrigues Shaheed Haque Shaun Stanworth +Sayyid Hamid Mahdavi Silvano Cerza Sora Yanai Sören Wegener diff --git a/CHANGELOG.md b/CHANGELOG.md index e72d9d550..7d213524c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION` ### Changed * Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims - * Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. +* #1446 use generic models pk instead of id. + ### Deprecated ### Removed * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 diff --git a/oauth2_provider/admin.py b/oauth2_provider/admin.py index cefc75bb6..dd636184c 100644 --- a/oauth2_provider/admin.py +++ b/oauth2_provider/admin.py @@ -19,7 +19,7 @@ class ApplicationAdmin(admin.ModelAdmin): - list_display = ("id", "name", "user", "client_type", "authorization_grant_type") + list_display = ("pk", "name", "user", "client_type", "authorization_grant_type") list_filter = ("client_type", "authorization_grant_type", "skip_authorization") radio_fields = { "client_type": admin.HORIZONTAL, diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 68d30f332..f979eef1c 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -244,7 +244,7 @@ def clean(self): raise ValidationError(_("You cannot use HS256 with public grants or clients")) def get_absolute_url(self): - return reverse("oauth2_provider:detail", args=[str(self.id)]) + return reverse("oauth2_provider:detail", args=[str(self.pk)]) def get_allowed_schemes(self): """ @@ -520,7 +520,7 @@ def revoke(self): self = list(token)[0] try: - access_token_model.objects.get(id=self.access_token_id).revoke() + access_token_model.objects.get(pk=self.access_token_id).revoke() except access_token_model.DoesNotExist: pass self.access_token = None diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 4ca1479d2..78667fa0e 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -622,7 +622,7 @@ def save_bearer_token(self, token, request, *args, **kwargs): # from the db while acquiring a lock on it # We also put it in the "request cache" refresh_token_instance = RefreshToken.objects.select_for_update().get( - id=refresh_token_instance.id + pk=refresh_token_instance.pk ) request.refresh_token_instance = refresh_token_instance @@ -756,7 +756,7 @@ def get_original_scopes(self, refresh_token, request, *args, **kwargs): rt = request.refresh_token_instance if not rt.access_token_id: try: - return AccessToken.objects.get(source_refresh_token_id=rt.id).scope + return AccessToken.objects.get(source_refresh_token_id=rt.pk).scope except AccessToken.DoesNotExist: return [] return rt.access_token.scope @@ -810,9 +810,9 @@ def get_jwt_bearer_token(self, token, token_handler, request): def get_claim_dict(self, request): if self._get_additional_claims_is_request_agnostic(): - claims = {"sub": lambda r: str(r.user.id)} + claims = {"sub": lambda r: str(r.user.pk)} else: - claims = {"sub": str(request.user.id)} + claims = {"sub": str(request.user.pk)} # https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims if self._get_additional_claims_is_request_agnostic(): diff --git a/oauth2_provider/templates/oauth2_provider/application_detail.html b/oauth2_provider/templates/oauth2_provider/application_detail.html index 440518903..74b71ee74 100644 --- a/oauth2_provider/templates/oauth2_provider/application_detail.html +++ b/oauth2_provider/templates/oauth2_provider/application_detail.html @@ -49,8 +49,8 @@ <h3 class="block-center-heading">{{ application.name }}</h3> <div class="btn-toolbar"> <a class="btn" href="{% url "oauth2_provider:list" %}">{% trans "Go Back" %}</a> - <a class="btn btn-primary" href="{% url "oauth2_provider:update" application.id %}">{% trans "Edit" %}</a> - <a class="btn btn-danger" href="{% url "oauth2_provider:delete" application.id %}">{% trans "Delete" %}</a> + <a class="btn btn-primary" href="{% url "oauth2_provider:update" application.pk %}">{% trans "Edit" %}</a> + <a class="btn btn-danger" href="{% url "oauth2_provider:delete" application.pk %}">{% trans "Delete" %}</a> </div> </div> {% endblock content %} diff --git a/oauth2_provider/templates/oauth2_provider/application_form.html b/oauth2_provider/templates/oauth2_provider/application_form.html index dd8a644e8..7d8c07989 100644 --- a/oauth2_provider/templates/oauth2_provider/application_form.html +++ b/oauth2_provider/templates/oauth2_provider/application_form.html @@ -3,7 +3,7 @@ {% load i18n %} {% block content %} <div class="block-center"> - <form class="form-horizontal" method="post" action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.id %}{% endblock app-form-action-url %}"> + <form class="form-horizontal" method="post" action="{% block app-form-action-url %}{% url 'oauth2_provider:update' application.pk %}{% endblock app-form-action-url %}"> <h3 class="block-center-heading"> {% block app-form-title %} {% trans "Edit application" %} {{ application.name }} @@ -31,7 +31,7 @@ <h3 class="block-center-heading"> <div class="control-group"> <div class="controls"> - <a class="btn" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.id %}{% endblock app-form-back-url %}"> + <a class="btn" href="{% block app-form-back-url %}{% url "oauth2_provider:detail" application.pk %}{% endblock app-form-back-url %}"> {% trans "Go Back" %} </a> <button type="submit" class="btn btn-primary">{% trans "Save" %}</button> diff --git a/oauth2_provider/templates/oauth2_provider/application_list.html b/oauth2_provider/templates/oauth2_provider/application_list.html index 807c050d3..509ccfc94 100644 --- a/oauth2_provider/templates/oauth2_provider/application_list.html +++ b/oauth2_provider/templates/oauth2_provider/application_list.html @@ -7,7 +7,7 @@ <h3 class="block-center-heading">{% trans "Your applications" %}</h3> {% if applications %} <ul> {% for application in applications %} - <li><a href="{{ application.get_absolute_url }}">{{ application.name }}</a></li> + <li><a href="{% url "oauth2_provider:detail" application.pk %}">{{ application.name }}</a></li> {% endfor %} </ul> diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 8655a5b3e..4883e850c 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -53,7 +53,7 @@ def test_revoke_access_token(self): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) self.assertEqual(response.content, b"") - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_access_token_public(self): public_app = Application( @@ -101,7 +101,7 @@ def test_revoke_access_token_with_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_access_token_with_invalid_hint(self): tok = AccessToken.objects.create( @@ -123,7 +123,7 @@ def test_revoke_access_token_with_invalid_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) def test_revoke_refresh_token(self): tok = AccessToken.objects.create( @@ -146,9 +146,9 @@ def test_revoke_refresh_token(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + refresh_token = RefreshToken.objects.filter(pk=rtok.pk).first() self.assertIsNotNone(refresh_token.revoked) - self.assertFalse(AccessToken.objects.filter(id=rtok.access_token.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=rtok.access_token.pk).exists()) def test_revoke_refresh_token_with_revoked_access_token(self): tok = AccessToken.objects.create( @@ -172,8 +172,8 @@ def test_revoke_refresh_token_with_revoked_access_token(self): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) - refresh_token = RefreshToken.objects.filter(id=rtok.id).first() + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) + refresh_token = RefreshToken.objects.filter(pk=rtok.pk).first() self.assertIsNotNone(refresh_token.revoked) def test_revoke_token_with_wrong_hint(self): @@ -202,4 +202,4 @@ def test_revoke_token_with_wrong_hint(self): url = reverse("oauth2_provider:revoke-token") response = self.client.post(url, data=data) self.assertEqual(response.status_code, 200) - self.assertFalse(AccessToken.objects.filter(id=tok.id).exists()) + self.assertFalse(AccessToken.objects.filter(pk=tok.pk).exists()) From 1dcef1b30c1e575fb305946465c0841478aae5d5 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 15 Aug 2024 22:15:14 +0800 Subject: [PATCH 626/722] compat with LoginRequiredMiddleware middleware (#1454) * compat with LoginRequiredMiddleware and login_not_required --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + oauth2_provider/compat.py | 11 +++++++++++ oauth2_provider/views/base.py | 5 +++++ oauth2_provider/views/introspect.py | 6 ++++-- oauth2_provider/views/oidc.py | 5 +++++ tests/conftest.py | 11 +++++++++++ tests/test_introspection_auth.py | 3 ++- tests/test_rest_framework.py | 1 + tox.ini | 1 + 9 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d213524c..738927c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added * Add migration to include `token_checksum` field in AbstractAccessToken model. +* Added compatibility with `LoginRequiredMiddleware` introduced in Django 5.1 * #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION` ### Changed * Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims diff --git a/oauth2_provider/compat.py b/oauth2_provider/compat.py index 0c83cb37a..846e32d0e 100644 --- a/oauth2_provider/compat.py +++ b/oauth2_provider/compat.py @@ -2,3 +2,14 @@ The `compat` module provides support for backwards compatibility with older versions of Django and Python. """ + +try: + # Django 5.1 introduced LoginRequiredMiddleware, and login_not_required decorator + from django.contrib.auth.decorators import login_not_required +except ImportError: + + def login_not_required(view_func): + return view_func + + +__all__ = ["login_not_required"] diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 52cb151d5..d2644f35f 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -13,6 +13,7 @@ from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import FormView, View +from ..compat import login_not_required from ..exceptions import OAuthToolkitError from ..forms import AllowForm from ..http import OAuth2ResponseRedirect @@ -26,6 +27,8 @@ log = logging.getLogger("oauth2_provider") +# login_not_required decorator to bypass LoginRequiredMiddleware +@method_decorator(login_not_required, name="dispatch") class BaseAuthorizationView(LoginRequiredMixin, OAuthLibMixin, View): """ Implements a generic endpoint to handle *Authorization Requests* as in :rfc:`4.1.1`. The view @@ -274,6 +277,7 @@ def handle_no_permission(self): @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") class TokenView(OAuthLibMixin, View): """ Implements an endpoint to provide access tokens @@ -301,6 +305,7 @@ def post(self, request, *args, **kwargs): @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") class RevokeTokenView(OAuthLibMixin, View): """ Implements an endpoint to revoke access or refresh tokens diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 05a77909f..5474c3a7e 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -6,11 +6,13 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt -from oauth2_provider.models import get_access_token_model -from oauth2_provider.views.generic import ClientProtectedScopedResourceView +from ..compat import login_not_required +from ..models import get_access_token_model +from ..views.generic import ClientProtectedScopedResourceView @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") class IntrospectTokenView(ClientProtectedScopedResourceView): """ Implements an endpoint for token introspection based diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index c9d10c25e..c746c30ce 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -14,6 +14,7 @@ from jwcrypto.jwt import JWTExpired from oauthlib.common import add_params_to_uri +from ..compat import login_not_required from ..exceptions import ( ClientIdMissmatch, InvalidIDTokenError, @@ -39,6 +40,7 @@ Application = get_application_model() +@method_decorator(login_not_required, name="dispatch") class ConnectDiscoveryInfoView(OIDCOnlyMixin, View): """ View used to show oidc provider configuration information per @@ -106,6 +108,7 @@ def get(self, request, *args, **kwargs): return response +@method_decorator(login_not_required, name="dispatch") class JwksInfoView(OIDCOnlyMixin, View): """ View used to show oidc json web key set document @@ -134,6 +137,7 @@ def get(self, request, *args, **kwargs): @method_decorator(csrf_exempt, name="dispatch") +@method_decorator(login_not_required, name="dispatch") class UserInfoView(OIDCOnlyMixin, OAuthLibMixin, View): """ View used to show Claims about the authenticated End-User @@ -211,6 +215,7 @@ def _validate_claims(request, claims): return True +@method_decorator(login_not_required, name="dispatch") class RPInitiatedLogoutView(OIDCLogoutOnlyMixin, FormView): template_name = "oauth2_provider/logout_confirm.html" form_class = ConfirmLogoutForm diff --git a/tests/conftest.py b/tests/conftest.py index eff48f7fb..2510025ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,6 +4,7 @@ from urllib.parse import parse_qs, urlparse import pytest +from django import VERSION from django.conf import settings as test_settings from django.contrib.auth import get_user_model from django.urls import reverse @@ -294,3 +295,13 @@ def oidc_non_confidential_tokens(oauth2_settings, public_application, test_user, "openid", "http://other.org", ) + + +@pytest.fixture(autouse=True) +def django_login_required_middleware(settings, request): + if "nologinrequiredmiddleware" in request.keywords: + return + + # Django 5.1 introduced LoginRequiredMiddleware + if VERSION[0] >= 5 and VERSION[1] >= 1: + settings.MIDDLEWARE = [*settings.MIDDLEWARE, "django.contrib.auth.middleware.LoginRequiredMiddleware"] diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index 100ef064e..d96a013e3 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -11,6 +11,7 @@ from django.utils import timezone from oauthlib.common import Request +from oauth2_provider.compat import login_not_required from oauth2_provider.models import get_access_token_model, get_application_model from oauth2_provider.oauth2_validators import OAuth2Validator from oauth2_provider.settings import oauth2_settings @@ -93,7 +94,7 @@ def mocked_introspect_request_short_living_token(url, data, *args, **kwargs): urlpatterns = [ path("oauth2/", include("oauth2_provider.urls")), - path("oauth2-test-resource/", ScopeResourceView.as_view()), + path("oauth2-test-resource/", login_not_required(ScopeResourceView.as_view())), ] diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 632c62e26..84b4ad7d9 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -127,6 +127,7 @@ class AuthenticationNoneOAuth2View(MockView): @override_settings(ROOT_URLCONF=__name__) +@pytest.mark.nologinrequiredmiddleware @pytest.mark.usefixtures("oauth2_settings") @pytest.mark.oauth2_settings(presets.REST_FRAMEWORK_SCOPES) class TestOAuth2Authentication(TestCase): diff --git a/tox.ini b/tox.ini index 56d249661..42819568f 100644 --- a/tox.ini +++ b/tox.ini @@ -34,6 +34,7 @@ addopts = -s markers = oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture + nologinrequiredmiddleware [testenv] commands = From 3e0329db561e882388b2a4a0c7022e28c0a61e52 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Thu, 15 Aug 2024 22:26:31 +0800 Subject: [PATCH 627/722] check format using ruff (#1457) * check format using ruff instead of black --- .github/workflows/lint.yaml | 20 ++++++++++++++++++++ .gitignore | 1 + .pre-commit-config.yaml | 7 +++---- pyproject.toml | 15 ++++----------- tox.ini | 1 - 5 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/lint.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 000000000..fe0637ca4 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,20 @@ +name: Lint + +on: [push, pull_request] + +jobs: + ruff: + name: Ruff + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.10" + - name: Install Ruff + run: | + python -m pip install ruff>=0.5 + - name: Format check (Ruff) + run: | + ruff format --check diff --git a/.gitignore b/.gitignore index 70d81b559..d64e1776b 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ pip-log.txt .coverage .tox .pytest_cache/ +.ruff_cache/ nosetests.xml # Translations diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8a2e65601..1b5e05178 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,8 @@ repos: - - repo: https://github.com/psf/black - rev: 24.8.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.7 hooks: - - id: black - exclude: ^(oauth2_provider/migrations/|tests/migrations/) + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: diff --git a/pyproject.toml b/pyproject.toml index 884f7aec4..568f7f3de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,17 +1,10 @@ -[tool.black] -line-length = 110 -target-version = ['py38'] -exclude = ''' -^/( - oauth2_provider/migrations/ - | tests/migrations/ - | .tox -) -''' - # Ref: https://github.com/codespell-project/codespell#using-a-config-file [tool.codespell] skip = '.git,package-lock.json,locale,AUTHORS,tox.ini' check-hidden = true ignore-regex = '.*pragma: codespell-ignore.*' ignore-words-list = 'assertIn' + +[tool.ruff] +line-length = 110 +exclude = [".tox", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] diff --git a/tox.ini b/tox.ini index 42819568f..f1c07f998 100644 --- a/tox.ini +++ b/tox.ini @@ -99,7 +99,6 @@ deps = flake8 flake8-isort flake8-quotes - flake8-black [testenv:migrations] setenv = From 56149aa6ffb2eb434f25cd2312ce34cdb2bb5e8d Mon Sep 17 00:00:00 2001 From: 9128305 <129173596+9128305@users.noreply.github.com> Date: Thu, 15 Aug 2024 18:53:38 +0300 Subject: [PATCH 628/722] Make pytz optional (#1458) Make pytz optional --- setup.cfg | 1 - tox.ini | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 4f25adf1d..e2cc2a577 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,7 +38,6 @@ install_requires = requests >= 2.13.0 oauthlib >= 3.1.0 jwcrypto >= 0.8.0 - pytz >= 2024.1 [options.packages.find] exclude = diff --git a/tox.ini b/tox.ini index f1c07f998..bf945d882 100644 --- a/tox.ini +++ b/tox.ini @@ -60,6 +60,7 @@ deps = pytest-xdist pytest-mock requests + pytz; python_version < '3.9' passenv = PYTEST_ADDOPTS From 7f9085fbebc944f8cbe2fc8d9cb7b7a844c03ca3 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 16 Aug 2024 00:32:28 +0800 Subject: [PATCH 629/722] replace isort with ruff (#1459) --- .github/workflows/lint.yaml | 5 +++-- .pre-commit-config.yaml | 9 +++------ docs/contributing.rst | 11 +++++------ pyproject.toml | 7 +++++++ tox.ini | 14 -------------- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index fe0637ca4..d5137af4a 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -14,7 +14,8 @@ jobs: python-version: "3.10" - name: Install Ruff run: | - python -m pip install ruff>=0.5 - - name: Format check (Ruff) + python -m pip install ruff>=0.6 + - name: Lint using Ruff run: | ruff format --check + ruff check diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1b5e05178..ab4763002 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,7 +1,9 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.7 + rev: v0.6.0 hooks: + - id: ruff + args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 @@ -14,11 +16,6 @@ repos: - id: check-yaml - id: mixed-line-ending args: ['--fix=lf'] - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/PyCQA/flake8 rev: 7.1.1 hooks: diff --git a/docs/contributing.rst b/docs/contributing.rst index c31e72990..ca72a74a5 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -28,13 +28,12 @@ Code Style ========== The project uses `flake8 <https://flake8.pycqa.org/en/latest/>`_ for linting, -`black <https://black.readthedocs.io/en/stable/>`_ for formatting the code, -`isort <https://pycqa.github.io/isort/>`_ for formatting and sorting imports, +`ruff <https://docs.astral.sh/ruff/>`_ for formatting the code and sorting imports, and `pre-commit <https://pre-commit.com/>`_ for checking/fixing commits for correctness before they are made. You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will -take care of installing ``flake8``, ``black`` and ``isort``. +take care of installing ``flake8`` and ``ruff``. After cloning your repository, go into it and run:: @@ -42,14 +41,14 @@ After cloning your repository, go into it and run:: to install the hooks. On the next commit that you make, ``pre-commit`` will download and install the necessary hooks (a one off task). If anything in the -commit would fail the hooks, the commit will be abandoned. For ``black`` and -``isort``, any necessary changes will be made automatically, but not staged. +commit would fail the hooks, the commit will be abandoned. For ``ruff``, any +necessary changes will be made automatically, but not staged. Review the changes, and then re-stage and commit again. Using ``pre-commit`` ensures that code that would fail in QA does not make it into a commit in the first place, and will save you time in the long run. You can also (largely) stop worrying about code style, although you should always -check how the code looks after ``black`` has formatted it, and think if there +check how the code looks after ``ruff`` has formatted it, and think if there is a better way to structure the code so that it is more readable. Documentation diff --git a/pyproject.toml b/pyproject.toml index 568f7f3de..0de9634fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,3 +8,10 @@ ignore-words-list = 'assertIn' [tool.ruff] line-length = 110 exclude = [".tox", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] + +[tool.ruff.lint] +select = ["I", "Q"] + +[tool.ruff.lint.isort] +lines-after-imports = 2 +known-first-party = ["oauth2_provider"] diff --git a/tox.ini b/tox.ini index bf945d882..52a5c76de 100644 --- a/tox.ini +++ b/tox.ini @@ -98,8 +98,6 @@ skip_install = True commands = flake8 {toxinidir} deps = flake8 - flake8-isort - flake8-quotes [testenv:migrations] setenv = @@ -138,15 +136,3 @@ exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, application-import-names = oauth2_provider inline-quotes = double extend-ignore = E203, W503 - -[isort] -default_section = THIRDPARTY -known_first_party = oauth2_provider -line_length = 110 -lines_after_imports = 2 -multi_line_output = 3 -include_trailing_comma = True -force_grid_wrap = 0 -use_parentheses = True -ensure_newline_before_comments = True -skip = oauth2_provider/migrations/, .tox/, tests/migrations/ From 0706fcb24807146c205d11e84194d9e88d68dfdb Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 16 Aug 2024 00:54:19 +0800 Subject: [PATCH 630/722] CI: bum actions/setup-python to v5 (#1460) --- .github/workflows/lint.yaml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d5137af4a..6e3710489 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -9,7 +9,7 @@ jobs: steps: - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" - name: Install Ruff diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d4683cfd..d9ac5b254 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.12' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 552e21281..d7af13b60 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,7 +29,7 @@ jobs: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} From 779da2b5de0e2815fa61251cd2112ba50058be46 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 16 Aug 2024 01:02:28 +0800 Subject: [PATCH 631/722] replace flake8 with ruff (#1462) --- .pre-commit-config.yaml | 5 ----- docs/contributing.rst | 10 ++++------ pyproject.toml | 4 ++-- tox.ini | 20 +++++++------------- 4 files changed, 13 insertions(+), 26 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ab4763002..ee5e97386 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,11 +16,6 @@ repos: - id: check-yaml - id: mixed-line-ending args: ['--fix=lf'] - - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 - hooks: - - id: flake8 - exclude: ^(oauth2_provider/migrations/|tests/migrations/) - repo: https://github.com/sphinx-contrib/sphinx-lint rev: v0.9.1 hooks: diff --git a/docs/contributing.rst b/docs/contributing.rst index ca72a74a5..425008a62 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -27,13 +27,11 @@ add a comment stating you're working on it. Code Style ========== -The project uses `flake8 <https://flake8.pycqa.org/en/latest/>`_ for linting, -`ruff <https://docs.astral.sh/ruff/>`_ for formatting the code and sorting imports, -and `pre-commit <https://pre-commit.com/>`_ for checking/fixing commits for -correctness before they are made. +The project uses `ruff <https://docs.astral.sh/ruff/>`_ for linting, formatting the code and sorting imports, +and `pre-commit <https://pre-commit.com/>`_ for checking/fixing commits for correctness before they are made. You will need to install ``pre-commit`` yourself, and then ``pre-commit`` will -take care of installing ``flake8`` and ``ruff``. +take care of installing ``ruff``. After cloning your repository, go into it and run:: @@ -264,7 +262,7 @@ add a comment. If you think a function is not trivial, add a docstrings. To see if your code formatting will pass muster use:: - tox -e flake8 + tox -e lint The contents of this page are heavily based on the docs from `django-admin2 <https://github.com/twoscoops/django-admin2>`_ diff --git a/pyproject.toml b/pyproject.toml index 0de9634fd..49990b57f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,10 @@ ignore-words-list = 'assertIn' [tool.ruff] line-length = 110 -exclude = [".tox", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] +exclude = [".tox", "build/", "dist/", "docs/", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] [tool.ruff.lint] -select = ["I", "Q"] +select = ["E", "F", "I", "Q", "W"] [tool.ruff.lint.isort] lines-after-imports = 2 diff --git a/tox.ini b/tox.ini index 52a5c76de..62e199868 100644 --- a/tox.ini +++ b/tox.ini @@ -1,9 +1,9 @@ [tox] envlist = - flake8, migrations, migrate_swapped, docs, + lint, sphinxlint, py{38,39,310,311,312}-dj42, py{310,311,312}-dj50, @@ -12,7 +12,7 @@ envlist = [gh-actions] python = - 3.8: py38, docs, flake8, migrations, migrate_swapped, sphinxlint + 3.8: py38, docs, lint, migrations, migrate_swapped, sphinxlint 3.9: py39 3.10: py310 3.11: py311 @@ -92,12 +92,13 @@ deps = jwcrypto django -[testenv:flake8] +[testenv:lint] basepython = python3.8 +deps = ruff>=0.6 skip_install = True -commands = flake8 {toxinidir} -deps = - flake8 +commands = + ruff format --check + ruff check [testenv:migrations] setenv = @@ -129,10 +130,3 @@ omit = */migrations/* [coverage:report] show_missing = True - -[flake8] -max-line-length = 110 -exclude = docs/, oauth2_provider/migrations/, tests/migrations/, .tox/, build/, dist/ -application-import-names = oauth2_provider -inline-quotes = double -extend-ignore = E203, W503 From 9fceef11c59a200711d1e7023495131e42dfae0e Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Fri, 16 Aug 2024 01:18:40 +0800 Subject: [PATCH 632/722] modernize packaging using pyproject.toml (#1461) --- .github/workflows/release.yml | 6 ++--- .github/workflows/test.yml | 2 +- pyproject.toml | 49 +++++++++++++++++++++++++++++++++++ setup.cfg | 45 -------------------------------- setup.py | 6 ----- tox.ini | 7 ++--- 6 files changed, 56 insertions(+), 59 deletions(-) delete mode 100644 setup.cfg delete mode 100755 setup.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9ac5b254..64302e819 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,13 +22,11 @@ jobs: - name: Install dependencies run: | - python -m pip install -U pip - python -m pip install -U setuptools twine wheel + python -m pip install -U pip build twine - name: Build package run: | - python setup.py --version - python setup.py sdist --format=gztar bdist_wheel + python -m build twine check dist/* - name: Upload packages to Jazzband diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7af13b60..f0bf9f155 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,7 +44,7 @@ jobs: with: path: ${{ steps.pip-cache.outputs.dir }} key: - ${{ matrix.python-version }}-v1-${{ hashFiles('**/setup.py') }}-${{ hashFiles('**/tox.ini') }} + ${{ matrix.python-version }}-v1-${{ hashFiles('**/pyproject.toml') }}-${{ hashFiles('**/tox.ini') }} restore-keys: | ${{ matrix.python-version }}-v1- diff --git a/pyproject.toml b/pyproject.toml index 49990b57f..8a7eb8d9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,52 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "django-oauth-toolkit" +dynamic = ["version"] +requires-python = ">= 3.8" +authors = [ + {name = "Federico Frenguelli"}, + {name = "Massimiliano Pippi"}, + {email = "synasius@gmail.com"}, +] +description = "OAuth2 Provider for Django" +keywords = ["django", "oauth", "oauth2", "oauthlib"] +license = {file = "LICENSE"} +readme = "README.rst" +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Web Environment", + "Framework :: Django", + "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", + "Framework :: Django :: 5.1", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Internet :: WWW/HTTP", +] +dependencies = [ + "django >= 4.2", + "requests >= 2.13.0", + "oauthlib >= 3.1.0", + "jwcrypto >= 0.8.0", +] + +[project.urls] +Homepage = "https://django-oauth-toolkit.readthedocs.io/" +Repository = "https://github.com/jazzband/django-oauth-toolkit" + +[tool.setuptools.dynamic] +version = {attr = "oauth2_provider.__version__"} + # Ref: https://github.com/codespell-project/codespell#using-a-config-file [tool.codespell] skip = '.git,package-lock.json,locale,AUTHORS,tox.ini' diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index e2cc2a577..000000000 --- a/setup.cfg +++ /dev/null @@ -1,45 +0,0 @@ -[metadata] -name = django-oauth-toolkit -version = attr: oauth2_provider.__version__ -description = OAuth2 Provider for Django -long_description = file: README.rst -long_description_content_type = text/x-rst -author = Federico Frenguelli, Massimiliano Pippi -author_email = synasius@gmail.com -url = https://github.com/jazzband/django-oauth-toolkit -keywords = django, oauth, oauth2, oauthlib -classifiers = - Development Status :: 5 - Production/Stable - Environment :: Web Environment - Framework :: Django - Framework :: Django :: 4.2 - Framework :: Django :: 5.0 - Framework :: Django :: 5.1 - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Programming Language :: Python :: 3.11 - Programming Language :: Python :: 3.12 - Topic :: Internet :: WWW/HTTP - -[options] -packages = find: -include_package_data = True -zip_safe = False -python_requires = >=3.8 -# jwcrypto has a direct dependency on six, but does not list it yet in a release -# Previously, cryptography also depended on six, so this was unnoticed -install_requires = - django >= 4.2 - requests >= 2.13.0 - oauthlib >= 3.1.0 - jwcrypto >= 0.8.0 - -[options.packages.find] -exclude = - tests - tests.* diff --git a/setup.py b/setup.py deleted file mode 100755 index dd4e63e40..000000000 --- a/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env python - -from setuptools import setup - - -setup() diff --git a/tox.ini b/tox.ini index 62e199868..2372f044b 100644 --- a/tox.ini +++ b/tox.ini @@ -117,12 +117,13 @@ commands = [testenv:build] deps = - setuptools>=39.0 - wheel + build + twine allowlist_externals = rm commands = rm -rf dist - python setup.py sdist bdist_wheel + python -m build + twine check dist/* [coverage:run] source = oauth2_provider From e1cfb4ccb1023b528922ff68e2f5001b20ea485e Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Tue, 27 Aug 2024 01:08:00 +0800 Subject: [PATCH 633/722] centralize tools config in pyproject.toml (#1463) --- pyproject.toml | 20 ++++++++++++++++++++ tox.ini | 18 ------------------ 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8a7eb8d9f..51dda61f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,26 @@ check-hidden = true ignore-regex = '.*pragma: codespell-ignore.*' ignore-words-list = 'assertIn' +[tool.coverage.run] +source = ["oauth2_provider"] +omit = ["*/migrations/*"] + +[tool.coverage.report] +show_missing = true + +[tool.pytest.ini_options] +django_find_project = false +addopts = [ + "--cov=oauth2_provider", + "--cov-report=", + "--cov-append", + "-s" +] +markers = [ + "oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture", + "nologinrequiredmiddleware", +] + [tool.ruff] line-length = 110 exclude = [".tox", "build/", "dist/", "docs/", "oauth2_provider/migrations/", "tests/migrations/", "manage.py"] diff --git a/tox.ini b/tox.ini index 2372f044b..a461b5de5 100644 --- a/tox.ini +++ b/tox.ini @@ -25,17 +25,6 @@ DJANGO = 5.1: dj51 main: djmain -[pytest] -django_find_project = false -addopts = - --cov=oauth2_provider - --cov-report= - --cov-append - -s -markers = - oauth2_settings: Custom OAuth2 settings to use - use with oauth2_settings fixture - nologinrequiredmiddleware - [testenv] commands = pytest {posargs} @@ -124,10 +113,3 @@ commands = rm -rf dist python -m build twine check dist/* - -[coverage:run] -source = oauth2_provider -omit = */migrations/* - -[coverage:report] -show_missing = True From 460387af8c987a769b7684be2277ea08dcebbf79 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Tue, 27 Aug 2024 01:18:58 +0800 Subject: [PATCH 634/722] CI: remove lint job (#1464) --- .github/workflows/lint.yaml | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 .github/workflows/lint.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml deleted file mode 100644 index 6e3710489..000000000 --- a/.github/workflows/lint.yaml +++ /dev/null @@ -1,21 +0,0 @@ -name: Lint - -on: [push, pull_request] - -jobs: - ruff: - name: Ruff - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.10" - - name: Install Ruff - run: | - python -m pip install ruff>=0.6 - - name: Lint using Ruff - run: | - ruff format --check - ruff check From 3426a38384704395c944db1de7c8d082a248a826 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Tue, 27 Aug 2024 01:28:54 +0800 Subject: [PATCH 635/722] bump oauthlib to 3.2 (#1465) --- CHANGELOG.md | 1 + README.rst | 2 +- docs/index.rst | 2 +- docs/requirements.txt | 2 +- pyproject.toml | 2 +- tox.ini | 4 ++-- 6 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 738927c5d..371abb56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims * Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. * #1446 use generic models pk instead of id. +* Bump oauthlib version to 3.2.0 and above ### Deprecated ### Removed diff --git a/README.rst b/README.rst index ff94b8c62..73707e079 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ Requirements * Python 3.8+ * Django 4.2, 5.0 or 5.1 -* oauthlib 3.1+ +* oauthlib 3.2+ Installation ------------ diff --git a/docs/index.rst b/docs/index.rst index 915a4f6b8..bb224d358 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,7 +23,7 @@ Requirements * Python 3.8+ * Django 4.2, 5.0 or 5.1 -* oauthlib 3.1+ +* oauthlib 3.2+ Index ===== diff --git a/docs/requirements.txt b/docs/requirements.txt index b47039487..f5dfe94aa 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ Django -oauthlib>=3.1.0 +oauthlib>=3.2.0 m2r>=0.2.1 mistune<2 sphinx==7.2.6 diff --git a/pyproject.toml b/pyproject.toml index 51dda61f2..354645b47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ dependencies = [ "django >= 4.2", "requests >= 2.13.0", - "oauthlib >= 3.1.0", + "oauthlib >= 3.2.0", "jwcrypto >= 0.8.0", ] diff --git a/tox.ini b/tox.ini index a461b5de5..63b8b7124 100644 --- a/tox.ini +++ b/tox.ini @@ -40,7 +40,7 @@ deps = dj51: Django>=5.1,<5.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - oauthlib>=3.1.0 + oauthlib>=3.2.0 jwcrypto coverage pytest @@ -73,7 +73,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.1.0 + oauthlib>=3.2.0 m2r>=0.2.1 mistune<2 sphinx-rtd-theme From 34912ff53d948831cf4d86f210290b06c04e4d09 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 Aug 2024 14:46:49 -0400 Subject: [PATCH 636/722] [pre-commit.ci] pre-commit autoupdate (#1467) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.0 → v0.6.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.0...v0.6.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ee5e97386..d240cdf98 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.0 + rev: v0.6.2 hooks: - id: ruff args: [ --fix ] From e63999d1c782cd9c4cc4dd2642687d4704a57fb7 Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Wed, 28 Aug 2024 15:45:04 +0200 Subject: [PATCH 637/722] Work around double parsing of ui_locales (#1469) * Work around double parsing of ui_locales * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + oauth2_provider/views/base.py | 4 +++ tests/test_ui_locales.py | 57 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 tests/test_ui_locales.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 371abb56c..3dfa94c4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1443 Query strings with invalid hex values now raise a SuspiciousOperation exception (in DRF extension) instead of raising a 500 ValueError: Invalid hex encoding in query string. +* #1468 `ui_locales` request parameter triggers `AttributeError` under certain circumstances ### Security ## [2.4.0] - 2024-05-13 diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index d2644f35f..1e0d12dea 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -186,6 +186,10 @@ def get(self, request, *args, **kwargs): # a successful response depending on "approval_prompt" url parameter require_approval = request.GET.get("approval_prompt", oauth2_settings.REQUEST_APPROVAL_PROMPT) + if "ui_locales" in credentials and isinstance(credentials["ui_locales"], list): + # Make sure ui_locales a space separated string for oauthlib to handle it correctly. + credentials["ui_locales"] = " ".join(credentials["ui_locales"]) + try: # If skip_authorization field is True, skip the authorization screen even # if this is the first use of the application and there was no previous authorization. diff --git a/tests/test_ui_locales.py b/tests/test_ui_locales.py new file mode 100644 index 000000000..d375dc55c --- /dev/null +++ b/tests/test_ui_locales.py @@ -0,0 +1,57 @@ +from django.contrib.auth import get_user_model +from django.test import TestCase, override_settings +from django.urls import reverse + +from oauth2_provider.models import get_application_model + + +UserModel = get_user_model() +Application = get_application_model() + + +@override_settings( + OAUTH2_PROVIDER={ + "OIDC_ENABLED": True, + "PKCE_REQUIRED": False, + "SCOPES": { + "openid": "OpenID connect", + }, + } +) +class TestUILocalesParam(TestCase): + @classmethod + def setUpTestData(cls): + cls.application = Application.objects.create( + name="Test Application", + client_id="test", + redirect_uris="https://www.example.com/", + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + cls.trusted_application = Application.objects.create( + name="Trusted Application", + client_id="trusted", + redirect_uris="https://www.example.com/", + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + skip_authorization=True, + ) + cls.user = UserModel.objects.create_user("test_user") + cls.url = reverse("oauth2_provider:authorize") + + def setUp(self): + self.client.force_login(self.user) + + def test_application_ui_locales_param(self): + response = self.client.get( + f"{self.url}?response_type=code&client_id=test&scope=openid&ui_locales=de", + ) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, "oauth2_provider/authorize.html") + + def test_trusted_application_ui_locales_param(self): + response = self.client.get( + f"{self.url}?response_type=code&client_id=trusted&scope=openid&ui_locales=de", + ) + self.assertEqual(response.status_code, 302) + self.assertRegex(response.url, r"https://www\.example\.com/\?code=[a-zA-Z0-9]+") From 3b429c95f3e8af109d2fabae828e059ea9ea9d66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 30 Aug 2024 13:49:19 -0400 Subject: [PATCH 638/722] Bump svelte from 4.2.18 to 4.2.19 in /tests/app/rp (#1473) Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.18 to 4.2.19. - [Release notes](https://github.com/sveltejs/svelte/releases) - [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.19/packages/svelte/CHANGELOG.md) - [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.19/packages/svelte) --- updated-dependencies: - dependency-name: svelte dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 8 ++++---- tests/app/rp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 80d8b1372..627ce8af4 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -16,7 +16,7 @@ "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", - "svelte": "^4.0.0", + "svelte": "^4.2.19", "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", @@ -2128,9 +2128,9 @@ } }, "node_modules/svelte": { - "version": "4.2.18", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", - "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", + "version": "4.2.19", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", + "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index d36c7b769..8caf72fe6 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -17,7 +17,7 @@ "@sveltejs/kit": "^2.5.10", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", - "svelte": "^4.0.0", + "svelte": "^4.2.19", "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", From aede24bef889d9bc9b5947e23145245e5f2e6e12 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Sep 2024 14:17:32 -0400 Subject: [PATCH 639/722] [pre-commit.ci] pre-commit autoupdate (#1475) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d240cdf98..b124c7342 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.2 + rev: v0.6.3 hooks: - id: ruff args: [ --fix ] From 62508b4a2dc563e850145675a7d92de0a730c255 Mon Sep 17 00:00:00 2001 From: Miriam Forner <miriam.forner@krakentechnologies.ltd> Date: Tue, 3 Sep 2024 15:53:17 +0100 Subject: [PATCH 640/722] Raise InvalidGrantError if no grant associated with auth code exists (#1476) Previously, when invalidating an authorization code after it has been used, if for whatever reason the associated grant object no longer exists, an uncaught exception would be raised - Grant.DoesNotExist. This could be caused by concurrent requests being made using the same authorization token. We now handle this scenario gracefully by catching Grant.DoesNotExist and returning an InvalidGrantError. --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/oauth2_validators.py | 13 +++++++++---- tests/test_oauth2_validators.py | 20 +++++++++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 584ecf59c..ba9afa8da 100644 --- a/AUTHORS +++ b/AUTHORS @@ -119,3 +119,4 @@ pySilver Wouter Klein Heerenbrink Yaroslav Halchenko Yuri Savin +Miriam Forner diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dfa94c4b..68d7f0081 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. * #1446 use generic models pk instead of id. * Bump oauthlib version to 3.2.0 and above +* Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist. ### Deprecated ### Removed diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 78667fa0e..7cb1ecfd5 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -24,7 +24,7 @@ from jwcrypto import jws, jwt from jwcrypto.common import JWException from jwcrypto.jwt import JWTExpired -from oauthlib.oauth2.rfc6749 import utils +from oauthlib.oauth2.rfc6749 import errors, utils from oauthlib.openid import RequestValidator from .exceptions import FatalClientError @@ -318,10 +318,15 @@ def confirm_redirect_uri(self, client_id, code, redirect_uri, client, *args, **k def invalidate_authorization_code(self, client_id, code, request, *args, **kwargs): """ - Remove the temporary grant used to swap the authorization token + Remove the temporary grant used to swap the authorization token. + + :raises: InvalidGrantError if the grant does not exist. """ - grant = Grant.objects.get(code=code, application=request.client) - grant.delete() + try: + grant = Grant.objects.get(code=code, application=request.client) + grant.delete() + except Grant.DoesNotExist: + raise errors.InvalidGrantError(request=request) def validate_client_id(self, client_id, request, *args, **kwargs): """ diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index ca80aedb0..f499faf2d 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -9,9 +9,15 @@ from django.utils import timezone from jwcrypto import jwt from oauthlib.common import Request +from oauthlib.oauth2.rfc6749 import errors as rfc6749_errors from oauth2_provider.exceptions import FatalClientError -from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_grant_model, + get_refresh_token_model, +) from oauth2_provider.oauth2_backends import get_oauthlib_core from oauth2_provider.oauth2_validators import OAuth2Validator @@ -28,6 +34,7 @@ UserModel = get_user_model() Application = get_application_model() AccessToken = get_access_token_model() +Grant = get_grant_model() RefreshToken = get_refresh_token_model() CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" @@ -578,3 +585,14 @@ def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, oidc_key): validator = OAuth2Validator() status = validator.validate_id_token(token.serialize(), ["openid"], mocker.sentinel.request) assert status is False + + +@pytest.mark.django_db +def test_invalidate_authorization_token_returns_invalid_grant_error_when_grant_does_not_exist(): + client_id = "123" + code = "12345" + request = Request("/") + assert Grant.objects.all().count() == 0 + with pytest.raises(rfc6749_errors.InvalidGrantError): + validator = OAuth2Validator() + validator.invalidate_authorization_code(client_id=client_id, code=code, request=request) From 1d19e3d962ad6f4a2dfe7768504a2df96123b323 Mon Sep 17 00:00:00 2001 From: Cristian Prigoana <cristian.prigoana@yahoo.com> Date: Wed, 4 Sep 2024 18:58:27 +0100 Subject: [PATCH 641/722] bump oauthlib to 3.2.2 (#1481) --- CHANGELOG.md | 2 +- README.rst | 2 +- docs/index.rst | 2 +- docs/requirements.txt | 2 +- pyproject.toml | 2 +- tox.ini | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 68d7f0081..7acd162ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims * Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. * #1446 use generic models pk instead of id. -* Bump oauthlib version to 3.2.0 and above +* Bump oauthlib version to 3.2.2 and above * Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist. ### Deprecated diff --git a/README.rst b/README.rst index 73707e079..dee670e4b 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ Requirements * Python 3.8+ * Django 4.2, 5.0 or 5.1 -* oauthlib 3.2+ +* oauthlib 3.2.2+ Installation ------------ diff --git a/docs/index.rst b/docs/index.rst index bb224d358..07ed24314 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -23,7 +23,7 @@ Requirements * Python 3.8+ * Django 4.2, 5.0 or 5.1 -* oauthlib 3.2+ +* oauthlib 3.2.2+ Index ===== diff --git a/docs/requirements.txt b/docs/requirements.txt index f5dfe94aa..aa59757a1 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,5 +1,5 @@ Django -oauthlib>=3.2.0 +oauthlib>=3.2.2 m2r>=0.2.1 mistune<2 sphinx==7.2.6 diff --git a/pyproject.toml b/pyproject.toml index 354645b47..84e800fe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ classifiers = [ dependencies = [ "django >= 4.2", "requests >= 2.13.0", - "oauthlib >= 3.2.0", + "oauthlib >= 3.2.2", "jwcrypto >= 0.8.0", ] diff --git a/tox.ini b/tox.ini index 63b8b7124..fc1c24507 100644 --- a/tox.ini +++ b/tox.ini @@ -40,7 +40,7 @@ deps = dj51: Django>=5.1,<5.2 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - oauthlib>=3.2.0 + oauthlib>=3.2.2 jwcrypto coverage pytest @@ -73,7 +73,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.2.0 + oauthlib>=3.2.2 m2r>=0.2.1 mistune<2 sphinx-rtd-theme From 956186666803a3f78bc02d7b15ad3bf33917a95f Mon Sep 17 00:00:00 2001 From: Sean Perry <shalehperry@gmail.com> Date: Wed, 4 Sep 2024 18:29:33 -0700 Subject: [PATCH 642/722] Honor database assignment from router (#1450) * Improve multiple database support. The token models might not be stored in the default database. There might not _be_ a default database. Intead, the code now relies on Django's routers to determine the actual database to use when creating transactions. This required moving from decorators to context managers for those transactions. To test the multiple database scenario a new settings file as added which derives from settings.py and then defines different databases and the routers needed to access them. The commit is larger than might be expected because when there are multiple databases the Django tests have to be told which databases to work on. Rather than copying the various test cases or making multiple database specific ones the decision was made to add wrappers around the standard Django TestCase classes and programmatically define the databases for them. This enables all of the same test code to work for both the one database and the multi database scenarios with minimal maintenance costs. A tox environment that uses the multi db settings file has been added to ensure both scenarios are always tested. * changelog entry and authors update * PR review response. Document multiple database requires in advanced_topics.rst. Add an ImproperlyConfigured validator to the ready method of the DOTConfig app. Fix IDToken doc string. Document the use of _save_bearer_token. Define LocalIDToken and use it for validating the configuration test. Questionably, define py39-multi-db-invalid-token-configuration-dj42. This will consistently cause tox runs to fail until it is worked out how to mark this as an expected failure. * move migration * update migration * use django checks system * drop misconfigured db check. Let's find a better way. * run checks * maybe a better test definition * listing tests was breaking things * No more magic. * Oops. Debugger. * Use retrieven_current_databases in django_db marked tests. * Updates. Prove the checks work. Document test requirements. * fix typo --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- AUTHORS | 1 + CHANGELOG.md | 2 + docs/advanced_topics.rst | 11 +++ docs/contributing.rst | 20 +++++ oauth2_provider/apps.py | 4 + oauth2_provider/checks.py | 28 +++++++ oauth2_provider/models.py | 17 +++-- oauth2_provider/oauth2_validators.py | 25 ++++-- tests/common_testing.py | 33 ++++++++ tests/db_router.py | 76 +++++++++++++++++++ tests/migrations/0007_add_localidtoken.py | 34 +++++++++ tests/models.py | 7 ++ tests/multi_db_settings.py | 19 +++++ ...db_settings_invalid_token_configuration.py | 8 ++ tests/test_application_views.py | 2 +- tests/test_auth_backends.py | 4 +- tests/test_authorization_code.py | 3 +- tests/test_client_credential.py | 3 +- tests/test_commands.py | 2 +- tests/test_decorators.py | 4 +- tests/test_django_checks.py | 20 +++++ tests/test_generator.py | 3 +- tests/test_hybrid.py | 8 +- tests/test_implicit.py | 3 +- tests/test_introspection_auth.py | 3 +- tests/test_introspection_view.py | 6 +- tests/test_mixins.py | 3 +- tests/test_models.py | 15 ++-- tests/test_oauth2_backends.py | 3 +- tests/test_oauth2_validators.py | 10 ++- tests/test_oidc_views.py | 64 ++++++++-------- tests/test_password.py | 3 +- tests/test_rest_framework.py | 2 +- tests/test_scopes.py | 3 +- tests/test_settings.py | 2 +- tests/test_token_endpoint_cors.py | 3 +- tests/test_token_revocation.py | 4 +- tests/test_token_view.py | 3 +- tests/test_validators.py | 3 +- tox.ini | 7 ++ 40 files changed, 392 insertions(+), 79 deletions(-) create mode 100644 oauth2_provider/checks.py create mode 100644 tests/common_testing.py create mode 100644 tests/db_router.py create mode 100644 tests/migrations/0007_add_localidtoken.py create mode 100644 tests/multi_db_settings.py create mode 100644 tests/multi_db_settings_invalid_token_configuration.py create mode 100644 tests/test_django_checks.py diff --git a/AUTHORS b/AUTHORS index ba9afa8da..431edeabd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -102,6 +102,7 @@ Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev Sandro Rodrigues +Sean 'Shaleh' Perry Shaheed Haque Shaun Stanworth Sayyid Hamid Mahdavi diff --git a/CHANGELOG.md b/CHANGELOG.md index 7acd162ae..8ce7d2294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims * Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. * #1446 use generic models pk instead of id. +* Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct + database to use instead of assuming that 'default' is the correct one. * Bump oauthlib version to 3.2.2 and above * Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist. diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 0b2ee20b0..204e3f860 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -65,6 +65,17 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application is because of the way Django currently implements swappable models. See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. +Configuring multiple databases +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +There is no requirement that the tokens are stored in the default database or that there is a +default database provided the database routers can determine the correct Token locations. Because the +Tokens have foreign keys to the ``User`` model, you likely want to keep the tokens in the same database +as your User model. It is also important that all of the tokens are stored in the same database. +This could happen for instance if one of the Tokens is locally overridden and stored in a separate database. +The reason for this is transactions will only be made for the database where AccessToken is stored +even when writing to RefreshToken or other tokens. + Multiple Grants ~~~~~~~~~~~~~~~ diff --git a/docs/contributing.rst b/docs/contributing.rst index 425008a62..648993024 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -252,6 +252,26 @@ Open :file:`mycoverage/index.html` in your browser and you can see a coverage su There's no need to wait for Codecov to complain after you submit your PR. +The tests are generic and written to work with both single database and multiple database configurations. tox will run +tests both ways. You can see the configurations used in tests/settings.py and tests/multi_db_settings.py. + +When there are multiple databases defined, Django tests will not work unless they are told which database(s) to work with. +For test writers this means any test must either: +- instead of Django's TestCase or TransactionTestCase use the versions of those + classes defined in tests/common_testing.py +- when using pytest's `django_db` mark, define it like this: + `@pytest.mark.django_db(databases=retrieve_current_databases())` + +In test code, anywhere the database is referenced the Django router needs to be used exactly like the package's code. + +.. code-block:: python + + token_database = router.db_for_write(AccessToken) + with self.assertNumQueries(1, using=token_database): + # call something using the database + +Without the 'using' option, this test fails in the multiple database scenario because 'default' will be used instead. + Code conventions matter ----------------------- diff --git a/oauth2_provider/apps.py b/oauth2_provider/apps.py index 887e4e3fb..3ad08b715 100644 --- a/oauth2_provider/apps.py +++ b/oauth2_provider/apps.py @@ -4,3 +4,7 @@ class DOTConfig(AppConfig): name = "oauth2_provider" verbose_name = "Django OAuth Toolkit" + + def ready(self): + # Import checks to ensure they run. + from . import checks # noqa: F401 diff --git a/oauth2_provider/checks.py b/oauth2_provider/checks.py new file mode 100644 index 000000000..848ba1af7 --- /dev/null +++ b/oauth2_provider/checks.py @@ -0,0 +1,28 @@ +from django.apps import apps +from django.core import checks +from django.db import router + +from .settings import oauth2_settings + + +@checks.register(checks.Tags.database) +def validate_token_configuration(app_configs, **kwargs): + databases = set( + router.db_for_write(apps.get_model(model)) + for model in ( + oauth2_settings.ACCESS_TOKEN_MODEL, + oauth2_settings.ID_TOKEN_MODEL, + oauth2_settings.REFRESH_TOKEN_MODEL, + ) + ) + + # This is highly unlikely, but let's warn people just in case it does. + # If the tokens were allowed to be in different databases this would require all + # writes to have a transaction around each database. Instead, let's enforce that + # they all live together in one database. + # The tokens are not required to live in the default database provided the Django + # routers know the correct database for them. + if len(databases) > 1: + return [checks.Error("The token models are expected to be stored in the same database.")] + + return [] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index f979eef1c..831fc551f 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -2,6 +2,7 @@ import logging import time import uuid +from contextlib import suppress from datetime import timedelta from urllib.parse import parse_qsl, urlparse @@ -9,7 +10,7 @@ from django.conf import settings from django.contrib.auth.hashers import identify_hasher, make_password from django.core.exceptions import ImproperlyConfigured -from django.db import models, transaction +from django.db import models, router, transaction from django.urls import reverse from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -512,17 +513,19 @@ def revoke(self): Mark this refresh token revoked and revoke related access token """ access_token_model = get_access_token_model() + access_token_database = router.db_for_write(access_token_model) refresh_token_model = get_refresh_token_model() - with transaction.atomic(): + + # Use the access_token_database instead of making the assumption it is in 'default'. + with transaction.atomic(using=access_token_database): token = refresh_token_model.objects.select_for_update().filter(pk=self.pk, revoked__isnull=True) if not token: return self = list(token)[0] - try: - access_token_model.objects.get(pk=self.access_token_id).revoke() - except access_token_model.DoesNotExist: - pass + with suppress(access_token_model.DoesNotExist): + access_token_model.objects.get(id=self.access_token_id).revoke() + self.access_token = None self.revoked = timezone.now() self.save() @@ -655,7 +658,7 @@ def get_access_token_model(): def get_id_token_model(): - """Return the AccessToken model that is active in this project.""" + """Return the IDToken model that is active in this project.""" return apps.get_model(oauth2_settings.ID_TOKEN_MODEL) diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index 7cb1ecfd5..b20d0dd6c 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -15,7 +15,7 @@ from django.contrib.auth import authenticate, get_user_model from django.contrib.auth.hashers import check_password, identify_hasher from django.core.exceptions import ObjectDoesNotExist -from django.db import transaction +from django.db import router, transaction from django.http import HttpRequest from django.utils import dateformat, timezone from django.utils.crypto import constant_time_compare @@ -567,11 +567,23 @@ def rotate_refresh_token(self, request): """ return oauth2_settings.ROTATE_REFRESH_TOKEN - @transaction.atomic def save_bearer_token(self, token, request, *args, **kwargs): """ - Save access and refresh token, If refresh token is issued, remove or - reuse old refresh token as in rfc:`6` + Save access and refresh token. + + Override _save_bearer_token and not this function when adding custom logic + for the storing of these token. This allows the transaction logic to be + separate from the token handling. + """ + # Use the AccessToken's database instead of making the assumption it is in 'default'. + with transaction.atomic(using=router.db_for_write(AccessToken)): + return self._save_bearer_token(token, request, *args, **kwargs) + + def _save_bearer_token(self, token, request, *args, **kwargs): + """ + Save access and refresh token. + + If refresh token is issued, remove or reuse old refresh token as in rfc:`6`. @see: https://rfc-editor.org/rfc/rfc6749.html#section-6 """ @@ -793,7 +805,6 @@ def validate_refresh_token(self, refresh_token, client, request, *args, **kwargs return rt.application == client - @transaction.atomic def _save_id_token(self, jti, request, expires, *args, **kwargs): scopes = request.scope or " ".join(request.scopes) @@ -894,7 +905,9 @@ def finalize_id_token(self, id_token, token, token_handler, request): claims=json.dumps(id_token, default=str), ) jwt_token.make_signed_token(request.client.jwk_key) - id_token = self._save_id_token(id_token["jti"], request, expiration_time) + # Use the IDToken's database instead of making the assumption it is in 'default'. + with transaction.atomic(using=router.db_for_write(IDToken)): + id_token = self._save_id_token(id_token["jti"], request, expiration_time) # this is needed by django rest framework request.access_token = id_token request.id_token = id_token diff --git a/tests/common_testing.py b/tests/common_testing.py new file mode 100644 index 000000000..6f6a5b745 --- /dev/null +++ b/tests/common_testing.py @@ -0,0 +1,33 @@ +from django.conf import settings +from django.test import TestCase as DjangoTestCase +from django.test import TransactionTestCase as DjangoTransactionTestCase + + +# The multiple database scenario setup for these tests purposefully defines 'default' as +# an empty database in order to catch any assumptions in this package about database names +# and in particular to ensure there is no assumption that 'default' is a valid database. +# +# When there are multiple databases defined, Django tests will not work unless they are +# told which database(s) to work with. + + +def retrieve_current_databases(): + if len(settings.DATABASES) > 1: + return [name for name in settings.DATABASES if name != "default"] + else: + return ["default"] + + +class OAuth2ProviderBase: + @classmethod + def setUpClass(cls): + cls.databases = retrieve_current_databases() + super().setUpClass() + + +class OAuth2ProviderTestCase(OAuth2ProviderBase, DjangoTestCase): + """Place holder to allow overriding behaviors.""" + + +class OAuth2ProviderTransactionTestCase(OAuth2ProviderBase, DjangoTransactionTestCase): + """Place holder to allow overriding behaviors.""" diff --git a/tests/db_router.py b/tests/db_router.py new file mode 100644 index 000000000..7aa354ed8 --- /dev/null +++ b/tests/db_router.py @@ -0,0 +1,76 @@ +apps_in_beta = {"some_other_app", "this_one_too"} + +# These are bare minimum routers to fake the scenario where there is actually a +# decision around where an application's models might live. + + +class AlphaRouter: + # alpha is where the core Django models are stored including user. To keep things + # simple this is where the oauth2 provider models are stored as well because they + # have a foreign key to User. + + def db_for_read(self, model, **hints): + if model._meta.app_label not in apps_in_beta: + return "alpha" + return None + + def db_for_write(self, model, **hints): + if model._meta.app_label not in apps_in_beta: + return "alpha" + return None + + def allow_relation(self, obj1, obj2, **hints): + if obj1._state.db == "alpha" and obj2._state.db == "alpha": + return True + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): + if app_label not in apps_in_beta: + return db == "alpha" + return None + + +class BetaRouter: + def db_for_read(self, model, **hints): + if model._meta.app_label in apps_in_beta: + return "beta" + return None + + def db_for_write(self, model, **hints): + if model._meta.app_label in apps_in_beta: + return "beta" + return None + + def allow_relation(self, obj1, obj2, **hints): + if obj1._state.db == "beta" and obj2._state.db == "beta": + return True + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): + if app_label in apps_in_beta: + return db == "beta" + + +class CrossDatabaseRouter: + # alpha is where the core Django models are stored including user. To keep things + # simple this is where the oauth2 provider models are stored as well because they + # have a foreign key to User. + def db_for_read(self, model, **hints): + if model._meta.model_name == "accesstoken": + return "beta" + return None + + def db_for_write(self, model, **hints): + if model._meta.model_name == "accesstoken": + return "beta" + return None + + def allow_relation(self, obj1, obj2, **hints): + if obj1._state.db == "beta" and obj2._state.db == "beta": + return True + return None + + def allow_migrate(self, db, app_label, model_name=None, **hints): + if model_name == "accesstoken": + return db == "beta" + return None diff --git a/tests/migrations/0007_add_localidtoken.py b/tests/migrations/0007_add_localidtoken.py new file mode 100644 index 000000000..f74cce5b6 --- /dev/null +++ b/tests/migrations/0007_add_localidtoken.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.25 on 2024-08-08 22:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.OAUTH2_PROVIDER_APPLICATION_MODEL), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tests', '0006_basetestapplication_token_family'), + ] + + operations = [ + migrations.CreateModel( + name='LocalIDToken', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('jti', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='JWT Token ID')), + ('expires', models.DateTimeField()), + ('scope', models.TextField(blank=True)), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('application', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='tests_localidtoken', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/tests/models.py b/tests/models.py index 355bc1b57..9f3643db8 100644 --- a/tests/models.py +++ b/tests/models.py @@ -4,6 +4,7 @@ AbstractAccessToken, AbstractApplication, AbstractGrant, + AbstractIDToken, AbstractRefreshToken, ) from oauth2_provider.settings import oauth2_settings @@ -54,3 +55,9 @@ class SampleRefreshToken(AbstractRefreshToken): class SampleGrant(AbstractGrant): custom_field = models.CharField(max_length=255) + + +class LocalIDToken(AbstractIDToken): + """Exists to be improperly configured for multiple databases.""" + + # The other token types will be in 'alpha' database. diff --git a/tests/multi_db_settings.py b/tests/multi_db_settings.py new file mode 100644 index 000000000..a6daf04a3 --- /dev/null +++ b/tests/multi_db_settings.py @@ -0,0 +1,19 @@ +# Import the test settings and then override DATABASES. + +from .settings import * # noqa: F401, F403 + + +DATABASES = { + "alpha": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, + "beta": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, + # As https://docs.djangoproject.com/en/4.2/topics/db/multi-db/#defining-your-databases + # indicates, it is ok to have no default database. + "default": {}, +} +DATABASE_ROUTERS = ["tests.db_router.AlphaRouter", "tests.db_router.BetaRouter"] diff --git a/tests/multi_db_settings_invalid_token_configuration.py b/tests/multi_db_settings_invalid_token_configuration.py new file mode 100644 index 000000000..ed2804f79 --- /dev/null +++ b/tests/multi_db_settings_invalid_token_configuration.py @@ -0,0 +1,8 @@ +from .multi_db_settings import * # noqa: F401, F403 + + +OAUTH2_PROVIDER = { + # The other two tokens will be in alpha. This will cause a failure when the + # app's ready method is called. + "ID_TOKEN_MODEL": "tests.LocalIDToken", +} diff --git a/tests/test_application_views.py b/tests/test_application_views.py index c8c145d9b..88617807d 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -1,11 +1,11 @@ import pytest from django.contrib.auth import get_user_model -from django.test import TestCase from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views.application import ApplicationRegistration +from .common_testing import OAuth2ProviderTestCase as TestCase from .models import SampleApplication diff --git a/tests/test_auth_backends.py b/tests/test_auth_backends.py index b0ff145ab..49729b1c4 100644 --- a/tests/test_auth_backends.py +++ b/tests/test_auth_backends.py @@ -5,7 +5,7 @@ from django.contrib.auth.models import AnonymousUser from django.core.exceptions import SuspiciousOperation from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.test.utils import modify_settings, override_settings from django.utils.timezone import now, timedelta @@ -13,6 +13,8 @@ from oauth2_provider.middleware import OAuth2ExtraTokenMiddleware, OAuth2TokenMiddleware from oauth2_provider.models import get_access_token_model, get_application_model +from .common_testing import OAuth2ProviderTestCase as TestCase + UserModel = get_user_model() ApplicationModel = get_application_model() diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index ae6e7e76e..122474950 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -7,7 +7,7 @@ import pytest from django.conf import settings from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from django.utils.crypto import get_random_string @@ -23,6 +23,7 @@ from oauth2_provider.views import ProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header diff --git a/tests/test_client_credential.py b/tests/test_client_credential.py index 4c6e384d0..3572f432d 100644 --- a/tests/test_client_credential.py +++ b/tests/test_client_credential.py @@ -4,7 +4,7 @@ import pytest from django.contrib.auth import get_user_model from django.core.exceptions import SuspiciousOperation -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.views.generic import View from oauthlib.oauth2 import BackendApplicationServer @@ -16,6 +16,7 @@ from oauth2_provider.views.mixins import OAuthLibMixin from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header diff --git a/tests/test_commands.py b/tests/test_commands.py index 8861f5698..5204ebf77 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -5,11 +5,11 @@ from django.contrib.auth.hashers import check_password from django.core.management import call_command from django.core.management.base import CommandError -from django.test import TestCase from oauth2_provider.models import get_application_model from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() diff --git a/tests/test_decorators.py b/tests/test_decorators.py index a8ee788b5..f91ada2ac 100644 --- a/tests/test_decorators.py +++ b/tests/test_decorators.py @@ -1,12 +1,14 @@ from datetime import timedelta from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.utils import timezone from oauth2_provider.decorators import protected_resource, rw_protected_resource from oauth2_provider.models import get_access_token_model, get_application_model +from .common_testing import OAuth2ProviderTestCase as TestCase + Application = get_application_model() AccessToken = get_access_token_model() diff --git a/tests/test_django_checks.py b/tests/test_django_checks.py new file mode 100644 index 000000000..77025b115 --- /dev/null +++ b/tests/test_django_checks.py @@ -0,0 +1,20 @@ +from django.core.management import call_command +from django.core.management.base import SystemCheckError +from django.test import override_settings + +from .common_testing import OAuth2ProviderTestCase as TestCase + + +class DjangoChecksTestCase(TestCase): + def test_checks_pass(self): + call_command("check") + + # CrossDatabaseRouter claims AccessToken is in beta while everything else is in alpha. + # This will cause the database checks to fail. + @override_settings( + DATABASE_ROUTERS=["tests.db_router.CrossDatabaseRouter", "tests.db_router.AlphaRouter"] + ) + def test_checks_fail_when_router_crosses_databases(self): + message = "The token models are expected to be stored in the same database." + with self.assertRaisesMessage(SystemCheckError, message): + call_command("check") diff --git a/tests/test_generator.py b/tests/test_generator.py index cc7928017..201200b00 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,8 +1,9 @@ import pytest -from django.test import TestCase from oauth2_provider.generators import BaseHashGenerator, generate_client_id, generate_client_secret +from .common_testing import OAuth2ProviderTestCase as TestCase + class MockHashGenerator(BaseHashGenerator): def hash(self): diff --git a/tests/test_hybrid.py b/tests/test_hybrid.py index 40cd8c56f..67c29a54e 100644 --- a/tests/test_hybrid.py +++ b/tests/test_hybrid.py @@ -5,7 +5,7 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from jwcrypto import jwt @@ -21,6 +21,8 @@ from oauth2_provider.views import ProtectedResourceView, ScopedProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase +from .common_testing import retrieve_current_databases from .utils import get_basic_auth_header, spy_on @@ -1318,7 +1320,7 @@ def test_pre_auth_default_scopes(self): self.assertEqual(form["client_id"].value(), self.application.client_id) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_application, client, oidc_key): client.force_login(test_user) @@ -1367,7 +1369,7 @@ def test_id_token_nonce_in_token_response(oauth2_settings, test_user, hybrid_app assert claims["nonce"] == "random_nonce_string" -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_claims_passed_to_code_generation( oauth2_settings, test_user, hybrid_application, client, mocker, oidc_key diff --git a/tests/test_implicit.py b/tests/test_implicit.py index 3f16cf71f..85e773d22 100644 --- a/tests/test_implicit.py +++ b/tests/test_implicit.py @@ -3,7 +3,7 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from jwcrypto import jwt @@ -11,6 +11,7 @@ from oauth2_provider.views import ProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() diff --git a/tests/test_introspection_auth.py b/tests/test_introspection_auth.py index d96a013e3..e1a096428 100644 --- a/tests/test_introspection_auth.py +++ b/tests/test_introspection_auth.py @@ -6,7 +6,7 @@ from django.conf.urls import include from django.contrib.auth import get_user_model from django.http import HttpResponse -from django.test import TestCase, override_settings +from django.test import override_settings from django.urls import path from django.utils import timezone from oauthlib.common import Request @@ -18,6 +18,7 @@ from oauth2_provider.views import ScopedProtectedResourceView from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase try: diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index b82e922be..3db23bbcd 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -3,13 +3,14 @@ import pytest from django.contrib.auth import get_user_model -from django.test import TestCase +from django.db import router from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header @@ -343,5 +344,6 @@ def test_view_post_invalid_client_creds_plaintext(self): self.assertEqual(response.status_code, 403) def test_select_related_in_view_for_less_db_queries(self): - with self.assertNumQueries(1): + token_database = router.db_for_write(AccessToken) + with self.assertNumQueries(1, using=token_database): self.client.post(reverse("oauth2_provider:introspect")) diff --git a/tests/test_mixins.py b/tests/test_mixins.py index 327a99194..1cefa1334 100644 --- a/tests/test_mixins.py +++ b/tests/test_mixins.py @@ -3,7 +3,7 @@ import pytest from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.views.generic import View from oauthlib.oauth2 import Server @@ -18,6 +18,7 @@ ) from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase @pytest.mark.usefixtures("oauth2_settings") diff --git a/tests/test_models.py b/tests/test_models.py index 24e4ceafe..58765db69 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model from django.contrib.auth.hashers import check_password from django.core.exceptions import ImproperlyConfigured, ValidationError -from django.test import TestCase from django.test.utils import override_settings from django.utils import timezone @@ -20,6 +19,8 @@ ) from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase +from .common_testing import retrieve_current_databases CLEARTEXT_SECRET = "1234567890abcdefghijklmnopqrstuvwxyz" @@ -466,7 +467,7 @@ def test_clear_expired_tokens_with_tokens(self): assert remaining_gt_count == initial_gt_count // 2, "half the remaining grants should still exist." -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_id_token_methods(oidc_tokens, rf): id_token = IDToken.objects.get() @@ -501,7 +502,7 @@ def test_id_token_methods(oidc_tokens, rf): assert IDToken.objects.filter(jti=id_token.jti).count() == 0 -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf): id_token = IDToken.objects.get() @@ -540,7 +541,7 @@ def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf): assert not IDToken.objects.filter(jti=id_token.jti).exists() -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_key(oauth2_settings, application): # RS256 key @@ -565,7 +566,7 @@ def test_application_key(oauth2_settings, application): assert "This application does not support signed tokens" == str(exc.value) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_application_clean(oauth2_settings, application): # RS256, RSA key is configured @@ -605,7 +606,7 @@ def test_application_clean(oauth2_settings, application): application.clean() -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_DEFAULT) def test_application_origin_allowed_default_https(oauth2_settings, cors_application): """Test that http schemes are not allowed because ALLOWED_SCHEMES allows only https""" @@ -613,7 +614,7 @@ def test_application_origin_allowed_default_https(oauth2_settings, cors_applicat assert not cors_application.origin_allowed("http://example.com") -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_HTTP) def test_application_origin_allowed_http(oauth2_settings, cors_application): """Test that http schemes are allowed because http was added to ALLOWED_SCHEMES""" diff --git a/tests/test_oauth2_backends.py b/tests/test_oauth2_backends.py index 21dd7a0c3..a4408f8e6 100644 --- a/tests/test_oauth2_backends.py +++ b/tests/test_oauth2_backends.py @@ -3,12 +3,13 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.utils.timezone import now, timedelta from oauth2_provider.backends import get_oauthlib_core from oauth2_provider.models import get_access_token_model, get_application_model, redirect_to_uri_allowed from oauth2_provider.oauth2_backends import JSONOAuthLibCore, OAuthLibCore +from tests.common_testing import OAuth2ProviderTestCase as TestCase try: diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index f499faf2d..31d97f64a 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -5,7 +5,6 @@ import pytest from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password -from django.test import TestCase, TransactionTestCase from django.utils import timezone from jwcrypto import jwt from oauthlib.common import Request @@ -22,6 +21,9 @@ from oauth2_provider.oauth2_validators import OAuth2Validator from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase +from .common_testing import OAuth2ProviderTransactionTestCase as TransactionTestCase +from .common_testing import retrieve_current_databases from .utils import get_basic_auth_header @@ -552,7 +554,7 @@ def test_get_jwt_bearer_token(oauth2_settings, mocker): assert mock_get_id_token.call_args[1] == {} -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_expired_jwt(oauth2_settings, mocker, oidc_tokens): mocker.patch("oauth2_provider.oauth2_validators.jwt.JWT", side_effect=jwt.JWTExpired) @@ -568,7 +570,7 @@ def test_validate_id_token_no_token(oauth2_settings, mocker): assert status is False -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): oidc_tokens.application.delete() @@ -577,7 +579,7 @@ def test_validate_id_token_app_removed(oauth2_settings, mocker, oidc_tokens): assert status is False -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) def test_validate_id_token_bad_token_no_aud(oauth2_settings, mocker, oidc_key): token = jwt.JWT(header=json.dumps({"alg": "RS256"}), claims=json.dumps({"bad": "token"})) diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index f44a808e7..8bdf18360 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -1,7 +1,7 @@ import pytest from django.contrib.auth import get_user from django.contrib.auth.models import AnonymousUser -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from pytest_django.asserts import assertRedirects @@ -18,6 +18,8 @@ from oauth2_provider.views.oidc import RPInitiatedLogoutView, _load_id_token, _validate_claims from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase +from .common_testing import retrieve_current_databases @pytest.mark.usefixtures("oauth2_settings") @@ -220,7 +222,7 @@ def mock_request_for(user): return request -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_validate_logout_request(oidc_tokens, public_application, rp_settings): oidc_tokens = oidc_tokens application = oidc_tokens.application @@ -298,7 +300,7 @@ def test_validate_logout_request(oidc_tokens, public_application, rp_settings): ) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.parametrize("ALWAYS_PROMPT", [True, False]) def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT): rp_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT = ALWAYS_PROMPT @@ -319,14 +321,14 @@ def is_logged_in(client): return get_user(client).is_authenticated -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get(logged_in_client, rp_settings): rsp = logged_in_client.get(reverse("oauth2_provider:rp-initiated-logout"), data={}) assert rsp.status_code == 200 assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_id_token(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"id_token_hint": oidc_tokens.id_token} @@ -336,7 +338,7 @@ def test_rp_initiated_logout_get_id_token(logged_in_client, oidc_tokens, rp_sett assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_revoked_id_token(logged_in_client, oidc_tokens, rp_settings): validator = oauth2_settings.OAUTH2_VALIDATOR_CLASS() validator._load_id_token(oidc_tokens.id_token).revoke() @@ -347,7 +349,7 @@ def test_rp_initiated_logout_get_revoked_id_token(logged_in_client, oidc_tokens, assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_id_token_redirect(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), @@ -358,7 +360,7 @@ def test_rp_initiated_logout_get_id_token_redirect(logged_in_client, oidc_tokens assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_id_token_redirect_with_state(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), @@ -373,7 +375,7 @@ def test_rp_initiated_logout_get_id_token_redirect_with_state(logged_in_client, assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_id_token_missmatch_client_id( logged_in_client, oidc_tokens, public_application, rp_settings ): @@ -385,7 +387,7 @@ def test_rp_initiated_logout_get_id_token_missmatch_client_id( assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_public_client_redirect_client_id( logged_in_client, oidc_non_confidential_tokens, public_application, rp_settings ): @@ -401,7 +403,7 @@ def test_rp_initiated_logout_public_client_redirect_client_id( assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_public_client_strict_redirect_client_id( logged_in_client, oidc_non_confidential_tokens, public_application, oauth2_settings ): @@ -418,7 +420,7 @@ def test_rp_initiated_logout_public_client_strict_redirect_client_id( assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_get_client_id(logged_in_client, oidc_tokens, rp_settings): rsp = logged_in_client.get( reverse("oauth2_provider:rp-initiated-logout"), data={"client_id": oidc_tokens.application.client_id} @@ -427,7 +429,7 @@ def test_rp_initiated_logout_get_client_id(logged_in_client, oidc_tokens, rp_set assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_post(logged_in_client, oidc_tokens, rp_settings): form_data = { "client_id": oidc_tokens.application.client_id, @@ -437,7 +439,7 @@ def test_rp_initiated_logout_post(logged_in_client, oidc_tokens, rp_settings): assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_post_allowed(logged_in_client, oidc_tokens, rp_settings): form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} rsp = logged_in_client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) @@ -446,7 +448,7 @@ def test_rp_initiated_logout_post_allowed(logged_in_client, oidc_tokens, rp_sett assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_rp_initiated_logout_post_no_session(client, oidc_tokens, rp_settings): form_data = {"client_id": oidc_tokens.application.client_id, "allow": True} rsp = client.post(reverse("oauth2_provider:rp-initiated-logout"), form_data) @@ -455,7 +457,7 @@ def test_rp_initiated_logout_post_no_session(client, oidc_tokens, rp_settings): assert not is_logged_in(client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_rp_initiated_logout_expired_tokens_accept(logged_in_client, application, expired_id_token): # Accepting expired (but otherwise valid and signed by us) tokens is enabled. Logout should go through. @@ -470,7 +472,7 @@ def test_rp_initiated_logout_expired_tokens_accept(logged_in_client, application assert not is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) def test_rp_initiated_logout_expired_tokens_deny(logged_in_client, application, expired_id_token): # Expired tokens should not be accepted by default. @@ -485,14 +487,14 @@ def test_rp_initiated_logout_expired_tokens_deny(logged_in_client, application, assert is_logged_in(logged_in_client) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_load_id_token_accept_expired(expired_id_token): id_token, _ = _load_id_token(expired_id_token) assert isinstance(id_token, get_id_token_model()) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_load_id_token_wrong_aud(id_token_wrong_aud): id_token, claims = _load_id_token(id_token_wrong_aud) @@ -500,7 +502,7 @@ def test_load_id_token_wrong_aud(id_token_wrong_aud): assert claims is None -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_DENY_EXPIRED) def test_load_id_token_deny_expired(expired_id_token): id_token, claims = _load_id_token(expired_id_token) @@ -508,7 +510,7 @@ def test_load_id_token_deny_expired(expired_id_token): assert claims is None -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_validate_claims_wrong_iss(id_token_wrong_iss): id_token, claims = _load_id_token(id_token_wrong_iss) @@ -517,7 +519,7 @@ def test_validate_claims_wrong_iss(id_token_wrong_iss): assert not _validate_claims(mock_request(), claims) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT) def test_validate_claims(oidc_tokens): id_token, claims = _load_id_token(oidc_tokens.id_token) @@ -525,7 +527,7 @@ def test_validate_claims(oidc_tokens): assert _validate_claims(mock_request_for(oidc_tokens.user), claims) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.parametrize("method", ["get", "post"]) def test_userinfo_endpoint(oidc_tokens, client, method): auth_header = "Bearer %s" % oidc_tokens.access_token @@ -538,7 +540,7 @@ def test_userinfo_endpoint(oidc_tokens, client, method): assert data["sub"] == str(oidc_tokens.user.pk) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_bad_token(oidc_tokens, client): # No access token rsp = client.get(reverse("oauth2_provider:user-info")) @@ -551,7 +553,7 @@ def test_userinfo_endpoint_bad_token(oidc_tokens, client): assert rsp.status_code == 401 -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_token_deletion_on_logout(oidc_tokens, logged_in_client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() @@ -574,7 +576,7 @@ def test_token_deletion_on_logout(oidc_tokens, logged_in_client, rp_settings): assert all([token.revoked <= timezone.now() for token in RefreshToken.objects.all()]) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() @@ -615,7 +617,7 @@ def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settin assert all(token.revoked <= timezone.now() for token in RefreshToken.objects.all()) -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RP_LOGOUT_KEEP_TOKENS) def test_token_deletion_on_logout_disabled(oidc_tokens, logged_in_client, rp_settings): rp_settings.OIDC_RP_INITIATED_LOGOUT_DELETE_TOKENS = False @@ -651,7 +653,7 @@ def claim_user_email(request): return EXAMPLE_EMAIL -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_custom_claims_callable(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): oidc_claim_scope = None @@ -679,7 +681,7 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_custom_claims_email_scope_callable( oidc_email_scope_tokens, client, oauth2_settings ): @@ -706,7 +708,7 @@ def get_additional_claims(self): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_custom_claims_plain(oidc_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): oidc_claim_scope = None @@ -734,7 +736,7 @@ def get_additional_claims(self, request): assert data["email"] == EXAMPLE_EMAIL -@pytest.mark.django_db +@pytest.mark.django_db(databases=retrieve_current_databases()) def test_userinfo_endpoint_custom_claims_email_scopeplain(oidc_email_scope_tokens, client, oauth2_settings): class CustomValidator(OAuth2Validator): def get_additional_claims(self, request): diff --git a/tests/test_password.py b/tests/test_password.py index ec9f17f54..65cf5a8b5 100644 --- a/tests/test_password.py +++ b/tests/test_password.py @@ -2,12 +2,13 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from oauth2_provider.models import get_application_model from oauth2_provider.views import ProtectedResourceView +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header diff --git a/tests/test_rest_framework.py b/tests/test_rest_framework.py index 84b4ad7d9..f8ff86f23 100644 --- a/tests/test_rest_framework.py +++ b/tests/test_rest_framework.py @@ -5,7 +5,6 @@ from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured from django.http import HttpResponse -from django.test import TestCase from django.test.utils import override_settings from django.urls import path, re_path from django.utils import timezone @@ -25,6 +24,7 @@ from oauth2_provider.models import get_access_token_model, get_application_model from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase Application = get_application_model() diff --git a/tests/test_scopes.py b/tests/test_scopes.py index ec36da418..4dae0d3c4 100644 --- a/tests/test_scopes.py +++ b/tests/test_scopes.py @@ -4,12 +4,13 @@ import pytest from django.contrib.auth import get_user_model from django.core.exceptions import ImproperlyConfigured -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from oauth2_provider.models import get_access_token_model, get_application_model, get_grant_model from oauth2_provider.views import ReadWriteScopedResourceView, ScopedProtectedResourceView +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header diff --git a/tests/test_settings.py b/tests/test_settings.py index f9f540339..b64fc31db 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,6 +1,5 @@ import pytest from django.core.exceptions import ImproperlyConfigured -from django.test import TestCase from django.test.utils import override_settings from oauthlib.common import Request @@ -19,6 +18,7 @@ CustomIDTokenAdmin, CustomRefreshTokenAdmin, ) +from tests.common_testing import OAuth2ProviderTestCase as TestCase from . import presets diff --git a/tests/test_token_endpoint_cors.py b/tests/test_token_endpoint_cors.py index 791237b4a..6eaea6560 100644 --- a/tests/test_token_endpoint_cors.py +++ b/tests/test_token_endpoint_cors.py @@ -3,12 +3,13 @@ import pytest from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from oauth2_provider.models import get_application_model from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase from .utils import get_basic_auth_header diff --git a/tests/test_token_revocation.py b/tests/test_token_revocation.py index 4883e850c..fa836b6a2 100644 --- a/tests/test_token_revocation.py +++ b/tests/test_token_revocation.py @@ -1,12 +1,14 @@ import datetime from django.contrib.auth import get_user_model -from django.test import RequestFactory, TestCase +from django.test import RequestFactory from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model, get_refresh_token_model +from .common_testing import OAuth2ProviderTestCase as TestCase + Application = get_application_model() AccessToken = get_access_token_model() diff --git a/tests/test_token_view.py b/tests/test_token_view.py index fc73c2a66..63e76ed2f 100644 --- a/tests/test_token_view.py +++ b/tests/test_token_view.py @@ -1,12 +1,13 @@ import datetime from django.contrib.auth import get_user_model -from django.test import TestCase from django.urls import reverse from django.utils import timezone from oauth2_provider.models import get_access_token_model, get_application_model +from .common_testing import OAuth2ProviderTestCase as TestCase + Application = get_application_model() AccessToken = get_access_token_model() diff --git a/tests/test_validators.py b/tests/test_validators.py index a28e54a4d..eb382c154 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,9 +1,10 @@ import pytest from django.core.validators import ValidationError -from django.test import TestCase from oauth2_provider.validators import AllowedURIValidator +from .common_testing import OAuth2ProviderTestCase as TestCase + @pytest.mark.usefixtures("oauth2_settings") class TestAllowedURIValidator(TestCase): diff --git a/tox.ini b/tox.ini index fc1c24507..303b0d51d 100644 --- a/tox.ini +++ b/tox.ini @@ -9,6 +9,7 @@ envlist = py{310,311,312}-dj50, py{310,311,312}-dj51, py{310,311,312}-djmain, + py39-multi-db-dj-42 [gh-actions] python = @@ -96,6 +97,12 @@ setenv = PYTHONWARNINGS = all commands = django-admin makemigrations --dry-run --check +[testenv:py39-multi-db-dj42] +setenv = + DJANGO_SETTINGS_MODULE = tests.multi_db_settings + PYTHONPATH = {toxinidir} + PYTHONWARNINGS = all + [testenv:migrate_swapped] setenv = DJANGO_SETTINGS_MODULE = tests.settings_swapped From 72d05513f9bcc790e1388c388ceb9d70a926b95c Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Wed, 4 Sep 2024 23:05:42 -0400 Subject: [PATCH 643/722] add link to new gh discussions (#1480) * add link to new gh discussions * fix typo in link --------- Co-authored-by: Darrel O'Pry <dopry@users.noreply.github.com> --- README.rst | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index dee670e4b..e8b49d2a6 100644 --- a/README.rst +++ b/README.rst @@ -114,6 +114,12 @@ info and the open especially those labeled `help-wanted <https://github.com/jazzband/django-oauth-toolkit/labels/help-wanted>`__. +Discussions +~~~~~~~~~~~ +Have questions or want to discuss the project? +See `the discussions <https://github.com/jazzband/django-oauth-toolkit/discussions>`__. + + Submit PRs and Perform Reviews ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -134,6 +140,5 @@ release for the leads to deal with “unexpected” merged PRs. Become a Project Lead ~~~~~~~~~~~~~~~~~~~~~ -If you are interested in stepping up to be a Project Lead, please join -the -`discussion <https://github.com/orgs/jazzband/teams/django-oauth-toolkit>`__. +If you are interested in stepping up to be a Project Lead, please take a look at +the `discussion about this <https://github.com/jazzband/django-oauth-toolkit/discussions/1479>`__. From 5ce5e7fc8698bdb9956a3b98b1b5b6fb6c5670bf Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Thu, 5 Sep 2024 18:23:02 -0400 Subject: [PATCH 644/722] Release 3.0.0 Changlelog, version and minor version dependency updates. See also #1474 (#1485) --- CHANGELOG.md | 39 ++++++++++++++++++++++++------------- oauth2_provider/__init__.py | 2 +- pyproject.toml | 2 +- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ce7d2294..2ba7e52f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,29 +14,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> -## [unreleased] +## [3.0.0] - 2024-09-05 + +### WARNING - POTENTIAL BREAKING CHANGES +* Changes to the `AbstractAccessToken` model require doing a `manage.py migrate` after upgrading. +* If you use swappable models you will need to make sure your custom models are also updated (usually `manage.py makemigrations`). +* Old Django versions below 4.2 are no longer supported. +* A few deprecations warned about in 2.4.0 (#1345) have been removed. See below. + ### Added -* Add migration to include `token_checksum` field in AbstractAccessToken model. -* Added compatibility with `LoginRequiredMiddleware` introduced in Django 5.1 -* #1404 Add a new setting `REFRESH_TOKEN_REUSE_PROTECTION` +* #1366 Add Docker containerized apps for testing IDP and RP. +* #1454 Added compatibility with `LoginRequiredMiddleware` introduced in Django 5.1. + ### Changed -* Update token to TextField from CharField with 255 character limit and SHA-256 checksum in AbstractAccessToken model. Removing the 255 character limit enables supporting JWT tokens with additional claims -* Update middleware, validators, and views to use token checksums instead of token for token retrieval and validation. -* #1446 use generic models pk instead of id. -* Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct +* Many documentation and project internals improvements. +* #1446 Use generic models `pk` instead of `id`. This enables, for example, custom swapped models to have a different primary key field. +* #1447 Update token to TextField from CharField. Removing the 255 character limit enables supporting JWT tokens with additional claims. + This adds a SHA-256 `token_checksum` field that is used to validate tokens. +* #1450 Transactions wrapping writes of the Tokens now rely on Django's database routers to determine the correct database to use instead of assuming that 'default' is the correct one. -* Bump oauthlib version to 3.2.2 and above -* Update the OAuth2Validator's invalidate_authorization_code method to return an InvalidGrantError if the associated grant does not exist. +* #1455 Changed minimum supported Django version to >=4.2. -### Deprecated ### Removed * #1425 Remove deprecated `RedirectURIValidator`, `WildcardSet` per #1345; `validate_logout_request` per #1274 -* Remove support for Django versions below 4.2 ### Fixed -* #1443 Query strings with invalid hex values now raise a SuspiciousOperation exception (in DRF extension) instead of raising a 500 ValueError: Invalid hex encoding in query string. -* #1468 `ui_locales` request parameter triggers `AttributeError` under certain circumstances +* #1444, #1476 Fix several 500 errors to instead raise appropriate errors. +* #1469 Fix `ui_locales` request parameter triggers `AttributeError` under certain circumstances + ### Security +* #1452 Add a new setting [`REFRESH_TOKEN_REUSE_PROTECTION`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#refresh-token-reuse-protection). + In combination with [`ROTATE_REFRESH_TOKEN`](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#rotate-refresh-token), + this prevents refresh tokens from being used more than once. See more at + [OAuth 2.0 Security Best Current Practice](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations) +* #1481 Bump oauthlib version required to 3.2.2 and above to address [CVE-2022-36087](https://github.com/advisories/GHSA-3pgj-pg6c-r5p7). ## [2.4.0] - 2024-05-13 diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 3d67cd6bb..528787cfc 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1 +1 @@ -__version__ = "2.4.0" +__version__ = "3.0.0" diff --git a/pyproject.toml b/pyproject.toml index 84e800fe2..ccd154d4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = [ "django >= 4.2", "requests >= 2.13.0", "oauthlib >= 3.2.2", - "jwcrypto >= 0.8.0", + "jwcrypto >= 1.5.0", ] [project.urls] From f2202357615d8f27a34c5605e0a6bd27cd0908c9 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 6 Sep 2024 12:42:22 -0400 Subject: [PATCH 645/722] Fix test for changed error message from newer Django (djmain) (#1486) * fix djmain changes the error message text * remove unnecceesary verbose assert message and avoid E501 * conditionalize error message test based on Django version --- tests/test_commands.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_commands.py b/tests/test_commands.py index 5204ebf77..c4d359ce5 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -130,6 +130,8 @@ def test_application_created_with_algorithm(self): self.assertEqual(app.algorithm, "RS256") def test_validation_failed_message(self): + import django + output = StringIO() call_command( "createapplication", @@ -140,6 +142,10 @@ def test_validation_failed_message(self): stdout=output, ) - self.assertIn("user", output.getvalue()) - self.assertIn("783", output.getvalue()) - self.assertIn("does not exist", output.getvalue()) + output_str = output.getvalue() + self.assertIn("user", output_str) + self.assertIn("783", output_str) + if django.VERSION < (5, 2): + self.assertIn("does not exist", output_str) + else: + self.assertIn("is not a valid choice", output_str) From 1d19e54c926f475b4b090533cb23d184ae2f39e2 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Sat, 7 Sep 2024 08:42:59 -0400 Subject: [PATCH 646/722] 3.0.1: fix for migration error on upgrade to 3.0.0 (#1491) --- CHANGELOG.md | 4 ++++ oauth2_provider/__init__.py | 2 +- .../migrations/0012_add_token_checksum.py | 20 ++++++++++++++++--- oauth2_provider/models.py | 2 +- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ba7e52f8..483336b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Security --> +## [3.0.1] - 2024-09-07 +### Fixed +* #1491 Fix migration error when there are pre-existing Access Tokens. + ## [3.0.0] - 2024-09-05 ### WARNING - POTENTIAL BREAKING CHANGES diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 528787cfc..055276878 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1 +1 @@ -__version__ = "3.0.0" +__version__ = "3.0.1" diff --git a/oauth2_provider/migrations/0012_add_token_checksum.py b/oauth2_provider/migrations/0012_add_token_checksum.py index 7f62955e3..476c3b402 100644 --- a/oauth2_provider/migrations/0012_add_token_checksum.py +++ b/oauth2_provider/migrations/0012_add_token_checksum.py @@ -4,6 +4,16 @@ from django.db import migrations, models from oauth2_provider.settings import oauth2_settings +def forwards_func(apps, schema_editor): + """ + Forward migration touches every "old" accesstoken.token which will cause the checksum to be computed. + """ + AccessToken = apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) + accesstokens = AccessToken._default_manager.all() + for accesstoken in accesstokens: + accesstoken.save(update_fields=['token_checksum']) + + class Migration(migrations.Migration): dependencies = [ ("oauth2_provider", "0011_refreshtoken_token_family"), @@ -14,13 +24,17 @@ class Migration(migrations.Migration): migrations.AddField( model_name="accesstoken", name="token_checksum", - field=oauth2_provider.models.TokenChecksumField( - blank=True, db_index=True, max_length=64, unique=True - ), + field=oauth2_provider.models.TokenChecksumField(blank=True, null=True, max_length=64), ), migrations.AlterField( model_name="accesstoken", name="token", field=models.TextField(), ), + migrations.RunPython(forwards_func, migrations.RunPython.noop), + migrations.AlterField( + model_name='accesstoken', + name='token_checksum', + field=oauth2_provider.models.TokenChecksumField(blank=False, max_length=64, db_index=True, unique=True), + ), ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 831fc551f..a987b4435 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -392,7 +392,7 @@ class AbstractAccessToken(models.Model): token = models.TextField() token_checksum = TokenChecksumField( max_length=64, - blank=True, + blank=False, unique=True, db_index=True, ) From 90d7300c06be80e565c6557f0b8f260968748634 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:08:14 -0400 Subject: [PATCH 647/722] [pre-commit.ci] pre-commit autoupdate (#1492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.3 → v0.6.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.3...v0.6.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b124c7342..371333ad6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.3 + rev: v0.6.4 hooks: - id: ruff args: [ --fix ] From a1538231ce6d076d42b63cb358dfec91d3c10740 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Fri, 20 Sep 2024 11:39:20 -0400 Subject: [PATCH 648/722] deal with 404 or 405 validator error (#1499) * deal with 404 or 405 validator error (apparently varies with version of django) * refactor: more precise test name * mock the post request instead of POSTing to example.com --------- Co-authored-by: Darrel O'Pry <darrel.opry@spry-group.com> --- tests/test_oauth2_validators.py | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 31d97f64a..14c74506e 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -3,6 +3,7 @@ import json import pytest +import requests from django.contrib.auth import get_user_model from django.contrib.auth.hashers import make_password from django.utils import timezone @@ -501,18 +502,26 @@ def setUpTestData(cls): cls.introspection_token = "test_introspection_token" cls.validator = OAuth2Validator() - def test_response_when_auth_server_response_return_404(self): - with self.assertLogs(logger="oauth2_provider") as mock_log: - self.validator._get_token_from_authentication_server( - self.token, self.introspection_url, self.introspection_token, None - ) - self.assertIn( - "ERROR:oauth2_provider:Introspection: Failed to " - "get a valid response from authentication server. " - "Status code: 404, Reason: " - "Not Found.\nNoneType: None", - mock_log.output, - ) + def test_response_when_auth_server_response_not_200(self): + """ + Ensure we log the error when the authentication server returns a non-200 response. + """ + mock_response = requests.Response() + mock_response.status_code = 404 + mock_response.reason = "Not Found" + with mock.patch("requests.post") as mock_post: + mock_post.return_value = mock_response + with self.assertLogs(logger="oauth2_provider") as mock_log: + self.validator._get_token_from_authentication_server( + self.token, self.introspection_url, self.introspection_token, None + ) + self.assertIn( + "ERROR:oauth2_provider:Introspection: Failed to " + "get a valid response from authentication server. " + "Status code: 404, Reason: " + "Not Found.\nNoneType: None", + mock_log.output, + ) @pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) From 88f052634a3f958726880310e7a7f99f5a3f8a44 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 20 Sep 2024 11:55:25 -0400 Subject: [PATCH 649/722] [pre-commit.ci] pre-commit autoupdate (#1497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.4 → v0.6.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.4...v0.6.5) - [github.com/sphinx-contrib/sphinx-lint: v0.9.1 → v1.0.0](https://github.com/sphinx-contrib/sphinx-lint/compare/v0.9.1...v1.0.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 371333ad6..a29f52aea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.4 + rev: v0.6.5 hooks: - id: ruff args: [ --fix ] @@ -17,7 +17,7 @@ repos: - id: mixed-line-ending args: ['--fix=lf'] - repo: https://github.com/sphinx-contrib/sphinx-lint - rev: v0.9.1 + rev: v1.0.0 hooks: - id: sphinx-lint # Configuration for codespell is in pyproject.toml From 610177e8840e3eec6aeca011478de5d44f149aa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:05:24 -0400 Subject: [PATCH 650/722] Bump vite from 5.2.13 to 5.4.6 in /tests/app/rp (#1500) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.13 to 5.4.6. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 364 +++++++++++++++++---------------- tests/app/rp/package.json | 2 +- 2 files changed, 185 insertions(+), 181 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 627ce8af4..b1836da61 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -20,7 +20,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.3" + "vite": "^5.4.6" } }, "node_modules/@ampproject/remapping": { @@ -45,9 +45,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -61,9 +61,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -77,9 +77,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -93,9 +93,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -109,9 +109,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -125,9 +125,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -141,9 +141,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -157,9 +157,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -173,9 +173,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -189,9 +189,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -205,9 +205,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -221,9 +221,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -237,9 +237,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -253,9 +253,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -269,9 +269,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -285,9 +285,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -301,9 +301,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -317,9 +317,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -333,9 +333,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -349,9 +349,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -365,9 +365,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -381,9 +381,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -397,9 +397,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -656,9 +656,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", - "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", + "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", "cpu": [ "arm" ], @@ -669,9 +669,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", - "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", + "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", "cpu": [ "arm64" ], @@ -682,9 +682,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", - "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", + "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", - "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", + "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", "cpu": [ "x64" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", - "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", + "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", "cpu": [ "arm" ], @@ -721,9 +721,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", - "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", + "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", "cpu": [ "arm" ], @@ -734,9 +734,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", - "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", + "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", "cpu": [ "arm64" ], @@ -747,9 +747,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", - "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", + "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", - "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", + "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", "cpu": [ "ppc64" ], @@ -773,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", - "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", + "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", "cpu": [ "riscv64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", - "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", + "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", "cpu": [ "s390x" ], @@ -799,9 +799,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", - "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", + "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", "cpu": [ "x64" ], @@ -812,9 +812,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", - "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", + "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", "cpu": [ "x64" ], @@ -825,9 +825,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", - "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", + "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", "cpu": [ "arm64" ], @@ -838,9 +838,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", - "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", + "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", "cpu": [ "ia32" ], @@ -851,9 +851,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", - "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", + "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", "cpu": [ "x64" ], @@ -1274,9 +1274,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -1286,29 +1286,29 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/esm-env": { @@ -1791,9 +1791,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true }, "node_modules/picomatch": { @@ -1809,9 +1809,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -1829,8 +1829,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -1951,9 +1951,9 @@ } }, "node_modules/rollup": { - "version": "4.18.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", - "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", + "version": "4.21.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", + "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -1966,22 +1966,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.18.0", - "@rollup/rollup-android-arm64": "4.18.0", - "@rollup/rollup-darwin-arm64": "4.18.0", - "@rollup/rollup-darwin-x64": "4.18.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", - "@rollup/rollup-linux-arm-musleabihf": "4.18.0", - "@rollup/rollup-linux-arm64-gnu": "4.18.0", - "@rollup/rollup-linux-arm64-musl": "4.18.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", - "@rollup/rollup-linux-riscv64-gnu": "4.18.0", - "@rollup/rollup-linux-s390x-gnu": "4.18.0", - "@rollup/rollup-linux-x64-gnu": "4.18.0", - "@rollup/rollup-linux-x64-musl": "4.18.0", - "@rollup/rollup-win32-arm64-msvc": "4.18.0", - "@rollup/rollup-win32-ia32-msvc": "4.18.0", - "@rollup/rollup-win32-x64-msvc": "4.18.0", + "@rollup/rollup-android-arm-eabi": "4.21.3", + "@rollup/rollup-android-arm64": "4.21.3", + "@rollup/rollup-darwin-arm64": "4.21.3", + "@rollup/rollup-darwin-x64": "4.21.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", + "@rollup/rollup-linux-arm-musleabihf": "4.21.3", + "@rollup/rollup-linux-arm64-gnu": "4.21.3", + "@rollup/rollup-linux-arm64-musl": "4.21.3", + "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", + "@rollup/rollup-linux-riscv64-gnu": "4.21.3", + "@rollup/rollup-linux-s390x-gnu": "4.21.3", + "@rollup/rollup-linux-x64-gnu": "4.21.3", + "@rollup/rollup-linux-x64-musl": "4.21.3", + "@rollup/rollup-win32-arm64-msvc": "4.21.3", + "@rollup/rollup-win32-ia32-msvc": "4.21.3", + "@rollup/rollup-win32-x64-msvc": "4.21.3", "fsevents": "~2.3.2" } }, @@ -2095,9 +2095,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -2312,14 +2312,14 @@ } }, "node_modules/vite": { - "version": "5.2.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.13.tgz", - "integrity": "sha512-SSq1noJfY9pR3I1TUENL3rQYDQCFqgD+lM6fTRAM8Nv6Lsg5hDLaXkjETVeBt+7vZBCMoibD+6IWnT2mJ+Zb/A==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", - "rollup": "^4.13.0" + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" @@ -2338,6 +2338,7 @@ "less": "*", "lightningcss": "^1.21.0", "sass": "*", + "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" @@ -2355,6 +2356,9 @@ "sass": { "optional": true }, + "sass-embedded": { + "optional": true + }, "stylus": { "optional": true }, diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 8caf72fe6..7f784006f 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -21,7 +21,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.0.3" + "vite": "^5.4.6" }, "type": "module", "dependencies": { From e34819a51da022279c4b0b14dfdae8d01adf5d27 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 20 Sep 2024 12:13:38 -0400 Subject: [PATCH 651/722] feat: VS Code Testing Activity Support (#1501) --- .env | 2 ++ .vscode/settings.json | 8 ++++++++ docs/contributing.rst | 21 +++++++++++++++++++++ pyproject.toml | 8 ++++++++ 4 files changed, 39 insertions(+) create mode 100644 .env create mode 100644 .vscode/settings.json diff --git a/.env b/.env new file mode 100644 index 000000000..dc223bf0b --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +# required for vscode testing activity to discover tests +DJANGO_SETTINGS_MODULE=tests.settings \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..fee847fe4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,8 @@ +{ + "python.testing.pytestArgs": [ + "tests", + "--no-cov" + ], + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/docs/contributing.rst b/docs/contributing.rst index 648993024..4f0b88b32 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -272,6 +272,27 @@ In test code, anywhere the database is referenced the Django router needs to be Without the 'using' option, this test fails in the multiple database scenario because 'default' will be used instead. +Debugging the Tests Interactively +--------------------------------- + +Interactive Debugging allows you to set breakpoints and inspect the state of the program at runtime. We strongly +recommend using an interactive debugger to streamline your development process. + +VS Code +^^^^^^^ + +VS Code is a popular IDE that supports debugging Python code. You can debug the tests interactively in VS Code by +following these steps: + +.. code-block:: bash + + pip install .[dev] + # open the project in VS Code + # click Testing (erlenmeyer flask) on the Activity Bar + # select the test you want to run or debug + + + Code conventions matter ----------------------- diff --git a/pyproject.toml b/pyproject.toml index ccd154d4d..401d33cab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,14 @@ dependencies = [ "jwcrypto >= 1.5.0", ] +[project.optional-dependencies] +dev = [ + "pytest", + "pytest-cov", + "m2r", + "sphinx-rtd-theme", +] + [project.urls] Homepage = "https://django-oauth-toolkit.readthedocs.io/" Repository = "https://github.com/jazzband/django-oauth-toolkit" From 937ae211d7c239cde79359ca85881cad5177dbbb Mon Sep 17 00:00:00 2001 From: Matej Spiller Muys <matej.spiller@gmail.com> Date: Sun, 22 Sep 2024 19:56:25 +0200 Subject: [PATCH 652/722] Support for specifying client secret hasher (#1498) Co-authored-by: Matej Spiller Muys <matej.spiller-muys@bitstamp.net> --- AUTHORS | 1 + CHANGELOG.md | 1 + docs/settings.rst | 4 ++++ oauth2_provider/models.py | 2 +- oauth2_provider/settings.py | 1 + tests/custom_hasher.py | 10 ++++++++++ tests/settings.py | 2 ++ tests/test_models.py | 16 ++++++++++++++++ 8 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 tests/custom_hasher.py diff --git a/AUTHORS b/AUTHORS index 431edeabd..d10ff1fb4 100644 --- a/AUTHORS +++ b/AUTHORS @@ -86,6 +86,7 @@ Ludwig Hähne Łukasz Skarżyński Madison Swain-Bowden Marcus Sonestedt +Matej Spiller Muys Matias Seniquiel Michael Howitz Owen Gong diff --git a/CHANGELOG.md b/CHANGELOG.md index 483336b04..39e11d4b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 <!-- ## [unreleased] ### Added +* Support for specifying client secret hasher via CLIENT_SECRET_HASHER setting. ### Changed ### Deprecated ### Removed diff --git a/docs/settings.rst b/docs/settings.rst index 4ebe6cc47..0b76129f9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -104,6 +104,10 @@ CLIENT_SECRET_GENERATOR_LENGTH The length of the generated secrets, in characters. If this value is too low, secrets may become subject to bruteforce guessing. +CLIENT_SECRET_HASHER +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The hasher for storing generated secrets. By default library will use the first hasher in PASSWORD_HASHERS. + EXTRA_SERVER_KWARGS ~~~~~~~~~~~~~~~~~~~ A dictionary to be passed to oauthlib's Server class. Three options diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index a987b4435..621ce5b34 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -40,7 +40,7 @@ def pre_save(self, model_instance, add): logger.debug(f"{model_instance}: {self.attname} is already hashed with {hasher}.") except ValueError: logger.debug(f"{model_instance}: {self.attname} is not hashed; hashing it now.") - hashed_secret = make_password(secret) + hashed_secret = make_password(secret, hasher=oauth2_settings.CLIENT_SECRET_HASHER) setattr(model_instance, self.attname, hashed_secret) return hashed_secret return super().pre_save(model_instance, add) diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index 329a1b354..f5a6a25d6 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -37,6 +37,7 @@ "CLIENT_ID_GENERATOR_CLASS": "oauth2_provider.generators.ClientIdGenerator", "CLIENT_SECRET_GENERATOR_CLASS": "oauth2_provider.generators.ClientSecretGenerator", "CLIENT_SECRET_GENERATOR_LENGTH": 128, + "CLIENT_SECRET_HASHER": "default", "ACCESS_TOKEN_GENERATOR": None, "REFRESH_TOKEN_GENERATOR": None, "EXTRA_SERVER_KWARGS": {}, diff --git a/tests/custom_hasher.py b/tests/custom_hasher.py new file mode 100644 index 000000000..5f7ceb89c --- /dev/null +++ b/tests/custom_hasher.py @@ -0,0 +1,10 @@ +from django.contrib.auth.hashers import PBKDF2PasswordHasher + + +class MyPBKDF2PasswordHasher(PBKDF2PasswordHasher): + """ + A subclass of PBKDF2PasswordHasher that uses less iterations. + """ + + algorithm = "fast_pbkdf2" + iterations = 10000 diff --git a/tests/settings.py b/tests/settings.py index db807947c..c4d9f59ad 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -89,6 +89,8 @@ "tests", ) +PASSWORD_HASHERS = django.conf.settings.PASSWORD_HASHERS + ["tests.custom_hasher.MyPBKDF2PasswordHasher"] + LOGGING = { "version": 1, "disable_existing_loggers": False, diff --git a/tests/test_models.py b/tests/test_models.py index 58765db69..123c41b35 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -72,6 +72,22 @@ def test_hashed_secret(self): self.assertNotEqual(app.client_secret, CLEARTEXT_SECRET) self.assertTrue(check_password(CLEARTEXT_SECRET, app.client_secret)) + @override_settings(OAUTH2_PROVIDER={"CLIENT_SECRET_HASHER": "fast_pbkdf2"}) + def test_hashed_from_settings(self): + app = Application.objects.create( + name="test_app", + redirect_uris="http://localhost http://example.com http://example.org", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + client_secret=CLEARTEXT_SECRET, + hash_client_secret=True, + ) + + self.assertNotEqual(app.client_secret, CLEARTEXT_SECRET) + self.assertIn("fast_pbkdf2", app.client_secret) + self.assertTrue(check_password(CLEARTEXT_SECRET, app.client_secret)) + def test_unhashed_secret(self): app = Application.objects.create( name="test_app", From a9f821dbe58eaa030bc70e8a09e34a3b8b46f9fd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Sep 2024 16:31:49 -0400 Subject: [PATCH 653/722] [pre-commit.ci] pre-commit autoupdate (#1504) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.5 → v0.6.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.5...v0.6.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a29f52aea..7311d2632 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.5 + rev: v0.6.7 hooks: - id: ruff args: [ --fix ] From 631291d5acebd66dd94d6fcb966d024b6a6cb64f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Sep 2024 06:04:58 -0400 Subject: [PATCH 654/722] Bump rollup from 4.21.3 to 4.22.4 in /tests/app/rp (#1505) --- tests/app/rp/package-lock.json | 134 ++++++++++++++++----------------- 1 file changed, 67 insertions(+), 67 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index b1836da61..6ab5dc90e 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -656,9 +656,9 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.21.3.tgz", - "integrity": "sha512-MmKSfaB9GX+zXl6E8z4koOr/xU63AMVleLEa64v7R0QF/ZloMs5vcD1sHgM64GXXS1csaJutG+ddtzcueI/BLg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", + "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", "cpu": [ "arm" ], @@ -669,9 +669,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.21.3.tgz", - "integrity": "sha512-zrt8ecH07PE3sB4jPOggweBjJMzI1JG5xI2DIsUbkA+7K+Gkjys6eV7i9pOenNSDJH3eOr/jLb/PzqtmdwDq5g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", + "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", "cpu": [ "arm64" ], @@ -682,9 +682,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.21.3.tgz", - "integrity": "sha512-P0UxIOrKNBFTQaXTxOH4RxuEBVCgEA5UTNV6Yz7z9QHnUJ7eLX9reOd/NYMO3+XZO2cco19mXTxDMXxit4R/eQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", + "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", "cpu": [ "arm64" ], @@ -695,9 +695,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.21.3.tgz", - "integrity": "sha512-L1M0vKGO5ASKntqtsFEjTq/fD91vAqnzeaF6sfNAy55aD+Hi2pBI5DKwCO+UNDQHWsDViJLqshxOahXyLSh3EA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", + "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", "cpu": [ "x64" ], @@ -708,9 +708,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.21.3.tgz", - "integrity": "sha512-btVgIsCjuYFKUjopPoWiDqmoUXQDiW2A4C3Mtmp5vACm7/GnyuprqIDPNczeyR5W8rTXEbkmrJux7cJmD99D2g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", + "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", "cpu": [ "arm" ], @@ -721,9 +721,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.21.3.tgz", - "integrity": "sha512-zmjbSphplZlau6ZTkxd3+NMtE4UKVy7U4aVFMmHcgO5CUbw17ZP6QCgyxhzGaU/wFFdTfiojjbLG3/0p9HhAqA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", + "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", "cpu": [ "arm" ], @@ -734,9 +734,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.21.3.tgz", - "integrity": "sha512-nSZfcZtAnQPRZmUkUQwZq2OjQciR6tEoJaZVFvLHsj0MF6QhNMg0fQ6mUOsiCUpTqxTx0/O6gX0V/nYc7LrgPw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", + "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", "cpu": [ "arm64" ], @@ -747,9 +747,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.21.3.tgz", - "integrity": "sha512-MnvSPGO8KJXIMGlQDYfvYS3IosFN2rKsvxRpPO2l2cum+Z3exiExLwVU+GExL96pn8IP+GdH8Tz70EpBhO0sIQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", + "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", "cpu": [ "arm64" ], @@ -760,9 +760,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.21.3.tgz", - "integrity": "sha512-+W+p/9QNDr2vE2AXU0qIy0qQE75E8RTwTwgqS2G5CRQ11vzq0tbnfBd6brWhS9bCRjAjepJe2fvvkvS3dno+iw==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", + "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", "cpu": [ "ppc64" ], @@ -773,9 +773,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.21.3.tgz", - "integrity": "sha512-yXH6K6KfqGXaxHrtr+Uoy+JpNlUlI46BKVyonGiaD74ravdnF9BUNC+vV+SIuB96hUMGShhKV693rF9QDfO6nQ==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", + "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", "cpu": [ "riscv64" ], @@ -786,9 +786,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.21.3.tgz", - "integrity": "sha512-R8cwY9wcnApN/KDYWTH4gV/ypvy9yZUHlbJvfaiXSB48JO3KpwSpjOGqO4jnGkLDSk1hgjYkTbTt6Q7uvPf8eg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", + "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", "cpu": [ "s390x" ], @@ -799,9 +799,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.21.3.tgz", - "integrity": "sha512-kZPbX/NOPh0vhS5sI+dR8L1bU2cSO9FgxwM8r7wHzGydzfSjLRCFAT87GR5U9scj2rhzN3JPYVC7NoBbl4FZ0g==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", + "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", "cpu": [ "x64" ], @@ -812,9 +812,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.21.3.tgz", - "integrity": "sha512-S0Yq+xA1VEH66uiMNhijsWAafffydd2X5b77eLHfRmfLsRSpbiAWiRHV6DEpz6aOToPsgid7TI9rGd6zB1rhbg==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", + "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", "cpu": [ "x64" ], @@ -825,9 +825,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.21.3.tgz", - "integrity": "sha512-9isNzeL34yquCPyerog+IMCNxKR8XYmGd0tHSV+OVx0TmE0aJOo9uw4fZfUuk2qxobP5sug6vNdZR6u7Mw7Q+Q==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", + "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", "cpu": [ "arm64" ], @@ -838,9 +838,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.21.3.tgz", - "integrity": "sha512-nMIdKnfZfzn1Vsk+RuOvl43ONTZXoAPUUxgcU0tXooqg4YrAqzfKzVenqqk2g5efWh46/D28cKFrOzDSW28gTA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", + "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", "cpu": [ "ia32" ], @@ -851,9 +851,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.21.3.tgz", - "integrity": "sha512-fOvu7PCQjAj4eWDEuD8Xz5gpzFqXzGlxHZozHP4b9Jxv9APtdxL6STqztDzMLuRXEc4UpXGGhx029Xgm91QBeA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", + "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", "cpu": [ "x64" ], @@ -1951,9 +1951,9 @@ } }, "node_modules/rollup": { - "version": "4.21.3", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.21.3.tgz", - "integrity": "sha512-7sqRtBNnEbcBtMeRVc6VRsJMmpI+JU1z9VTvW8D4gXIYQFz0aLcsE6rRkyghZkLfEgUZgVvOG7A5CVz/VW5GIA==", + "version": "4.22.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", + "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -1966,22 +1966,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.21.3", - "@rollup/rollup-android-arm64": "4.21.3", - "@rollup/rollup-darwin-arm64": "4.21.3", - "@rollup/rollup-darwin-x64": "4.21.3", - "@rollup/rollup-linux-arm-gnueabihf": "4.21.3", - "@rollup/rollup-linux-arm-musleabihf": "4.21.3", - "@rollup/rollup-linux-arm64-gnu": "4.21.3", - "@rollup/rollup-linux-arm64-musl": "4.21.3", - "@rollup/rollup-linux-powerpc64le-gnu": "4.21.3", - "@rollup/rollup-linux-riscv64-gnu": "4.21.3", - "@rollup/rollup-linux-s390x-gnu": "4.21.3", - "@rollup/rollup-linux-x64-gnu": "4.21.3", - "@rollup/rollup-linux-x64-musl": "4.21.3", - "@rollup/rollup-win32-arm64-msvc": "4.21.3", - "@rollup/rollup-win32-ia32-msvc": "4.21.3", - "@rollup/rollup-win32-x64-msvc": "4.21.3", + "@rollup/rollup-android-arm-eabi": "4.22.4", + "@rollup/rollup-android-arm64": "4.22.4", + "@rollup/rollup-darwin-arm64": "4.22.4", + "@rollup/rollup-darwin-x64": "4.22.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", + "@rollup/rollup-linux-arm-musleabihf": "4.22.4", + "@rollup/rollup-linux-arm64-gnu": "4.22.4", + "@rollup/rollup-linux-arm64-musl": "4.22.4", + "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", + "@rollup/rollup-linux-riscv64-gnu": "4.22.4", + "@rollup/rollup-linux-s390x-gnu": "4.22.4", + "@rollup/rollup-linux-x64-gnu": "4.22.4", + "@rollup/rollup-linux-x64-musl": "4.22.4", + "@rollup/rollup-win32-arm64-msvc": "4.22.4", + "@rollup/rollup-win32-ia32-msvc": "4.22.4", + "@rollup/rollup-win32-x64-msvc": "4.22.4", "fsevents": "~2.3.2" } }, From b48fd8bf21af530c696eb08cd99161e794ec4f3d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Sep 2024 15:08:15 -0400 Subject: [PATCH 655/722] [pre-commit.ci] pre-commit autoupdate (#1509) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.7 → v0.6.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.7...v0.6.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7311d2632..f2046a327 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.7 + rev: v0.6.8 hooks: - id: ruff args: [ --fix ] From 13f0aceb010eececfb581a0e21f013f47a8f5f6b Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Wed, 2 Oct 2024 09:27:28 -0400 Subject: [PATCH 656/722] feat: allowed_origins and redirect_uris wildcards (#1508) --- CHANGELOG.md | 8 +- docs/settings.rst | 31 +++++++ oauth2_provider/models.py | 65 +++++++++----- oauth2_provider/settings.py | 1 + oauth2_provider/validators.py | 62 ++++++++++++- tests/test_application_views.py | 150 ++++++++++++++++++++++++++++++++ tests/test_models.py | 106 ++++++++++++++++++++++ tests/test_validators.py | 24 +++++ 8 files changed, 422 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39e11d4b4..38b1d8b78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,16 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -<!-- + ## [unreleased] ### Added -* Support for specifying client secret hasher via CLIENT_SECRET_HASHER setting. +* Support for Wildcard Origin and Redirect URIs, https://github.com/jazzband/django-oauth-toolkit/issues/1506 +<!-- ### Changed ### Deprecated ### Removed ### Fixed ### Security - --> +--> + ## [3.0.1] - 2024-09-07 ### Fixed diff --git a/docs/settings.rst b/docs/settings.rst index 0b76129f9..545736ccb 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -63,6 +63,37 @@ assigned ports. Note that you may override ``Application.get_allowed_schemes()`` to set this on a per-application basis. +ALLOW_URI_WILDCARDS +~~~~~~~~~~~~~~~~~~~ + +Default: ``False`` + +SECURITY WARNING: Enabling this setting can introduce security vulnerabilities. Only enable +this setting if you understand the risks. https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 +states "The redirection endpoint URI MUST be an absolute URI as defined by [RFC3986] Section 4.3." The +intent of the URI restrictions is to prevent open redirects and phishing attacks. If you do enable this +ensure that the wildcards restrict URIs to resources under your control. You are strongly encouragd not +to use this feature in production. + +When set to ``True``, the server will allow wildcard characters in the domains for allowed_origins and +redirect_uris. + +``*`` is the only wildcard character allowed. + +``*`` can only be used as a prefix to a domain, must be the first character in +the domain, and cannot be in the top or second level domain. Matching is done using an +endsWith check. + +For example, +``https://*.example.com`` is allowed, +``https://*-myproject.example.com`` is allowed, +``https://*.sub.example.com`` is not allowed, +``https://*.com`` is not allowed, and +``https://example.*.com`` is not allowed. + +This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch +deployments for development previews and user acceptance testing. + ALLOWED_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 621ce5b34..0467ddfa1 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -213,7 +213,11 @@ def clean(self): if redirect_uris: validator = AllowedURIValidator( - allowed_schemes, name="redirect uri", allow_path=True, allow_query=True + allowed_schemes, + name="redirect uri", + allow_path=True, + allow_query=True, + allow_hostname_wildcard=oauth2_settings.ALLOW_URI_WILDCARDS, ) for uri in redirect_uris: validator(uri) @@ -227,7 +231,11 @@ def clean(self): allowed_origins = self.allowed_origins.strip().split() if allowed_origins: # oauthlib allows only https scheme for CORS - validator = AllowedURIValidator(oauth2_settings.ALLOWED_SCHEMES, "allowed origin") + validator = AllowedURIValidator( + oauth2_settings.ALLOWED_SCHEMES, + "allowed origin", + allow_hostname_wildcard=oauth2_settings.ALLOW_URI_WILDCARDS, + ) for uri in allowed_origins: validator(uri) @@ -777,12 +785,28 @@ def redirect_to_uri_allowed(uri, allowed_uris): :param allowed_uris: A list of URIs that are allowed """ + if not isinstance(allowed_uris, list): + raise ValueError("allowed_uris must be a list") + parsed_uri = urlparse(uri) uqs_set = set(parse_qsl(parsed_uri.query)) for allowed_uri in allowed_uris: parsed_allowed_uri = urlparse(allowed_uri) + if parsed_allowed_uri.scheme != parsed_uri.scheme: + # match failed, continue + continue + + """ check hostname """ + if oauth2_settings.ALLOW_URI_WILDCARDS and parsed_allowed_uri.hostname.startswith("*"): + """ wildcard hostname """ + if not parsed_uri.hostname.endswith(parsed_allowed_uri.hostname[1:]): + continue + elif parsed_allowed_uri.hostname != parsed_uri.hostname: + continue + # From RFC 8252 (Section 7.3) + # https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 # # Loopback redirect URIs use the "http" scheme # [...] @@ -790,26 +814,26 @@ def redirect_to_uri_allowed(uri, allowed_uris): # time of the request for loopback IP redirect URIs, to accommodate # clients that obtain an available ephemeral port from the operating # system at the time of the request. + allowed_uri_is_loopback = parsed_allowed_uri.scheme == "http" and parsed_allowed_uri.hostname in [ + "127.0.0.1", + "::1", + ] + """ check port """ + if not allowed_uri_is_loopback and parsed_allowed_uri.port != parsed_uri.port: + continue + + """ check path """ + if parsed_allowed_uri.path != parsed_uri.path: + continue + + """ check querystring """ + aqs_set = set(parse_qsl(parsed_allowed_uri.query)) + if not aqs_set.issubset(uqs_set): + continue # circuit break - allowed_uri_is_loopback = ( - parsed_allowed_uri.scheme == "http" - and parsed_allowed_uri.hostname in ["127.0.0.1", "::1"] - and parsed_allowed_uri.port is None - ) - if ( - allowed_uri_is_loopback - and parsed_allowed_uri.scheme == parsed_uri.scheme - and parsed_allowed_uri.hostname == parsed_uri.hostname - and parsed_allowed_uri.path == parsed_uri.path - ) or ( - parsed_allowed_uri.scheme == parsed_uri.scheme - and parsed_allowed_uri.netloc == parsed_uri.netloc - and parsed_allowed_uri.path == parsed_uri.path - ): - aqs_set = set(parse_qsl(parsed_allowed_uri.query)) - if aqs_set.issubset(uqs_set): - return True + return True + # if uris matched then it's not allowed return False @@ -833,4 +857,5 @@ def is_origin_allowed(origin, allowed_origins): and parsed_allowed_origin.netloc == parsed_origin.netloc ): return True + return False diff --git a/oauth2_provider/settings.py b/oauth2_provider/settings.py index f5a6a25d6..9771aa4e7 100644 --- a/oauth2_provider/settings.py +++ b/oauth2_provider/settings.py @@ -71,6 +71,7 @@ "REQUEST_APPROVAL_PROMPT": "force", "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], "ALLOWED_SCHEMES": ["https"], + "ALLOW_URI_WILDCARDS": False, "OIDC_ENABLED": False, "OIDC_ISS_ENDPOINT": "", "OIDC_USERINFO_ENDPOINT": "", diff --git a/oauth2_provider/validators.py b/oauth2_provider/validators.py index b238b12d6..b2370cfd0 100644 --- a/oauth2_provider/validators.py +++ b/oauth2_provider/validators.py @@ -21,7 +21,15 @@ class URIValidator(URLValidator): class AllowedURIValidator(URIValidator): # TODO: find a way to get these associated with their form fields in place of passing name # TODO: submit PR to get `cause` included in the parent class ValidationError params` - def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fragments=False): + def __init__( + self, + schemes, + name, + allow_path=False, + allow_query=False, + allow_fragments=False, + allow_hostname_wildcard=False, + ): """ :param schemes: List of allowed schemes. E.g.: ["https"] :param name: Name of the validated URI. It is required for validation message. E.g.: "Origin" @@ -34,6 +42,7 @@ def __init__(self, schemes, name, allow_path=False, allow_query=False, allow_fra self.allow_path = allow_path self.allow_query = allow_query self.allow_fragments = allow_fragments + self.allow_hostname_wildcard = allow_hostname_wildcard def __call__(self, value): value = force_str(value) @@ -68,8 +77,57 @@ def __call__(self, value): params={"name": self.name, "value": value, "cause": "path not allowed"}, ) + if self.allow_hostname_wildcard and "*" in netloc: + domain_parts = netloc.split(".") + if netloc.count("*") > 1: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "only one wildcard is allowed in the hostname", + }, + ) + if not netloc.startswith("*"): + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "wildcards must be at the beginning of the hostname", + }, + ) + if len(domain_parts) < 3: + raise ValidationError( + "%(name)s URI validation error. %(cause)s: %(value)s", + params={ + "name": self.name, + "value": value, + "cause": "wildcards cannot be in the top level or second level domain", + }, + ) + + # strip the wildcard from the netloc, we'll reassamble the value later to pass to URI Validator + if netloc.startswith("*."): + netloc = netloc[2:] + else: + netloc = netloc[1:] + + # domains cannot start with a hyphen, but can have them in the middle, so we strip hyphens + # after the wildcard so the final domain is valid and will succeed in URIVAlidator + if netloc.startswith("-"): + netloc = netloc[1:] + + # we stripped the wildcard from the netloc and path if they were allowed and present since they would + # fail validation we'll reassamble the URI to pass to the URIValidator + reassambled_uri = f"{scheme}://{netloc}{path}" + if query: + reassambled_uri += f"?{query}" + if fragment: + reassambled_uri += f"#{fragment}" + try: - super().__call__(value) + super().__call__(reassambled_uri) except ValidationError as e: raise ValidationError( "%(name)s URI validation error. %(cause)s: %(value)s", diff --git a/tests/test_application_views.py b/tests/test_application_views.py index 88617807d..d4c7e28a9 100644 --- a/tests/test_application_views.py +++ b/tests/test_application_views.py @@ -63,6 +63,156 @@ def test_application_registration_user(self): self.assertEqual(app.algorithm, form_data["algorithm"]) +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings({"ALLOW_URI_WILDCARDS": True}) +class TestApplicationRegistrationViewRedirectURIWithWildcard(BaseTest): + def _test_valid(self, redirect_uri): + self.client.login(username="foo_user", password="123456") + + form_data = { + "name": "Foo app", + "client_id": "client_id", + "client_secret": "client_secret", + "client_type": Application.CLIENT_CONFIDENTIAL, + "redirect_uris": redirect_uri, + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 302) + + app = get_application_model().objects.get(name="Foo app") + self.assertEqual(app.user.username, "foo_user") + app = Application.objects.get() + self.assertEqual(app.name, form_data["name"]) + self.assertEqual(app.client_id, form_data["client_id"]) + self.assertEqual(app.redirect_uris, form_data["redirect_uris"]) + self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEqual(app.client_type, form_data["client_type"]) + self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"]) + self.assertEqual(app.algorithm, form_data["algorithm"]) + + def _test_invalid(self, uri, error_message): + self.client.login(username="foo_user", password="123456") + + form_data = { + "name": "Foo app", + "client_id": "client_id", + "client_secret": "client_secret", + "client_type": Application.CLIENT_CONFIDENTIAL, + "redirect_uris": uri, + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, error_message) + + def test_application_registration_valid_3ld_wildcard(self): + self._test_valid("https://*.example.com") + + def test_application_registration_valid_3ld_partial_wildcard(self): + self._test_valid("https://*-partial.example.com") + + def test_application_registration_invalid_star(self): + self._test_invalid("*", "invalid_scheme: *") + + def test_application_registration_invalid_tld_wildcard(self): + self._test_invalid("https://*", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_tld_partial_wildcard(self): + self._test_invalid("https://*-partial", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_tld_not_startswith_wildcard_tld(self): + self._test_invalid("https://example.*", "wildcards must be at the beginning of the hostname") + + def test_application_registration_invalid_2ld_wildcard(self): + self._test_invalid("https://*.com", "wildcards cannot be in the top level or second level domain") + + def test_application_registration_invalid_2ld_partial_wildcard(self): + self._test_invalid( + "https://*-partial.com", "wildcards cannot be in the top level or second level domain" + ) + + def test_application_registration_invalid_2ld_not_startswith_wildcard_tld(self): + self._test_invalid("https://example.*.com", "wildcards must be at the beginning of the hostname") + + def test_application_registration_invalid_3ld_partial_not_startswith_wildcard_2ld(self): + self._test_invalid( + "https://invalid-*.example.com", "wildcards must be at the beginning of the hostname" + ) + + def test_application_registration_invalid_4ld_not_startswith_wildcard_3ld(self): + self._test_invalid( + "https://invalid.*.invalid.example.com", + "wildcards must be at the beginning of the hostname", + ) + + def test_application_registration_invalid_4ld_partial_not_startswith_wildcard_2ld(self): + self._test_invalid( + "https://invalid-*.invalid.example.com", + "wildcards must be at the beginning of the hostname", + ) + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings({"ALLOW_URI_WILDCARDS": True}) +class TestApplicationRegistrationViewAllowedOriginWithWildcard( + TestApplicationRegistrationViewRedirectURIWithWildcard +): + def _test_valid(self, uris): + self.client.login(username="foo_user", password="123456") + + form_data = { + "name": "Foo app", + "client_id": "client_id", + "client_secret": "client_secret", + "client_type": Application.CLIENT_CONFIDENTIAL, + "allowed_origins": uris, + "redirect_uris": "https://example.com", + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 302) + + app = get_application_model().objects.get(name="Foo app") + self.assertEqual(app.user.username, "foo_user") + app = Application.objects.get() + self.assertEqual(app.name, form_data["name"]) + self.assertEqual(app.client_id, form_data["client_id"]) + self.assertEqual(app.redirect_uris, form_data["redirect_uris"]) + self.assertEqual(app.post_logout_redirect_uris, form_data["post_logout_redirect_uris"]) + self.assertEqual(app.client_type, form_data["client_type"]) + self.assertEqual(app.authorization_grant_type, form_data["authorization_grant_type"]) + self.assertEqual(app.algorithm, form_data["algorithm"]) + + def _test_invalid(self, uri, error_message): + self.client.login(username="foo_user", password="123456") + + form_data = { + "name": "Foo app", + "client_id": "client_id", + "client_secret": "client_secret", + "client_type": Application.CLIENT_CONFIDENTIAL, + "allowed_origins": uri, + "redirect_uris": "http://example.com", + "post_logout_redirect_uris": "http://example.com", + "authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE, + "algorithm": "", + } + + response = self.client.post(reverse("oauth2_provider:register"), form_data) + self.assertEqual(response.status_code, 200) + self.assertContains(response, error_message) + + class TestApplicationViews(BaseTest): @classmethod def _create_application(cls, name, user): diff --git a/tests/test_models.py b/tests/test_models.py index 123c41b35..32ca07627 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -16,6 +16,7 @@ get_grant_model, get_id_token_model, get_refresh_token_model, + redirect_to_uri_allowed, ) from . import presets @@ -622,6 +623,79 @@ def test_application_clean(oauth2_settings, application): application.clean() +def _test_wildcard_redirect_uris_valid(oauth2_settings, application, uris): + oauth2_settings.ALLOW_URI_WILDCARDS = True + application.redirect_uris = uris + application.clean() + + +def _test_wildcard_redirect_uris_invalid(oauth2_settings, application, uris): + oauth2_settings.ALLOW_URI_WILDCARDS = True + application.redirect_uris = uris + with pytest.raises(ValidationError): + application.clean() + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_valid_3ld(oauth2_settings, application): + _test_wildcard_redirect_uris_valid(oauth2_settings, application, "https://*.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_valid_partial_3ld(oauth2_settings, application): + _test_wildcard_redirect_uris_valid(oauth2_settings, application, "https://*-partial.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_3ld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*.example.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_2ld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_partial_2ld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*-partial.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_2ld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*.com/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld_partial(oauth2_settings, application): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://*-partial/path") + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +@pytest.mark.oauth2_settings(presets.OIDC_SETTINGS_RW) +def test_application_clean_wildcard_redirect_uris_invalid_tld_not_starting_with_wildcard( + oauth2_settings, application +): + _test_wildcard_redirect_uris_invalid(oauth2_settings, application, "https://invalid-*/path") + + @pytest.mark.django_db(databases=retrieve_current_databases()) @pytest.mark.oauth2_settings(presets.ALLOWED_SCHEMES_DEFAULT) def test_application_origin_allowed_default_https(oauth2_settings, cors_application): @@ -636,3 +710,35 @@ def test_application_origin_allowed_http(oauth2_settings, cors_application): """Test that http schemes are allowed because http was added to ALLOWED_SCHEMES""" assert cors_application.origin_allowed("https://example.com") assert cors_application.origin_allowed("http://example.com") + + +def test_redirect_to_uri_allowed_expects_allowed_uri_list(): + with pytest.raises(ValueError): + redirect_to_uri_allowed("https://example.com", "https://example.com") + assert redirect_to_uri_allowed("https://example.com", ["https://example.com"]) + + +valid_wildcard_redirect_to_params = [ + ("https://valid.example.com", ["https://*.example.com"]), + ("https://valid.valid.example.com", ["https://*.example.com"]), + ("https://valid-partial.example.com", ["https://*-partial.example.com"]), + ("https://valid.valid-partial.example.com", ["https://*-partial.example.com"]), +] + + +@pytest.mark.parametrize("uri, allowed_uri", valid_wildcard_redirect_to_params) +def test_wildcard_redirect_to_uri_allowed_valid(uri, allowed_uri, oauth2_settings): + oauth2_settings.ALLOW_URI_WILDCARDS = True + assert redirect_to_uri_allowed(uri, allowed_uri) + + +invalid_wildcard_redirect_to_params = [ + ("https://invalid.com", ["https://*.example.com"]), + ("https://invalid.example.com", ["https://*-partial.example.com"]), +] + + +@pytest.mark.parametrize("uri, allowed_uri", invalid_wildcard_redirect_to_params) +def test_wildcard_redirect_to_uri_allowed_invalid(uri, allowed_uri, oauth2_settings): + oauth2_settings.ALLOW_URI_WILDCARDS = True + assert not redirect_to_uri_allowed(uri, allowed_uri) diff --git a/tests/test_validators.py b/tests/test_validators.py index eb382c154..a77a1e16a 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -171,3 +171,27 @@ def test_allow_fragment_invalid_urls(self): for uri in bad_uris: with self.assertRaises(ValidationError): validator(uri) + + def test_allow_hostname_wildcard(self): + validator = AllowedURIValidator(["https"], "test", allow_hostname_wildcard=True) + good_uris = [ + "https://*.example.com", + "https://*-partial.example.com", + "https://*.partial.example.com", + "https://*-partial.valid.example.com", + ] + for uri in good_uris: + # Check ValidationError not thrown + validator(uri) + + bad_uris = [ + "https://*/", + "https://*-partial", + "https://*.com", + "https://*-partial.com", + "https://*.*.example.com", + "https://invalid.*.example.com", + ] + for uri in bad_uris: + with self.assertRaises(ValidationError): + validator(uri) From ce34da45a5254c44726e33ff68e4185eb673ceba Mon Sep 17 00:00:00 2001 From: Jaap Roes <jroes@leukeleu.nl> Date: Mon, 7 Oct 2024 19:20:37 +0200 Subject: [PATCH 657/722] Add client_secret to sensitive_post_parameters (#1512) The client_secret is posted to the token endpoint when using the client_credentials grant. --- oauth2_provider/views/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_provider/views/base.py b/oauth2_provider/views/base.py index 1e0d12dea..c5c904b14 100644 --- a/oauth2_provider/views/base.py +++ b/oauth2_provider/views/base.py @@ -292,7 +292,7 @@ class TokenView(OAuthLibMixin, View): * Client credentials """ - @method_decorator(sensitive_post_parameters("password")) + @method_decorator(sensitive_post_parameters("password", "client_secret")) def post(self, request, *args, **kwargs): url, headers, body, status = self.create_token_response(request) if status == 200: From eed322a33392521b3cb8e440426a1bc3d53de887 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Oct 2024 15:59:13 -0400 Subject: [PATCH 658/722] [pre-commit.ci] pre-commit autoupdate (#1513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.8 → v0.6.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.8...v0.6.9) - [github.com/pre-commit/pre-commit-hooks: v4.6.0 → v5.0.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.6.0...v5.0.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f2046a327..7a50f8d2b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,12 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.8 + rev: v0.6.9 hooks: - id: ruff args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-ast - id: trailing-whitespace From f2f2e247bee66a8db692734fa2d431aeffbf3e60 Mon Sep 17 00:00:00 2001 From: Alan Crosswell <alan@columbia.edu> Date: Mon, 7 Oct 2024 18:03:31 -0400 Subject: [PATCH 659/722] bump to actions/setup-node@v4 (#1514) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0bf9f155..d0521b5bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -78,7 +78,7 @@ jobs: uses: actions/checkout@v4 - name: Set up NodeJS - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} From 28b512a677e8d916392865dd2802616285b864d5 Mon Sep 17 00:00:00 2001 From: Dulmandakh <dulmandakh@gmail.com> Date: Tue, 8 Oct 2024 06:14:53 +0800 Subject: [PATCH 660/722] Bump actions/cache to v4 (#1516) --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d0521b5bc..e3073dc7a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,7 +40,7 @@ jobs: echo "::set-output name=dir::$(pip cache dir)" - name: Cache - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ${{ steps.pip-cache.outputs.dir }} key: From 59bd7af7d6c2b1126e28ea40c5a7618bbbe754ab Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Wed, 9 Oct 2024 11:11:17 -0400 Subject: [PATCH 661/722] fix: OP prompts for logout when no OP session (#1517) The OAuth provider is prompting users who no longer have an user session with the OAuth Provider to logout of the OP. This happens in scenarios given the user has logged out of the OP directly or via another client. In cases where the user does not have a session on the OP we should not prompt them to log out of the OP as there is no session, but we should still clear out their tokens to terminate the session for the Application. --- CHANGELOG.md | 6 +++- oauth2_provider/views/oidc.py | 67 ++++++++++++++++++++++++++++++----- tests/test_oidc_views.py | 35 ++++++++++++++---- 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38b1d8b78..f16317265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added -* Support for Wildcard Origin and Redirect URIs, https://github.com/jazzband/django-oauth-toolkit/issues/1506 +* #1506 Support for Wildcard Origin and Redirect URIs <!-- ### Changed ### Deprecated ### Removed +--> ### Fixed +* #1517 OP prompts for logout when no OP session +* #1512 client_secret not marked sensitive +<!-- ### Security --> diff --git a/oauth2_provider/views/oidc.py b/oauth2_provider/views/oidc.py index c746c30ce..a252f1be4 100644 --- a/oauth2_provider/views/oidc.py +++ b/oauth2_provider/views/oidc.py @@ -367,17 +367,66 @@ def validate_logout_request(self, id_token_hint, client_id, post_logout_redirect return application, id_token.user if id_token else None def must_prompt(self, token_user): - """Indicate whether the logout has to be confirmed by the user. This happens if the - specifications force a confirmation, or it is enabled by `OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT`. + """ + per: https://openid.net/specs/openid-connect-rpinitiated-1_0.html + + > At the Logout Endpoint, the OP SHOULD ask the End-User whether to log + > out of the OP as well. Furthermore, the OP MUST ask the End-User this + > question if an id_token_hint was not provided or if the supplied ID + > Token does not belong to the current OP session with the RP and/or + > currently logged in End-User. - A logout without user interaction (i.e. no prompt) is only allowed - if an ID Token is provided that matches the current user. """ - return ( - oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT - or token_user is None - or token_user != self.request.user - ) + + if not self.request.user.is_authenticated: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + + If the user does not have an active session with the OP, they cannot + end their OP session, so there is nothing to prompt for. This occurs + in cases where the user has logged out of the OP via another channel + such as the OP's own logout page, session timeout or another RP's + logout page. + """ + return False + + if oauth2_settings.OIDC_RP_INITIATED_LOGOUT_ALWAYS_PROMPT: + """ + > At the Logout Endpoint, the OP SHOULD ask the End-User whether to + > log out of the OP as well + + The admin has configured the OP to always prompt the userfor logout + per the SHOULD recommendation. + """ + return True + + if token_user is None: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + > well if the supplied ID Token does not belong to the current OP + > session with the RP. + + token_user will only be populated if an ID token was found for the + RP (Application) that is requesting the logout. If token_user is not + then we must prompt the user. + """ + return True + + if token_user != self.request.user: + """ + > the OP MUST ask ask the End-User whether to log out of the OP as + > well if the supplied ID Token does not belong to the logged in + > End-User. + + is_authenticated indicates that there is a logged in user and was + tested in the first condition. + token_user != self.request.user indicates that the token does not + belong to the logged in user, Therefore we need to prompt the user. + """ + return True + + """ We didn't find a reason to prompt the user """ + return False def do_logout(self, application=None, post_logout_redirect_uri=None, state=None, token_user=None): user = token_user or self.request.user diff --git a/tests/test_oidc_views.py b/tests/test_oidc_views.py index 8bdf18360..65197cbd1 100644 --- a/tests/test_oidc_views.py +++ b/tests/test_oidc_views.py @@ -311,6 +311,10 @@ def test_must_prompt(oidc_tokens, other_user, rp_settings, ALWAYS_PROMPT): == ALWAYS_PROMPT ) assert RPInitiatedLogoutView(request=mock_request_for(other_user)).must_prompt(oidc_tokens.user) is True + assert ( + RPInitiatedLogoutView(request=mock_request_for(AnonymousUser())).must_prompt(oidc_tokens.user) + is False + ) def test__load_id_token(): @@ -577,13 +581,14 @@ def test_token_deletion_on_logout(oidc_tokens, logged_in_client, rp_settings): @pytest.mark.django_db(databases=retrieve_current_databases()) -def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settings): +def test_token_deletion_on_logout_without_op_session_get(oidc_tokens, client, rp_settings): AccessToken = get_access_token_model() IDToken = get_id_token_model() RefreshToken = get_refresh_token_model() assert AccessToken.objects.count() == 1 assert IDToken.objects.count() == 1 assert RefreshToken.objects.count() == 1 + rsp = client.get( reverse("oauth2_provider:rp-initiated-logout"), data={ @@ -591,15 +596,31 @@ def test_token_deletion_on_logout_expired_session(oidc_tokens, client, rp_settin "client_id": oidc_tokens.application.client_id, }, ) - assert rsp.status_code == 200 + assert rsp.status_code == 302 assert not is_logged_in(client) # Check that all tokens are active. - access_token = AccessToken.objects.get() - assert not access_token.is_expired() - id_token = IDToken.objects.get() - assert not id_token.is_expired() + assert AccessToken.objects.count() == 0 + assert IDToken.objects.count() == 0 + assert RefreshToken.objects.count() == 1 + + with pytest.raises(AccessToken.DoesNotExist): + AccessToken.objects.get() + + with pytest.raises(IDToken.DoesNotExist): + IDToken.objects.get() + refresh_token = RefreshToken.objects.get() - assert refresh_token.revoked is None + assert refresh_token.revoked is not None + + +@pytest.mark.django_db(databases=retrieve_current_databases()) +def test_token_deletion_on_logout_without_op_session_post(oidc_tokens, client, rp_settings): + AccessToken = get_access_token_model() + IDToken = get_id_token_model() + RefreshToken = get_refresh_token_model() + assert AccessToken.objects.count() == 1 + assert IDToken.objects.count() == 1 + assert RefreshToken.objects.count() == 1 rsp = client.post( reverse("oauth2_provider:rp-initiated-logout"), From 907d70f08c1bef94a485bde8fd3edb51952aec03 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Oct 2024 15:29:47 -0400 Subject: [PATCH 662/722] [pre-commit.ci] pre-commit autoupdate (#1518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.7.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.7.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a50f8d2b..42a6cb209 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.6.9 + rev: v0.7.1 hooks: - id: ruff args: [ --fix ] From 0d32dec28b2368e292c54c51665ec6e7837b3c3b Mon Sep 17 00:00:00 2001 From: Alex Kerkum <acidtv@users.noreply.github.com> Date: Mon, 4 Nov 2024 16:49:27 +0100 Subject: [PATCH 663/722] Use iterator in access token migration (#1522) --- AUTHORS | 1 + CHANGELOG.md | 1 + oauth2_provider/migrations/0012_add_token_checksum.py | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/AUTHORS b/AUTHORS index d10ff1fb4..e2da60020 100644 --- a/AUTHORS +++ b/AUTHORS @@ -122,3 +122,4 @@ Wouter Klein Heerenbrink Yaroslav Halchenko Yuri Savin Miriam Forner +Alex Kerkum diff --git a/CHANGELOG.md b/CHANGELOG.md index f16317265..f86b8a8af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * #1517 OP prompts for logout when no OP session * #1512 client_secret not marked sensitive +* #1521 Fix 0012 migration loading access token table into memory <!-- ### Security --> diff --git a/oauth2_provider/migrations/0012_add_token_checksum.py b/oauth2_provider/migrations/0012_add_token_checksum.py index 476c3b402..d27c65e54 100644 --- a/oauth2_provider/migrations/0012_add_token_checksum.py +++ b/oauth2_provider/migrations/0012_add_token_checksum.py @@ -9,7 +9,7 @@ def forwards_func(apps, schema_editor): Forward migration touches every "old" accesstoken.token which will cause the checksum to be computed. """ AccessToken = apps.get_model(oauth2_settings.ACCESS_TOKEN_MODEL) - accesstokens = AccessToken._default_manager.all() + accesstokens = AccessToken._default_manager.iterator() for accesstoken in accesstokens: accesstoken.save(update_fields=['token_checksum']) From 1c5e36d59cd8f740650a72ad80a455f8ed05ff3c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Nov 2024 13:43:40 -0500 Subject: [PATCH 664/722] [pre-commit.ci] pre-commit autoupdate (#1523) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.7.1 → v0.7.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.1...v0.7.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 42a6cb209..f59dff364 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.7.2 hooks: - id: ruff args: [ --fix ] From ad77eac9b7cc6d984c35ab3e8cb35778719bc84c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Nov 2024 13:31:08 -0500 Subject: [PATCH 665/722] [pre-commit.ci] pre-commit autoupdate (#1524) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.7.2 → v0.7.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.2...v0.7.3) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f59dff364..086c7af38 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.2 + rev: v0.7.3 hooks: - id: ruff args: [ --fix ] From 1285ab0ec99d507c72f87242f545c50f9a5eef3d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 18 Nov 2024 13:19:09 -0500 Subject: [PATCH 666/722] [pre-commit.ci] pre-commit autoupdate (#1528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.7.3 → v0.7.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.7.3...v0.7.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 086c7af38..7d546c18f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.3 + rev: v0.7.4 hooks: - id: ruff args: [ --fix ] From e950255f3be99ae57153f4f94cbb1fd012281005 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 11:36:43 -0500 Subject: [PATCH 667/722] Bump @sveltejs/kit from 2.5.10 to 2.8.3 in /tests/app/rp (#1529) Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.10 to 2.8.3. - [Release notes](https://github.com/sveltejs/kit/releases) - [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md) - [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.8.3/packages/kit) --- updated-dependencies: - dependency-name: "@sveltejs/kit" dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/app/rp/package-lock.json | 34 +++++++++++++++++----------------- tests/app/rp/package.json | 2 +- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 6ab5dc90e..1a22dffe4 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -13,7 +13,7 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.5.10", + "@sveltejs/kit": "^2.8.3", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", @@ -496,9 +496,9 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", + "version": "1.0.0-next.28", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", + "integrity": "sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==", "dev": true }, "node_modules/@rollup/plugin-commonjs": { @@ -891,15 +891,15 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.5.10", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.10.tgz", - "integrity": "sha512-OqoyTmFG2cYmCFAdBfW+Qxbg8m23H4dv6KqwEt7ofr/ROcfcIl3Z/VT56L22H9f0uNZyr+9Bs1eh2gedOCK9kA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.8.3.tgz", + "integrity": "sha512-DVBVwugfzzn0SxKA+eAmKqcZ7aHZROCHxH7/pyrOi+HLtQ721eEsctGb9MkhEuqj6q/9S/OFYdn37vdxzFPdvw==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^5.0.0", + "devalue": "^5.1.0", "esm-env": "^1.0.0", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", @@ -907,7 +907,7 @@ "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^2.0.4", + "sirv": "^3.0.0", "tiny-glob": "^0.2.9" }, "bin": { @@ -917,7 +917,7 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3" } @@ -1262,9 +1262,9 @@ } }, "node_modules/devalue": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", - "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", + "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", "dev": true }, "node_modules/es6-promise": { @@ -2066,9 +2066,9 @@ "dev": true }, "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.0.tgz", + "integrity": "sha512-BPwJGUeDaDCHihkORDchNyyTvWFhcusy1XMmhEVTQTwGeybFbp8YEmB+njbPnth1FibULBSBVwCQni25XlCUDg==", "dev": true, "dependencies": { "@polka/url": "^1.0.0-next.24", @@ -2076,7 +2076,7 @@ "totalist": "^3.0.0" }, "engines": { - "node": ">= 10" + "node": ">=18" } }, "node_modules/sorcery": { diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 7f784006f..8bd3bb8fe 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.5.10", + "@sveltejs/kit": "^2.8.3", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", From d2a4c3fa45d72816e04249911de751e445db1da7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:23:10 -0500 Subject: [PATCH 668/722] [pre-commit.ci] pre-commit autoupdate (#1530) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d546c18f..ca719c9a2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + rev: v0.8.0 hooks: - id: ruff args: [ --fix ] From c403b2e5974a259b814330923da37012a875e729 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Dec 2024 14:30:41 -0500 Subject: [PATCH 669/722] [pre-commit.ci] pre-commit autoupdate (#1531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.0 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.0...v0.8.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ca719c9a2..40c6824a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.0 + rev: v0.8.1 hooks: - id: ruff args: [ --fix ] From 7b244a34d640ca5ac0fd1c024624a7144eca021e Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:16:06 -0500 Subject: [PATCH 670/722] [pre-commit.ci] pre-commit autoupdate (#1532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.1 → v0.8.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.1...v0.8.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40c6824a8..e134ab26f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.1 + rev: v0.8.2 hooks: - id: ruff args: [ --fix ] From ee65197cbb3833e28bd655b33b59c884745c5299 Mon Sep 17 00:00:00 2001 From: IT-Native <support@it-native.com> Date: Thu, 12 Dec 2024 15:12:35 +0100 Subject: [PATCH 671/722] Fixes typo (#1533) Update getting_started.rst --- docs/getting_started.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/getting_started.rst b/docs/getting_started.rst index e95618723..d2ce14ca1 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -243,9 +243,9 @@ Start the development server:: python manage.py runserver -Point your browser to http://127.0.0.1:8000/o/applications/register/ lets create an application. +Point your browser to http://127.0.0.1:8000/o/applications/register/ and let's create an application. -Fill the form as show in the screenshot below and before save take note of ``Client id`` and ``Client secret``, we will use it in a minute. +Fill the form as shown in the screenshot below and before clicking on save take note of ``Client id`` and ``Client secret``, we will use it in a minute. If you want to use this application with OIDC and ``HS256`` (see :doc:`OpenID Connect <oidc>`), uncheck ``Hash client secret`` to allow verifying tokens using JWT signatures. This means your client secret will be stored in cleartext but is the only way to successfully use signed JWT's with ``HS256``. From f6db0fa362e916762d4e7a5bcb84fa9c49a34610 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Dec 2024 13:55:09 -0500 Subject: [PATCH 672/722] [pre-commit.ci] pre-commit autoupdate (#1534) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e134ab26f..b72e72b11 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.2 + rev: v0.8.3 hooks: - id: ruff args: [ --fix ] From aa6d566881965c43cedae8e04a84beda9633be90 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 23 Dec 2024 14:52:24 -0500 Subject: [PATCH 673/722] [pre-commit.ci] pre-commit autoupdate (#1537) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b72e72b11..e5429e8a8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.3 + rev: v0.8.4 hooks: - id: ruff args: [ --fix ] From c61d852ef500ef8a925383b654d1273a89639bb4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:16:36 -0500 Subject: [PATCH 674/722] [pre-commit.ci] pre-commit autoupdate (#1538) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.4 → v0.8.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.4...v0.8.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e5429e8a8..7c3e7e799 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + rev: v0.8.6 hooks: - id: ruff args: [ --fix ] From 1b0520ac992e6c6179cdbcef68f383eb7a0c7980 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jan 2025 14:21:47 -0500 Subject: [PATCH 675/722] [pre-commit.ci] pre-commit autoupdate (#1540) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.8.6 → v0.9.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.8.6...v0.9.1) * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- oauth2_provider/models.py | 2 +- tests/test_models.py | 48 +++++++++++++++++++-------------------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7c3e7e799..d337a46d8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.6 + rev: v0.9.1 hooks: - id: ruff args: [ --fix ] diff --git a/oauth2_provider/models.py b/oauth2_provider/models.py index 0467ddfa1..a76db37c0 100644 --- a/oauth2_provider/models.py +++ b/oauth2_provider/models.py @@ -715,7 +715,7 @@ def batch_delete(queryset, query): flat_queryset = queryset.values_list("id", flat=True)[:CLEAR_EXPIRED_TOKENS_BATCH_SIZE] batch_length = flat_queryset.count() queryset.model.objects.filter(id__in=list(flat_queryset)).delete() - logger.debug(f"{batch_length} tokens deleted, {current_no-batch_length} left") + logger.debug(f"{batch_length} tokens deleted, {current_no - batch_length} left") queryset = queryset.model.objects.filter(query) time.sleep(CLEAR_EXPIRED_TOKENS_BATCH_INTERVAL) current_no = queryset.count() diff --git a/tests/test_models.py b/tests/test_models.py index 32ca07627..2c7ff8114 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -433,25 +433,25 @@ def test_clear_expired_tokens_with_tokens(self): initial_at_count = AccessToken.objects.count() assert initial_at_count == 2 * self.num_tokens, f"{2 * self.num_tokens} access tokens should exist." initial_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() - assert ( - initial_expired_at_count == self.num_tokens - ), f"{self.num_tokens} expired access tokens should exist." + assert initial_expired_at_count == self.num_tokens, ( + f"{self.num_tokens} expired access tokens should exist." + ) initial_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() - assert ( - initial_current_at_count == self.num_tokens - ), f"{self.num_tokens} current access tokens should exist." + assert initial_current_at_count == self.num_tokens, ( + f"{self.num_tokens} current access tokens should exist." + ) initial_rt_count = RefreshToken.objects.count() - assert ( - initial_rt_count == self.num_tokens // 2 - ), f"{self.num_tokens // 2} refresh tokens should exist." + assert initial_rt_count == self.num_tokens // 2, ( + f"{self.num_tokens // 2} refresh tokens should exist." + ) initial_rt_expired_at_count = RefreshToken.objects.filter(access_token__expires__lte=self.now).count() - assert ( - initial_rt_expired_at_count == initial_rt_count / 2 - ), "half the refresh tokens should be for expired access tokens." + assert initial_rt_expired_at_count == initial_rt_count / 2, ( + "half the refresh tokens should be for expired access tokens." + ) initial_rt_current_at_count = RefreshToken.objects.filter(access_token__expires__gt=self.now).count() - assert ( - initial_rt_current_at_count == initial_rt_count / 2 - ), "half the refresh tokens should be for current access tokens." + assert initial_rt_current_at_count == initial_rt_count / 2, ( + "half the refresh tokens should be for current access tokens." + ) initial_gt_count = Grant.objects.count() assert initial_gt_count == self.num_tokens * 2, f"{self.num_tokens * 2} grants should exist." @@ -459,15 +459,15 @@ def test_clear_expired_tokens_with_tokens(self): # after clear_expired(): remaining_at_count = AccessToken.objects.count() - assert ( - remaining_at_count == initial_at_count // 2 - ), "half the initial access tokens should still exist." + assert remaining_at_count == initial_at_count // 2, ( + "half the initial access tokens should still exist." + ) remaining_expired_at_count = AccessToken.objects.filter(expires__lte=self.now).count() assert remaining_expired_at_count == 0, "no remaining expired access tokens should still exist." remaining_current_at_count = AccessToken.objects.filter(expires__gt=self.now).count() - assert ( - remaining_current_at_count == initial_current_at_count - ), "all current access tokens should still exist." + assert remaining_current_at_count == initial_current_at_count, ( + "all current access tokens should still exist." + ) remaining_rt_count = RefreshToken.objects.count() assert remaining_rt_count == initial_rt_count // 2, "half the refresh tokens should still exist." remaining_rt_expired_at_count = RefreshToken.objects.filter( @@ -477,9 +477,9 @@ def test_clear_expired_tokens_with_tokens(self): remaining_rt_current_at_count = RefreshToken.objects.filter( access_token__expires__gt=self.now ).count() - assert ( - remaining_rt_current_at_count == initial_rt_current_at_count - ), "all the refresh tokens for current access tokens should still exist." + assert remaining_rt_current_at_count == initial_rt_current_at_count, ( + "all the refresh tokens for current access tokens should still exist." + ) remaining_gt_count = Grant.objects.count() assert remaining_gt_count == initial_gt_count // 2, "half the remaining grants should still exist." From e11cfb2b63fd133a808142bd67488f4d7fe33d21 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jan 2025 14:58:25 -0500 Subject: [PATCH 676/722] [pre-commit.ci] pre-commit autoupdate (#1541) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d337a46d8..3d89ddfa1 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.1 + rev: v0.9.2 hooks: - id: ruff args: [ --fix ] From ffcf60987f4e6ade6632dccd97fd5d1824f45b9d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Jan 2025 07:13:57 -0500 Subject: [PATCH 677/722] Bump vite from 5.4.6 to 5.4.14 in /tests/app/rp (#1542) --- tests/app/rp/package-lock.json | 9 +++++---- tests/app/rp/package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 1a22dffe4..be5db7cb4 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -20,7 +20,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.4.6" + "vite": "^5.4.14" } }, "node_modules/@ampproject/remapping": { @@ -2312,10 +2312,11 @@ } }, "node_modules/vite": { - "version": "5.4.6", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", - "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", + "version": "5.4.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", + "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", "dev": true, + "license": "MIT", "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 8bd3bb8fe..3ec9569bd 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -21,7 +21,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.4.6" + "vite": "^5.4.14" }, "type": "module", "dependencies": { From a24d0a8e83af98d827d313881b71fa049d193f35 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 27 Jan 2025 13:44:03 -0500 Subject: [PATCH 678/722] [pre-commit.ci] pre-commit autoupdate (#1544) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [pre-commit.ci] pre-commit autoupdate updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.2 → v0.9.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.2...v0.9.3) - [github.com/codespell-project/codespell: v2.3.0 → v2.4.0](https://github.com/codespell-project/codespell/compare/v2.3.0...v2.4.0) * codespell --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell <alan@columbia.edu> --- .pre-commit-config.yaml | 4 ++-- CHANGELOG.md | 2 +- tests/test_authorization_code.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3d89ddfa1..facfd8f7d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.2 + rev: v0.9.3 hooks: - id: ruff args: [ --fix ] @@ -22,7 +22,7 @@ repos: - id: sphinx-lint # Configuration for codespell is in pyproject.toml - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 + rev: v2.4.0 hooks: - id: codespell exclude: (package-lock.json|/locale/) diff --git a/CHANGELOG.md b/CHANGELOG.md index f86b8a8af..8dfe6c3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -411,7 +411,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th * **New feature**: The new setting `ERROR_RESPONSE_WITH_SCOPES` can now be set to True to include required scopes when DRF authorization fails due to improper scopes. * **New feature**: The new setting `REFRESH_TOKEN_GRACE_PERIOD_SECONDS` controls a grace period during which - refresh tokens may be re-used. + refresh tokens may be reused. * An `app_authorized` signal is fired when a token is generated. ## 1.0.0 [2017-06-07] diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 122474950..f162e211a 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -989,7 +989,7 @@ def test_refresh_fail_repeating_requests(self): def test_refresh_repeating_requests_revokes_old_token(self): """ If a refresh token is reused, the server should invalidate *all* access tokens that have a relation - to the re-used token. This forces a malicious actor to be logged out. + to the reused token. This forces a malicious actor to be logged out. The server can't determine whether the first or the second client was legitimate, so it needs to revoke both. See https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics-29#name-recommendations From 48f4d545a6025ed88e3b42119be17d7316e4d229 Mon Sep 17 00:00:00 2001 From: Raphael Lullis <lullis@users.noreply.github.com> Date: Wed, 29 Jan 2025 17:56:21 +0000 Subject: [PATCH 679/722] Fix range of allowed django versions (#1547) * Fix range of allowed django versions Running `docker-compose build` fails at the moment due to a package resolution conflict. Allowing to use django 4.2 solves the issue. * Update tests/app/idp/requirements.txt Co-authored-by: Alan Crosswell <alan@crosswell.us> --------- Co-authored-by: Alan Crosswell <alan@crosswell.us> --- tests/app/idp/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/app/idp/requirements.txt b/tests/app/idp/requirements.txt index ba8e75052..ec77fcf9d 100644 --- a/tests/app/idp/requirements.txt +++ b/tests/app/idp/requirements.txt @@ -1,5 +1,5 @@ -Django>=3.2,<4.2 +Django>=4.2,<=5.1 django-cors-headers==3.14.0 django-environ==0.11.2 --e ../../../ \ No newline at end of file +-e ../../../ From 6d21bfb5ccea3564ab010b919cfc613f714bb51e Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 31 Jan 2025 18:28:11 -0500 Subject: [PATCH 680/722] fix: idp image can't find templates or statics (#1550) --- CHANGELOG.md | 1 + Dockerfile | 5 ++++- docs/contributing.rst | 22 ++++++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfe6c3e5..8c4770459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1517 OP prompts for logout when no OP session * #1512 client_secret not marked sensitive * #1521 Fix 0012 migration loading access token table into memory +* #1584 Fix IDP container in docker compose environment could not find templates and static files. <!-- ### Security --> diff --git a/Dockerfile b/Dockerfile index e501e84d2..4565df5ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -57,7 +57,10 @@ ENV DATABASE_URL="sqlite:////data/db.sqlite3" COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" COPY --from=builder /code /code -RUN mkdir -p /code/tests/app/idp/static /code/tests/app/idp/templates +RUN mkdir -p /data/static /data/templates +COPY --from=builder /code/tests/app/idp/static /data/static +COPY --from=builder /code/tests/app/idp/templates /data/templates + WORKDIR /code/tests/app/idp RUN apt-get update && apt-get install -y \ libpq5 \ diff --git a/docs/contributing.rst b/docs/contributing.rst index 4f0b88b32..569f5eab2 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -325,6 +325,28 @@ Reviewing and Merging PRs PRs that are incorrectly merged may (reluctantly) be reverted by the Project Leads. +End to End Testing +------------------ + +There is a demonstration Identity Provider (IDP) and Relying Party (RP) to allow for +end to end testing. They can be launched directly by following the instructions in +/test/apps/README.md or via docker compose. To launch via docker compose + +.. code-block:: bash + + # build the images with the current code + docker compose build + # wipe any existing services and volumes + docker compose rm -v + # start the services + docker compose up -d + +Please verify the RP behaves as expected by logging in, reloading, and logging out. + +open http://localhost:5173 in your browser and login with the following credentials: + +username: superuser +password: password Publishing a Release -------------------- From bc7edb05aab8d962704e4ec294d6dbd3953f45b2 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Feb 2025 14:46:55 -0500 Subject: [PATCH 681/722] [pre-commit.ci] pre-commit autoupdate (#1551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.3 → v0.9.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.3...v0.9.4) - [github.com/codespell-project/codespell: v2.4.0 → v2.4.1](https://github.com/codespell-project/codespell/compare/v2.4.0...v2.4.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index facfd8f7d..b67b72438 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.3 + rev: v0.9.4 hooks: - id: ruff args: [ --fix ] @@ -22,7 +22,7 @@ repos: - id: sphinx-lint # Configuration for codespell is in pyproject.toml - repo: https://github.com/codespell-project/codespell - rev: v2.4.0 + rev: v2.4.1 hooks: - id: codespell exclude: (package-lock.json|/locale/) From 87bcd28226996f11cd9daaaeaf542c71f800abcd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Feb 2025 13:47:45 -0500 Subject: [PATCH 682/722] [pre-commit.ci] pre-commit autoupdate (#1553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.4 → v0.9.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.4...v0.9.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b67b72438..50f835567 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.4 + rev: v0.9.6 hooks: - id: ruff args: [ --fix ] From c9218e4f2e7ec3a68391d25d724c751b8505f496 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Feb 2025 13:00:02 -0500 Subject: [PATCH 683/722] [pre-commit.ci] pre-commit autoupdate (#1554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.6 → v0.9.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.6...v0.9.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 50f835567..7fba749ce 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.6 + rev: v0.9.7 hooks: - id: ruff args: [ --fix ] From 9805863a3b718adbc2d0b8c95d9c9967d066e4f7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 3 Mar 2025 13:57:01 -0500 Subject: [PATCH 684/722] [pre-commit.ci] pre-commit autoupdate (#1555) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7fba749ce..3b24f4649 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.7 + rev: v0.9.9 hooks: - id: ruff args: [ --fix ] From be86257d327ccf5396f8986308027b0fdc216590 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 10 Mar 2025 13:59:33 -0400 Subject: [PATCH 685/722] [pre-commit.ci] pre-commit autoupdate (#1556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.9 → v0.9.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.9...v0.9.10) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3b24f4649..f03a3a002 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.9 + rev: v0.9.10 hooks: - id: ruff args: [ --fix ] From 25ac4682f6eb981647286aab78cddec4f67083af Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 17 Mar 2025 15:12:05 -0400 Subject: [PATCH 686/722] [pre-commit.ci] pre-commit autoupdate (#1559) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.9.10 → v0.11.0](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.10...v0.11.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f03a3a002..a35ef3e49 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.10 + rev: v0.11.0 hooks: - id: ruff args: [ --fix ] From ec5984591b348b06ac60ef9d156be9a295d65110 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 24 Mar 2025 13:44:28 -0400 Subject: [PATCH 687/722] [pre-commit.ci] pre-commit autoupdate (#1560) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a35ef3e49..3671e158b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.0 + rev: v0.11.2 hooks: - id: ruff args: [ --fix ] From 172c636cf6c0db0740b02bac67e74b3702f6029d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 7 Apr 2025 14:15:52 -0400 Subject: [PATCH 688/722] [pre-commit.ci] pre-commit autoupdate (#1561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.2 → v0.11.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.2...v0.11.4) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3671e158b..239764310 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.2 + rev: v0.11.4 hooks: - id: ruff args: [ --fix ] From 231669f06889562df1793e49702e2aafba3063fc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 14:11:09 -0400 Subject: [PATCH 689/722] [pre-commit.ci] pre-commit autoupdate (#1563) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 239764310..e64c2528f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.4 + rev: v0.11.5 hooks: - id: ruff args: [ --fix ] From 839952f0e27a87583ff5506b750db18f576ff600 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 14 Apr 2025 20:35:34 -0400 Subject: [PATCH 690/722] Bump @sveltejs/kit from 2.8.3 to 2.20.6 in /tests/app/rp (#1564) --- tests/app/rp/package-lock.json | 50 ++++++++++------------------------ tests/app/rp/package.json | 2 +- 2 files changed, 15 insertions(+), 37 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index be5db7cb4..6f33dbbec 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -13,7 +13,7 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.8.3", + "@sveltejs/kit": "^2.20.6", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", @@ -891,24 +891,23 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.8.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.8.3.tgz", - "integrity": "sha512-DVBVwugfzzn0SxKA+eAmKqcZ7aHZROCHxH7/pyrOi+HLtQ721eEsctGb9MkhEuqj6q/9S/OFYdn37vdxzFPdvw==", + "version": "2.20.6", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.6.tgz", + "integrity": "sha512-ImUkSQ//Xf4N9r0HHAe5vRA7RyQ7U1Ue1YUT235Ig+IiIqbsixEulHTHrP5LtBiC8xOkJoPZQ1VZ/nWHNOaGGw==", "dev": true, - "hasInstallScript": true, + "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", "devalue": "^5.1.0", - "esm-env": "^1.0.0", + "esm-env": "^1.2.2", "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", - "sirv": "^3.0.0", - "tiny-glob": "^0.2.9" + "sirv": "^3.0.0" }, "bin": { "svelte-kit": "svelte-kit.js" @@ -917,9 +916,9 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3" + "vite": "^5.0.3 || ^6.0.0" } }, "node_modules/@sveltejs/vite-plugin-svelte": { @@ -1312,10 +1311,11 @@ } }, "node_modules/esm-env": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.0.0.tgz", - "integrity": "sha512-Cf6VksWPsTuW01vU9Mk/3vRue91Zevka5SjyNf3nEpokFRuqt/KjUQoGAwq9qMmhpLTHmXzSIrFRw8zxWzmFBA==", - "dev": true + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" }, "node_modules/estree-walker": { "version": "3.0.3", @@ -1425,18 +1425,6 @@ "node": ">= 6" } }, - "node_modules/globalyzer": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/globalyzer/-/globalyzer-0.1.0.tgz", - "integrity": "sha512-40oNTM9UfG6aBmuKxk/giHn5nQ8RVz/SS4Ir6zgzOv9/qC3kKZ9v4etGTcJbEl/NyVQH7FGU7d+X1egr57Md2Q==", - "dev": true - }, - "node_modules/globrex": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", - "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", - "dev": true - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -2261,16 +2249,6 @@ "node": ">=0.4.0" } }, - "node_modules/tiny-glob": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", - "integrity": "sha512-g/55ssRPUjShh+xkfx9UPDXqhckHEsHr4Vd9zX55oSdGZc/MD0m3sferOkwWtp98bv+kcVfEHtRJgBVJzelrzg==", - "dev": true, - "dependencies": { - "globalyzer": "0.1.0", - "globrex": "^0.1.2" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 3ec9569bd..2e1a3afb6 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -14,7 +14,7 @@ "devDependencies": { "@sveltejs/adapter-auto": "^3.1.1", "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.8.3", + "@sveltejs/kit": "^2.20.6", "prettier": "^3.3.2", "prettier-plugin-svelte": "^3.2.4", "svelte": "^4.2.19", From b63c5766b5f8c37fa42f8a0134261198574df8bc Mon Sep 17 00:00:00 2001 From: Brian Helba <brian.helba@kitware.com> Date: Fri, 18 Apr 2025 13:31:16 -0400 Subject: [PATCH 691/722] Improve settings documentation (#1567) Previously, it was unclear that the swappable model settings should always be set without a namespace, as the sub-section titles didn't include the required `OAUTH2_PROVIDER_` prefix. The warning at the top may not be noticed by people looking for a specific settings, and it was still unclear given the sub-section titles. Additionally, this documents the `OAUTH2_PROVIDER_ID_TOKEN_MODEL` and `OAUTH2_PROVIDER_GRANT_MODEL` settings, which were previously undocumented. Also, this corrects a mistake where `ACCESS_TOKEN_GENERATOR` was mentioned as the setting which enables the use of `SettingsScopes`; the actual setting is `SCOPES_BACKEND_CLASS`. --- docs/settings.rst | 80 ++++++++++++++++++++++++++--------------------- 1 file changed, 45 insertions(+), 35 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 545736ccb..985ca5d2c 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -1,10 +1,8 @@ Settings ======== -Our configurations are all namespaced under the ``OAUTH2_PROVIDER`` settings with the exception of -``OAUTH2_PROVIDER_APPLICATION_MODEL``, ``OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL``, ``OAUTH2_PROVIDER_GRANT_MODEL``, -``OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL``: this is because of the way Django currently implements -swappable models. See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. +Our configurations are all namespaced under the ``OAUTH2_PROVIDER`` settings, with the exception +of the `List of non-namespaced settings`_. For example: @@ -24,24 +22,17 @@ For example: A big *thank you* to the guys from Django REST Framework for inspiring this. -List of available settings --------------------------- +List of available settings within OAUTH2_PROVIDER +------------------------------------------------- ACCESS_TOKEN_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Default: ``36000`` The number of seconds an access token remains valid. Requesting a protected resource after this duration will fail. Keep this value high enough so clients can cache the token for a reasonable amount of time. -ACCESS_TOKEN_MODEL -~~~~~~~~~~~~~~~~~~ -The import string of the class (model) representing your access tokens. Overwrite -this value if you wrote your own implementation (subclass of -``oauth2_provider.models.AccessToken``). - ACCESS_TOKEN_GENERATOR ~~~~~~~~~~~~~~~~~~~~~~ Import path of a callable used to generate access tokens. @@ -49,7 +40,6 @@ Import path of a callable used to generate access tokens. ALLOWED_REDIRECT_URI_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Default: ``["http", "https"]`` A list of schemes that the ``redirect_uri`` field will be validated against. @@ -65,7 +55,6 @@ a per-application basis. ALLOW_URI_WILDCARDS ~~~~~~~~~~~~~~~~~~~ - Default: ``False`` SECURITY WARNING: Enabling this setting can introduce security vulnerabilities. Only enable @@ -96,7 +85,6 @@ deployments for development previews and user acceptance testing. ALLOWED_SCHEMES ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - Default: ``["https"]`` A list of schemes that the ``allowed_origins`` field will be validated against. @@ -105,13 +93,6 @@ Adding ``"http"`` to the list is considered to be safe only for local developmen Note that `OAUTHLIB_INSECURE_TRANSPORT <https://oauthlib.readthedocs.io/en/latest/oauth2/security.html#envvar-OAUTHLIB_INSECURE_TRANSPORT>`_ environment variable should be also set to allow HTTP origins. - -APPLICATION_MODEL -~~~~~~~~~~~~~~~~~ -The import string of the class (model) representing your applications. Overwrite -this value if you wrote your own implementation (subclass of -``oauth2_provider.models.Application``). - AUTHORIZATION_CODE_EXPIRE_SECONDS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ Default: ``60`` @@ -214,12 +195,6 @@ period the application, the app then has only a consumed refresh token and the only recourse is to have the user re-authenticate. A suggested value, if this is enabled, is 2 minutes. -REFRESH_TOKEN_MODEL -~~~~~~~~~~~~~~~~~~~ -The import string of the class (model) representing your refresh tokens. Overwrite -this value if you wrote your own implementation (subclass of -``oauth2_provider.models.RefreshToken``). - REFRESH_TOKEN_REUSE_PROTECTION ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ When this is set to ``True`` (default ``False``), and ``ROTATE_REFRESH_TOKEN`` is used, the server will check @@ -257,7 +232,7 @@ Defaults to ``oauth2_provider.scopes.SettingsScopes``, which reads scopes throug SCOPES ~~~~~~ -.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. A dictionary mapping each scope name to its human description. @@ -265,7 +240,7 @@ A dictionary mapping each scope name to its human description. DEFAULT_SCOPES ~~~~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. A list of scopes that should be returned by default. This is a subset of the keys of the ``SCOPES`` setting. @@ -277,13 +252,13 @@ By default this is set to ``'__all__'`` meaning that the whole set of ``SCOPES`` READ_SCOPE ~~~~~~~~~~ -.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. The name of the *read* scope. WRITE_SCOPE ~~~~~~~~~~~ -.. note:: (0.12.0+) Only used if ``ACCESS_TOKEN_GENERATOR`` is set to the SettingsScopes default. +.. note:: (0.12.0+) Only used if ``SCOPES_BACKEND_CLASS`` is set to the SettingsScopes default. The name of the *write* scope. @@ -340,7 +315,6 @@ Default: ``False`` Whether or not :doc:`oidc` support is enabled. - OIDC_RSA_PRIVATE_KEY ~~~~~~~~~~~~~~~~~~~~ Default: ``""`` @@ -470,11 +444,47 @@ Time of sleep in seconds used by ``cleartokens`` management command between batc Set this to a non-zero value (e.g. ``0.1``) to add a pause between batch sizes to reduce system load when clearing large batches of expired tokens. +List of non-namespaced settings +------------------------------- +.. note:: + These settings must be set as top-level Django settings (outside of ``OAUTH2_PROVIDER``), + because of the way Django currently implements swappable models. + See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. + + +OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your access tokens. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.AccessToken``). + +OAUTH2_PROVIDER_APPLICATION_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your applications. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.Application``). + +OAUTH2_PROVIDER_ID_TOKEN_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your OpenID Connect ID Token. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.IDToken``). + +OAUTH2_PROVIDER_GRANT_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your OAuth2 grants. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.Grant``). + +OAUTH2_PROVIDER_REFRESH_TOKEN_MODEL +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +The import string of the class (model) representing your refresh tokens. +Overwrite this value if you wrote your own implementation (subclass of +``oauth2_provider.models.RefreshToken``). Settings imported from Django project ------------------------------------- USE_TZ ~~~~~~ - Used to determine whether or not to make token expire dates timezone aware. From 6507e66c2d76a6acef860a4bd40390e7d054c243 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 21 Apr 2025 16:26:45 -0400 Subject: [PATCH 692/722] [pre-commit.ci] pre-commit autoupdate (#1569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.5 → v0.11.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.5...v0.11.6) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e64c2528f..485a3091a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.5 + rev: v0.11.6 hooks: - id: ruff args: [ --fix ] From db4c6c75c19596e9c70be9f6b4c8f68ef21c9a43 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 28 Apr 2025 13:43:15 -0400 Subject: [PATCH 693/722] [pre-commit.ci] pre-commit autoupdate (#1570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.6 → v0.11.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.6...v0.11.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 485a3091a..7cb239c7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.6 + rev: v0.11.7 hooks: - id: ruff args: [ --fix ] From 64b1681ad4c6e05a41139b274e625fff38afab20 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 5 May 2025 14:29:38 -0400 Subject: [PATCH 694/722] [pre-commit.ci] pre-commit autoupdate (#1572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.7 → v0.11.8](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.7...v0.11.8) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7cb239c7e..c24b5a443 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.7 + rev: v0.11.8 hooks: - id: ruff args: [ --fix ] From 877e62542e44c1d0a2ddad8a09bb7a8d7b41c44c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 12 May 2025 13:48:50 -0400 Subject: [PATCH 695/722] [pre-commit.ci] pre-commit autoupdate (#1574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.8 → v0.11.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.8...v0.11.9) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c24b5a443..11f11a935 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.8 + rev: v0.11.9 hooks: - id: ruff args: [ --fix ] From 1d5bfe776fd3e18a94715aaa6fd2940ae041797b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 19 May 2025 14:08:12 -0400 Subject: [PATCH 696/722] [pre-commit.ci] pre-commit autoupdate (#1575) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 11f11a935..9aa44ada9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.9 + rev: v0.11.10 hooks: - id: ruff args: [ --fix ] From 6eb18c3cd31a14aed290e276f71c84a44f085d9c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 26 May 2025 14:39:41 -0400 Subject: [PATCH 697/722] [pre-commit.ci] pre-commit autoupdate (#1577) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9aa44ada9..d36da279e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.10 + rev: v0.11.11 hooks: - id: ruff args: [ --fix ] From 8d3e7a907c5c82c66953b74a015bbc98aa4e636c Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 2 Jun 2025 13:56:42 -0400 Subject: [PATCH 698/722] [pre-commit.ci] pre-commit autoupdate (#1578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.11 → v0.11.12](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.11...v0.11.12) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d36da279e..8c244f88b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.11 + rev: v0.11.12 hooks: - id: ruff args: [ --fix ] From 2f2b7f0b5172a0144e390a5555bf7b2fe23a384e Mon Sep 17 00:00:00 2001 From: q0w <43147888+q0w@users.noreply.github.com> Date: Thu, 5 Jun 2025 23:10:34 +0500 Subject: [PATCH 699/722] Remove unnecessary select for removing an auth code grant (#1568) --- AUTHORS | 1 + oauth2_provider/oauth2_validators.py | 6 ++---- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/AUTHORS b/AUTHORS index e2da60020..e5357ae7c 100644 --- a/AUTHORS +++ b/AUTHORS @@ -123,3 +123,4 @@ Yaroslav Halchenko Yuri Savin Miriam Forner Alex Kerkum +q0w diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index b20d0dd6c..db459a446 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -322,10 +322,8 @@ def invalidate_authorization_code(self, client_id, code, request, *args, **kwarg :raises: InvalidGrantError if the grant does not exist. """ - try: - grant = Grant.objects.get(code=code, application=request.client) - grant.delete() - except Grant.DoesNotExist: + deleted_grant_count, _ = Grant.objects.filter(code=code, application=request.client).delete() + if not deleted_grant_count: raise errors.InvalidGrantError(request=request) def validate_client_id(self, client_id, request, *args, **kwargs): From 362fecb87f439069cc967f47ebbd3ebde614abbe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 9 Jun 2025 14:33:13 -0400 Subject: [PATCH 700/722] [pre-commit.ci] pre-commit autoupdate (#1580) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.12 → v0.11.13](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.12...v0.11.13) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8c244f88b..34a93c193 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.12 + rev: v0.11.13 hooks: - id: ruff args: [ --fix ] From d862f531a2ab96fb3cf05f6ffbf9baf8192c1459 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Jun 2025 13:43:56 -0400 Subject: [PATCH 701/722] [pre-commit.ci] pre-commit autoupdate (#1581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.13 → v0.12.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.13...v0.12.1) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 34a93c193..e117b66b4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.11.13 + rev: v0.12.1 hooks: - id: ruff args: [ --fix ] From bd6336993c2339876907d54fb6556cc79bfbe377 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Sun, 20 Jul 2025 13:52:50 -0400 Subject: [PATCH 702/722] fix: codecov uploads failing (#1585) --- .github/workflows/test.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e3073dc7a..0b453d269 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,6 +6,8 @@ jobs: test-package: name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) runs-on: ubuntu-latest + permissions: + id-token: write # Required for Codecov OIDC token strategy: fail-fast: false matrix: @@ -60,9 +62,10 @@ jobs: DJANGO: ${{ matrix.django-version }} - name: Upload coverage - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v5 with: name: Python ${{ matrix.python-version }} + use_oidc: true test-demo-rp: name: Test Demo Relying Party From 3fad7654182d7eddf076d7070e2af4bfe95341e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 20 Jul 2025 14:11:06 -0400 Subject: [PATCH 703/722] Bump vite from 5.4.14 to 5.4.19 in /tests/app/rp (#1587) --- tests/app/rp/package-lock.json | 8 ++++---- tests/app/rp/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index 6f33dbbec..c8186b56d 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -20,7 +20,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.4.14" + "vite": "^5.4.19" } }, "node_modules/@ampproject/remapping": { @@ -2290,9 +2290,9 @@ } }, "node_modules/vite": { - "version": "5.4.14", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", - "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 2e1a3afb6..603114a1a 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -21,7 +21,7 @@ "svelte-check": "^3.8.0", "tslib": "^2.4.1", "typescript": "^5.0.0", - "vite": "^5.4.14" + "vite": "^5.4.19" }, "type": "module", "dependencies": { From 121abd4fb8398401907860005a8be8bdec0149f3 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 4 Aug 2025 14:35:33 -0400 Subject: [PATCH 704/722] [pre-commit.ci] pre-commit autoupdate (#1584) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.12.1 → v0.12.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.12.1...v0.12.7) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e117b66b4..f4eb471cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.1 + rev: v0.12.7 hooks: - id: ruff args: [ --fix ] From 1c2d99673169d0962c215a835fed392c3e2fdbf0 Mon Sep 17 00:00:00 2001 From: Dawid Wolski <130552247+dawidwolski-identt@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:08:38 +0200 Subject: [PATCH 705/722] Fix: AttributeError in IntrospectTokenView when token not provided (#1562) When token is not provided explicitly respond with a 400 status and properly structured JSON error. Before this a 500 was being returned for an unhandled exception. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Darrel O'Pry <darrel.opry@spry-group.com> --- CHANGELOG.md | 1 + oauth2_provider/views/introspect.py | 5 +++++ tests/test_introspection_view.py | 14 ++++++++++++++ tox.ini | 11 ++++++----- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c4770459..514c45ec6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1512 client_secret not marked sensitive * #1521 Fix 0012 migration loading access token table into memory * #1584 Fix IDP container in docker compose environment could not find templates and static files. +* #1562 Fix: Handle AttributeError in IntrospectTokenView <!-- ### Security --> diff --git a/oauth2_provider/views/introspect.py b/oauth2_provider/views/introspect.py index 5474c3a7e..5b9810c82 100644 --- a/oauth2_provider/views/introspect.py +++ b/oauth2_provider/views/introspect.py @@ -26,6 +26,11 @@ class IntrospectTokenView(ClientProtectedScopedResourceView): @staticmethod def get_token_response(token_value=None): + if token_value is None: + return JsonResponse( + {"error": "invalid_request", "error_description": "Token parameter is missing."}, + status=400, + ) try: token_checksum = hashlib.sha256(token_value.encode("utf-8")).hexdigest() token = ( diff --git a/tests/test_introspection_view.py b/tests/test_introspection_view.py index 3db23bbcd..ad7d8983d 100644 --- a/tests/test_introspection_view.py +++ b/tests/test_introspection_view.py @@ -279,6 +279,20 @@ def test_view_post_notexisting_token(self): }, ) + def test_view_post_no_token(self): + """ + Test that when you pass no token HTTP 400 is returned + """ + auth_headers = { + "HTTP_AUTHORIZATION": "Bearer " + self.resource_server_token.token, + } + response = self.client.post(reverse("oauth2_provider:introspect"), **auth_headers) + + self.assertEqual(response.status_code, 400) + content = response.json() + self.assertIsInstance(content, dict) + self.assertEqual(content["error"], "invalid_request") + def test_view_post_valid_client_creds_basic_auth(self): """Test HTTP basic auth working""" auth_headers = get_basic_auth_header(self.application.client_id, CLEARTEXT_SECRET) diff --git a/tox.ini b/tox.ini index 303b0d51d..d5cf8d2dc 100644 --- a/tox.ini +++ b/tox.ini @@ -5,10 +5,10 @@ envlist = docs, lint, sphinxlint, - py{38,39,310,311,312}-dj42, - py{310,311,312}-dj50, - py{310,311,312}-dj51, - py{310,311,312}-djmain, + py{38,39,310,311,312,313}-dj42, + py{310,311,312,313}-dj50, + py{310,311,312,313}-dj51, + py{310,311,312,313}-djmain, py39-multi-db-dj-42 [gh-actions] @@ -18,6 +18,7 @@ python = 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 [gh-actions:env] DJANGO = @@ -54,7 +55,7 @@ deps = passenv = PYTEST_ADDOPTS -[testenv:py{310,311,312}-djmain] +[testenv:py{310,311,312,313}-djmain] ignore_errors = true ignore_outcome = true From 8f132e7a26f8fb47ea0e3f8bc86af3fde2440a52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cihad=20G=C3=9CNDO=C4=9EDU?= <cihadgundogdu@gmail.com> Date: Tue, 12 Aug 2025 20:00:11 +0300 Subject: [PATCH 706/722] feat: add Turkish translations for OAuth2 provider messages (#1586) * Add Turkish translations for OAuth2 provider messages --- AUTHORS | 1 + CHANGELOG.md | 1 + .../locale/tr/LC_MESSAGES/django.po | 215 ++++++++++++++++++ 3 files changed, 217 insertions(+) create mode 100644 oauth2_provider/locale/tr/LC_MESSAGES/django.po diff --git a/AUTHORS b/AUTHORS index e5357ae7c..7d24d3afd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -35,6 +35,7 @@ Bart Merenda Bas van Oostveen Brian Helba Carl Schwan +Cihad GUNDOGDU Daniel Golding Daniel 'Vector' Kerr Darrel O'Pry diff --git a/CHANGELOG.md b/CHANGELOG.md index 514c45ec6..a8bc0fa32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [unreleased] ### Added * #1506 Support for Wildcard Origin and Redirect URIs +* #1586 Turkish language support added <!-- ### Changed ### Deprecated diff --git a/oauth2_provider/locale/tr/LC_MESSAGES/django.po b/oauth2_provider/locale/tr/LC_MESSAGES/django.po new file mode 100644 index 000000000..cf49b1ccc --- /dev/null +++ b/oauth2_provider/locale/tr/LC_MESSAGES/django.po @@ -0,0 +1,215 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR. +# +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-07-11 14:11+0300\n" +"PO-Revision-Date: 2025-07-11 14:15+0300\n" +"Last-Translator: Cihad GUNDOGDU <cihadgundogdu@gmail.com>\n" +"Language-Team: LANGUAGE <LL@li.org>\n" +"Language: tr\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" +#: oauth2_provider/models.py:84 +msgid "Confidential" +msgstr "Gizli" + +#: oauth2_provider/models.py:85 +msgid "Public" +msgstr "Herkese açık" + +#: oauth2_provider/models.py:94 +msgid "Authorization code" +msgstr "Yetkilendirme kodu" + +#: oauth2_provider/models.py:95 +msgid "Implicit" +msgstr "Açık" + +#: oauth2_provider/models.py:96 +msgid "Resource owner password-based" +msgstr "Kaynak sahibi şifre tabanlı" + +#: oauth2_provider/models.py:97 +msgid "Client credentials" +msgstr "İstemci kimlik bilgileri" + +#: oauth2_provider/models.py:98 +msgid "OpenID connect hybrid" +msgstr "OpenID connect hibrit" + +#: oauth2_provider/models.py:105 +msgid "No OIDC support" +msgstr "OpenID Connect desteği yok" + +#: oauth2_provider/models.py:106 +msgid "RSA with SHA-2 256" +msgstr "RSA ile SHA-2 256" + +#: oauth2_provider/models.py:107 +msgid "HMAC with SHA-2 256" +msgstr "HMAC ile SHA-2 256" + +#: oauth2_provider/models.py:122 +msgid "Allowed URIs list, space separated" +msgstr "İzin verilen URI'ler listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:126 +msgid "Allowed Post Logout URIs list, space separated" +msgstr "İzin verilen Oturum Kapatma URI'leri listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:136 +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Kaydedildiğinde Hashlendi. Bu yeni bir gizli anahtar ise şimdi kopyalayın." + +#: oauth2_provider/models.py:147 +msgid "Allowed origins list to enable CORS, space separated" +msgstr "CORS'u etkinleştirmek için izin verilen kökenler listesi, boşlukla ayrılmış" + +#: oauth2_provider/models.py:227 +#, python-brace-format +msgid "redirect_uris cannot be empty with grant_type {grant_type}" +msgstr "redirect_uris {grant_type} ile boş olamaz" + +#: oauth2_provider/models.py:244 +msgid "You must set OIDC_RSA_PRIVATE_KEY to use RSA algorithm" +msgstr "RSA algoritmasını kullanmak için OIDC_RSA_PRIVATE_KEY ayarlanmalıdır" + +#: oauth2_provider/models.py:253 +msgid "You cannot use HS256 with public grants or clients" +msgstr "HS256'yı herkese açık izinler veya istemcilerle kullanamazsınız" + +#: oauth2_provider/oauth2_validators.py:225 +msgid "The access token is invalid." +msgstr "Geçersiz erişim belirteci." + +#: oauth2_provider/oauth2_validators.py:232 +msgid "The access token has expired." +msgstr "Erişim belirteci süresi dolmuş." + +#: oauth2_provider/oauth2_validators.py:239 +msgid "The access token is valid but does not have enough scope." +msgstr "Erişim belirteci geçerli ancak yeterli kapsamı yok." + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:6 +msgid "Are you sure to delete the application" +msgstr "Uygulamayı silmek istediğinize emin misiniz" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:12 +#: oauth2_provider/templates/oauth2_provider/authorize.html:29 +msgid "Cancel" +msgstr "İptal" + +#: oauth2_provider/templates/oauth2_provider/application_confirm_delete.html:13 +#: oauth2_provider/templates/oauth2_provider/application_detail.html:53 +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:7 +msgid "Delete" +msgstr "Sil" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:10 +msgid "Client id" +msgstr "İstemci kimliği" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:15 +msgid "Client secret" +msgstr "İstemci gizli anahtarı" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:20 +msgid "Hash client secret" +msgstr "İstemci gizli anahtarını hashle" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:21 +msgid "yes,no" +msgstr "evet,hayır" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:25 +msgid "Client type" +msgstr "İstemci türü" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:30 +msgid "Authorization Grant Type" +msgstr "Yetkilendirme İzni Türü" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:35 +msgid "Redirect Uris" +msgstr "Yönlendirme URI'leri" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:40 +msgid "Post Logout Redirect Uris" +msgstr "Oturum Kapatma Yönlendirme URI'leri" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:45 +msgid "Allowed Origins" +msgstr "İzin Verilen Orijinler" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:51 +#: oauth2_provider/templates/oauth2_provider/application_form.html:35 +msgid "Go Back" +msgstr "Geri Dön" + +#: oauth2_provider/templates/oauth2_provider/application_detail.html:52 +msgid "Edit" +msgstr "Düzenle" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:9 +msgid "Edit application" +msgstr "Uygulamayı düzenle" + +#: oauth2_provider/templates/oauth2_provider/application_form.html:37 +msgid "Save" +msgstr "Kaydet" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:6 +msgid "Your applications" +msgstr "Uygulamalarınız" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:14 +msgid "New Application" +msgstr "Yeni Uygulama" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "No applications defined" +msgstr "Tanımlı uygulama yok" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "Click here" +msgstr "Buraya tıklayın" + +#: oauth2_provider/templates/oauth2_provider/application_list.html:17 +msgid "if you want to register a new one" +msgstr "eğer yeni bir tane kaydetmek istiyorsanız" + +#: oauth2_provider/templates/oauth2_provider/application_registration_form.html:5 +msgid "Register a new application" +msgstr "Yeni bir uygulama kaydet" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:8 +#: oauth2_provider/templates/oauth2_provider/authorize.html:30 +msgid "Authorize" +msgstr "Yetkilendir" + +#: oauth2_provider/templates/oauth2_provider/authorize.html:17 +msgid "Application requires the following permissions" +msgstr "Uygulama aşağıdaki izinleri gerektirir" + +#: oauth2_provider/templates/oauth2_provider/authorized-token-delete.html:6 +msgid "Are you sure you want to delete this token?" +msgstr "Bu tokeni silmek istediğinize emin misiniz?" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:6 +msgid "Tokens" +msgstr "Tokenler" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:11 +msgid "revoke" +msgstr "iptal et" + +#: oauth2_provider/templates/oauth2_provider/authorized-tokens.html:19 +msgid "There are no authorized tokens yet." +msgstr "Henüz yetkilendirilmiş token yok." From 467ab44bce920e95089e13dc52f7ab5365b117b4 Mon Sep 17 00:00:00 2001 From: Thales Bento <dev.tbarbosa.bento@gmail.com> Date: Tue, 12 Aug 2025 14:14:02 -0300 Subject: [PATCH 707/722] fix: missing pt_BR translations (#1583) --- AUTHORS | 1 + CHANGELOG.md | 2 + .../locale/pt_BR/LC_MESSAGES/django.po | 265 +++++++++++++++++- 3 files changed, 263 insertions(+), 5 deletions(-) diff --git a/AUTHORS b/AUTHORS index 7d24d3afd..2d3f80527 100644 --- a/AUTHORS +++ b/AUTHORS @@ -113,6 +113,7 @@ Sora Yanai Sören Wegener Spencer Carroll Stéphane Raimbault +Thales Barbosa Bento Tom Evans Vinay Karanam Víðir Valberg Guðmundsson diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bc0fa32..32dd1734c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * #1506 Support for Wildcard Origin and Redirect URIs * #1586 Turkish language support added + <!-- ### Changed ### Deprecated @@ -20,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1521 Fix 0012 migration loading access token table into memory * #1584 Fix IDP container in docker compose environment could not find templates and static files. * #1562 Fix: Handle AttributeError in IntrospectTokenView +* #1583 Fix: Missing pt_BR translations <!-- ### Security --> diff --git a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po index 48d673e33..e852622e3 100644 --- a/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po +++ b/oauth2_provider/locale/pt_BR/LC_MESSAGES/django.po @@ -9,10 +9,10 @@ msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2021-12-30 09:50-0300\n" -"PO-Revision-Date: 2021-12-30 09:50-0300\n" -"Last-Translator: Eduardo Oliveira <eduardo_y05@outlook.com>\n" +"PO-Revision-Date: 2025-07-01 12:35-0300\n" +"Last-Translator: Thales Barbosa Bento <dev.tbarbosa.bento@gmail.com>\n" "Language-Team: LANGUAGE <LL@li.org>\n" -"Language: \n" +"Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" @@ -101,7 +101,7 @@ msgstr "Tem certeza que deseja remover a aplicação?" msgid "Cancel" msgstr "Cancelar" -#: templates/oauth2_provider/application_confirm_delete.html:13 +#: templates/oauth2_provider/application_confirm_delete.html:13F #: templates/oauth2_provider/application_detail.html:38 #: templates/oauth2_provider/authorized-token-delete.html:7 msgid "Delete" @@ -113,7 +113,7 @@ msgstr "ID do Cliente" #: templates/oauth2_provider/application_detail.html:15 msgid "Client secret" -msgstr "Palavra-Chave Secreta do Cliente" +msgstr "Chave Secreta do Cliente" #: templates/oauth2_provider/application_detail.html:20 msgid "Client type" @@ -200,3 +200,258 @@ msgstr "Revogar" #: templates/oauth2_provider/authorized-tokens.html:19 msgid "There are no authorized tokens yet." msgstr "Não existem tokens autorizados ainda." + +msgid "Allowed origins list to enable CORS, space separated" +msgstr "Lista de origens permitidas para habilitar CORS, separadas por espaços" + +msgid "Access tokens" +msgstr "Tokens de acesso" + +msgid "Applications" +msgstr "Aplicações" + +msgid "Grants" +msgstr "Concessões" + +msgid "Id tokens" +msgstr "Tokens de ID" + +msgid "Refresh tokens" +msgstr "Tokens de atualização" + +msgid "Access token" +msgstr "Token de acesso" + +msgid "Application" +msgstr "Aplicação" + +msgid "Grant" +msgstr "Concessão" + +msgid "Id token" +msgstr "Token de ID" + +msgid "Refresh token" +msgstr "Token de atualização" + +msgid "Skip authorization" +msgstr "Pular autorização" + +msgid "Algorithm" +msgstr "Algoritmo" + +msgid "Created" +msgstr "Criado" + +msgid "Updated" +msgstr "Atualizado" + +msgid "Scope" +msgstr "Escopo" + +msgid "Scopes" +msgstr "Escopos" + +msgid "Expires" +msgstr "Expira" + +msgid "Token" +msgstr "Token" + +msgid "User" +msgstr "Usuário" + +msgid "Code" +msgstr "Código" + +msgid "Redirect URI" +msgstr "URI de Redirecionamento" + +msgid "State" +msgstr "Estado" + +msgid "Nonce" +msgstr "Nonce (Number Used Once)" + +msgid "Code Challenge" +msgstr "Desafio do Código" + +msgid "Code Challenge Method" +msgstr "Método do Desafio do Código" + +msgid "PKCE required" +msgstr "PKCE obrigatório" + +msgid "Post logout redirect URI" +msgstr "URI de redirecionamento pós-logout" + +msgid "Hash client secret" +msgstr "Hash da chave secreta do cliente" + +msgid "The client secret will be hashed" +msgstr "A chave secreta do cliente será transformada em hash" + +msgid "Skip authorization completely" +msgstr "Pular autorização completamente" + +msgid "Post logout redirect URIs" +msgstr "URIs de redirecionamento pós-logout" + +msgid "Allowed Post Logout Redirect URIs list, space separated" +msgstr "Lista de URIs de redirecionamento pós-logout permitidas, separadas por espaços" + +msgid "Authorization Grant" +msgstr "Concessão de Autorização" + +msgid "Authorization Grants" +msgstr "Concessões de Autorização" + +msgid "Invalid client or client credentials" +msgstr "Cliente inválido ou credenciais do cliente inválidas" + +msgid "Invalid authorization code" +msgstr "Código de autorização inválido" + +msgid "Invalid redirect URI" +msgstr "URI de redirecionamento inválida" + +msgid "Invalid grant type" +msgstr "Tipo de concessão inválido" + +msgid "Invalid scope" +msgstr "Escopo inválido" + +msgid "Invalid request" +msgstr "Solicitação inválida" + +msgid "Unsupported grant type" +msgstr "Tipo de concessão não suportado" + +msgid "Unsupported response type" +msgstr "Tipo de resposta não suportado" + +msgid "Authorization server error" +msgstr "Erro do servidor de autorização" + +msgid "Temporarily unavailable" +msgstr "Temporariamente indisponível" + +msgid "Access denied" +msgstr "Acesso negado" + +msgid "Invalid client" +msgstr "Cliente inválido" + +msgid "The client identifier provided is invalid" +msgstr "O identificador do cliente fornecido é inválido" + +msgid "The client authentication failed" +msgstr "A autenticação do cliente falhou" + +msgid "The authorization grant is invalid, expired, or revoked" +msgstr "A concessão de autorização é inválida, expirada ou revogada" + +msgid "The authenticated client is not authorized to use this authorization grant type" +msgstr "O cliente autenticado não está autorizado a usar este tipo de concessão de autorização" + +msgid "The request is missing a required parameter" +msgstr "A solicitação está perdendo um parâmetro obrigatório" + +msgid "The authorization server encountered an unexpected condition" +msgstr "O servidor de autorização encontrou uma condição inesperada" + +msgid "The authorization server is currently unable to handle the request" +msgstr "O servidor de autorização atualmente não consegue processar a solicitação" + +msgid "The resource owner or authorization server denied the request" +msgstr "O proprietário do recurso ou servidor de autorização negou a solicitação" + +msgid "The authorization server does not support obtaining authorization codes" +msgstr "O servidor de autorização não suporta obter códigos de autorização" + +msgid "The authorization server does not support the revocation of access tokens" +msgstr "O servidor de autorização não suporta a revogação de tokens de acesso" + +msgid "The authorization server does not support the revocation of refresh tokens" +msgstr "O servidor de autorização não suporta a revogação de tokens de atualização" + +msgid "The authorization server does not support the use of the transformation parameter" +msgstr "O servidor de autorização não suporta o uso do parâmetro de transformação" + +msgid "The target resource is invalid, missing, malformed, or not supported" +msgstr "O recurso de destino é inválido, ausente, malformado ou não suportado" + +msgid "Rotate refresh token" +msgstr "Rotacionar token de atualização" + +msgid "Reuse refresh token" +msgstr "Reutilizar token de atualização" + +msgid "Application name" +msgstr "Nome da aplicação" + +msgid "Application description" +msgstr "Descrição da aplicação" + +msgid "Application logo" +msgstr "Logo da aplicação" + +msgid "Application website" +msgstr "Website da aplicação" + +msgid "Terms of service" +msgstr "Termos de serviço" + +msgid "Privacy policy" +msgstr "Política de privacidade" + +msgid "Support email" +msgstr "Email de suporte" + +msgid "Support URL" +msgstr "URL de suporte" + +msgid "Redirect URIs" +msgstr "URIs de Redirecionamento" + +msgid "Source refresh token" +msgstr "Token de atualização de origem" + +msgid "Token checksum" +msgstr "Checksum do token" + +msgid "Token family" +msgstr "Família do token" + +msgid "Code challenge" +msgstr "Desafio do código" + +msgid "Code challenge method" +msgstr "Método do desafio do código" + +msgid "Claims" +msgstr "Reivindicações" + +msgid "JWT Token ID" +msgstr "ID do Token JWT" + +msgid "Allowed origins" +msgstr "Origens permitidas" + +msgid "Client ID" +msgstr "ID do Cliente" + +msgid "Revoked" +msgstr "Revogado" + +msgid "Authorization grant type" +msgstr "Tipo de concessão de autorização" + +msgid "Hashed on Save. Copy it now if this is a new secret." +msgstr "Hashed em Salvar. Copie agora se este for um novo segredo." + +msgid "allowed origin URI Validation error" +msgstr "erro de validação de URI de origem permitido. invalid_scheme" + +msgid "Date" +msgstr "Data" From 52b0b2f103c647280ed7711fda0ba674913addc0 Mon Sep 17 00:00:00 2001 From: David Reguera <33068707+nablabits@users.noreply.github.com> Date: Tue, 12 Aug 2025 19:47:06 +0200 Subject: [PATCH 708/722] chore(docs): Move around label targets. (#1576) The ...createapplication anchor seems to have ended up in the wrong spot. This moves it to just before the heading. --- docs/management_commands.rst | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/management_commands.rst b/docs/management_commands.rst index 83770041e..0a3f1bda0 100644 --- a/docs/management_commands.rst +++ b/docs/management_commands.rst @@ -5,8 +5,6 @@ Django OAuth Toolkit exposes some useful management commands that can be run via or :doc:`Celery <tutorial/tutorial_05>`. .. _cleartokens: -.. _createapplication: - cleartokens ~~~~~~~~~~~ @@ -27,7 +25,7 @@ The ``cleartokens`` management command will also delete expired access and ID to Note: Refresh tokens need to expire before AccessTokens can be removed from the database. Using ``cleartokens`` without ``REFRESH_TOKEN_EXPIRE_SECONDS`` has limited effect. - +.. _createapplication: createapplication ~~~~~~~~~~~~~~~~~ From 0a5ffdbca596d1b21e7f98ea56ead2cec76192c7 Mon Sep 17 00:00:00 2001 From: Subham <subham.sangwan@adypu.edu.in> Date: Thu, 14 Aug 2025 04:55:52 +0530 Subject: [PATCH 709/722] chore: update deprecated jwcrypto key_type to kty in tests (#1590) key_type was deprecated in latchset/jwcrypto@0edf66d, https://github.com/latchset/jwcrypto/releases/tag/v0.9.0 --- tests/test_authorization_code.py | 2 +- tests/test_models.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index f162e211a..660e5e5d4 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1867,7 +1867,7 @@ def test_id_token(self): # Check decoding JWT using HS256 key = self.application.jwk_key - assert key.key_type == "oct" + assert key.kty == "oct" jwt_token = jwt.JWT(key=key, jwt=content["id_token"]) claims = json.loads(jwt_token.claims) assert claims["sub"] == "1" diff --git a/tests/test_models.py b/tests/test_models.py index 2c7ff8114..eb01aac8f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -563,7 +563,7 @@ def test_clear_expired_id_tokens(oauth2_settings, oidc_tokens, rf): def test_application_key(oauth2_settings, application): # RS256 key key = application.jwk_key - assert key.key_type == "RSA" + assert key.kty == "RSA" # RS256 key, but not configured oauth2_settings.OIDC_RSA_PRIVATE_KEY = None @@ -574,7 +574,7 @@ def test_application_key(oauth2_settings, application): # HS256 key application.algorithm = Application.HS256_ALGORITHM key = application.jwk_key - assert key.key_type == "oct" + assert key.kty == "oct" # No algorithm application.algorithm = Application.NO_ALGORITHM From 762cf8d1d35f72071626de788aa8948111f6cdcc Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 3 Oct 2025 11:21:06 -0400 Subject: [PATCH 710/722] chore: update from jazzband to django-oauth (#1592) --- .github/workflows/release.yml | 11 ++- CHANGELOG.md | 22 ++--- CODE_OF_CONDUCT.md | 150 ++++++++++++++++++++++--------- CONTRIBUTING.md | 8 +- Dockerfile | 4 +- README.rst | 47 ++++------ docs/advanced_topics.rst | 2 +- docs/contributing.rst | 24 +++-- docs/index.rst | 2 +- docs/settings.rst | 2 +- pyproject.toml | 2 +- tests/test_authorization_code.py | 2 +- tests/test_models.py | 2 +- 13 files changed, 163 insertions(+), 115 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 64302e819..0c4329265 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: jobs: build: - if: github.repository == 'jazzband/django-oauth-toolkit' + if: github.repository == 'django-oauth/django-oauth-toolkit' runs-on: ubuntu-latest steps: @@ -29,10 +29,9 @@ jobs: python -m build twine check dist/* - - name: Upload packages to Jazzband + - name: Upload packages to PyPI if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: - user: jazzband - password: ${{ secrets.JAZZBAND_RELEASE_KEY }} - repository_url: https://jazzband.co/projects/django-oauth-toolkit/upload + user: __token__ + password: ${{ secrets.PYPI_PUBLISH_TOKEN }} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 32dd1734c..59eb50c61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1506 Support for Wildcard Origin and Redirect URIs * #1586 Turkish language support added -<!-- ### Changed +The project is now hosted in the django-oauth organization. + +<!-- ### Deprecated ### Removed --> @@ -241,7 +243,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ## [1.6.1] 2021-12-23 ### Changed -* Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/jazzband/django-oauth-toolkit/pull/1046#issuecomment-998015272) +* Note: Only Django 4.0.1+ is supported due to a regression in Django 4.0.0. [Explanation](https://github.com/django-oauth/django-oauth-toolkit/pull/1046#issuecomment-998015272) ### Fixed * Miscellaneous 1.6.0 packaging issues. @@ -332,7 +334,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Fixed * #812: Reverts #643 pass wrong request object to authenticate function. -* Fix concurrency issue with refresh token requests (#[810](https://github.com/jazzband/django-oauth-toolkit/pull/810)) +* Fix concurrency issue with refresh token requests (#[810](https://github.com/django-oauth/django-oauth-toolkit/pull/810)) * #817: Reverts #734 tutorial documentation error. @@ -371,16 +373,16 @@ This is a major release with **BREAKING** changes. Please make sure to review th ### Fixed * Fix a race condition in creation of AccessToken with external oauth2 server. -* Fix several concurrency issues. (#[638](https://github.com/jazzband/django-oauth-toolkit/issues/638)) -* Fix to pass `request` to `django.contrib.auth.authenticate()` (#[636](https://github.com/jazzband/django-oauth-toolkit/issues/636)) +* Fix several concurrency issues. (#[638](https://github.com/django-oauth/django-oauth-toolkit/issues/638)) +* Fix to pass `request` to `django.contrib.auth.authenticate()` (#[636](https://github.com/django-oauth/django-oauth-toolkit/issues/636)) * Fix missing `oauth2_error` property exception oauthlib_core.verify_request method raises exceptions in authenticate. - (#[633](https://github.com/jazzband/django-oauth-toolkit/issues/633)) + (#[633](https://github.com/django-oauth/django-oauth-toolkit/issues/633)) * Fix "django.db.utils.NotSupportedError: FOR UPDATE cannot be applied to the nullable side of an outer join" for postgresql. - (#[714](https://github.com/jazzband/django-oauth-toolkit/issues/714)) + (#[714](https://github.com/django-oauth/django-oauth-toolkit/issues/714)) * Fix to return a new refresh token during grace period rather than the recently-revoked one. - (#[702](https://github.com/jazzband/django-oauth-toolkit/issues/702)) + (#[702](https://github.com/django-oauth/django-oauth-toolkit/issues/702)) * Fix a bug in refresh token revocation. - (#[625](https://github.com/jazzband/django-oauth-toolkit/issues/625)) + (#[625](https://github.com/django-oauth/django-oauth-toolkit/issues/625)) ## 1.2.0 [2018-06-03] @@ -402,7 +404,7 @@ This is a major release with **BREAKING** changes. Please make sure to review th * **Critical**: Django OAuth Toolkit 1.1.0 contained a migration that would revoke all existing RefreshTokens (`0006_auto_20171214_2232`). This release corrects the migration. If you have already ran it in production, please see the following issue for more details: - https://github.com/jazzband/django-oauth-toolkit/issues/589 + https://github.com/django-oauth/django-oauth-toolkit/issues/589 ## 1.1.0 [2018-04-13] diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index e0d5efab5..e5ee8d275 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -1,46 +1,108 @@ # Code of Conduct -As contributors and maintainers of the Jazzband projects, and in the interest of -fostering an open and welcoming community, we pledge to respect all people who -contribute through reporting issues, posting feature requests, updating documentation, -submitting pull requests or patches, and other activities. - -We are committed to making participation in the Jazzband a harassment-free experience -for everyone, regardless of the level of experience, gender, gender identity and -expression, sexual orientation, disability, personal appearance, body size, race, -ethnicity, age, religion, or nationality. - -Examples of unacceptable behavior by participants include: - -- The use of sexualized language or imagery -- Personal attacks -- Trolling or insulting/derogatory comments -- Public or private harassment -- Publishing other's private information, such as physical or electronic addresses, - without explicit permission -- Other unethical or unprofessional conduct - -The Jazzband roadies have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are not -aligned to this Code of Conduct, or to ban temporarily or permanently any contributor -for other behaviors that they deem inappropriate, threatening, offensive, or harmful. - -By adopting this Code of Conduct, the roadies commit themselves to fairly and -consistently applying these principles to every aspect of managing the jazzband -projects. Roadies who do not follow or enforce the Code of Conduct may be permanently -removed from the Jazzband roadies. - -This code of conduct applies both within project spaces and in public spaces when an -individual is representing the project or its community. - -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by -contacting the roadies at `roadies@jazzband.co`. All complaints will be reviewed and -investigated and will result in a response that is deemed necessary and appropriate to -the circumstances. Roadies are obligated to maintain confidentiality with regard to the -reporter of an incident. - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version -1.3.0, available at [https://contributor-covenant.org/version/1/3/0/][version] - -[homepage]: https://contributor-covenant.org -[version]: https://contributor-covenant.org/version/1/3/0/ +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Maintainers are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +[django-oauth-coc@googlegroups.com](mailto:django-oauth-coc@googlegroups.com). +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Warning + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 2. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 3. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Django Commons Code of Conduct](https://github.com/django-commons/membership/blob/main/CODE_OF_CONDUCT.md) \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 49518f460..b89d471e6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,10 +1,10 @@ -[![Jazzband](https://jazzband.co/static/img/jazzband.svg)](https://jazzband.co/) +# Contribute to Django OAuth Toolkit -This is a [Jazzband](https://jazzband.co/) project. By contributing you agree to abide by the [Contributor Code of Conduct](https://jazzband.co/about/conduct) and follow the [guidelines](https://jazzband.co/about/guidelines). +Thanks for your interest, we love contributions! There are many ways to participate. We are always in need of help with code review, bug fixes, feature development, documentation, and community development. -# Contribute to Django OAuth Toolkit +By contributing you agree to abide by the [Code of Conduct](./CODE_OF_CONDUCT.md) -Thanks for your interest, we love contributions! +We are striving to make a free and open source standards compliant OAuth2/OIDC Identity Provider that adheres to best practices out of the box. Let that goal be your guide as you make decisions related to the project. Please [follow these guidelines](https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html) when submitting pull requests. diff --git a/Dockerfile b/Dockerfile index 4565df5ff..8828cead5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,8 +37,8 @@ FROM python:3.11.6-slim # allow embed sha1 at build time as release. ARG GIT_SHA1 -LABEL org.opencontainers.image.authors="https://jazzband.co/projects/django-oauth-toolkit" -LABEL org.opencontainers.image.source="https://github.com/jazzband/django-oauth-toolkit" +LABEL org.opencontainers.image.authors="https://github.com/django-oauth/django-oauth-toolkit/blob/master/AUTHORS" +LABEL org.opencontainers.image.source="https://github.com/django-oauth/django-oauth-toolkit" LABEL org.opencontainers.image.revision=${GIT_SHA1} diff --git a/README.rst b/README.rst index e8b49d2a6..e58098806 100644 --- a/README.rst +++ b/README.rst @@ -1,21 +1,17 @@ Django OAuth Toolkit ==================== -.. image:: https://jazzband.co/static/img/badge.svg - :target: https://jazzband.co/ - :alt: Jazzband - *OAuth2 goodies for the Djangonauts!* .. image:: https://badge.fury.io/py/django-oauth-toolkit.svg :target: http://badge.fury.io/py/django-oauth-toolkit -.. image:: https://github.com/jazzband/django-oauth-toolkit/workflows/Test/badge.svg - :target: https://github.com/jazzband/django-oauth-toolkit/actions +.. image:: https://github.com/django-oauth/django-oauth-toolkit/workflows/Test/badge.svg + :target: https://github.com/django-oauth/django-oauth-toolkit/actions :alt: GitHub Actions -.. image:: https://codecov.io/gh/jazzband/django-oauth-toolkit/branch/master/graph/badge.svg - :target: https://codecov.io/gh/jazzband/django-oauth-toolkit +.. image:: https://codecov.io/gh/django-oauth/django-oauth-toolkit/branch/master/graph/badge.svg + :target: https://codecov.io/gh/django-oauth/django-oauth-toolkit :alt: Coverage .. image:: https://img.shields.io/pypi/pyversions/django-oauth-toolkit.svg @@ -38,7 +34,7 @@ capabilities to your Django projects. Django OAuth Toolkit makes extensive use o Reporting security issues ------------------------- -Please report any security issues to the JazzBand security team at <security@jazzband.co>. Do not file an issue on the tracker. +Please report any security issues to the Django OAuth security team at <django-oauth-security@googlegroups.com>. Do not file an issue on the tracker. Requirements ------------ @@ -78,7 +74,7 @@ If you need an OAuth2 provider you'll want to add the following to your ``urls.p Changelog --------- -See `CHANGELOG.md <https://github.com/jazzband/django-oauth-toolkit/blob/master/CHANGELOG.md>`_. +See `CHANGELOG.md <https://github.com/django-oauth/django-oauth-toolkit/blob/master/CHANGELOG.md>`_. Documentation @@ -99,9 +95,8 @@ We need help maintaining and enhancing django-oauth-toolkit (DOT). Join the team ~~~~~~~~~~~~~ -Please consider joining `Jazzband <https://jazzband.co>`__ (If not -already a member) and the `DOT project -team <https://jazzband.co/projects/django-oauth-toolkit>`__. +There are no barriers to participation. Anyone can open an issue, pr, or review a pull request. Please +dive in! How you can help ~~~~~~~~~~~~~~~~ @@ -109,15 +104,15 @@ How you can help See our `contributing <https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html>`__ info and the open -`issues <https://github.com/jazzband/django-oauth-toolkit/issues>`__ and -`PRs <https://github.com/jazzband/django-oauth-toolkit/pulls>`__, +`issues <https://github.com/django-oauth/django-oauth-toolkit/issues>`__ and +`PRs <https://github.com/django-oauth/django-oauth-toolkit/pulls>`__, especially those labeled -`help-wanted <https://github.com/jazzband/django-oauth-toolkit/labels/help-wanted>`__. +`help-wanted <https://github.com/django-oauth/django-oauth-toolkit/labels/help-wanted>`__. Discussions ~~~~~~~~~~~ Have questions or want to discuss the project? -See `the discussions <https://github.com/jazzband/django-oauth-toolkit/discussions>`__. +See `the discussions <https://github.com/django-oauth/django-oauth-toolkit/discussions>`__. Submit PRs and Perform Reviews @@ -127,18 +122,12 @@ PR submissions and reviews are always appreciated! Since we require an independent review of any PR before it can be merged, having your second set of eyes looking at PRs is extremely valuable. -Please don’t merge PRs -~~~~~~~~~~~~~~~~~~~~~~ - -Please be aware that we don’t want *every* Jazzband member to merge PRs -but just a handful of project team members so that we can maintain a -modicum of control over what goes into a release of this security oriented code base. Only `project -leads <https://jazzband.co/projects/django-oauth-toolkit>`__ are able to -publish releases to Pypi and it becomes difficult when creating a new -release for the leads to deal with “unexpected” merged PRs. -Become a Project Lead +Become a Maintainer ~~~~~~~~~~~~~~~~~~~~~ -If you are interested in stepping up to be a Project Lead, please take a look at -the `discussion about this <https://github.com/jazzband/django-oauth-toolkit/discussions/1479>`__. +If you are interested in stepping up to be a Maintainer, please open an issue. For maintainers we're +looking for a positive attitude, attentiveness to the specifications, strong coding and +communication skills, and a willingness to work with others. Maintainers are responsible for +merging pull requests, managing issues, creating releases, and ensuring the overall health of the +project. diff --git a/docs/advanced_topics.rst b/docs/advanced_topics.rst index 204e3f860..a99559e93 100644 --- a/docs/advanced_topics.rst +++ b/docs/advanced_topics.rst @@ -63,7 +63,7 @@ That's all, now Django OAuth Toolkit will use your model wherever an Application .. note:: ``OAUTH2_PROVIDER_APPLICATION_MODEL`` is the only setting variable that is not namespaced, this is because of the way Django currently implements swappable models. - See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. + See `issue #90 <https://github.com/django-oauth/django-oauth-toolkit/issues/90>`_ for details. Configuring multiple databases ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/contributing.rst b/docs/contributing.rst index 569f5eab2..1b491d70b 100644 --- a/docs/contributing.rst +++ b/docs/contributing.rst @@ -2,17 +2,13 @@ Contributing ============ -.. image:: https://jazzband.co/static/img/jazzband.svg - :target: https://jazzband.co/ - :alt: Jazzband - -This is a `Jazzband <https://jazzband.co>`_ project. By contributing you agree to abide by the `Contributor Code of Conduct <https://jazzband.co/about/conduct>`_ and follow the `guidelines <https://jazzband.co/about/guidelines>`_. +By contributing you agree to abide by the `Code of Conduct <https://github.com/django-oauth/django-oauth-toolkit/blob/master/CODE_OF_CONDUCT.md>`_ and follow the `guidelines <https://django-oauth-toolkit.readthedocs.io/en/latest/contributing.html>`_. Setup ===== -Fork ``django-oauth-toolkit`` repository on `GitHub <https://github.com/jazzband/django-oauth-toolkit>`_ and follow these steps: +Fork ``django-oauth-toolkit`` repository on `GitHub <https://github.com/django-oauth/django-oauth-toolkit>`_ and follow these steps: * Create a virtualenv and activate it * Clone your repository locally @@ -21,7 +17,7 @@ Issues ====== You can find the list of bugs, enhancements and feature requests on the -`issue tracker <https://github.com/jazzband/django-oauth-toolkit/issues>`_. If you want to fix an issue, pick up one and +`issue tracker <https://github.com/django-oauth/django-oauth-toolkit/issues>`_. If you want to fix an issue, pick up one and add a comment stating you're working on it. Code Style @@ -161,7 +157,7 @@ When you begin your PR, you'll be asked to provide the following: * ``Fixed`` for any bug fixes. * ``Security`` in case of vulnerabilities. (Please report any security issues to the - JazzBand security team ``<security@jazzband.co>``. Do not file an issue on the tracker + security team ``<django-oauth-security@googlegroups.com>``. Do not file an issue on the tracker or submit a PR until directed to do so.) * Make sure your name is in :file:`AUTHORS`. We want to give credit to all contributors! @@ -169,7 +165,7 @@ When you begin your PR, you'll be asked to provide the following: If your PR is not yet ready to be merged mark it as a Work-in-Progress By prepending ``WIP:`` to the PR title so that it doesn't get inadvertently approved and merged. -Make sure to request a review by assigning Reviewer ``jazzband/django-oauth-toolkit``. +Make sure to request a review by assigning Reviewer ``django-oauth/django-oauth-toolkit``. This will assign the review to the project team and a member will review it. In the meantime you can continue to add commits to your topic branch (and push them up to GitHub) either if you see something that needs changing, or in response to a reviewer's comments. If a reviewer asks for changes, you do not need to close the pull and reissue it @@ -184,7 +180,7 @@ outdated code and your changes diverge too far from master, the pull request has To pull in upstream changes:: - git remote add upstream https://github.com/jazzband/django-oauth-toolkit.git + git remote add upstream https://github.com/django-oauth/django-oauth-toolkit.git git fetch upstream Then merge the changes that you fetched:: @@ -316,7 +312,7 @@ Reviewing and Merging PRs ------------------------- - Make sure the PR description includes the `pull request template - <https://github.com/jazzband/django-oauth-toolkit/blob/master/.github/pull_request_template.md>`_ + <https://github.com/django-oauth/django-oauth-toolkit/blob/master/.github/pull_request_template.md>`_ - Confirm that all required checklist items from the PR template are both indicated as done in the PR description and are actually done. - Perform a careful review and ask for any needed changes. @@ -351,11 +347,11 @@ password: password Publishing a Release -------------------- -Only Project Leads can `publish a release <https://jazzband.co/about/releases>`_ to pypi.org +Only maintainers can publish a release to pypi.org and rtfd.io. This checklist is a reminder of the required steps. - When planning a new release, create a `milestone - <https://github.com/jazzband/django-oauth-toolkit/milestones>`_ + <https://github.com/django-oauth/django-oauth-toolkit/milestones>`_ and assign issues, PRs, etc. to that milestone. - Review all commits since the last release and confirm that they are properly documented in the CHANGELOG. Reword entries as appropriate with links to docs @@ -366,7 +362,7 @@ and rtfd.io. This checklist is a reminder of the required steps. - :file:`oauth2_provider/__init__.py` to set ``__version__ = "..."`` - Once the final PR is merged, create and push a tag for the release. You'll shortly - get a notification from Jazzband of the availability of two pypi packages (source tgz + get a notification of the availability of two pypi packages (source tgz and wheel). Download these locally before releasing them. - Do a ``tox -e build`` and extract the downloaded and built wheel zip and tgz files into temp directories and do a ``diff -r`` to make sure they have the same content. diff --git a/docs/index.rst b/docs/index.rst index 07ed24314..8519827ed 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,7 +16,7 @@ See our :doc:`Changelog <changelog>` for information on updates. Support ------- -If you need help please submit a `question <https://github.com/jazzband/django-oauth-toolkit/issues/new?assignees=&labels=question&template=question.md&title=>`_. +If you need help please submit a `question <https://github.com/django-oauth/django-oauth-toolkit/issues/new?assignees=&labels=question&template=question.md&title=>`_. Requirements ------------ diff --git a/docs/settings.rst b/docs/settings.rst index 985ca5d2c..9c71bb2a8 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -449,7 +449,7 @@ List of non-namespaced settings .. note:: These settings must be set as top-level Django settings (outside of ``OAUTH2_PROVIDER``), because of the way Django currently implements swappable models. - See `issue #90 <https://github.com/jazzband/django-oauth-toolkit/issues/90>`_ for details. + See `issue #90 <https://github.com/django-oauth/django-oauth-toolkit/issues/90>`_ for details. OAUTH2_PROVIDER_ACCESS_TOKEN_MODEL diff --git a/pyproject.toml b/pyproject.toml index 401d33cab..2bb4a83b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dev = [ [project.urls] Homepage = "https://django-oauth-toolkit.readthedocs.io/" -Repository = "https://github.com/jazzband/django-oauth-toolkit" +Repository = "https://github.com/django-oauth/django-oauth-toolkit" [tool.setuptools.dynamic] version = {attr = "oauth2_provider.__version__"} diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 660e5e5d4..360fac957 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -505,7 +505,7 @@ def test_code_post_auth_failing_redirection_uri_with_querystring(self): """ Test that in case of error the querystring of the redirection uri is preserved - See https://github.com/jazzband/django-oauth-toolkit/issues/238 + See https://github.com/django-oauth/django-oauth-toolkit/issues/238 """ self.client.login(username="test_user", password="123456") diff --git a/tests/test_models.py b/tests/test_models.py index eb01aac8f..8c0048066 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -168,7 +168,7 @@ def test_custom_application_model(self): If a custom application model is installed, it should be present in the related objects and not the swapped out one. - See issue #90 (https://github.com/jazzband/django-oauth-toolkit/issues/90) + See issue #90 (https://github.com/django-oauth/django-oauth-toolkit/issues/90) """ related_object_names = [ f.name From 842a4d5b0f2ab7019b42a0cd3ae913008e7fce6a Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Fri, 3 Oct 2025 12:11:08 -0400 Subject: [PATCH 711/722] Release 3.1 (#1600) * chore: prepare 3.1 release * chore: full release --- CHANGELOG.md | 18 ++++++++++++++++-- oauth2_provider/__init__.py | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59eb50c61..1f17685ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - ## [unreleased] +<!-- +### Added +### Changed +### Deprecated +### Removed +### Fixed +### Security +--> + +## [3.1.0] + + +**NOTE**: This is the first release under the new [django-oauth](https://github.com/django-oauth) organization. The project moved in order to be more independent and to bypass quota limits on parallel CI jobs we were encountering in Jazzband. The project will emulateDjango Commons going forward in it's operation. We're always on the look for willing maintainers and contributors. Feel free to start participating any time. PR's are always welcome. + ### Added -* #1506 Support for Wildcard Origin and Redirect URIs +* #1506 Support for Wildcard Origin and Redirect URIs - Adds a new setting [ALLOW_URL_WILDCARDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#allow-uri-wildcards). This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch +deployments for development previews and user acceptance testing. * #1586 Turkish language support added ### Changed diff --git a/oauth2_provider/__init__.py b/oauth2_provider/__init__.py index 055276878..f5f41e567 100644 --- a/oauth2_provider/__init__.py +++ b/oauth2_provider/__init__.py @@ -1 +1 @@ -__version__ = "3.0.1" +__version__ = "3.1.0" From 0164aec7fb7ee7f8f755d4f992252133c4d72ab7 Mon Sep 17 00:00:00 2001 From: Darrel O'Pry <dopry@users.noreply.github.com> Date: Sun, 2 Nov 2025 01:09:20 -0400 Subject: [PATCH 712/722] chore: update rp and idp styling (#1607) - rp improve styling for readability - rp updated to latest @dopry/svelte-oidc to get EventLog - idp add home page with links to admin and rp - add tasks to pr template asking contributors to update the idp and rp with new features --- .github/pull_request_template.md | 2 + .github/workflows/test.yml | 8 +- tests/app/idp/idp/urls.py | 2 + tests/app/idp/templates/home/index.html | 19 + tests/app/rp/package-lock.json | 1924 ++---- tests/app/rp/package.json | 24 +- tests/app/rp/src/app.html | 73 +- tests/app/rp/src/routes/+page.svelte | 67 +- tests/app/rp/static/materialize.min.css | 7732 +++++++++++++++++++++++ 9 files changed, 8556 insertions(+), 1295 deletions(-) create mode 100644 tests/app/idp/templates/home/index.html create mode 100644 tests/app/rp/static/materialize.min.css diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9e41b33cf..cf8622d1a 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -14,3 +14,5 @@ Fixes # - [ ] documentation updated - [ ] `CHANGELOG.md` updated (only for user relevant changes) - [ ] author name in `AUTHORS` +- [ ] tests/app/idp updated to demonstrate new features +- [ ] tests/app/rp updated to demonstrate new features diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b453d269..b4cdaa8fa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,10 @@ name: Test on: [push, pull_request] +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test-package: name: Test Package (Python ${{ matrix.python-version }}, Django ${{ matrix.django-version }}) @@ -74,8 +78,8 @@ jobs: fail-fast: false matrix: node-version: - - "18.x" - - "20.x" + - "22.x" + - "24.x" steps: - name: Checkout uses: actions/checkout@v4 diff --git a/tests/app/idp/idp/urls.py b/tests/app/idp/idp/urls.py index 90e8abd48..8be65c35b 100644 --- a/tests/app/idp/idp/urls.py +++ b/tests/app/idp/idp/urls.py @@ -17,9 +17,11 @@ from django.contrib import admin from django.urls import include, path +from django.views.generic import TemplateView urlpatterns = [ + path('', TemplateView.as_view(template_name='home/index.html'), name='home'), # Maps the root URL to your home_view path("admin/", admin.site.urls), path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), path("accounts/", include("django.contrib.auth.urls")), diff --git a/tests/app/idp/templates/home/index.html b/tests/app/idp/templates/home/index.html new file mode 100644 index 000000000..5176db721 --- /dev/null +++ b/tests/app/idp/templates/home/index.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Identity Provider Home + + + +

Welcome to the Identity Provider (IdP)

+

This is the home page of the Identity Provider used for testing OAuth2 flows.

+

Please ensure that the test relying party is running to proceed with authentication tests.

+ + + \ No newline at end of file diff --git a/tests/app/rp/package-lock.json b/tests/app/rp/package-lock.json index c8186b56d..2e63efd62 100644 --- a/tests/app/rp/package-lock.json +++ b/tests/app/rp/package-lock.json @@ -8,46 +8,35 @@ "name": "rp", "version": "0.0.1", "dependencies": { - "@dopry/svelte-oidc": "^1.1.0" + "@dopry/svelte-oidc": "^1.2.0", + "jose": "^6.1.0" }, "devDependencies": { - "@sveltejs/adapter-auto": "^3.1.1", - "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.20.6", - "prettier": "^3.3.2", - "prettier-plugin-svelte": "^3.2.4", - "svelte": "^4.2.19", - "svelte-check": "^3.8.0", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.4.19" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.4.0", + "@sveltejs/kit": "^2.48.4", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.43.2", + "svelte-check": "^4.3.3", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "vite": "^7.1.12" } }, "node_modules/@dopry/svelte-oidc": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@dopry/svelte-oidc/-/svelte-oidc-1.1.0.tgz", - "integrity": "sha512-FfXm/f2vRNxFsYxKs8hal1Huf94dqKrRIppDzjDIH9cNy683b9sN9NUY0mZtrHc1yJL+jyfNNsB+bY9/9fCErA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@dopry/svelte-oidc/-/svelte-oidc-1.2.0.tgz", + "integrity": "sha512-iQKkgxjua264dgkm9u2vxMRwN4CKQywOaAQlszFVxcricAjWb5hCmcxD6qeMvprhmE5gB+Bk7JXpuUAq21O72A==", "dependencies": { - "oidc-client": "1.11.5" + "oidc-client-ts": "^3.3.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], @@ -57,13 +46,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ "arm" ], @@ -73,13 +62,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ "arm64" ], @@ -89,13 +78,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], @@ -105,13 +94,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], @@ -121,13 +110,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], @@ -137,13 +126,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], @@ -153,13 +142,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], @@ -169,13 +158,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ "arm" ], @@ -185,13 +174,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ "arm64" ], @@ -201,13 +190,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ "ia32" ], @@ -217,13 +206,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ "loong64" ], @@ -233,13 +222,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ "mips64el" ], @@ -249,13 +238,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", "cpu": [ "ppc64" ], @@ -265,13 +254,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", "cpu": [ "riscv64" ], @@ -281,13 +270,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", "cpu": [ "s390x" ], @@ -297,13 +286,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", "cpu": [ "x64" ], @@ -313,13 +302,29 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", "cpu": [ "x64" ], @@ -329,13 +334,29 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", "cpu": [ "x64" ], @@ -345,13 +366,29 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", "cpu": [ "x64" ], @@ -361,13 +398,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", "cpu": [ "arm64" ], @@ -377,13 +414,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", "cpu": [ "ia32" ], @@ -393,13 +430,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", "cpu": [ "x64" ], @@ -409,21 +446,27 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -435,19 +478,10 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, "node_modules/@jridgewell/trace-mapping": { @@ -460,41 +494,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, - "dependencies": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, - "dependencies": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" - }, - "engines": { - "node": ">= 8" - } - }, "node_modules/@polka/url": { "version": "1.0.0-next.28", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.28.tgz", @@ -502,20 +501,21 @@ "dev": true }, "node_modules/@rollup/plugin-commonjs": { - "version": "25.0.8", - "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-25.0.8.tgz", - "integrity": "sha512-ZEZWTK5n6Qde0to4vS9Mr5x/0UZoqCxPVR9KRUjU4kA2sO7GEUn1fop0DAwpO6z0Nw/kJON9bDmSxdWxO/TT1A==", + "version": "28.0.9", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.9.tgz", + "integrity": "sha512-PIR4/OHZ79romx0BVVll/PkwWpJ7e5lsqFa3gFfcrFPWwLXLV39JVUzQV9RKjWerE7B845Hqjj9VYlQeieZ2dA==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "commondir": "^1.0.1", "estree-walker": "^2.0.2", - "glob": "^8.0.3", + "fdir": "^6.2.0", "is-reference": "1.2.1", - "magic-string": "^0.30.3" + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" }, "engines": { - "node": ">=14.0.0" + "node": ">=16.0.0 || 14 >= 14.17" }, "peerDependencies": { "rollup": "^2.68.0||^3.0.0||^4.0.0" @@ -526,62 +526,6 @@ } } }, - "node_modules/@rollup/plugin-commonjs/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/@rollup/plugin-commonjs/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", - "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", - "dev": true, - "dependencies": { - "@types/estree": "*" - } - }, - "node_modules/@rollup/plugin-commonjs/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/@rollup/plugin-json": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", @@ -603,15 +547,14 @@ } }, "node_modules/@rollup/plugin-node-resolve": { - "version": "15.2.3", - "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-15.2.3.tgz", - "integrity": "sha512-j/lym8nf5E21LwBT4Df1VD6hRO2L2iwUeUmP7litikRsVp1H6NWx20NEp0Y7su+7XGc476GnXXc4kFeZNGmaSQ==", + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", "dev": true, "dependencies": { "@rollup/pluginutils": "^5.0.1", "@types/resolve": "1.20.2", "deepmerge": "^4.2.2", - "is-builtin-module": "^3.2.1", "is-module": "^1.0.0", "resolve": "^1.22.1" }, @@ -649,16 +592,22 @@ } } }, - "node_modules/@rollup/pluginutils/node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", - "integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", "cpu": [ "arm" ], @@ -669,9 +618,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz", - "integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", "cpu": [ "arm64" ], @@ -682,9 +631,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz", - "integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "cpu": [ "arm64" ], @@ -695,9 +644,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz", - "integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", "cpu": [ "x64" ], @@ -707,10 +656,36 @@ "darwin" ] }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz", - "integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", "cpu": [ "arm" ], @@ -721,9 +696,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz", - "integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", "cpu": [ "arm" ], @@ -734,9 +709,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz", - "integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", "cpu": [ "arm64" ], @@ -747,9 +722,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz", - "integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", "cpu": [ "arm64" ], @@ -759,10 +734,23 @@ "linux" ] }, - "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz", - "integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", "cpu": [ "ppc64" ], @@ -773,9 +761,22 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz", - "integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", "cpu": [ "riscv64" ], @@ -786,9 +787,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz", - "integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", "cpu": [ "s390x" ], @@ -799,9 +800,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz", - "integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", "cpu": [ "x64" ], @@ -812,9 +813,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz", - "integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", "cpu": [ "x64" ], @@ -824,10 +825,23 @@ "linux" ] }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz", - "integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", "cpu": [ "arm64" ], @@ -838,9 +852,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz", - "integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", "cpu": [ "ia32" ], @@ -850,10 +864,23 @@ "win32" ] }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz", - "integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", "cpu": [ "x64" ], @@ -863,27 +890,39 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", + "dev": true + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", + "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, "node_modules/@sveltejs/adapter-auto": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", - "integrity": "sha512-6LeZft2Fo/4HfmLBi5CucMYmgRxgcETweQl/yQoZo/895K3S9YWYN4Sfm/IhwlIpbJp3QNvhKmwCHbsqQNYQpw==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.0.tgz", + "integrity": "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw==", "dev": true, - "dependencies": { - "import-meta-resolve": "^4.0.0" - }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/adapter-node": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.0.1.tgz", - "integrity": "sha512-eYdmxdUWMW+dad1JfMsWBPY2vjXz9eE+52A2AQnXPScPJlIxIVk5mmbaEEzrZivLfO2wEcLTZ5vdC03W69x+iA==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.4.0.tgz", + "integrity": "sha512-NMsrwGVPEn+J73zH83Uhss/hYYZN6zT3u31R3IHAn3MiKC3h8fjmIAhLfTSOeNHr5wPYfjjMg8E+1gyFgyrEcQ==", "dev": true, "dependencies": { - "@rollup/plugin-commonjs": "^25.0.7", + "@rollup/plugin-commonjs": "^28.0.1", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^15.2.3", + "@rollup/plugin-node-resolve": "^16.0.0", "rollup": "^4.9.5" }, "peerDependencies": { @@ -891,17 +930,18 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.20.6", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.20.6.tgz", - "integrity": "sha512-ImUkSQ//Xf4N9r0HHAe5vRA7RyQ7U1Ue1YUT235Ig+IiIqbsixEulHTHrP5LtBiC8xOkJoPZQ1VZ/nWHNOaGGw==", + "version": "2.48.4", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.48.4.tgz", + "integrity": "sha512-TGFX1pZUt9qqY20Cv5NyYvy0iLWHf2jXi8s+eCGsig7jQMdwZWKUFMR6TbvFNhfDSUpc1sH/Y5EHv20g3HHA3g==", "dev": true, - "license": "MIT", "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", + "acorn": "^8.14.1", "cookie": "^0.6.0", - "devalue": "^5.1.0", + "devalue": "^5.3.2", "esm-env": "^1.2.2", - "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", @@ -916,50 +956,52 @@ "node": ">=18.13" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0", + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.3 || ^6.0.0" + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.2.tgz", - "integrity": "sha512-MpmF/cju2HqUls50WyTHQBZUV3ovV/Uk8k66AN2gwHogNAG8wnW8xtZDhzNBsFJJuvmq1qnzA5kE7YfMJNFv2Q==", + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.1.tgz", + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", "dev": true, - "peer": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0", - "debug": "^4.3.4", + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", + "debug": "^4.4.1", "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "svelte-hmr": "^0.15.3", - "vitefu": "^0.2.5" + "magic-string": "^0.30.17", + "vitefu": "^1.1.1" }, "engines": { - "node": "^18.0.0 || >=20" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" } }, - "node_modules/@sveltejs/vite-plugin-svelte/node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", - "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.1.tgz", + "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", "dev": true, - "peer": true, "dependencies": { - "debug": "^4.3.4" + "debug": "^4.4.1" }, "engines": { - "node": "^18.0.0 || >=20" + "node": "^20.19 || ^22.12 || >=24" }, "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^3.0.0", - "svelte": "^4.0.0 || ^5.0.0-next.0", - "vite": "^5.0.0" + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", + "svelte": "^5.0.0", + "vite": "^6.3.0 || ^7.0.0" } }, "node_modules/@types/cookie": { @@ -969,15 +1011,9 @@ "dev": true }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", - "dev": true - }, - "node_modules/@types/pug": { - "version": "2.0.10", - "resolved": "https://registry.npmjs.org/@types/pug/-/pug-2.0.10.tgz", - "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, "node_modules/@types/resolve": { @@ -987,9 +1023,10 @@ "dev": true }, "node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "bin": { "acorn": "bin/acorn" }, @@ -997,187 +1034,54 @@ "node": ">=0.4.0" } }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "dev": true, - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, "engines": { - "node": ">= 8" + "node": ">= 0.4" } }, - "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "dev": true, - "dependencies": { - "dequal": "^2.0.3" + "engines": { + "node": ">= 0.4" } }, - "node_modules/axobject-query": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz", - "integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==", - "dev": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "dependencies": { - "fill-range": "^7.1.1" + "readdirp": "^4.0.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "dev": true, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/builtin-modules": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", - "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", - "dev": true, - "engines": { - "node": ">=6" + "node": ">= 14.16.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://paulmillr.com/funding/" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "dev": true, "engines": { "node": ">=6" } }, - "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/code-red": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz", - "integrity": "sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@types/estree": "^1.0.1", - "acorn": "^8.10.0", - "estree-walker": "^3.0.3", - "periscopic": "^3.1.0" - } - }, - "node_modules/code-red/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", "dev": true }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true - }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1187,42 +1091,13 @@ "node": ">= 0.6" } }, - "node_modules/core-js": { - "version": "3.30.1", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.30.1.tgz", - "integrity": "sha512-ZNS5nbiSwDTq4hFosEDqm65izl2CWmLz0hARJMyNQBgkUZMIF51cQiMvIQKA6hvuaeWxQDP3hEedM1JZIgTldQ==", - "hasInstallScript": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "node_modules/crypto-js": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz", - "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" - }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dev": true, - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dev": true, - "peer": true, "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -1242,72 +1117,51 @@ "node": ">=0.10.0" } }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/detect-indent": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", - "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/devalue": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.1.1.tgz", - "integrity": "sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==", - "dev": true - }, - "node_modules/es6-promise": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", - "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.4.2.tgz", + "integrity": "sha512-MwPZTKEPK2k8Qgfmqrd48ZKVvzSQjgW0lXLxiIBA8dQjtf/6mw6pggHNLcyDKyf+fI6eXxlQwPsfaCMTU5U+Bw==", "dev": true }, "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/esm-env": { @@ -1317,58 +1171,38 @@ "dev": true, "license": "MIT" }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/fast-glob": { - "version": "3.2.12", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.12.tgz", - "integrity": "sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w==", + "node_modules/esrap": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.2.tgz", + "integrity": "sha512-DgvlIQeowRNyvLPWW4PT7Gu13WznY288Du086E751mwwbsgr29ytBiYeLzAGIo0qk3Ujob0SDk8TiSaM5WQzNg==", "dev": true, "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.4" - }, - "engines": { - "node": ">=8.6.0" + "@jridgewell/sourcemap-codec": "^1.4.15" } }, - "node_modules/fastq": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", - "integrity": "sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==", - "dev": true, - "dependencies": { - "reusify": "^1.0.4" - } + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, - "dependencies": { - "to-regex-range": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } } }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1392,45 +1226,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true - }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -1443,133 +1238,52 @@ "node": ">= 0.4" } }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/import-meta-resolve": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", - "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-builtin-module": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/is-builtin-module/-/is-builtin-module-3.2.1.tgz", - "integrity": "sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==", + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", "dev": true, "dependencies": { - "builtin-modules": "^3.3.0" + "hasown": "^2.0.2" }, "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-module": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", "dev": true }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-reference": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.2.tgz", - "integrity": "sha512-v3rht/LgVcsdZa3O2Nqs+NMowLOxeOm7Ay9+/ARQ2F+qEoANRcqrjAZKGN0v8ymUetZGgkp26LTnGT7H0Qo9Pg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", "dev": true, "dependencies": { "@types/estree": "*" } }, + "node_modules/jose": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz", + "integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/kleur": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", @@ -1586,85 +1300,12 @@ "dev": true }, "node_modules/magic-string": { - "version": "0.30.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz", - "integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", - "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "dev": true, "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/mri": { @@ -1686,16 +1327,15 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", - "dev": true, - "peer": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", "dev": true, "funding": [ { @@ -1710,55 +1350,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/oidc-client": { - "version": "1.11.5", - "resolved": "https://registry.npmjs.org/oidc-client/-/oidc-client-1.11.5.tgz", - "integrity": "sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==", - "dependencies": { - "acorn": "^7.4.1", - "base64-js": "^1.5.1", - "core-js": "^3.8.3", - "crypto-js": "^4.0.0", - "serialize-javascript": "^4.0.0" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "node_modules/oidc-client-ts": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/oidc-client-ts/-/oidc-client-ts-3.3.0.tgz", + "integrity": "sha512-t13S540ZwFOEZKLYHJwSfITugupW4uYLwuQSSXyKH/wHwZ+7FvgHE7gnNJh1YQIZ1Yd1hKSRjqeXGSUtS0r9JA==", "dependencies": { - "callsites": "^3.0.0" + "jwt-decode": "^4.0.0" }, "engines": { - "node": ">=6" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, "node_modules/path-parse": { @@ -1767,39 +1367,28 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/periscopic": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/periscopic/-/periscopic-3.1.0.tgz", - "integrity": "sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==", - "dev": true, - "dependencies": { - "@types/estree": "^1.0.0", - "estree-walker": "^3.0.0", - "is-reference": "^3.0.0" - } - }, "node_modules/picocolors": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", - "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "engines": { - "node": ">=8.6" + "node": ">=12" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "dev": true, "funding": [ { @@ -1816,8 +1405,8 @@ } ], "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -1825,9 +1414,9 @@ } }, "node_modules/prettier": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", - "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -1840,111 +1429,55 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.4.tgz", - "integrity": "sha512-tZv+ADfeOWFNQkXkRh6zUXE16w3Vla8x2Ug0B/EnSmjR4EnwdwZbGgL/liSwR1kcEALU5mAAyua98HBxheCxgg==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.4.0.tgz", + "integrity": "sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==", "dev": true, "peerDependencies": { "prettier": "^3.0.0", "svelte": "^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0" } }, - "node_modules/queue-microtask": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", - "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "dev": true, - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "dev": true, "dependencies": { - "is-core-module": "^2.13.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, "engines": { - "node": ">=4" - } - }, - "node_modules/reusify": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", - "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", - "dev": true, - "engines": { - "iojs": ">=1.0.0", - "node": ">=0.10.0" - } - }, - "node_modules/rimraf": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", - "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "dependencies": { - "glob": "^7.1.3" + "node": ">= 0.4" }, - "bin": { - "rimraf": "bin.js" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/rollup": { - "version": "4.22.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz", - "integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "dev": true, "dependencies": { - "@types/estree": "1.0.5" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -1954,48 +1487,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.22.4", - "@rollup/rollup-android-arm64": "4.22.4", - "@rollup/rollup-darwin-arm64": "4.22.4", - "@rollup/rollup-darwin-x64": "4.22.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.22.4", - "@rollup/rollup-linux-arm-musleabihf": "4.22.4", - "@rollup/rollup-linux-arm64-gnu": "4.22.4", - "@rollup/rollup-linux-arm64-musl": "4.22.4", - "@rollup/rollup-linux-powerpc64le-gnu": "4.22.4", - "@rollup/rollup-linux-riscv64-gnu": "4.22.4", - "@rollup/rollup-linux-s390x-gnu": "4.22.4", - "@rollup/rollup-linux-x64-gnu": "4.22.4", - "@rollup/rollup-linux-x64-musl": "4.22.4", - "@rollup/rollup-win32-arm64-msvc": "4.22.4", - "@rollup/rollup-win32-ia32-msvc": "4.22.4", - "@rollup/rollup-win32-x64-msvc": "4.22.4", + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, - "node_modules/run-parallel": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", - "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "queue-microtask": "^1.2.2" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2008,45 +1524,6 @@ "node": ">=6" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/sander": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/sander/-/sander-0.5.1.tgz", - "integrity": "sha512-3lVqBir7WuKDHGrKRDn/1Ye3kwpXaDOMsiRP1wd6wpZW56gJhsbp5RqQpA6JG/P+pkXizygnr1dKR8vzWaVsfA==", - "dev": true, - "dependencies": { - "es6-promise": "^3.1.2", - "graceful-fs": "^4.1.3", - "mkdirp": "^0.5.1", - "rimraf": "^2.5.2" - } - }, - "node_modules/serialize-javascript": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-4.0.0.tgz", - "integrity": "sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==", - "dependencies": { - "randombytes": "^2.1.0" - } - }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -2067,21 +1544,6 @@ "node": ">=18" } }, - "node_modules/sorcery": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", - "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^1.0.0", - "minimist": "^1.2.0", - "sander": "^0.5.0" - }, - "bin": { - "sorcery": "bin/sorcery" - } - }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2091,18 +1553,6 @@ "node": ">=0.10.0" } }, - "node_modules/strip-indent": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", - "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", - "dev": true, - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2116,149 +1566,76 @@ } }, "node_modules/svelte": { - "version": "4.2.19", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.19.tgz", - "integrity": "sha512-IY1rnGr6izd10B0A8LqsBfmlT5OILVuZ7XsI0vdGPEvuonFV7NYEUK4dAkm9Zg2q0Um92kYjTpS1CAP3Nh/KWw==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.1", - "@jridgewell/sourcemap-codec": "^1.4.15", - "@jridgewell/trace-mapping": "^0.3.18", - "@types/estree": "^1.0.1", - "acorn": "^8.9.0", - "aria-query": "^5.3.0", - "axobject-query": "^4.0.0", - "code-red": "^1.0.3", - "css-tree": "^2.3.1", - "estree-walker": "^3.0.3", - "is-reference": "^3.0.1", + "version": "5.43.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.43.2.tgz", + "integrity": "sha512-ro1umEzX8rT5JpCmlf0PPv7ncD8MdVob9e18bhwqTKNoLjS8kDvhVpaoYVPc+qMwDAOfcwJtyY7ZFSDbOaNPgA==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.5", + "@types/estree": "^1.0.5", + "acorn": "^8.12.1", + "aria-query": "^5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "esm-env": "^1.2.1", + "esrap": "^2.1.0", + "is-reference": "^3.0.3", "locate-character": "^3.0.0", - "magic-string": "^0.30.4", - "periscopic": "^3.1.0" + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" }, "engines": { - "node": ">=16" + "node": ">=18" } }, "node_modules/svelte-check": { - "version": "3.8.0", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.0.tgz", - "integrity": "sha512-7Nxn+3X97oIvMzYJ7t27w00qUf1Y52irE2RU2dQAd5PyvfGp4E7NLhFKVhb6PV2fx7dCRMpNKDIuazmGthjpSQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.3.3.tgz", + "integrity": "sha512-RYP0bEwenDXzfv0P1sKAwjZSlaRyqBn0Fz1TVni58lqyEiqgwztTpmodJrGzP6ZT2aHl4MbTvWP6gbmQ3FOnBg==", "dev": true, "dependencies": { - "@jridgewell/trace-mapping": "^0.3.17", - "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", + "@jridgewell/trace-mapping": "^0.3.25", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", "picocolors": "^1.0.0", - "sade": "^1.7.4", - "svelte-preprocess": "^5.1.3", - "typescript": "^5.0.3" + "sade": "^1.7.4" }, "bin": { "svelte-check": "bin/svelte-check" }, - "peerDependencies": { - "svelte": "^3.55.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0" - } - }, - "node_modules/svelte-hmr": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", - "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", - "dev": true, - "peer": true, "engines": { - "node": "^12.20 || ^14.13.1 || >= 16" + "node": ">= 18.0.0" }, "peerDependencies": { - "svelte": "^3.19.0 || ^4.0.0" + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": ">=5.0.0" } }, - "node_modules/svelte-preprocess": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", - "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", + "node_modules/svelte/node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "dev": true, - "hasInstallScript": true, "dependencies": { - "@types/pug": "^2.0.6", - "detect-indent": "^6.1.0", - "magic-string": "^0.30.5", - "sorcery": "^0.11.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">= 16.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.10.2", - "coffeescript": "^2.5.1", - "less": "^3.11.3 || ^4.0.0", - "postcss": "^7 || ^8", - "postcss-load-config": "^2.1.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", - "pug": "^3.0.0", - "sass": "^1.26.8", - "stylus": "^0.55.0", - "sugarss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "svelte": "^3.23.0 || ^4.0.0-next.0 || ^4.0.0 || ^5.0.0-next.0", - "typescript": ">=3.9.5 || ^4.0.0 || ^5.0.0" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "coffeescript": { - "optional": true - }, - "less": { - "optional": true - }, - "postcss": { - "optional": true - }, - "postcss-load-config": { - "optional": true - }, - "pug": { - "optional": true - }, - "sass": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "typescript": { - "optional": true - } + "@types/estree": "^1.0.6" } }, - "node_modules/svelte/node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "dev": true, "dependencies": { - "is-number": "^7.0.0" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { - "node": ">=8.0" + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/totalist": { @@ -2271,40 +1648,42 @@ } }, "node_modules/tslib": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", - "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, "node_modules/typescript": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", - "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=12.20" + "node": ">=14.17" } }, "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "version": "7.1.12", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", + "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "dev": true, - "license": "MIT", "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || >=20.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -2313,19 +1692,25 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "jiti": { + "optional": true + }, "less": { "optional": true }, @@ -2346,17 +1731,22 @@ }, "terser": { "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true } } }, "node_modules/vitefu": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-0.2.5.tgz", - "integrity": "sha512-SgHtMLoqaeeGnd2evZ849ZbACbnwQCIwRH57t18FxcXoZop0uQu0uzlIhJBlF/eWVzuce0sHeqPcDo+evVcg8Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.1.tgz", + "integrity": "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==", "dev": true, - "peer": true, "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0" + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "peerDependenciesMeta": { "vite": { @@ -2364,10 +1754,10 @@ } } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true } } diff --git a/tests/app/rp/package.json b/tests/app/rp/package.json index 603114a1a..8662ba38a 100644 --- a/tests/app/rp/package.json +++ b/tests/app/rp/package.json @@ -12,19 +12,21 @@ "format": "prettier --plugin-search-dir . --write ." }, "devDependencies": { - "@sveltejs/adapter-auto": "^3.1.1", - "@sveltejs/adapter-node": "^5.0.1", - "@sveltejs/kit": "^2.20.6", - "prettier": "^3.3.2", - "prettier-plugin-svelte": "^3.2.4", - "svelte": "^4.2.19", - "svelte-check": "^3.8.0", - "tslib": "^2.4.1", - "typescript": "^5.0.0", - "vite": "^5.4.19" + "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-node": "^5.4.0", + "@sveltejs/kit": "^2.48.4", + "@sveltejs/vite-plugin-svelte": "^6.2.1", + "prettier": "^3.6.2", + "prettier-plugin-svelte": "^3.4.0", + "svelte": "^5.43.2", + "svelte-check": "^4.3.3", + "tslib": "^2.8.1", + "typescript": "^5.9.3", + "vite": "^7.1.12" }, "type": "module", "dependencies": { - "@dopry/svelte-oidc": "^1.1.0" + "@dopry/svelte-oidc": "^1.2.0", + "jose": "^6.1.0" } } diff --git a/tests/app/rp/src/app.html b/tests/app/rp/src/app.html index 77ec85d79..7a8d95035 100644 --- a/tests/app/rp/src/app.html +++ b/tests/app/rp/src/app.html @@ -3,10 +3,79 @@ - + + + Django OAuth Toolkit RP Demo + %sveltekit.head% + -
%sveltekit.body%
+
+

Django OAuth Toolkit Test RP

+ +
%sveltekit.body%
+
diff --git a/tests/app/rp/src/routes/+page.svelte b/tests/app/rp/src/routes/+page.svelte index 1df1a226b..9641425b7 100644 --- a/tests/app/rp/src/routes/+page.svelte +++ b/tests/app/rp/src/routes/+page.svelte @@ -1,17 +1,16 @@ + + + + + +
+ +
+ + diff --git a/tests/app/rp/src/routes/device/+page.svelte b/tests/app/rp/src/routes/device/+page.svelte new file mode 100644 index 000000000..cfa9555e7 --- /dev/null +++ b/tests/app/rp/src/routes/device/+page.svelte @@ -0,0 +1,490 @@ + + + + Device Authorization Flow Test + + +
+

Test the OAuth 2.0 Device Authorization Grant

+

+ This page demonstrates the Device Authorization Flow (RFC 8628), which is used by devices + with limited input capabilities (like smart TVs, IoT devices, etc.) to obtain OAuth tokens. + Do not use device-authorization in a browser, this is just an illustrative example to + streamline manual testing for maintainers. It shows how you'd need to implement the flow on + your device. Have a look at this full user journey test for an implementation in Python. +

+
+ +{#if status === 'idle'} +
+

Step 1: Initiate Authorization

+

Click the button below to start the device authorization flow.

+ +
+{/if} + +{#if status === 'authorizing'} +
+

Initiating...

+

Contacting the authorization server...

+
+
+{/if} + +{#if status === 'polling'} +
+

Step 2: Authorize the Device

+

+ Open the verification URL below in a new tab, enter the user code, and approve the + authorization. +

+ +
+
+ User Code: + {userCode} +
+
+ Verification URL: + + {verificationUri} + +
+
+ Expires in: + {expiresIn} seconds +
+
+ + + +
+
+

Polling for authorization... (checking every {interval} seconds)

+
+ + +
+{/if} + +{#if status === 'complete'} +
+

✓ Authorization Complete!

+

Successfully obtained an access token.

+ +
+
+ Token Type: + {tokenType} +
+
+ Expires In: + {expiresInToken} seconds +
+ {#if scope} +
+ Scope: + {scope} +
+ {/if} +
+ Access Token: + +
+ {#if refreshToken} +
+ Refresh Token: + +
+ {/if} +
+ + +
+{/if} + +{#if status === 'error'} +
+

Error

+

{errorMessage}

+ +
+{/if} + +
+

How it works

+
    +
  1. + Device requests authorization: The device sends a request to the authorization + server with its client ID. +
  2. +
  3. + Server returns codes: The server responds with a device code, user code, + and verification URI. +
  4. +
  5. + User authorizes: The user visits the verification URI on another device + (like a phone or computer), enters the user code, and approves the authorization. +
  6. +
  7. + Device polls for token: Meanwhile, the device polls the token endpoint using + the device code until the user completes authorization. +
  8. +
  9. + Token granted: Once the user approves, the polling request returns the access + token. +
  10. +
+
+ + diff --git a/tests/test_device.py b/tests/test_device.py new file mode 100644 index 000000000..727c81002 --- /dev/null +++ b/tests/test_device.py @@ -0,0 +1,769 @@ +from datetime import datetime, timedelta, timezone +from unittest import mock +from urllib.parse import urlencode + +import django.http.response +import pytest +from django import http +from django.conf import settings +from django.contrib.auth import get_user_model +from django.test import RequestFactory +from django.urls import reverse + +import oauth2_provider.models +from oauth2_provider.models import ( + get_access_token_model, + get_application_model, + get_device_grant_model, + get_refresh_token_model, +) +from oauth2_provider.utils import set_oauthlib_user_to_device_request_user + +from . import presets +from .common_testing import OAuth2ProviderTestCase as TestCase + + +Application = get_application_model() +AccessToken = get_access_token_model() +RefreshToken = get_refresh_token_model() +UserModel = get_user_model() +DeviceModel: oauth2_provider.models.DeviceGrant = get_device_grant_model() + + +@pytest.mark.usefixtures("oauth2_settings") +@pytest.mark.oauth2_settings(presets.DEFAULT_SCOPES_RW) +class DeviceFlowBaseTestCase(TestCase): + factory = RequestFactory() + + @classmethod + def setUpTestData(cls): + cls.test_user = UserModel.objects.create_user("test_user", "test@example.com", "123456") + cls.dev_user = UserModel.objects.create_user("dev_user", "dev@example.com", "123456") + + cls.application = Application.objects.create( + name="test_client_credentials_app", + user=cls.dev_user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_DEVICE_CODE, + client_secret="abcdefghijklmnopqrstuvwxyz1234567890", + ) + + def tearDown(self): + DeviceModel.objects.all().delete() + return super().tearDown() + + +class TestDeviceFlow(DeviceFlowBaseTestCase): + """ + The first 2 tests test the device flow in order + how the device flow works + """ + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_initiation(self): + """ + Tests the initial stage of the flow when the device sends its device authorization + request to the authorization server. + + Device Authorization Request(https://datatracker.ietf.org/doc/html/rfc8628#section-3.1) + + This request shape: + POST /device_authorization HTTP/1.1 + Host: server.example.com + Content-Type: application/x-www-form-urlencoded + + client_id=1406020730&scope=example_scope + + Should respond with this response shape: + Device Authorization Response (https://datatracker.ietf.org/doc/html/rfc8628#section-3.2) + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "expires_in": 1800, + "interval": 5 + } + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + # let's make sure the device was created in the db + assert DeviceModel.objects.get(device_code="abc").status == DeviceModel.AUTHORIZATION_PENDING + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 5, + } + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_flow_authorization_user_code_confirm_and_access_token(self): + """ + This is a full user journey test. + + The device initiates the flow by calling the /device-authorization endpoint and starts + polling the /authorize endpoint getting back error until the user approves in the + browser. + + In the meantime, the user visits the /device endpoint in their browsers to submit the + user code and approve, after which the /authorize returns the tokens to the device. + """ + + # ----------------------- + # 0: Setup device flow, where the device sends an authorization request and + # starts polling. The polling will fail because the user has not approved yet + # ----------------------- + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + self.oauth2_settings.OAUTH_PRE_TOKEN_VALIDATION = [set_oauthlib_user_to_device_request_user] + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + device_authorization_response: http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert device_authorization_response.__getitem__("content-type") == "application/json" + device = DeviceModel.objects.get(device_code="abc") + self.assertJSONEqual( + raw=device_authorization_response.content, + expected_data={ + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": device.user_code, + "device_code": device.device_code, + "interval": 5, + }, + ) + + # Device polls /token and gets back error because the user hasn't approved yet + token_payload = { + "device_code": device.device_code, + "client_id": self.application.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + token_response: http.response.JsonResponse = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + assert token_response.__getitem__("content-type") == "application/json" + assert token_response.status_code == 400 + self.assertJSONEqual(raw=token_response.content, expected_data={"error": "authorization_pending"}) + + # /device and /device_confirm require a user to be logged in + # to access it + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + # -------------------------------------------------------------------------------- + # 1. User visits the /device endpoint in their browsers and submits the user code + # submits wrong code then right code + # -------------------------------------------------------------------------------- + + # 1. User visits the /device endpoint in their browsers and submits the user code + # (GET Request to load it) + get_response = self.client.get(reverse("oauth2_provider:device")) + assert get_response.status_code == 200 + assert "form" in get_response.context # Ensure the form is rendered in the context + + # 1.1.0 User visits the /device endpoint in their browsers and submits wrong user code + self.assertContains( + self.client.post(reverse("oauth2_provider:device"), data={"user_code": "invalid_code"}), + status_code=200, + text="Incorrect user code", + count=1, + ) + + # Note: the device not being in the expected test covered in the other tests + + # 1.1.1: user submits valid user code + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) + + self.assertRedirects( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "xyz"}, + ), + expected_url=device_confirm_url, + ) + + # -------------------------------------------------------------------------------- + # 2: We redirect to the accept/deny form (the user is still in their browser) + # and approves + # -------------------------------------------------------------------------------- + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "xyz", "client_id": self.application.client_id}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "accept"}), + expected_url=device_grant_status_url, + ) + + # -------------------------------------------------------------------------------- + # 3: We redirect to the device grant status page (the user is still in their browser) + # -------------------------------------------------------------------------------- + self.assertContains( + response=self.client.get(device_grant_status_url), + text="Device Authorized", + count=1, + ) + + device = DeviceModel.objects.get(device_code="abc") + assert device.status == device.AUTHORIZED + + # ------------------------- + # 4: Device polls /token successfully + # ------------------------- + token_payload = { + "device_code": device.device_code, + "client_id": self.application.client_id, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + token_response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + assert token_response.__getitem__("content-type") == "application/json" + assert token_response.status_code == 200 + + token_data = token_response.json() + assert token_data == { + "access_token": mock.ANY, + "expires_in": 36000, + "token_type": "Bearer", + "scope": "read write", + "refresh_token": mock.ANY, + } + + # ensure the access token and refresh token have the same user as the device that just authenticated + access_token: oauth2_provider.models.AccessToken = AccessToken.objects.get( + token=token_data["access_token"] + ) + assert access_token.user == device.user + + refresh_token: oauth2_provider.models.RefreshToken = RefreshToken.objects.get( + token=token_data["refresh_token"] + ) + assert refresh_token.user == device.user + + def test_user_denies_access(self): + """ + This test asserts the when the user denies access, the state of the grant is saved + and the user is redirected to the page where they can see the "denied" state. + + The /token View returning the appropriate message for the "denied" state is covered + in test_token_view_returns_error_if_device_in_invalid_state. + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + device_grant_status_url = reverse( + "oauth2_provider:device-grant-status", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + + self.assertRedirects( + response=self.client.post(device_confirm_url, data={"action": "deny"}), + expected_url=device_grant_status_url, + ) + + device.refresh_from_db() + assert device.status == device.DENIED + + def test_device_confirm_view_returns_400_on_incorrect_action(self): + """ + This test asserts that the confirm view returns 400 if action is not + "accept" or "deny". + """ + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + status=DeviceModel.AUTHORIZATION_PENDING, + ) + device.save() + + device_confirm_url = reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ) + response = self.client.post(device_confirm_url, data={"action": "inccorect_action"}) + + assert response.status_code == 400 + + def test_device_flow_authorization_device_invalid_state_returns_form_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(days=1), + ) + device.save() + + # This simulates pytest.mark.parameterize, which unfortunately does not work with unittest + # and consequently with Django TestCase. + for invalid_state in ["authorized", "denied", "LOL_status"]: + # Set the device into an incorrect state. + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=200, + text="User code has already been used", + count=1, + ) + + def test_device_flow_authorization_device_expired_returns_form_error(self): + """ + This test asserts that only devices in the expected state (authorization-pending) + can be approved/denied by the user. + """ + + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse("oauth2_provider:device"), + data={"user_code": "user_code"}, + ), + status_code=200, + text="Expired user code", + count=1, + ) + + def test_token_view_returns_error_if_device_in_invalid_state(self): + """ + This test asserts that the token view returns the appropriate errors as specified + in https://datatracker.ietf.org/doc/html/rfc8628#section-3.5, in case the device + has not yet been approved by the user. + """ + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + testcases = [ + ("authorization-pending", '{"error": "authorization_pending"}', 400), + ("expired", '{"error": "expired_token"}', 400), + ("denied", '{"error": "access_denied"}', 400), + ("LOL_status", '{"error": "internal_error"}', 500), + ] + for invalid_state, expected_error_message, expected_error_code in testcases: + device.status = invalid_state + device.save(update_fields=["status"]) + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=expected_error_code, + text=expected_error_message, + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + + def test_token_view_returns_404_error_if_device_not_found(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + ) + device.save() + + token_payload = { + "device_code": "another_device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + self.assertContains( + response=response, + status_code=404, + text="device_not_found", + count=1, + ) + # TokenView should always respond with application/json as it's meant to be + # consumed by devices. + self.assertEqual(response.__getitem__("content-type"), "application/json") + + def test_token_view_status_equals_what_oauthlib_token_response_method_returns(self): + """ + Tests the use case where oauthlib create_token_response returns a status different + than 200. + """ + + class MockOauthlibCoreClass: + def create_token_response(self, _): + return "url", {"headers_are_ignored": True}, '{"Key": "Value"}', 299 + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now() + timedelta(seconds=60), + status="authorized", + ) + device.save() + + token_payload = { + "device_code": "device_code", + "client_id": "client_id", + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + with mock.patch( + "oauth2_provider.views.mixins.OAuthLibMixin.get_oauthlib_core", MockOauthlibCoreClass + ): + response = self.client.post( + "/o/token/", + data=urlencode(token_payload), + content_type="application/x-www-form-urlencoded", + ) + + self.assertEqual(response["content-type"], "application/json") + self.assertContains( + response=response, + status_code=299, + text='{"Key": "Value"}', + count=1, + ) + assert not response.has_header("headers_are_ignored") + + @mock.patch( + "oauthlib.oauth2.rfc8628.endpoints.device_authorization.generate_token", + lambda: "abc", + ) + def test_device_polling_interval_can_be_changed(self): + """ + Tests the device polling rate(interval) can be changed to something other than the default + of 5 seconds. + """ + + self.oauth2_settings.OAUTH_DEVICE_VERIFICATION_URI = "example.com/device" + self.oauth2_settings.OAUTH_DEVICE_USER_CODE_GENERATOR = lambda: "xyz" + + self.oauth2_settings.DEVICE_FLOW_INTERVAL = 10 + + request_data: dict[str, str] = { + "client_id": self.application.client_id, + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + + assert response.json() == { + "verification_uri": "example.com/device", + "expires_in": 1800, + "user_code": "xyz", + "device_code": "abc", + "interval": 10, + } + + def test_incorrect_client_id_sent(self): + """ + Ensure the correct error is returned when an invalid client is sent + """ + request_data: dict[str, str] = { + "client_id": "client_id_that_does_not_exist", + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + + assert response.json() == { + "error": "invalid_request", + "error_description": "Invalid client_id parameter value.", + } + + def test_missing_client_id(self): + """ + Ensure the correct error is returned when the client id is missing. + """ + request_data: dict[str, str] = { + "not_client_id": "client_id_that_does_not_exist", + } + request_as_x_www_form_urlencoded: str = urlencode(request_data) + + response: django.http.response.JsonResponse = self.client.post( + reverse("oauth2_provider:device-authorization"), + data=request_as_x_www_form_urlencoded, + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 400 + + assert response.json() == { + "error": "invalid_request", + "error_description": "Missing client_id parameter.", + } + + def test_device_confirm_and_user_code_views_require_login(self): + URLs = [ + reverse("oauth2_provider:device-confirm", kwargs={"user_code": None, "client_id": "abc"}), + reverse("oauth2_provider:device-confirm", kwargs={"user_code": "abc", "client_id": "abc"}), + reverse("oauth2_provider:device"), + ] + + for url in URLs: + r = self.client.get(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + r = self.client.post(url) + assert r.status_code == 302 + assert r["Location"] == f"{settings.LOGIN_URL}?next={url}" + + def test_device_confirm_view_GET_returns_404_when_device_does_not_exist(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + self.assertContains( + response=self.client.get( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "not_client_id"}, + ) + ), + status_code=404, + text="The requested resource was not found on this server.", + ) + + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.get( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ) + ), + status_code=404, + text="The requested resource was not found on this server.", + ) + + def test_device_confirm_view_POST_returns_404_when_device_does_not_exist(self): + UserModel.objects.create_user( + username="test_user_device_flow", + email="test_device@example.com", + password="password123", + ) + self.client.login(username="test_user_device_flow", password="password123") + + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(), + ) + device.save() + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "not_user_code", "client_id": "client_id"}, + ), + data={"action": "accept"}, + ), + status_code=404, + text="The requested resource was not found on this server.", + count=1, + ) + + # Asserts for valid user_code and client_id but invalid states + for invalid_state in ["authorized", "denied", "expired"]: + device.status = invalid_state + device.save(update_fields=["status"]) + + self.assertContains( + response=self.client.post( + reverse( + "oauth2_provider:device-confirm", + kwargs={"user_code": "user_code", "client_id": "client_id"}, + ), + data={"action": "accept"}, + ), + status_code=404, + text="The requested resource was not found on this server.", + count=1, + ) + + def test_device_is_expired_method_sets_status_to_expired_if_deadline_passed(self): + device = DeviceModel( + client_id="client_id", + device_code="device_code", + user_code="user_code", + scope="scope", + expires=datetime.now(tz=timezone.utc) + timedelta(seconds=-1), # <- essentially expired + ) + device.save() + + assert device.status == device.AUTHORIZATION_PENDING # default value + + # call is_expired() which should update the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED + + # calling again is_expired() should return true and not change the state + is_expired = device.is_expired() + + assert is_expired + assert device.status == device.EXPIRED diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 14c74506e..7e7e46de7 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -180,6 +180,12 @@ def test_authenticate_basic_auth_not_utf8(self): self.request.headers = {"HTTP_AUTHORIZATION": "Basic test"} self.assertFalse(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_basic_auth_public_app_with_device_code(self): + self.request.grant_type = "urn:ietf:params:oauth:grant-type:device_code" + self.request.headers = get_basic_auth_header("client_id", CLEARTEXT_SECRET) + self.application.client_type = Application.CLIENT_PUBLIC + self.assertTrue(self.validator._authenticate_basic_auth(self.request)) + def test_authenticate_check_secret(self): hashed = make_password(CLEARTEXT_SECRET) self.assertTrue(self.validator._check_secret(CLEARTEXT_SECRET, CLEARTEXT_SECRET)) diff --git a/tests/test_utils.py b/tests/test_utils.py index 2c319b6ea..eef4b985c 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,3 +1,5 @@ +import pytest + from oauth2_provider import utils @@ -25,3 +27,24 @@ def test_jwk_from_pem_caches_jwk(): jwk3 = utils.jwk_from_pem(a_different_tiny_rsa_key) assert jwk3 is not jwk1 + + +def test_user_code_generator(): + # Default argument, 8 characters + user_code = utils.user_code_generator() + assert isinstance(user_code, str) + assert len(user_code) == 8 + + for character in user_code: + assert character >= "0" + assert character <= "V" + + another_user_code = utils.user_code_generator() + assert another_user_code != user_code + + shorter_user_code = utils.user_code_generator(user_code_length=1) + assert len(shorter_user_code) == 1 + + with pytest.raises(ValueError): + utils.user_code_generator(user_code_length=0) + utils.user_code_generator(user_code_length=-1) diff --git a/tox.ini b/tox.ini index 0a85f5fb8..29e93a2ae 100644 --- a/tox.ini +++ b/tox.ini @@ -46,7 +46,7 @@ deps = dj52: Django>=5.2,<6.0 djmain: https://github.com/django/django/archive/main.tar.gz djangorestframework - oauthlib>=3.2.2 + oauthlib>=3.3.0 jwcrypto coverage pytest @@ -79,7 +79,7 @@ commands = deps = Jinja2<3.1 sphinx<3 - oauthlib>=3.2.2 + oauthlib>=3.3.0 m2r>=0.2.1 mistune<2 sphinx-rtd-theme diff --git a/uv.lock b/uv.lock index 43764ff93..d5f28ba2c 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.8, <3.14" +requires-python = ">=3.8, <=3.14" resolution-markers = [ "python_full_version >= '3.11'", "python_full_version == '3.10.*'", @@ -260,6 +260,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/c0/cc/08ed5a43f2996a16b462f64a7055c6e962803534924b9b2f1371d8c00b7b/cffi-2.0.0-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf", size = 184288, upload-time = "2025-09-08T23:23:48.404Z" }, { url = "https://files.pythonhosted.org/packages/3d/de/38d9726324e127f727b4ecc376bc85e505bfe61ef130eaf3f290c6847dd4/cffi-2.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7", size = 180509, upload-time = "2025-09-08T23:23:49.73Z" }, { url = "https://files.pythonhosted.org/packages/9b/13/c92e36358fbcc39cf0962e83223c9522154ee8630e1df7c0b3a39a8124e2/cffi-2.0.0-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c", size = 208813, upload-time = "2025-09-08T23:23:51.263Z" }, @@ -344,6 +366,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, { url = "https://files.pythonhosted.org/packages/0a/4e/3926a1c11f0433791985727965263f788af00db3482d89a7545ca5ecc921/charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84", size = 198599, upload-time = "2025-10-14T04:41:53.213Z" }, { url = "https://files.pythonhosted.org/packages/ec/7c/b92d1d1dcffc34592e71ea19c882b6709e43d20fa498042dea8b815638d7/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3", size = 143090, upload-time = "2025-10-14T04:41:54.385Z" }, { url = "https://files.pythonhosted.org/packages/84/ce/61a28d3bb77281eb24107b937a497f3c43089326d27832a63dcedaab0478/charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac", size = 139490, upload-time = "2025-10-14T04:41:55.551Z" }, @@ -547,6 +585,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/51/a03bec00d37faaa891b3ff7387192cef20f01604e5283a5fabc95346befa/coverage-7.10.7-cp313-cp313t-win32.whl", hash = "sha256:2a78cd46550081a7909b3329e2266204d584866e8d97b898cd7fb5ac8d888b1a", size = 221403, upload-time = "2025-09-21T20:02:37.034Z" }, { url = "https://files.pythonhosted.org/packages/53/22/3cf25d614e64bf6d8e59c7c669b20d6d940bb337bdee5900b9ca41c820bb/coverage-7.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:33a5e6396ab684cb43dc7befa386258acb2d7fae7f67330ebb85ba4ea27938eb", size = 222469, upload-time = "2025-09-21T20:02:39.011Z" }, { url = "https://files.pythonhosted.org/packages/49/a1/00164f6d30d8a01c3c9c48418a7a5be394de5349b421b9ee019f380df2a0/coverage-7.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:86b0e7308289ddde73d863b7683f596d8d21c7d8664ce1dee061d0bcf3fbb4bb", size = 220731, upload-time = "2025-09-21T20:02:40.939Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/5844ab4ca6a4dd97a1850e030a15ec7d292b5c5cb93082979225126e35dd/coverage-7.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b06f260b16ead11643a5a9f955bd4b5fd76c1a4c6796aeade8520095b75de520", size = 218302, upload-time = "2025-09-21T20:02:42.527Z" }, + { url = "https://files.pythonhosted.org/packages/f0/89/673f6514b0961d1f0e20ddc242e9342f6da21eaba3489901b565c0689f34/coverage-7.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:212f8f2e0612778f09c55dd4872cb1f64a1f2b074393d139278ce902064d5b32", size = 218578, upload-time = "2025-09-21T20:02:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/05/e8/261cae479e85232828fb17ad536765c88dd818c8470aca690b0ac6feeaa3/coverage-7.10.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3445258bcded7d4aa630ab8296dea4d3f15a255588dd535f980c193ab6b95f3f", size = 249629, upload-time = "2025-09-21T20:02:46.503Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/14ed6546d0207e6eda876434e3e8475a3e9adbe32110ce896c9e0c06bb9a/coverage-7.10.7-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb45474711ba385c46a0bfe696c695a929ae69ac636cda8f532be9e8c93d720a", size = 252162, upload-time = "2025-09-21T20:02:48.689Z" }, + { url = "https://files.pythonhosted.org/packages/ff/49/07f00db9ac6478e4358165a08fb41b469a1b053212e8a00cb02f0d27a05f/coverage-7.10.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:813922f35bd800dca9994c5971883cbc0d291128a5de6b167c7aa697fcf59360", size = 253517, upload-time = "2025-09-21T20:02:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/a2/59/c5201c62dbf165dfbc91460f6dbbaa85a8b82cfa6131ac45d6c1bfb52deb/coverage-7.10.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:93c1b03552081b2a4423091d6fb3787265b8f86af404cff98d1b5342713bdd69", size = 249632, upload-time = "2025-09-21T20:02:51.971Z" }, + { url = "https://files.pythonhosted.org/packages/07/ae/5920097195291a51fb00b3a70b9bbd2edbfe3c84876a1762bd1ef1565ebc/coverage-7.10.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cc87dd1b6eaf0b848eebb1c86469b9f72a1891cb42ac7adcfbce75eadb13dd14", size = 251520, upload-time = "2025-09-21T20:02:53.858Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3c/a815dde77a2981f5743a60b63df31cb322c944843e57dbd579326625a413/coverage-7.10.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:39508ffda4f343c35f3236fe8d1a6634a51f4581226a1262769d7f970e73bffe", size = 249455, upload-time = "2025-09-21T20:02:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/aa/99/f5cdd8421ea656abefb6c0ce92556709db2265c41e8f9fc6c8ae0f7824c9/coverage-7.10.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:925a1edf3d810537c5a3abe78ec5530160c5f9a26b1f4270b40e62cc79304a1e", size = 249287, upload-time = "2025-09-21T20:02:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/e9a2da6a1fc5d007dd51fca083a663ab930a8c4d149c087732a5dbaa0029/coverage-7.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2c8b9a0636f94c43cd3576811e05b89aa9bc2d0a85137affc544ae5cb0e4bfbd", size = 250946, upload-time = "2025-09-21T20:02:59.431Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5b/0b5799aa30380a949005a353715095d6d1da81927d6dbed5def2200a4e25/coverage-7.10.7-cp314-cp314-win32.whl", hash = "sha256:b7b8288eb7cdd268b0304632da8cb0bb93fadcfec2fe5712f7b9cc8f4d487be2", size = 221009, upload-time = "2025-09-21T20:03:01.324Z" }, + { url = "https://files.pythonhosted.org/packages/da/b0/e802fbb6eb746de006490abc9bb554b708918b6774b722bb3a0e6aa1b7de/coverage-7.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:1ca6db7c8807fb9e755d0379ccc39017ce0a84dcd26d14b5a03b78563776f681", size = 221804, upload-time = "2025-09-21T20:03:03.4Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e8/71d0c8e374e31f39e3389bb0bd19e527d46f00ea8571ec7ec8fd261d8b44/coverage-7.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:097c1591f5af4496226d5783d036bf6fd6cd0cbc132e071b33861de756efb880", size = 220384, upload-time = "2025-09-21T20:03:05.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/09/9a5608d319fa3eba7a2019addeacb8c746fb50872b57a724c9f79f146969/coverage-7.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a62c6ef0d50e6de320c270ff91d9dd0a05e7250cac2a800b7784bae474506e63", size = 219047, upload-time = "2025-09-21T20:03:06.795Z" }, + { url = "https://files.pythonhosted.org/packages/f5/6f/f58d46f33db9f2e3647b2d0764704548c184e6f5e014bef528b7f979ef84/coverage-7.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9fa6e4dd51fe15d8738708a973470f67a855ca50002294852e9571cdbd9433f2", size = 219266, upload-time = "2025-09-21T20:03:08.495Z" }, + { url = "https://files.pythonhosted.org/packages/74/5c/183ffc817ba68e0b443b8c934c8795553eb0c14573813415bd59941ee165/coverage-7.10.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8fb190658865565c549b6b4706856d6a7b09302c797eb2cf8e7fe9dabb043f0d", size = 260767, upload-time = "2025-09-21T20:03:10.172Z" }, + { url = "https://files.pythonhosted.org/packages/0f/48/71a8abe9c1ad7e97548835e3cc1adbf361e743e9d60310c5f75c9e7bf847/coverage-7.10.7-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:affef7c76a9ef259187ef31599a9260330e0335a3011732c4b9effa01e1cd6e0", size = 262931, upload-time = "2025-09-21T20:03:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/84/fd/193a8fb132acfc0a901f72020e54be5e48021e1575bb327d8ee1097a28fd/coverage-7.10.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e16e07d85ca0cf8bafe5f5d23a0b850064e8e945d5677492b06bbe6f09cc699", size = 265186, upload-time = "2025-09-21T20:03:13.539Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/74ecc30607dd95ad50e3034221113ccb1c6d4e8085cc761134782995daae/coverage-7.10.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03ffc58aacdf65d2a82bbeb1ffe4d01ead4017a21bfd0454983b88ca73af94b9", size = 259470, upload-time = "2025-09-21T20:03:15.584Z" }, + { url = "https://files.pythonhosted.org/packages/0f/55/79ff53a769f20d71b07023ea115c9167c0bb56f281320520cf64c5298a96/coverage-7.10.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1b4fd784344d4e52647fd7857b2af5b3fbe6c239b0b5fa63e94eb67320770e0f", size = 262626, upload-time = "2025-09-21T20:03:17.673Z" }, + { url = "https://files.pythonhosted.org/packages/88/e2/dac66c140009b61ac3fc13af673a574b00c16efdf04f9b5c740703e953c0/coverage-7.10.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ebbaddb2c19b71912c6f2518e791aa8b9f054985a0769bdb3a53ebbc765c6a1", size = 260386, upload-time = "2025-09-21T20:03:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f1/f48f645e3f33bb9ca8a496bc4a9671b52f2f353146233ebd7c1df6160440/coverage-7.10.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a2d9a3b260cc1d1dbdb1c582e63ddcf5363426a1a68faa0f5da28d8ee3c722a0", size = 258852, upload-time = "2025-09-21T20:03:21.007Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3b/8442618972c51a7affeead957995cfa8323c0c9bcf8fa5a027421f720ff4/coverage-7.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a3cc8638b2480865eaa3926d192e64ce6c51e3d29c849e09d5b4ad95efae5399", size = 261534, upload-time = "2025-09-21T20:03:23.12Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dc/101f3fa3a45146db0cb03f5b4376e24c0aac818309da23e2de0c75295a91/coverage-7.10.7-cp314-cp314t-win32.whl", hash = "sha256:67f8c5cbcd3deb7a60b3345dffc89a961a484ed0af1f6f73de91705cc6e31235", size = 221784, upload-time = "2025-09-21T20:03:24.769Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a1/74c51803fc70a8a40d7346660379e144be772bab4ac7bb6e6b905152345c/coverage-7.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e1ed71194ef6dea7ed2d5cb5f7243d4bcd334bfb63e59878519be558078f848d", size = 222905, upload-time = "2025-09-21T20:03:26.93Z" }, + { url = "https://files.pythonhosted.org/packages/12/65/f116a6d2127df30bcafbceef0302d8a64ba87488bf6f73a6d8eebf060873/coverage-7.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:7fe650342addd8524ca63d77b2362b02345e5f1a093266787d210c70a50b471a", size = 220922, upload-time = "2025-09-21T20:03:28.672Z" }, { url = "https://files.pythonhosted.org/packages/a3/ad/d1c25053764b4c42eb294aae92ab617d2e4f803397f9c7c8295caa77a260/coverage-7.10.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fff7b9c3f19957020cac546c70025331113d2e61537f6e2441bc7657913de7d3", size = 217978, upload-time = "2025-09-21T20:03:30.362Z" }, { url = "https://files.pythonhosted.org/packages/52/2f/b9f9daa39b80ece0b9548bbb723381e29bc664822d9a12c2135f8922c22b/coverage-7.10.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc91b314cef27742da486d6839b677b3f2793dfe52b51bbbb7cf736d5c29281c", size = 218370, upload-time = "2025-09-21T20:03:32.147Z" }, { url = "https://files.pythonhosted.org/packages/dd/6e/30d006c3b469e58449650642383dddf1c8fb63d44fdf92994bfd46570695/coverage-7.10.7-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:567f5c155eda8df1d3d439d40a45a6a5f029b429b06648235f1e7e51b522b396", size = 244802, upload-time = "2025-09-21T20:03:33.919Z" }, @@ -641,6 +705,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, + { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, + { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, + { url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, + { url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, + { url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, + { url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, + { url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, + { url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, + { url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, + { url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, + { url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, + { url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, + { url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, + { url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, + { url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, + { url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, { url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, ] @@ -676,6 +766,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, @@ -764,7 +869,7 @@ requires-dist = [ { name = "django", specifier = ">=4.2" }, { name = "jwcrypto", specifier = ">=1.5.0" }, { name = "m2r", marker = "extra == 'dev'" }, - { name = "oauthlib", specifier = ">=3.2.2" }, + { name = "oauthlib", specifier = ">=3.3.0" }, { name = "pytest", marker = "extra == 'dev'" }, { name = "pytest-cov", marker = "extra == 'dev'" }, { name = "requests", specifier = ">=2.13.0" }, @@ -833,7 +938,7 @@ dependencies = [ [package.metadata] requires-dist = [ - { name = "django", specifier = ">=4.2,<=5.1" }, + { name = "django", specifier = ">=4.2,<=5.2" }, { name = "django-cors-headers", specifier = "==3.14.0" }, { name = "django-environ", specifier = "==0.11.2" }, { name = "django-oauth-toolkit", editable = "." }, @@ -1069,6 +1174,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/56/23/0d8c13a44bde9154821586520840643467aee574d8ce79a17da539ee7fed/markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26", size = 11623, upload-time = "2025-09-27T18:37:29.296Z" }, { url = "https://files.pythonhosted.org/packages/fd/23/07a2cb9a8045d5f3f0890a8c3bc0859d7a47bfd9a560b563899bec7b72ed/markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc", size = 12049, upload-time = "2025-09-27T18:37:30.234Z" }, { url = "https://files.pythonhosted.org/packages/bc/e4/6be85eb81503f8e11b61c0b6369b6e077dcf0a74adbd9ebf6b349937b4e9/markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c", size = 21923, upload-time = "2025-09-27T18:37:31.177Z" }, @@ -1634,6 +1761,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/92/afed3d497f7c186dc71e6ee6d4fcb0acfa5f7d0a1a2878f8beae379ae0cc/tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05", size = 248909, upload-time = "2025-10-08T22:01:23.859Z" }, { url = "https://files.pythonhosted.org/packages/f8/84/ef50c51b5a9472e7265ce1ffc7f24cd4023d289e109f669bdb1553f6a7c2/tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606", size = 96946, upload-time = "2025-10-08T22:01:24.893Z" }, { url = "https://files.pythonhosted.org/packages/b2/b7/718cd1da0884f281f95ccfa3a6cc572d30053cba64603f79d431d3c9b61b/tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999", size = 107705, upload-time = "2025-10-08T22:01:26.153Z" }, + { url = "https://files.pythonhosted.org/packages/19/94/aeafa14a52e16163008060506fcb6aa1949d13548d13752171a755c65611/tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e", size = 154244, upload-time = "2025-10-08T22:01:27.06Z" }, + { url = "https://files.pythonhosted.org/packages/db/e4/1e58409aa78eefa47ccd19779fc6f36787edbe7d4cd330eeeedb33a4515b/tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3", size = 148637, upload-time = "2025-10-08T22:01:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/26/b6/d1eccb62f665e44359226811064596dd6a366ea1f985839c566cd61525ae/tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc", size = 241925, upload-time = "2025-10-08T22:01:29.066Z" }, + { url = "https://files.pythonhosted.org/packages/70/91/7cdab9a03e6d3d2bb11beae108da5bdc1c34bdeb06e21163482544ddcc90/tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0", size = 249045, upload-time = "2025-10-08T22:01:31.98Z" }, + { url = "https://files.pythonhosted.org/packages/15/1b/8c26874ed1f6e4f1fcfeb868db8a794cbe9f227299402db58cfcc858766c/tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879", size = 245835, upload-time = "2025-10-08T22:01:32.989Z" }, + { url = "https://files.pythonhosted.org/packages/fd/42/8e3c6a9a4b1a1360c1a2a39f0b972cef2cc9ebd56025168c4137192a9321/tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005", size = 253109, upload-time = "2025-10-08T22:01:34.052Z" }, + { url = "https://files.pythonhosted.org/packages/22/0c/b4da635000a71b5f80130937eeac12e686eefb376b8dee113b4a582bba42/tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463", size = 97930, upload-time = "2025-10-08T22:01:35.082Z" }, + { url = "https://files.pythonhosted.org/packages/b9/74/cb1abc870a418ae99cd5c9547d6bce30701a954e0e721821df483ef7223c/tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8", size = 107964, upload-time = "2025-10-08T22:01:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/54/78/5c46fff6432a712af9f792944f4fcd7067d8823157949f4e40c56b8b3c83/tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77", size = 163065, upload-time = "2025-10-08T22:01:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/39/67/f85d9bd23182f45eca8939cd2bc7050e1f90c41f4a2ecbbd5963a1d1c486/tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf", size = 159088, upload-time = "2025-10-08T22:01:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/26/5a/4b546a0405b9cc0659b399f12b6adb750757baf04250b148d3c5059fc4eb/tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530", size = 268193, upload-time = "2025-10-08T22:01:39.712Z" }, + { url = "https://files.pythonhosted.org/packages/42/4f/2c12a72ae22cf7b59a7fe75b3465b7aba40ea9145d026ba41cb382075b0e/tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b", size = 275488, upload-time = "2025-10-08T22:01:40.773Z" }, + { url = "https://files.pythonhosted.org/packages/92/04/a038d65dbe160c3aa5a624e93ad98111090f6804027d474ba9c37c8ae186/tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67", size = 272669, upload-time = "2025-10-08T22:01:41.824Z" }, + { url = "https://files.pythonhosted.org/packages/be/2f/8b7c60a9d1612a7cbc39ffcca4f21a73bf368a80fc25bccf8253e2563267/tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f", size = 279709, upload-time = "2025-10-08T22:01:43.177Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/cc36c679f09f27ded940281c38607716c86cf8ba4a518d524e349c8b4874/tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0", size = 107563, upload-time = "2025-10-08T22:01:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/426ca8683cf7b753614480484f6437f568fd2fda2edbdf57a2d3d8b27a0b/tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba", size = 119756, upload-time = "2025-10-08T22:01:45.234Z" }, { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] From 2cc2b60be3bbd6b687531b788bd2f7edd39e6001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=9Fuayip=20=C3=BCz=C3=BClmez?= <17948971+realsuayip@users.noreply.github.com> Date: Fri, 14 Nov 2025 01:37:34 +0000 Subject: [PATCH 721/722] fix: token request throws an error when client is provided in body (#1252) It ends up overwriting request.client which should be the application. This fix ensures request client is set to the client_id --- AUTHORS | 1 + CHANGELOG.md | 6 +++- oauth2_provider/oauth2_validators.py | 31 ++++++++++++------ tests/test_authorization_code.py | 21 ++++++++++++ tests/test_oauth2_validators.py | 48 ++++++++++++++++++++++++++-- 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/AUTHORS b/AUTHORS index 2d8d5465b..e35ed4cb9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -102,6 +102,7 @@ Peter Karman Peter McDonald Petr Dlouhý pySilver +@realsuayip Rodney Richardson Rustem Saiargaliev Rustem Saiargaliev diff --git a/CHANGELOG.md b/CHANGELOG.md index 1821a8ae1..eadc051d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Support for Django 5.2 * Support for Python 3.14 (Django >= 5.2.8) +* #1539 Add device authorization grant support + ### Fixed +* #1252 Fix crash when 'client' is in token request body + @@ -27,7 +32,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * #1506 Support for Wildcard Origin and Redirect URIs - Adds a new setting [ALLOW_URL_WILDCARDS](https://django-oauth-toolkit.readthedocs.io/en/latest/settings.html#allow-uri-wildcards). This feature is useful for working with CI service such as cloudflare, netlify, and vercel that offer branch deployments for development previews and user acceptance testing. * #1586 Turkish language support added -* #1539 Add device authorization grant support ### Changed The project is now hosted in the django-oauth organization. diff --git a/oauth2_provider/oauth2_validators.py b/oauth2_provider/oauth2_validators.py index ec974b0c6..a202a6a82 100644 --- a/oauth2_provider/oauth2_validators.py +++ b/oauth2_provider/oauth2_validators.py @@ -214,19 +214,31 @@ def _load_application(self, client_id, request): If request.client was not set, load application instance for given client_id and store it in request.client """ - - # we want to be sure that request has the client attribute! - assert hasattr(request, "client"), '"request" instance has no "client" attribute' - + if request.client: + # check for cached client, to save the db hit if this has already been loaded + if not isinstance(request.client, Application): + # resetting request.client (client_id=%r): not an Application, something else set request.client erroneously + request.client = None + elif request.client.client_id != client_id: + # resetting request.client (client_id=%r): request.client.client_id does not match the given client_id + request.client = None + elif not request.client.is_usable(request): + # resetting request.client (client_id=%r): request.client is a valid Application, but is not usable + request.client = None + else: + # request.client is a valid Application, reusing it + return request.client try: - request.client = request.client or Application.objects.get(client_id=client_id) - # Check that the application can be used (defaults to always True) - if not request.client.is_usable(request): - log.debug("Failed body authentication: Application %r is disabled" % (client_id)) + # cache not hit, loading application from database for client_id %r + client = Application.objects.get(client_id=client_id) + if not client.is_usable(request): + # Failed to load application: Application %r is not usable return None + request.client = client + # Loaded application with client_id %r from database return request.client except Application.DoesNotExist: - log.debug("Failed body authentication: Application %r does not exist" % (client_id)) + # Failed to load application: Application with client_id %r does not exist return None def _set_oauth2_error_on_request(self, request, access_token, scopes): @@ -289,6 +301,7 @@ def client_authentication_required(self, request, *args, **kwargs): pass self._load_application(request.client_id, request) + log.debug("Determining if client authentication is required for client %r", request.client) if request.client: return request.client.client_type == AbstractApplication.CLIENT_CONFIDENTIAL diff --git a/tests/test_authorization_code.py b/tests/test_authorization_code.py index 360fac957..369b1939f 100644 --- a/tests/test_authorization_code.py +++ b/tests/test_authorization_code.py @@ -1308,6 +1308,27 @@ def test_request_body_params(self): self.assertEqual(content["scope"], "read write") self.assertEqual(content["expires_in"], self.oauth2_settings.ACCESS_TOKEN_EXPIRE_SECONDS) + def test_request_body_params_client_typo(self): + """ + Verify that using incorrect parameter name (client instead of client_id) returns invalid_client error + """ + self.client.login(username="test_user", password="123456") + authorization_code = self.get_auth() + + token_request_data = { + "grant_type": "authorization_code", + "code": authorization_code, + "redirect_uri": "http://example.org", + "client": self.application.client_id, + "client_secret": CLEARTEXT_SECRET, + } + + response = self.client.post(reverse("oauth2_provider:token"), data=token_request_data) + self.assertEqual(response.status_code, 401) + + content = json.loads(response.content.decode("utf-8")) + self.assertEqual(content["error"], "invalid_client") + def test_public(self): """ Request an access token using client_type: public diff --git a/tests/test_oauth2_validators.py b/tests/test_oauth2_validators.py index 7e7e46de7..3fb292060 100644 --- a/tests/test_oauth2_validators.py +++ b/tests/test_oauth2_validators.py @@ -216,8 +216,52 @@ def test_client_authentication_required(self): self.request.client = "" self.assertTrue(self.validator.client_authentication_required(self.request)) - def test_load_application_fails_when_request_has_no_client(self): - self.assertRaises(AssertionError, self.validator.authenticate_client_id, "client_id", {}) + def test_load_application_loads_client_id_when_request_has_no_client(self): + self.request.client = None + application = self.validator._load_application("client_id", self.request) + self.assertEqual(application, self.application) + + def test_load_application_uses_cached_when_request_has_valid_client_matching_client_id(self): + self.request.client = self.application + application = self.validator._load_application("client_id", self.request) + self.assertIs(application, self.application) + self.assertIs(self.request.client, self.application) + + def test_load_application_succeeds_when_request_has_invalid_client_valid_client_id(self): + self.request.client = 'invalid_client' + application = self.validator._load_application("client_id", self.request) + self.assertEqual(application, self.application) + self.assertEqual(self.request.client, self.application) + + def test_load_application_overwrites_client_on_client_id_mismatch(self): + another_application = Application.objects.create( + client_id="another_client_id", + client_secret=CLEARTEXT_SECRET, + user=self.user, + client_type=Application.CLIENT_PUBLIC, + authorization_grant_type=Application.GRANT_PASSWORD, + ) + self.request.client = another_application + application = self.validator._load_application("client_id", self.request) + self.assertEqual(application, self.application) + self.assertEqual(self.request.client, self.application) + another_application.delete() + + @mock.patch.object(Application, "is_usable") + def test_load_application_returns_none_when_client_not_usable_cached(self, mock_is_usable): + mock_is_usable.return_value = False + self.request.client = self.application + application = self.validator._load_application("client_id", self.request) + self.assertIsNone(application) + self.assertIsNone(self.request.client) + + @mock.patch.object(Application, "is_usable") + def test_load_application_returns_none_when_client_not_usable_db_lookup(self, mock_is_usable): + mock_is_usable.return_value = False + self.request.client = None + application = self.validator._load_application("client_id", self.request) + self.assertIsNone(application) + self.assertIsNone(self.request.client) def test_rotate_refresh_token__is_true(self): self.assertTrue(self.validator.rotate_refresh_token(mock.MagicMock())) From bade920ee5951c22f9aa20ac39f234a7e8498968 Mon Sep 17 00:00:00 2001 From: Tuhin Mitra <48290754+Tuhin-thinks@users.noreply.github.com> Date: Fri, 14 Nov 2025 07:36:02 +0530 Subject: [PATCH 722/722] Fixed Handled error in OAuth2ExtraTokenMiddleware when authheader has `Bearer` with no token-string following up (#1502) * fix: error when authheader is `Bearer ` with no token-string it throws an error. --- AUTHORS | 3 +- CHANGELOG.md | 1 + oauth2_provider/middleware.py | 5 +- tests/test_oauth2_provider_middleware.py | 98 ++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 tests/test_oauth2_provider_middleware.py diff --git a/AUTHORS b/AUTHORS index e35ed4cb9..4ebe787cd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -128,4 +128,5 @@ Yaroslav Halchenko Yuri Savin Miriam Forner Alex Kerkum -q0w +Tuhin Mitra +q0w \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index eadc051d3..a29772c13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --> ### Fixed * #1252 Fix crash when 'client' is in token request body +* #1496 Fix error when Bearer token string is empty but preceded by `Bearer` keyword. diff --git a/oauth2_provider/middleware.py b/oauth2_provider/middleware.py index 65c9cf03c..5a8a86d87 100644 --- a/oauth2_provider/middleware.py +++ b/oauth2_provider/middleware.py @@ -52,8 +52,9 @@ def __init__(self, get_response): def __call__(self, request): authheader = request.META.get("HTTP_AUTHORIZATION", "") - if authheader.startswith("Bearer"): - tokenstring = authheader.split()[1] + splits = authheader.split(maxsplit=1) + if authheader.startswith("Bearer") and len(splits) == 2: + tokenstring = splits[1] AccessToken = get_access_token_model() try: token_checksum = hashlib.sha256(tokenstring.encode("utf-8")).hexdigest() diff --git a/tests/test_oauth2_provider_middleware.py b/tests/test_oauth2_provider_middleware.py new file mode 100644 index 000000000..90610f78b --- /dev/null +++ b/tests/test_oauth2_provider_middleware.py @@ -0,0 +1,98 @@ +import datetime +import hashlib + +from django.contrib.auth import get_user_model +from django.test import RequestFactory, TestCase + +from oauth2_provider.middleware import OAuth2ExtraTokenMiddleware +from oauth2_provider.models import get_access_token_model, get_application_model + + +Application = get_application_model() +AccessToken = get_access_token_model() +User = get_user_model() + + +class TestOAuth2ExtraTokenMiddleware(TestCase): + def setUp(self): + self.factory = RequestFactory() + self.middleware = OAuth2ExtraTokenMiddleware(lambda r: None) + + # Create test user and application for valid token tests + self.user = User.objects.create_user("test_user", "test@example.com", "123456") + self.application = Application.objects.create( + name="Test Application", + user=self.user, + client_type=Application.CLIENT_CONFIDENTIAL, + authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE, + ) + + def test_malformed_bearer_header_no_token(self): + """Test that 'Authorization: Bearer' without token doesn't crash""" + request = self.factory.get("/", HTTP_AUTHORIZATION="Bearer") + + # This should not raise an IndexError + _ = self.middleware(request) + + # Should not have access_token attribute + self.assertFalse(hasattr(request, "access_token")) + + def test_malformed_bearer_header_empty_token(self): + """Test that 'Authorization: Bearer ' with empty token doesn't crash""" + request = self.factory.get("/", HTTP_AUTHORIZATION="Bearer ") + + # This should not raise an IndexError + _ = self.middleware(request) + + # Should not have access_token attribute + self.assertFalse(hasattr(request, "access_token")) + + def test_valid_bearer_token(self): + """Test that valid bearer token works correctly""" + # Create a valid access token + token_string = "test-token-12345" + token_checksum = hashlib.sha256(token_string.encode("utf-8")).hexdigest() + access_token = AccessToken.objects.create( + user=self.user, + scope="read", + expires=datetime.datetime.now() + datetime.timedelta(days=1), + token=token_string, + token_checksum=token_checksum, + application=self.application, + ) + + request = self.factory.get("/", HTTP_AUTHORIZATION=f"Bearer {token_string}") + + _ = self.middleware(request) + + # Should have access_token attribute set + self.assertTrue(hasattr(request, "access_token")) + self.assertEqual(request.access_token, access_token) + + def test_invalid_bearer_token(self): + """Test that invalid bearer token doesn't crash but doesn't set access_token""" + request = self.factory.get("/", HTTP_AUTHORIZATION="Bearer invalid-token-xyz") + + # This should not raise an exception + _ = self.middleware(request) + + # Should not have access_token attribute + self.assertFalse(hasattr(request, "access_token")) + + def test_no_authorization_header(self): + """Test that request without Authorization header works normally""" + request = self.factory.get("/") + + _ = self.middleware(request) + + # Should not have access_token attribute + self.assertFalse(hasattr(request, "access_token")) + + def test_non_bearer_authorization_header(self): + """Test that non-Bearer authorization headers are ignored""" + request = self.factory.get("/", HTTP_AUTHORIZATION="Basic dXNlcjpwYXNz") + + _ = self.middleware(request) + + # Should not have access_token attribute + self.assertFalse(hasattr(request, "access_token"))