From 6f3ebcf7e675be098d6fb593ed87b2dee70a68d3 Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Sat, 7 May 2022 14:35:27 -0400 Subject: [PATCH 001/252] 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 002/252] [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 --- .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?= Date: Tue, 17 May 2022 15:59:31 +0200 Subject: [PATCH 003/252] 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 --- 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 004/252] [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 005/252] 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 , 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\n" +"Language-Team: LANGUAGE \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 Date: Sat, 4 Jun 2022 03:04:28 +1000 Subject: [PATCH 006/252] support prompt login (#1164) Co-authored-by: Alan Crosswell Co-authored-by: Alan Crosswell Co-authored-by: Alan Crosswell --- 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 `_ +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?= Date: Fri, 3 Jun 2022 19:28:42 +0200 Subject: [PATCH 007/252] Adds french translation file (#1163) * Adds french translation file Co-authored-by: Alan Crosswell --- 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 \n" +"Language-Team: LANGUAGE \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 008/252] 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. `_ +A detailed dive into the `Django REST framework permissions is here. `_ From 155bef3709f1373f6e61270dd0fdf0a77095bf6e Mon Sep 17 00:00:00 2001 From: Joseph Abrahams Date: Fri, 3 Jun 2022 12:15:47 -0600 Subject: [PATCH 009/252] 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 Date: Thu, 9 Jun 2022 02:21:33 +0500 Subject: [PATCH 010/252] 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 011/252] [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 Date: Tue, 14 Jun 2022 08:54:30 -0400 Subject: [PATCH 012/252] 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 013/252] [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 Date: Wed, 22 Jun 2022 03:40:31 +0800 Subject: [PATCH 014/252] 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 Date: Thu, 23 Jun 2022 10:56:13 -0400 Subject: [PATCH 015/252] 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 016/252] [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 Date: Wed, 6 Jul 2022 01:35:34 +1000 Subject: [PATCH 017/252] 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 ``. 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 Date: Sat, 6 Aug 2022 10:43:33 -0400 Subject: [PATCH 018/252] 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 Date: Sat, 6 Aug 2022 18:21:17 +0200 Subject: [PATCH 019/252] 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 `_ 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 020/252] [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 Date: Tue, 6 Sep 2022 18:30:35 +0100 Subject: [PATCH 021/252] 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 022/252] 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 023/252] 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 Date: Tue, 4 Oct 2022 16:58:23 +0200 Subject: [PATCH 024/252] 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?= Date: Mon, 10 Oct 2022 15:52:06 +0200 Subject: [PATCH 025/252] handle oauthlib errors on create token requests (#1210) Co-authored-by: andrej --- 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 026/252] [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?= Date: Tue, 18 Oct 2022 18:38:17 +0200 Subject: [PATCH 027/252] Release 2.2.0 (#1214) * Release 2.2.0 * Sort AUTHORS alphabetically Co-authored-by: Alan Crosswell --- 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 Date: Sat, 22 Oct 2022 16:35:35 +0200 Subject: [PATCH 028/252] 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 Date: Mon, 31 Oct 2022 09:20:40 -0500 Subject: [PATCH 029/252] 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?= Date: Fri, 18 Nov 2022 22:39:38 +0100 Subject: [PATCH 030/252] 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 Date: Mon, 5 Dec 2022 11:01:41 -0500 Subject: [PATCH 031/252] 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 032/252] 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 , 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 \n" +"Language-Team: LANGUAGE \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 Date: Tue, 6 Dec 2022 12:03:45 -0500 Subject: [PATCH 033/252] Update getting_started (#1224) There was a missing part about the code verifier. Co-authored-by: Alan Crosswell --- 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 034/252] [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 --- .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 035/252] [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 036/252] [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 Date: Wed, 4 Jan 2023 14:18:47 -0500 Subject: [PATCH 037/252] 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 038/252] [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 --- .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 Date: Fri, 20 Jan 2023 19:28:14 +0100 Subject: [PATCH 039/252] 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 040/252] [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 Date: Sun, 12 Feb 2023 14:03:35 +0100 Subject: [PATCH 041/252] 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 042/252] [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 --- .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 043/252] documentation seems to be outdated regarding rotate_refresh_token setting known bug (#1250) Co-authored-by: Alan Crosswell --- 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 Date: Wed, 15 Feb 2023 16:17:30 +0100 Subject: [PATCH 044/252] 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 * 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 --- 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 `_ 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 `_ 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 Date: Thu, 16 Feb 2023 14:48:14 -0600 Subject: [PATCH 045/252] 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 --- .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 Date: Tue, 7 Mar 2023 23:05:45 +0100 Subject: [PATCH 046/252] 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 Date: Wed, 22 Mar 2023 14:35:55 +0100 Subject: [PATCH 047/252] 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 Date: Wed, 22 Mar 2023 15:04:36 +0100 Subject: [PATCH 048/252] Upgrade GitHub Actions (#1256) Co-authored-by: Alan Crosswell --- .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 049/252] [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 Date: Fri, 14 Apr 2023 19:55:59 +0100 Subject: [PATCH 050/252] 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 `_, so that everything is `rfc-compliant `_. -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 `_. - - 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 Date: Mon, 8 May 2023 20:32:04 +0700 Subject: [PATCH 051/252] 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 052/252] 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 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 `_. + 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 ``/o/userinfo/``. +OIDC_RP_INITIATED_LOGOUT_ENABLED +~~~~~~~~~~~~~~~~~~~~~~~~ +Default: ``False`` + +When is set to `False` (default) the `OpenID Connect RP-Initiated Logout `_ +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 %} +
+ {% if not error %} +
+ {% if application %} +

Confirm Logout requested by {{ application.name }}

+ {% else %} +

Confirm Logout

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

Error: {{ error.error }}

+

{{ error.description }}

+ {% endif %} +
+{% 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 Date: Sun, 21 May 2023 01:31:31 +0200 Subject: [PATCH 053/252] 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 * Update tests/test_oidc_views.py Co-authored-by: François Freitag * Update tests/test_oidc_views.py Co-authored-by: François Freitag * 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 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 Date: Sun, 21 May 2023 02:12:36 +0200 Subject: [PATCH 054/252] 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 Date: Wed, 31 May 2023 19:44:06 +0530 Subject: [PATCH 055/252] 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 --- 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 Date: Wed, 31 May 2023 16:14:28 -0400 Subject: [PATCH 056/252] 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 Date: Wed, 31 May 2023 16:47:14 -0400 Subject: [PATCH 057/252] 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 Date: Thu, 8 Jun 2023 16:25:11 +0200 Subject: [PATCH 058/252] 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 Date: Mon, 12 Jun 2023 17:56:15 +0200 Subject: [PATCH 059/252] 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 Date: Mon, 12 Jun 2023 19:05:53 +0200 Subject: [PATCH 060/252] 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 Date: Tue, 13 Jun 2023 14:26:10 +0200 Subject: [PATCH 061/252] 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 @@

{{ application.name }}

{% trans "Redirect Uris" %}

+ +
  • +

    {% trans "Post Logout Redirect Uris" %}

    + +
  • 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 062/252] [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 063/252] [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 064/252] 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 065/252] [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 066/252] 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 067/252] 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 068/252] [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 Date: Tue, 12 Sep 2023 09:07:01 -0300 Subject: [PATCH 069/252] 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 `_, so that everything is -`rfc-compliant `_. +`rfc-compliant `_. 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 `. .. _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 `_, so that everything is -`rfc-compliant `_. +`rfc-compliant `_. See our :doc:`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 `_ 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. 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 `, 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: +`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?= Date: Wed, 13 Sep 2023 20:51:21 +0200 Subject: [PATCH 070/252] 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 Co-authored-by: Alan Crosswell --- 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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 vATTZ^g-EkZzbFkTL{2fIMc@AE@!=LMb3Ey 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ΜZW{a&l8&OmuYgg9lod zFAMCWKs%1#;ENuLA#@RW4jRu)*W1u97SR*Am)?H*{F#rPy+67uNSW&a)NhpIt@RNZ z@?;!z^xSm0)>c*$;^HeSE7%*K+7G+u=H`Hsz|XCWK}_h#HWah%DuAcJXFLUhu$B?w6k_`gn=jhntTqj!&``o%wgO#Pf=aC^Rdf zJ{J@`R2o!wMKg|bb?A&2%@Inn9qF-hoM_f0Az9hjOuo6+C|z==dIKuR&B*xL-o7zc$9DbmLS>bJMUM<(vuv%_-?EP1_eE+lL0z}K1@qM zW&b-<={Gk>N+kEYo0FxWZ=@)INm2B!F0uO5bu# zWQ;t&JUInk5(_HnG+d8*2c;yU(HpoD9WFHM<9azoB_&PJ>v7s3^3~;nNsW(>$CgMR zJ7$G&t`Xc1)&g}Hpm=$vkH|(B7ZcwsJ;|3-G7ujHAOZ#GUx1F&z86bKOAuk60xSc( z0rCMt*|U51RpGcIR{Zl|vA)5fSRpPA^=zA0AE>QPVaFHv|2C?oVAxrr#kApKQNcF;c8 z8bj{k=jW%VqvPb{q}a1Z(6}Uk<_Q1`4!oUR4#?w5zskbG!dtht5$-ma2L%K?#aRaM zfecPTMfE~!Ypb*S`*-m(XMh5N^jAPR&UrkEilU8eEcaS`sn1s{2)9wGP6T~cW@ZCS z29i1`E07Y07FJh|O?SAsIB6Of6l2*TS*=*zym=G;CU`0k*pMm>UHQQ1LT-mmDifBE z(NQHzAJ{_BIqYQ-<>TSm#W6fKrtBMwm`0$^z3;-o{fB8Hsf08HdiB@9z%805LRUWk zo}sIL^!Tyml`H*&gSRI@+@s{E#*4};C}3C6mPhN)gOaHWV?hJc{eD*<4Z%h7V4qTE zDuoC$znBoQidlPebl)dvq%YJGAV1YxA0#(?@?zkRz+^XbzkVIq_$WVV59N>IQ~PDgygSvb&5bRCMJEgwZD;91s$ib=yU)>5wS>jsX zzfZaT(C0j5JuQ>aLm3ffRn91v@=YwKlao{Z^_R2y46EA+N@&H|I8TF;0p%h3Aw+(H~e`v<1BHWZ)>={l3Ao~A|oEz>sD%|(tR(rz^8 z&Uy6wzLD|rWmk#o@k3S5wK7qGYBUwN=&&6pnC`=+=(Ot(k|$0bzP3@G=|?Q9VZy!6 zmX-rJa_C}-u0*hOD6D^1R|jH8T}n`h@hB`@#_2_xMp(?L05Eu1Mt!kZ9&M@a>#Hm( z0tLs+0jq&80ugNkD3EvbhC-%_dHQ^)+rYW6e@tTcKgWwvb3D(@^_%KC1 M9vuUN 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 z zj3$m%XlZ?#Hr+d8ugnN(b~$G_qk}%3R|PD_c+xd5wAWN;F!e`|E|X%Y3?%t#v7xSa zgT9>{H$v;l)5PcmwQZxVADpr)NQkNW{bY#lw`+?W4xY>`n~~WNZS5La-`s8;l6Z%s z=Xy%}J=kz%r6eHDyW?fu*4deJJkqutxUzm)V4>z2?=R^;D(*H1yD3gS(dJJ%K2iud zaxEdBb+q;zxPGX9?*ESv&cz)LSX&cSeJ|o5Bd^UiM*U#pk(!U;1QU<6ZGF&}>VVq^ zKJt|A+7VjKQ$CiB{`J!hQ+}d}|Dx_acw!(XE$@fhCR-ryHHzW;J+u`e6JJ^RDJp~` zmBWnVidZgvj))&`+Aa7R>h~ZrCEs?20BtzgaRtW9I&Xb(Tm^}OW8tQse>*pN#{jO}$yssVMh7+fC0yH~ hO!HHn5C^x<&E1_-E4{(#I)5p(c;V6o3EDQl|1YivA432D 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 zcmc$_bChMl6DC;IW!tuG+qP}n?n0NkY};m+ySi*!UAE0PuYa?1cINDy+4*Dk>|c4` zy)W}dMn*(teDU2VWko3jSX@{T5D)|zX>nB$5YRCY5HM0`NZ=g^GHx6Y5MmG+aS?Uz zoQtg>{nW=#!0ivhnf{V}EU4m3;%qBUXGv_zT#A#U%IiT+i_D_Z4sy;5 zBuZ8^+Bt^@cs2UH+obB!gTxsXu4*G@pX%l#M2v>r(sB+> zO8J5x$3lSJ!6-ETr&Tk2Zs*{H1Y{W5f66H}?D1`lFdtzYUd}R?gR+#<)}_ZhJ3I4! z+5N%eeM5})Is= zeci^(NJ|$qHO=3Lh>FO_z)&gWm9({`%?JT{RZ~O81K2R$tC~T=%0##Y0M-%w5PXrLDUSjosasK}O z`^!mj{IAQljh7Ljul`6BvX_SirH;?1jnkF-{6*6L`hqyepRuv#H-Gydu6KyCr-q+< zp9S)~_h3xT%y3xEq05!>)11*op>J~+kK_2R{-)_U_FsA~0PTX5l$4r*;V@ZAaCfm% zZE$eV8ED4;G3%GQ;cn| zWc$-8b|BE&ikxx6e5<|M*@H)}QNk zV&>|5#U-gr#$o?cvRcG&xOuMBd%G1B)Z2$2d8r zJ#Bem7aUnH+d(JVUiMPCUOOSa*q<3d|DzeT3b@z(fU1aA9xoOvAQ zP(RPPzcIbc0j3MiwL^Z+U*fUkK)wZRg`)|KYxSnpgxY;TRMo&gFq)n@ruReD{Jp8UBrx7)7-aRNe!yk%z5#0 z*{s;oixIEAPwv$QFR&d!OvsGH4XlmDMuvrxU4Ex2JKSsUR0=m=q66+5(PXK4<}>AS zlk(TFBqvs?>qiSjwp=(ba~O$Gyp`{~yXkjOEgZTlBViResIym7RgWQL&h*^+8PRiw znA2Q;SFelGkFapdKH(OxZK;nMan!5Mhe+dX-WV7jje=+rkK(+l8hiMB_I)nukQxWL zsHfYSzuY>LW!<9Dba7^FJi>*NY}84sQBmYd(HGA8yv|DvF5~Q+`QS^d^iT2*#6nI~ z*MZtU5IEKoZ^@^#fA)$w9M~TBo9Oqy_(*6g7aHk^E^9l{#L{8&)kDJ}b6c=c$dL*x zFi6m2_kCxD0#_U_J+RpkSy=jP?M|Fr#hMU7kvVHCeZg=jF*f4y;1v2EF`SUl5_w9d z8`l@QR~{s@E(ismH*|y{)PJ+i`J|b_KO({v-a*qGIj4VWy!WpAjoYcn+fFc?!UCUb z<2O@6LSf_JOvc1Rq5}~ILrUNFSEm|87_<2xb{Un=%#cQ1S3;6{V8>)I7{*&_5S5*M z_PrIGiFa{uE?{4$@7OoiVzZ?~ex;RW#KRCzbW`5(cS4Kvhq&CeEU`$IT)X!~2nM-; zL0DyL@qEZ@M#%SA)$zEV(W7#+ZZ2sk4n53-2Um^rzeFRauF;y)2QLP?SzYRgcos~P z=KUK-oD2A?X9_9T(`DU%#Fv*3PwF#-^DL?i6-jO6vcEKbgl9d&jU3sM+zYrxrp^{pu>>o)$D3IQ7XKoXF0~kE*C}hFqnqKn_q4%lD-||VI_vri{wxBWZJN|@N-jvc$@iNrI!PD|}n#{sJ>TSrJ z%5KWwlLX^t?&-+dguQkxhtv7Y;v!y3BriE`3OM&wr3y3VogeBK#sq^1x(6@uRK?_z zduk5aadb^C5)k?52+4h)EQmCJi_wuMIx|qjn5xOqcU{@G{{GN%-FW~nd7C~pcU+%-VmQ~~UiX7o#A8->eLSyI!;Ci6p2JND={zm5G;iXk z$aCfh{&|<4o?tLH<|M6Ehd|;ex}}%QuOsj2>vEhAAeJWnx37jGa4n%q>#8c(F%PT6mwi~!{)=kG54eBp?bq9{%8K>D2?tYkRlA!e^b z(YX+$5jl%h>_ig{|{8OYhw# zDmzzx5B#$f55)Lw#pRkQkWB*^*ghmDsHPSJPG5y4V9+d0nR9Qp6VP={*Pj{-D0Iui zkv0}B{b7ERP`Zud1k6SSKzEX?60dl_~B9V_usO6oFNi_*%$5x%_N<_L65^s~5_B@lvRA+5%Huxc>Tr5%NV5K=H@)~a&_ z?@__Hw&KsniepJ3clZSlD;hr4(9?n9MetM~+l^?F*g22whL5Eut#G3sV^x8H6`*j= z%ggYWPn$@oM(?kmZ$4DNvRvG2)!vibtn{+2H9qS+1@SYs$1I25`cy4!+z#}-kkwak zq&fBMXrSRwoO3Wlz+3rK^4fjk95&XyvHryp$t}a=2en{XG#Sz1m>jkl^L-YmHnMD; z1j0X0Io`SvEfr2GV4TX2LRYpq zVog*m*%yrkHHSHeRA#0)`O{A5;s|@TKU;yJL#Hf!z}60-?&}OO>csZBgz`+ioz=QSevq({LGPwv z(}G`rK*3RQFyd^jORQ$2Yhz8Xg&8hKvFo@IpF5vdM3w}>(q%1*TPE=)t)csR#A?Sq z@CSOIRupvP=!AtBLPhJd>lgike>*BrsmWuXfcmt4^Tm zgY;eTzq*9kB$dn!F*Fb>#`J!FzmJNAYgKvl6bz^8(#CXSIXArNo+%eB6aAbl7KaiW zXmpj|elMkMIV{`%k)PkrGwG7ER~w!ICW_5P6LDe`Pj-_lO3#P}F)@GAcc4=APEdXm zu7IBJ9!#6lOSa!pw=(ly!9VpK(K$mQV_M8yi20iCZ#_fE=Ecb#9g~3@&|rfLS;V0* zl^2tgKO=ylL>YnXO;N*6_J#JR3bzTC#RC%c*G%Q!zg~WwYkyOdx5p3&ebsVLp9R310TlhXTD$%^?q=K z$O@Cn$^j<^9TO3!VK`Td!L%!D^-k$0T8aJGOiryd4Z+CKxW4SkTzW}_2-kg=6sLRrMmQFV9=Qx>-@ z@ylbz6m9sjv?LB{+{=(Ur$bAlEjK?y(EA)hk|D?ysKxMRgHKBkq~sKSuLqZA)?c6B z{j>0j2$W(qwTHN8OroP&5magO6wK}W2yth67O{?|^KB!HED@0m;F)@;w=KCXv77jehR z!n5w5O{vKl4P@YF3K=s-Sv`e?ph{|$*XMQjTnZfICFN=yOrd)Vi1fqXnl?C9=)Ohi zB#do#nIp#`GdSiPEn`+Wmk9}My>H`b%QE%=Dl!v>&UxUXR%~D{ps3X=#K@xL#!cU| z!Z?c`)x&0{%_bIZ#eY<((5QnW#+fX`=}8mqe+nuL9fq>4!-@CV^Xbi(7x$?xbP8w` zq>a9pCwI`)Z%v8wSwkRlH>WW`rH-vWPwvR4v@n^OUcTqBLvMF>Fk57qW zqPD<1l?S8cX8P?cl_oeytA{tJWWGP50FY@K6x2n;$=+Wmk22$2^ z3=;$Sl_G*|IM{u;)h!;b(ivUHB<@A#ka;(|@%XdHaVTZnWbMY7d^DAB!?CY=B^V4* zrtsNBee+jhy{iDM%l)ZE`^FXsV>(>+RD`5k>-E+d=;R@`EipBDWC2k`fz5rLq{1|K znFB{CY1X1Z2f6=(Q66Kb!uiTPiuA_>1^tSF%@wDST={(R>offxIYZdXG;{V85Pd_B zeo{K;(sw&w*)wOMOGEd0bN*(Uk`-X2wp~myV-DC^sn73dXE=+^4j;Q-BUjp zn0Sj#vM6~Xh9zkr+NR~{_9XKU!(I8Um4EP)t5XAr4*n96mi~87@c&G+nG*ydzPUJQ z$%cS{z@0mN#TbpORjN)4#0isx2^aTaH(+33L@L!iy_x2 zwj@xBT-{EY-!g7up1Y(55xhVynrR|zGAJ}Bj234 z0P^aWm>dqC(Wn)G25-uU6OG-KBva{p4qJEz*-`aJ2OH7$K={?!G zPS=+AWK#$A^n)L@*9FL;{v5#3loshPe;7YMOoj z*SfE+xFniSQKW;EJxIom;(nsV{3dPKiBAb74Tv%Va9(5zW*a zpDosqTnomY!JTk)aIsVZTjIJ$?3BNPgRLA&?zba-stCbWz9kKKd-#XJqb3O?{2L=jk?QmPxg1zB z+A=^V=@c(iF3_wl^(*+16v9_Da=N9*s5jR<%CX@#mWc+G*gqiw2=M>kO$Z-X_C00v zebZW7J?wpY!J7%Y+7Xk*@~ChFMzgUh56m^a)crcU=8Hv>PuWuzm&tanq)2{giC}>v z*H+50S?>D+wpx)D0Pd~6$x7>>hApAb1!?IxmwHJ>K7bWv>$&zqMydEK5rG^rr1R6~ zpx5B%QYpE3zVx^l%1e9AZ;gpI2^;K|n6(D!72cjNC!0wo=0ba)oChS!7pEkdEaV8l z$cA=HQHHOU9gTC(#GQF2adI@A4R8F>*p!@}8WVkhi- z4FWlBd!w}gdA(l>GsNE}(@6!g9o{r6OoS5iIb6i`jhb^)=6yD<0y=|1H-N#;6ZCF3 z_Mbrw9eDqJ7R2p)+L_qfi_-gj1ODfE5y)x(v=U}AoUzx%2gi5cpRilk?Fp`4E7KC6 z@2>|Kn!Zme1pSL0GAHytlN$#!Y7FnY76DwLi?RaedC@)YjGccpWVUlOu(P^HFfqU& zNA6BG#odK&T4NJi-C_rOv%V{ul)qi_w|#nXSY04~fDOzEWQ@knk?5Zeksgnsx*DyC zwHibP8FM2|DUx%F!w@-{%}Gk@+*5aVpkJmetu%4%Nm>GTT+gnpfTs%`F9U;Iy$5rT zt3JV3y62L~o~xkzM|m4+z2iKe9eJ-WnD&hT&w9Q?Eu5=)ILG#P`_Qi4i${mU*{a*C z6oXv)3%lG1iz_3>faf>PWf2L9@ONNwji4;grLWaI&+pUkXI_4ES_e_kY5tWPu)F@i z@s%4Qu+(_!_}oOb(gj#M@$BmRXn$u?nsIaDJ+n3l-FL3@+rB&AnxPOlW_0}3ax??5 z%Y1JXBJ5?~DZlQ_58_|?T+WQ6c;EcIf`i+dN%s%5nGX-@0^GfaU)8quK9wa~bid!H zQ`~Q63%Yh~QMGkq33e9axrtg)Q$js^d)} zN5hlAk5|*@V=VluP$vGpz-{O0W}#N6j&zFBPA(1abX$5)q32cHXCbB++Ix*0fs;VN zQy#Cz_Tc<<{_8-0iN>y5(Vu?je|)YKmM))zp6`6FHlr0ik8Bz7J&y%@Cwtx(((-3I z?7muNKeeBPwt82NoVk|s-S{>g+lx(m-ft=NwupSa-g7EfKmF%LpN-_T)=zMrjneq9 z49#wGn4~U^HYf4|CqA!%i_zs-?YH}<_A1E8xeL%k_Si(hWY~RDS5fin=@We?tFx2$ zd9B%Miy?FMPRTZjD;WuHOvs05Vr8&U!-o z2Zgg9ebj_%R99`rqKrhvm8wbOs&xYd3lBqb*wHuFXD(w_Mq)l^0XV5@K7;ZO@|c!x zRa|uuaog;^wNF8ZL1}uKXYcJ?c${bSOihX}?X_mX&^Ia0o7qa)v+ImVMiAkcuWb-k zL1qziY~$mbkOAp?rgB?)e~$%|lb81y-zc zng9z7zAVbSNi|@zDRq1?Y#RYTZ74 ziuY=y-H`Tn<&Bp#>>;#zn;^YA`vcRE-*4NBc5~1Dr;l6yvHWqdS~9W8oz$cUh)#V{ zn{0oBw1e8~t4HZFrGL{7(oEId?WXCT|K(qtvZNKu|c4Mv82QbYOI z3s3rkHU2f+^UoV!!~S69a^lONwkYp8wQ2=4H1=N|ujHsCh1ZMMQCQfZKZNom77sXg z`A>`FWEY+}U%KCW((3^QjpUT?YCBjB=}oqCCVQ@G{H^a|c9}lHBh_#PS250}W&+1@ zugSY%8~2qT_M|J@gk-+3Fk{(~rDYKj8~Zp#wn7Gcl-u`m_UGm#b}ZFKtZY~DT~Tn5 z8zC5zx;oP4ib2-a#MXPBtkBkFyOHk_{EAHTpVrWMRCr8HB(Jmhmfsr$9{tZeLC*odu#&u75CeKxLe?t!Op zl0{S*ODZ6lydaT|0^C?y`xNXQFk8}VP1Z?f0DRfR=9_Hf5N&I{%~|c+byDrd^agRG zb~?ivGyMoSDfp_aEN+X3piu;7yM)-;QFQ|}E`&S7>s-09MtRce*)tcdeA=z8VV!)c z-c@G8oH%2dJhhKfO9WB&+GjSj^-lA5lg%>VIoth3YhR6{(a3_jew7CFr=5=|BA!Q+h2T@ZY^g~A*^Vg9I9{Rn0 z{A}FKSLKoaah==0IL<+zYesxvwJW}f5`MLBoZWU4G8w0Znq|qbrY^S z#1d^jk^(&tz$0(5Q^|jS10u_LRnS&BaGa(pYde4gnD{$UcKk6H>FEi(FSZe+?pG1F zOA|AfyDoe6_4Y*cw3OIn;dd_Rq*LW|$prsp6Gqkj?K{q_k=!a0$OjHG$aEPe(*)l+ zwNAQ7qc1HDT6gQ+b-buhMOvtaqVtlf?`Zz2k#)bt-i;J(Q6Nf$Pyv34mHKL_M&%!O zcr7q$n3#E0K)vnEemrSxrOvi1roG7{xwqXEr>%iz6~KbZwxwN@pu*~Qo!s3aA0mY$ z)}SFyYejHq(XZ9HDXcXny=i}+uKT8b_HH&=?G`XXdo-mdK3xbJ29Q4e?6IrfZ>98Q z{pV!7ABv)i>MBdSUM-dr-{w8D`HV&(;1$TPf978Jn^Ex@?M$C%W1{YV4n5m(-Ft9m z)AH6G+i@C$fQc5(NdLZSi@hVTT1My_#mWr(2^}O)lzXe#s1{HGdD)ZB<@UBb#_EYy3S}#hik%Lo z@8H`pGo68-j_|7p>q@x(!o#(zt|e#!oQfEF==JdsBz?e8)&j4&5(>^dviau5zTevOs- z1Tmi028MeLZ99SyR6)1D^Ups-ml-P73>+CX=|56Fn|&`FAmudrQU~gScvK+6kWtvu zD#uRw@9~(iZ5gXE+CGc$rFNhR13ba%d%NQvc5Ga)pyx3;JV{{Sh?!3r zK4pR0r=+GV{JrCO*W#j%t|*g54BYF;)}gDe1gKzxZERkABQ1umU^Q;}ra%cVNr&QP zx0@V_UqhsL7op|Dv2yP;F zt;oCFwb*x4VicifH67g;v>9oOeX8`k)!W3xP|V*Jr4Pak{fX)ISUHQSI4qT|Vc_6s zC}kyYpSCjtVXZ0M7_ZoRU3e+r&$U)F`R%H{>-WEQThz^JY|a11e-}B8@2f^!u*!45 zrL*n!5Th%tH%|x}ZI=HGF|`#l?fKkvsN_{d7RY#z*;Z#K@4g|enXv2+t+sIv_BCc# zV|C5o$e4a72iuoX;z~^8`3b{FOq?Y#qbMcTI8z%YE}_=$iVBCmp(9k8e1i~YWfL8R zL4!uETrp=G+Pik7X#uX5A4_qaDlzHw%|aMm?f4AFS`X_(1b?h=jDjjaa6=gtKY4IZ zx?)>B`hFWx!KKi5_{$#2?f!GRZ&xd3x17;;qb>w62tMZjBYR zQJ%nxoXP|DwI?(xQc02sI$gsG%uFW7Cu328Ck0;7>H_Jk(oUJh**KfPx~k+?OP;T5 zCKp5d)E`>+zM$0IOC&-Q7@64mY2tF??~_pVCPKWsHLF8;@#ii%@`sJzt_O)?so3mj zbs-93g@d#RZ2+-=i$*Gi2|6 z85d07uTPsg<(G(t1D?quv-wh?A(&~xe`(!mXYC1B;tLOw$((@6tNXTNf1HPi^s}P9%}U$VE=xJ>ObHYA_^r9j;wHo)CT=F3vlG;2+|+_5T*DE;YB?@ zuP=lUc@Xcp@a)3-edub^ymp{>HiU2)iX>?Ka7Woc~nnWRB9+hTjO^NYWO-1qF_0aE0=cD9Dn6^7cU?Kz7z zt5vAih34cGR5x?8p;2L-hSZFk~lj5*L)raeeFMp zm{jrHD1sF9ZEeDl=Klaw{g0s4|H)sken~|mEZI}Q+M2@{4Z+Cf6dl$adDv$5GaU=3%X)R#nbh>y%Plk-ki@C zX<)_(rT>kgSf2d#`MyxCRsRnFG$>N0R<2%}7nl_r`%N&n|8T{SUVEZ*kagK{F!_&f z`u_kQJ@N1~oGcUhE$GdLV@)Q*bV_;*!x2E(AeyO?1K=x0O+{rBz1}#g7>GH_FV5*7 zJXo$3$I_PExv5nC%SUBvddM3hJ@-`EYJ#JBZaAAs1zCNrH(ftdT}K#uu}88Q^HJxEJs@wh z=L!k?!IWy^kZA>T-c_CPbVKTyV>9j<8Q^tr}fYfBt6%I+M4$BJ>>nVOFXK8M^*f1O0>cnvz3V2#dx1It$YNL(-u{o8+@L6 z@aC&Z?GK^a(q&x#M+Z9hk^`q_`$l_)c!J2iC98**_G#sr{-8w^lvAbCUR%N}9FgM2oCc@qzI@F_1-4a6-J#j8QiL;O9pALtE{BT(r|&-2ZG z?2)K8<&n}0M}3xrd^}cbdwl7z1`+IaF{7xBeI%j=<)~xT4Iw9pTPlKA#kVJBePW{U zu+2&toNjss^ntzUu@E+~dgeTP6qgO)*FXT$>`vLx%C40t&X=NEm|O&A!_UPYpiz^y zcDq9y`0NJ(E+oF!KP54$tZX7edye@g^-|^Lmz~>=2ltsB$;|>-j+jC=Ol9=bWc4e; z|CErAv+KTWN{WfGi3O&Q-5F(#2G-(d4~gHE5xZJR8S0jo#$G)9OyS_R_N&Q7o`=HL~3BDud7@^CKGHTDI zX;U1x+LFKef|MVIFo&wM+C4dserIeZ+Uc+QNh!(T6Wu7tRZ4yqNbp)uF!-=hX^jzF zoi_ih5vz*}$%ytui2>G2^cRNQ0z?IdhWrc8y2@P7gyX5%YSh~dB&Kh?=f<6;)JUA! z!-knJkIR{xCb3@_x)_hA#Qe90UG)|yvF>|G z#M`~6D;8dZAw1<-zqn;AqR6WY%*VrAy|YOqUKD$~=MN<%Q_>`p&C7d zN*(fe7*f|^E~12KAj0v4;Upa4d)=!b>1wHHwhlkjms#DT;32ex7Lzz1F&>(DK&a3- zTbFh!Z~iNd%>j|??45~uM33-V_#r={`Ig!LP49QDwWEBDBDZbZ~DWUH0LV8iRa3Fj? zke1LM-5l&3cFrEC_O5PAYBqm&?wnTuCU8Cd;Wd*_&!~(Zuz|rx#N|xVV6< zsL2uGxT? z8$>aj{P$y2`tq+f>D!z34zhp?i7avO>YNGtS2bi;WmcAP?z1;FOm=V9Bvg;gZL#y8 zC6F)hpdF&FEyS+@91I<)(|1j7A7ry@?T64D?%YT9d_)t;VMtDXKEkD-dJCykv<9Th?1llWF#NnUtlR-hh;JXDj-4wvVk!KHnZ@W&> zwhYEKsQFY7UZkchOBHl|&if#AFoh2-cz{1cSe$4N?_8F=ITPGjL~&u+I9?)VIY&@G zxjvvp+Nj~Ubf-@xHA&B>!*}@OU@@~Nc6Wm-X?NIb*93yo+N19s!&EVta-Tv?|nNPX=)*ZbXg zxGc6jaDj9CjXb$Bv_7yJHV#MP#to`6upw`Lo%x};$00XAH3?wE9Oy~kFZ5Fsut5E= zPdX|?({CMRVV~{s^8762(UvH={vev$ZgzdXQ+9ppIxPz}3}F8g!GjH|uh^i&;?JKb z6||QtgA(f)CS-@Sgc{hE*03u6kqw_)->~(nD0Dx^lO=X8`Fmy$%? z6IZ(dpR$a13m9q!FD5VOi@wQF344HeJB#cw!|OV`xG5gMuEJkj5gf_s^IJA{YEpA8 zK>P$(qK1Tm5;JD*cNoWV_TvWos?N$PLo5txiok7|d;wjYE&L#;zwQNtKB=bgk6m`l zxt=XntKXf(3I`__e!egnlvx&`#Zu9#2%tlq#WO@emQwnR| z5#VM^xb(~d#YGDQ@bhXU8=ZT4Ld{MKxpn^9PJ zO@z6`z?5sc=S4*ZAlXOvXQ5f@8SLS`ns@KPD_$wQ2p-NvEKNT)XU|eAuDvn+8Mc5<*ur`v)*azdpko?{4Ve}wbmR<>~Cf~UWnGm^{580 zm&Tgas+FoLpWt6h-josgy~O_p`C+fm#A2U=0O{`gUIg8pPWCxH(3rP7;tA;C$Yr%uw@d>s8p5__h%hWi4(g@qF)Q_UB|uGDCRP=>U*FK#krNWN&e=e+7Lgx#&5Cr#NTguE}Fa#%qSZ}Kk?V^7Lt zElXyHBrqWf!G1@Fq! z^Ik)seN>YZe$vOcq!1L`Ncjb^%7~q%GOOoHXIL9j_&&_FxhqD)IDBaWAQfwOq-D z!15!)U)PR?HTye;$GwQBekdYl;HPP?-`&nuQ#&C?Ha+AC9yscjHI!tQX{Y5!NpIuu z>b<63zjTjMPQ4#1R|%J%kN+DW5#R+Zc)tJ>f0gPbYV^x|$14XJV~95n;V;K=|jnr`2P#X;9o7jXwDi)M_OE5{Oj&{ zdQAv3R|_0TL_~y@y6OKfj=}$$Q1RbGF#(-jAfoy)6hWb_0Fd_h8K^HJ?@tu$(ofA8 zim+cY|4~}+H`iH^nLaiE+y-6WqbUrDqbh2WLyASL#j>VbmrdIi9QLa_OPrlyHZaKo ze#_u3K*6HM?8mw`5*$rWS%PlTmAABrnUJOCDIgbEJK41`>U-Sz^p~~QHbD>d_d~1~ zp%EL91H+MYw2Wt9&gwJsR@j?op`@Ym)p2yrILA@Vrzv6Z!qxs|1z`GcWuBz}IBFva zdvQ!k=*CQAa(Y`J`iQiUoLJ;NzCm{Y=VTEV&rI)$t>BvhRP|ml%ico zBPiV!MQ*$|GJgdl5Iy%tkg(HKQ(hBh6A_=$pT2_$Oc$&lr?9c(-f-JB0>Z69-(M+~+IdXWo6uc(9 zJgzS*1O=ol9+k3rxfjf^Von+g`y^!Tr%nylZ%m`>Q5`lrQhEh}QSN_h0$XWze()Ij zv>?O-4FU|J#Ra|e-IT-52mzuUqI`GO2*Zi>D~|YW>DidJrWL;iM7`v%<1$- z(=*9vrL!nMru?RUxwThU@*H|nJ1LB#_u}2}KO=(2?6-5ncM|Q(1hk}=R>Q?<8Ebq0 z3=g*-rg1I#OQn`Wx51yD(Ew>BGwo$kBgGtUxmVXtco_T=G28N zak5>~%y{>a_gb)j`nH2Nlm5aCr8T7JmLTtC6O*jPMUR$T+UL1cwpnfT1UFv$ zd>QfWOQaFfl%D+j%uvKH9X}$T1r_lcD$;EUv&r8c2_Q4e;AetFz&t?{f5@;_QnOh_ zZaJ3YdtlK0&k@>QAOxiU*1QJ=DTC%jyQNBXT!>-h;lTV3)cUAq04X^JFtfe&LrRAz zavRXOFsZ$2y&2ZRhZ5iH3De5XAe$zb^d{{N*Opo_lul;L6e$JDAZ;WZPeS^a_T}4A zB38R(zQii2vV(E0cN?An?q+F43ym+dotJlPrXxvZM@9szOu1kXIC=sRF{#Yv^>OE~ zXs@gHuTNH)k@T~MZYOEfhkHHm|=P|2?Uqg1q48RZP;u{C2Z`3B*x<(gOb}Yk@+n0+SK4kE#Z~w zzdoA8{;I(?G=dHaJ6S{1ZnR|o$;36=V8Vh%RA26UXihBYzLUA21iBXboLP7u55*`` zu6mOx2A>ZxU1xqePuFFZs<5z3NG6nIiP3naOi(H|{hWvznacWl`x<-I?#1X}WBO{N z32}@~)>$9i|9Wfa9$((|)$tw#cnF#>0XuTPy>!R*C(ySv;n@%@XVDHT> zM6>pF|Av-Uio<^Jh22gBB&)b`R!tMCjU^h$=g?DZZ6Oo-2IZHu8OJD)zr|0Wy52 z@Xt2lUE_Xa8qt5t%_aK!`YXosx%dnSToLlhw-wM90qnD;ko%DPJfOxBY7HY{#+q$5 z>6?C{4$h3vW}TJ1G|9?VRb%sEnkG_5HM0Hsg`6i4-GjNqoKAO@H}$JAcaRh!MKM!G zLXe1mE6Yg4!y~DxV;?&i;!ZZx^SE`Z+(S%IOLi>a{?`Gbw%hZ`^HqYtgFo8fRgQ@3 zmHMzCf_OA_p95p@eQDw zx3p{$x-mJ03@md}5;+96j7-wE*b;IXP)XJQLw^%oT0lz>tWl?xu(P8xFfb4hz&MR` zb#iP3aOBTdS$AhLUOh^wZ|5MrhI;M|&F?Tpa zSc)ZCT@R@1GMW9*ld%oxvLYBG99 zJLnL1B|_2Kt$P3Zw6EJ87Ml{8cd;V(Yi{$k-04oZ9B@3m%@>@x7t$M;G*Ot$Zp#>+ z{#=epVKG~Smn9ov9c9c4Og(re@>WG;xc*EM%A5K`_{8BbLx~sq;tmQrrV!8X%ZK{1 z7-6|`X2VnJ$bx8f>p1v0ugH7Qcq5vDxwbLUyUV@mlbSBP+m@Z?Da$WaELp17YIZxG zMxx*8aHC@CzWEcCpCE|Yn<>ncrmr^<(M;9sX%Eu@a-iz^DPQH*jyiGCZrT>&v$CRg zyQwc}p1iol_v0{o!90@HdlKFj0GvY3Z#1D$Jp7*on-G=Irt={a#stfDTzb$rL?{8_(4?_b6 z1bV-R5qrn;?>VI7y#i6ya>iQ*UX2t0_s(^`6YuiSqUdXuL%59!o!-Q!9f#eoA8r}0 zU!0agY>__g>1bPD_;Q%_|E0b60BY)8_kUFcl&VOtDqT7#y{PmqpwfGj-a8~B0s_)P zClu+uD;)yTYv{cbO6UYa=(+K?&pvyfIp>~x@60}T{`0>#Gs$GFcap4kWv%sopU?As z-eo?YL5l}{0i(l15x&03QPx2L=iu2=VX+A+&GwJP052o+xVm8bvu}FJy;e@NPi~I8 z>~&kC@@v%+IoV)WT6nrX^K$*S0KATIu@@sjP$8)_id#p}2oyL!!OdpJz?9 zIW~KUOCs!ik;Id4W`ebgF7uO$M4yTme5Y5v*ZWhBy#ny^RM_sfSt-Po^Z$U#5e zt}guP>Pz@9aZlBdxhCz{OJVx_pna7w688M_A5XW^KFIkfJ{$QR&7h~)BkP559_knb zCz}j_^r**Pd*bc`eO^s|m>j$sey2Aj-c#GmpKl-*-~1-Y?TTyT3cEPiBf6AP)RUfg zda%It9mPIy=T^y8l@JNk=(|o0^*Ik@XA+z~>fAh-?%kge_@;>Ys+J~D=Vn2nQ(w;B zoHE-m!*H%m(z~C_k1Y^g9+=5Yg0YD?S5|Rfhy}tj7?$C_W3T>4Ce5z zu^KYl8cxT(?}}N2gLv|rv2Tg<-t}>ZeT>;ShazbI!*@TxVYZY7+qnlv8a9*Cg(m%L zZrNik1+-*9{MAswHBC@aKzZP)wri1}$nduO+7U~qQa}3u);CRle_pNuY8x@TCtyHL z7|g5u2l;1$CQ);ynn&n*b;es=((3fpiaIA#ecuO3KxX34w?K*13V^4m{?gayYUU#p z;f>QY?-Z6YxK?)o`}#9ul3$gF_a%?@+yPWU8LIchW){u`x#(W0pf8p@Q0}gJ#aHgO zt6LF$SiS7IymfJBi;qOTz?5!c3Hru4w<(c7n-^uq57ZE6v^UaO(NN4DNPcBEYIL%fib7nc8t_4887_0czEFB@kp!NHGWGyTi(bL7`wg1)%f|A z0AhOgiSk)gx?{l!Ox&|nQ zbN?Nr|> zxZ4H6$dSy;_ETI5zMM$nC!*AKbQ4wWIz9od|1#&_?-h;^czM_r7~R zc`8jOdTJ9}3{0&aVsV9*8-_xfNvns?u(qR<4ZAR8%e%KXte_0lSB#V*PtZg%s#gcKeM+W zcQbFMe`uy+^}fEoXK{VBKC(p=eHQ#M80_@)<==$NmoN8H!C=C?%kL^I(ik>?jg5_w zJakx23mTau(LIWJnlE3y8lBv@f)=6%`uehd{Af#>Mq)HefSNcx>Y@DD!&Ugle~}71 zI$@gJ%GG4o)7QTlu|z%uY`ZR7F-;a=R(T#Ssk*rERq2!#6fn|hu*79GG^D(LVaay& zRK2cP-NwPea-}U0M>9Z0o0WatrLc-xpUD>PlbK?%UkN{@`5J~F_MS?tA1cx5lX01%vZUY9`n zF)%7#o$e@jwra`;+&)ketjGuSXxJZxmR%RyOdnH;Yj%IP6kv<=KUdXX`jpE&euL_) zr#_$o3ro6P>g;91wG{#AK-Q5F#1hZN`s~8;NT!AW`SJOB^a;w#=(r26grqn7nlFqp74`)bUvzu*mMHZcw_;QjEx!e`~S)^errKX|dZBo+uVP-v-7_EFAcMTQb|4y3sEl*RXG zaY9UAfbm>s8%3nMuoCPczcRwNSjK&|$x3Pu%Z&U$`Mn9^wO0HMc3XTv55}@|D*o7Z z1Kn3icDlo$Eqz7DwEo}Mp@rV&cy4BHKKB04c^zp=Skx?}#qUB?$LCT_kLtGgPj+!z zwuKja5wj;cNUPKtYbC`!N2c4L$%WL6j^v&(ra7QuwKp|KD|p}uY6@|!Z^IAledoMX zHrP^@cHS-W{lYY4OlwYIf3B+>K*7I$@DO(E{!D7Nm8^K{5d_GRgWnXYEZMM(IAqcB z&<xP53 zH+%4SNcHhqWc5)6LLs{y`8-Q_b+V0zXV>fIpb}Dgrb7+*u67xQ+NKAh)7-C=8t65D#niNFPdo z>?;Z!*NypHExuGo^nWtcpAMjY#g;j$RblCTZDzF)z#JxpH>M_wy#QQh+bv2~?fm>FkB(omp}{DWNzIDZ_Rc^l;uOEqpe*%4`gMfg;JZ-XlUn^ovyD>>RbY$JHX{$oE-T(*Bj{tU%1u!<$~T4dd!f;9kbIKn?4hDdulD~;TcZFK40Y*>de6H^8AxGiHT`Tn?Bb__a2(sD!<7>Bi79=#PjbWZ z-dYdlaKL)n6m$k@PTeWC-3+j1)SJ<+X)Pmn(Hy4~1>M`L6Xn+WDA-m( z+2GCw?Y9(l0F>eF_ag$cu~pVosU)CAAR8L93vLT2{ETHOOoo8?r;lq zwOYzF=@=-{x&0VkMuQGgd2@cM@4bM(s)bEJ--wF0{|l?(8YvI4*myJde-mMb-6JUe zu@(WXK2);)Ssy>vd;noBheT6BKK(f8+fr=3P^KPFhaG}HX2C(TB2dxI#=uRoj>w;%a!KzFUH|$?`EM_LD z7*m4Jdjk3oq1iDvF$HhxTDlX{^}VkS^N-E%G;Yseap`^@r#uaADX_pL1=U#(m{!cU z++31hW?tR2$Q#KT0Jg+UaCgrRSXo_@kLR-OZ5VztZ+SA*lBFAW|FDj0BN7y9RaKTJ z90#^~{g6mcOZ3HEYoqES-MV*L;QSgWD*9VM<$FVVe_4eFdQeVp$uZl;i`B>PO#d0k zq<4jGVu=sTwMDd%wmR+Bw~|JTbiUQ*HEFwp`3I3kPpyYiWlc?2{Q?bse$r4^*XU8? ziL?MU{(>-#Uo#k-AP)+30n2iitk5w}Kv!UimDub_Pn+X&z9n^A((079@T<;m^wsT{ zG2Tnm1Izko=7XPkZB7(ih?Dga$-ySpl(vhfX+Pslx~%tUp`&|!h4m(g<)R2RZTFw} z}8(VB<<=sMKhP=Mam-)H5-nr! zghM6fTeHl#C#CPyRYa4ZRJ%k4ky$@sXuV___V6{i*D$>-{p;GGGxzvr1zabF$~BE^ zEUtmOTh@(pUPo#~kNSMj*0j{ozEk^<3I+GWZ-%ddAmCxRYTsXB zg6QO+3IIIjiq!A35U}{Hb6-@9m|#AX!AmOkexCHWz0R@%>qtXO6cQpj;Whi4qhc@n zG*!RBQ$@r${MG2W=O$Lz%?|=UqJ}ZRz*T~#ixH?x8O(MeH|rNIZFO>a04}a}C`H>a z${MpX*X728gm%5xC>G_5iwa8WH=~}oH@>oF%aM~U0-in;ytPJ+1LfVYehnCX5g>ul z*@E9lQ%-0?8_WC-fj3FG$+s)MM1B=I_qTV}R-VKb09%uzjGJX#iDMj0Z=`E*9lBgQqktLv_-S}m_I~$8FT3cD_E2?fUa&0s6DK~By>*MTy6?dKK z=b1CU8n^Z4a$?(9F1A>cmEH5cO7Dm|F`jLGM?}fn_8RaOiD3z9f)X_ugM}&a`(BFC zH81L2XP$5x&v*XPHXaBTAm?`+eCsNuJ-kwby2Y3B;x&F@FnBt!DS$p}JHW57*z~Fs zu%6TTh~=N3M&)IsEPhJb+LSSu2Uei?$&W1luqc0bwEexLGuQOSu<6$nc6OLPxb^U{;KLI5_lNC7G%|rP=TrMC*@+2WiwdgptfRLN zLjWgN{)cwK&V{EN2?FqZa$EY8b}rqgUJs_#Tfe@Y>j+BY`}ziA^g9xNW!1)Vuc?B0 zYhgFJz9l3TUiUgzDj2BMmkYTd<+&Lj{f?WY(wa7x_hA8%LyH&|O&>qI74RJSu)MAx z&1rcSU+|eWyuX0g@)Oa0<6zC+AMHnNh5{=)D(+k0k9Dq8y+Fq6lAk&BCB0DAe=VW_vGBX?|X z;B>I>YHThMGoZU~VwkQ6!TI`b-V^JL`qx%qZ~JSRjBf_**Qra_sp^WNt>U2fnQM^l zN$rcK`KI6~CcmvnF%T2--rulxkt8!KYpBYmLWNg?ZxSZPREu)zAEBU9M6zQ-O$;)p zQ9@FssDx~8TH)d0`TD*P#aUV`mSm=n#;|lu-FtS)0E+a(VNzV2^qV(jN(cV}cS@zhtk%U!mv5cY)!R2`%+~c#(oU7fIOBl)e*H z-(Ripot-sI%SLXT+RmTWear$9@tR@d&p!H3`HNYsF}niSB_BqrSGJ@%t&-sy82GL~j%s!i#0xKnXw2~t$JWsMtR`cP`wFYl=;mlSf8T6K+g z$zWtxCVxV(b;at`B7QQe`vJY48&}vOI2kC71()6vMfRAM>Ih{DvwvJPz+Y!D-#;=? znB=?J^eN>@Q8(MLPt@ugD;Vfo*62Asq4?R}XQt6Rcd zpofMiVk$hqV*ga4T)3PX<74dXy!UCwoxd>~Ciyeo`@VvcnY4L0Ea=J1Pn2DXO1uDg zeg+xwGEXQzj`*EU`!y@aXQpVnjylAW_Z9oOE7hotl*xhv=?2)NKA z_c6%S-=v@cmNBM;7!tgr$>V5Zw|TUzi2-iOm7GH>8ru99wSDoi*&C=H#EOC@^jM)f zNx{H_BS}&sJy_ki9~6et@Hw<+G5UxfOBv=gXmQ}9;hNGunExW**aO>NRPERXTYip1 zdr2e+wovpvfchJ$24LPmZtJel;6`|pwSc2NYn8&?JS{8DSmU;Q({vSx4K1rw^_Pc1 zXZx7iH@otj+oj|0y5u?A&6-M4&bW5RmI?9iwfQ_bJD+Qim|Hs^ht?0XE4H*N($Uc! z-m|FD9fSWo)T}VtpVT(qNOK!LCLEqD_%lIw^VytTn%_YKhSgzX@R+NcTbK+{bNlb{ zvj5_;@(&XDKb<}O*XCmX|8M(8s(XDXzv=H@fd7HC_y20U8gA}Bt}28)B|UwN>Fs6m z0%lPh(aR_yK^Sl!qbwwGa1e3r3QA)xR!^lwFk4S2kBd8I2MPek#^gwv!m!CVZ`Q!Rx;^M}&w8rI~|4Bfpiqjv$MHDe}O@%Kuc?meLyf&-S zQCF82&Hft{zqv%7T>PR?&eMc#ZjxZmG@aWb`qXH5m9=AG%xCiRSsEJ~eSJm89&f;4 z@7}#Ld3wdhh(XTu(N|v?;a`tZ|5X-+b05%-n`RLy##{i{uD-l4<=*gDFEOhySB%be z7mQ=?ukFS#Zvd)kIrc9WNwK#n5s@H{E|?`>zipVP`#*LjIIn>0b0(=|Id=3ZyZuU1h7k#kW@) zNr)OU{czGgfC09vc;u}vw3~h}Yq_j_heFnCo4T*Md2Li_{_bC3@QBXpfp6Ctdz_GQ zUQf!WhcY3FT7*D%F0>IN$~-5Ch(AxeA`z)Bkl)AH%Mr0T+3^a{^(~`3jM29wRuwWt zi@uTTPp-1Z$%~C{%!$4f-f)BO<-I2dPWUKj9*%?>-2*8f%UX77vnpC;{qT{%KP<7- zdLg|z{*XIeZ|LjlMK17Hnh0t#cF^L7@x$ql7_W2xTR?RH$9w2A09r+kg5DNwBPbym z8Vt2(UVMn%!I5$igh!&GaAPm|A%gl-B#i%j?_TX~YZ@8x$4B#kLJjl z6lZQX**DOfha$BqRW&WN*m*gdun;c$4GYnwMQKcY)Fm!*7j>Kups zBafA|9czj`x8;*9{+s8^-NRvTSqYUtXstHJ4dlZ{MWQr?IA zB^RB%q3J7*3~rXW6pM@aB2IBX!ny;awq_x@qmfiQ#l5A@YZ__=2D8EmhR`arMgO&2 z9S=9I@4)ZBI^3Rp=s5*%Rkd13h9%VKsxd2PaBb>7X|No5KpN4;|AlPJ^^&i4 zAk&h+z5iK`~qs&Nk zJQ-ledt;-%(GU+S)gI~U(uOTTw13mu~a%456NMi(d!jf^DozkliZT&o%= z;?A%TpfkFrsdRGZpG4u!8UsJ+bz?#qS0rgmBD4g+Yc-H_pChJ3f$ z(_2xS8uN9A)*(5KliMu;R@;M{nt*v*E28D5h<`{&8H{vXR5p*DxpMpOlw(gD>J%eY z|6A3laMx3vp({JRyW__(ESZ_R)O`+2$8%EsZT@q zEN0x2^$6)dz8#R79{owvTdM)74V)R#yq%{ABmrn?;T)&6B^2n`z?fV4Zi4yu-Ta-$?*)OV3?df7~4Gk!C zS=;Dgp0#7PqbkZbeV*QY=apNbQ?!;IiTOBG&?B?l;op3J#1d+u7a8|b>C@Z3TH4@5 zH-h@=RP(=+azUi71wj+@rw5FEF$!Pow%+qQ9msTZ2J5^W?k%>J+uDlDaDGN}K-H5R zYS#JOMQ%w`)rBd^utPKjFfObHu)uzT6X2ZGK%r{B39c!k7Q*=!D^?1+SdYju<`ZBQ z)bKc=OawN5UK7B>7|=Suxb-pz40%i}i)HW)n31MyD=8iQD-zQfoVZu8~EKBkm zEj20Lx?D15Os#xz4O_B(mN)|7;$*-i*%4g0tALDlHBiZUBV9mYIK6+SAW-4)M%|F5 zJH2yB;@INQW4R?sAY6CayvaT;5cQU5Gln{+A9whj?t2_OeO(FA4dsbUG$kUF~fyLg=383_U8XcWY$o;T={s6M1G$M zii_*Wknr>Mr5?kj<&MDsxU{sijMP-gFL4;&wS?VkRg*hzvv%yQ4pqcc%G2CiY5`;J zheZ<6^_R@G8M*iGz30l#j^CIE7kWi8WlWQA79XaBkYyMBhk;%#N@z^Lf3S6 zzwq7PNTX&^eAZr&IEFLk{rZ)F=@+#ElB!DQJLcWk+}zx_)+iq@CU(GxdV<``G#$>( zjX=r%|H^u7VVvM#v1-{t=_4-zzabZzG-&^7S+rh>{u`p>taK0=w-a>dSr!h}(ApuQ ztkLds%sJb1QMnowJcFp3oq%XkZXk0~3$1g4Clg<&7We_Q>l{uNbB4-3j&?nM#!FST z;WSD1I40}T>-7jowE05)fz-NFdG_Y4gZo}x(^+>xVNq)^4M&eaD}ub3zKFG9YX&kI zy_msOYH&n4Uo9c=xM+*APqO$VL3HOt{#~`tE2NlY$lX5_P%voh!O=%XFaUf(iF0wj z=k58B1AW7|pqjiiIM!r4>)Ji}o=3NR{a3{K?X+tluri#Ls9~8csNA?SoQy-K$_$$r z{Ufa$*qE8R)zqNP=bD^(jNJpG%Eu8drW4)a#+55}hn~@<~@$0`d zkNg&TvK=?}veewFAEC8mFj%hk_OAm{U$a-BoUV z@YB&`wR6K>>EH|0uxr7O^&N>m^t5E>TLxX;eqGs5D~L?j^4oO?vyIJC*Gd=)GoRZ- z-)9~$_kKuhkFv5h|Mk)?WxU%;Qj;FF&e?YK?7|ebh-`d-ABlDnjIMGxY701MXKgU_ z#fdOa>W&EMjd6m%lQo&ZRCDJ>T@r&Q-VKz~dhHdsVWWVj${eOWi*U!f>ET>K1gxL( zWM7A{%LsL}_wCPIlD+!YO} zFvAb_!|E|L1##s>lJ2C*^D1CMB95$MNfTMqNrqmqBDq7%?xizMR{_B z61Vl(SJ!Y+n)%B0 z5hYRX#<);BdaI?*`}&C!W6o_C>)arSW7g40&>0RQAlR6V<*1&wwJj7;>p4bac0L4xM?Y|5mW{S zuNwoLOwEGWMa1xA*n6QH6|G0iu6Gf5y+eD?1D;nn$RgNifD#2xI=}mY%ZFdBuE=Zi zLf_pc9K1xLcSrq^HLCVLCv8Jb)#*ppqtbiATaO0r-QIt+cvWC`2xL$Mk}wz4Y>dJe zYAl#(*w-&B0**M_?>;XOICTndL}vhrq!G1Mc@0-mPqv5(9zE6U9D#NuPU|bQX24FX zM+tV+QR^>V9~f3Rnc}wfJq#h}7rBNS6HxN5v@rGtWGzU=M38gsz}a_%`9J|`f93a2 z9Nm-}OV{^QPzzW|{Cy#^omUa_j*{YUKVX4M) z8$hY_q{Q-E8#IkY)VY3U$B!D4J@0@4+;WP8na1EV0TQODwW~b^!K(EdSyI^uHp!fz zdc0`G;JCvy(`UO^l;thC%#ZbRdc=BVr6?JkTehQ`E-huTl z`!R(ULh8CUQnsc3bUVZftrj{sfxTADTe|;koEZ!1)#{HSw^ZNRs?T#oLWUqul zn9#2>YSY(tnu&^kr-XDYbCn`#)Tl1LfO@`39i1O1j@ni-8c>Z9_Ca&a`y6E0OgQqG zollqNBN{8l&_YfpYPC`~Qj5d$P99Ld9;i?&uwUp(5KPmk&f#^_m=SXJEX~adl?0Lk zh9z=8ZArN_Zi@juCoxty^6Xd=v#Z&+_jkb;2?}v%a5><)XVG*CgWkws9JLti*GJ=Z z8UOF;?2UuE^HRKW+Ozuu(}Eg}PP*4hR%-UyJDYr*X!pBa)})Ujy1vPh7>E^seB4<1 zt=K!WUF1G~5D;ZZecJ&jj|W|Cy7bXiS`GGG5&{SiY)z}af{yxQ0q-dlkID`W)b__- zI9@z3!=+P&#x`~ZB%J1ko3WmRu5qs zk!lKNuA3ZRBC{On(kc|kcO5Ra7}HlSyjbFLmv!yEy53Ns0)Mt22wc{Lydmh*?~~AS zt#8Q(J!^q;zTv6(@r{FzK%9eDXfR#rC6eN6FBHfiwMcwXnS8|XUs1G2*NJPXYM!B2BfZplop z@Ulert_cA;G9_bgP;;NVu>iA7gPjH5f^j+4GAap z>*v^ygM9HZ)3-;$=*#eI$2n@zfs2btf~6&}HxJ--@E(>>dpuMu0l-t;;1@eIh^*|7~F zpn5AOhtqmka4&~doogWb=g;{5ex>k;2(5vS%`KUqJnu402(O5h=}uu^yKCc zJlwK27E^!5o|P37KXQbbxznYR6CZk_xg0>BU!_CP<02XWtXyIVCpOYcI{aDUh;Ui+ z+@Y2E{TlO;9EMpzdSONM&*?|w>7p6L2iBgiWF(*9DL$lwo;1n|Y!_wFcQN~CJpt)e zD2N5;ejS55-ijNJtNo?~nQ~P4AFwkqyU!iU-wS+Ikx_rm#!3R}qb@ADC-zY8KL6$0 zcHII%?$5WHZ{;w*5q~FzDkaz<2(4Wz^miBy{!{VRWARw zV&UYx!|6+$*|vavLZuzweU{!@v3fDTsqyN{W|K(yJ|!P{_wCypOy<|!>Tkk?&ZC=pYO$?-2|Cn2ER2bTt{>egXt5^9 z^o&=edvp}QE5M%VA<0bU$L7QI#*j14Mhmyf*Gf=2X&O;Ae+P-%hHOJVtD!`k{P4uJ zAPO#Ji}{*r7$ZSyhJ2@wok6S<!{Uv(2%D-IHP_dp3vlGYQi?-M6%oz*s`-dRu z-M#h{^R>^K;%!cVcbr&ljsje%y-vnDlzregw)R4yrqj2{7h1!N1t;xoxI!tNwE@m? z=aOl&O~ic51*;WM#7MQro}|6GkUjypH6~a)6Dw@7)W?Dy+NvL^>eOG3ek^q>XJnfR zK(Zv>9`RAn!!GyGI)err8ozOaP9t$!nBi;(iM!=mJX*)i9$ znb|U13SVZUgMP|wlS(&w+fU^JGGt&Bi&Z{Ehu_+fTvsO$AWsy%bWGQcx(9kVC3z+2 z#^Hzia*4`NcGUkdih5MKmS0xhg8`wW@vNrp&Gx-;(f9ba)j&B zy+rhZnd@$CrNzP9k3`-eefcg?dIHeAF5j-}$Meq)<|yhRb$|rHvQ%%#GDH^Lz;Qks zOuh&BLUZVwf+|my?{+%e5;4aa$Y4-kZI`NCsonB++bf+4|A^EVKilp3)ChPe)pE^g zfMS||nqCWanYJ_O<4`TlxN#_MrLkTwmoBaF&YVQYsTvwN$pMjXx#wzvs*UNgML04i zA++74*~ImSJcRMl{W&nM=oah?m3hz=S~X6APmA=XT@-*27pIIPFww z2A@C6#+;&xc4-##(Jm7wM**4L9#R{Pp-aDUOgOz-d?kSMi5)Wtetvr}jEXUtpF2rx z`9cgZ+ZKfzG=sg+Z01wk;4#gC;)Ps4#3@VbnnW!~Z)Bo7Yt#?67!tr2=g#be6G!c5 za&kkI@@mTXeb*Hy5e2NP!ni(NWt-8&3$o_RRN}~lsn6nYmrO}GVUe@#%#|;pdF`p` z_C-swj$Usk{0Te5716r3u)VJ@p<8GY|CW9Sn}}HSsI`m^*i;kcn1+lCPj>CbEp7R9 z=f-oQMo_OU4P?+NY)&HPc!#%l@QBj)jJ1+6@^|5{4}YEaal^uWX;~D`!-z=xq9zc} zmM={8T%bzx7*>vFK@20)OYCyPpof1E;JO!BZobe z-aDV;7+6yrRT21Gm6z4j8j#A)> zH2)Q`qeFjWk8VrNH!<#??DfJCzjy#L0@xPJC2Wur>5o1sEB!jJsC{I1=O zhMG#y?Dh1O1HKs4&I)bkR9QCN5U(dT+gSHi@x;+$tzNlrxhB%EmiW|s;)vkIQsIww zRMw^kee;c};6xAud}!ra62|#Kv)Ul<4bK6Zrsc}d2lTD0*gb#uv3t!Pbvr0G#n7LgAZ}E+ulm+?pk=TWKkMqk;=7D>$*abV zp!v-DC=DDLtHhH?+$7f%Las+u^((1O4M-51QK+_aN-Oc1pEzLr{zo}~0{&jOarZBL zbX?YjpjK-`B6E%Pfckphojdn+6=mMMN7x3+TDkB^m%ccg_Wj-~0S zHNl0Stn;8^Ot0hTmC?|3-)=o}RLTXO{qR&Q``Sj)>w$s;of~~v)p;XNl}o=c!sX&T z$CsSvN{nZ!Y0rP3muE0-Fv_XvC2MNYB~t`Q^6?COtT`tGSye+>QxvYsbtkmJY2&Vv zBQg1Jbktlgjk#_?=hU)++Zhagg@J)T3A@*PT3HeWLoft?KWbH z<)6nY*wS?HezLI%s59>gsHh+s`q7IUIqJ@@3v7H9S)jt<1 zkyCeBT)QLwaNNWG5S&tlHYd-@kvq=P~TyO%C|L zr_n^6mRNfgw|Kt(^U<%8lzLaNM9! z6(*8rBw;40R<;YwUw8HHrw1u`Wx0UH7?bnEOevG|eI_ItGx~YIPZZ?v8brjxwm`Hv>M5uXL9}W`}XbI{dWrC{~Nq+lY&A_SA3S>FCxBu>t)~ap${>2Zz$DL zNX=J#9xKcoOoExqmWU+me*UaShwaZBh7i+7k#b=@>vt?HEwLS$okUSPrL literal 33524 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, 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 @@

    {{ application.name }}

    +
  • +

    {% trans "Hash client secret" %}

    +

    {{ application.hash_client_secret|yesno:_("yes,no") }}

    +
  • +
  • {% trans "Client type" %}

    {{ application.client_type }}

    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 Date: Fri, 15 Sep 2023 21:13:06 +0200 Subject: [PATCH 071/252] 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 072/252] 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 Date: Fri, 15 Sep 2023 21:20:13 +0100 Subject: [PATCH 073/252] 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 " http://localhost:8000/groups/ # Insert a new user - curl -H "Authorization: Bearer " -X POST -d"username=foo&password=bar" http://localhost:8000/users/ + curl -H "Authorization: Bearer " -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 Date: Mon, 18 Sep 2023 13:34:06 +0200 Subject: [PATCH 074/252] 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 Date: Fri, 22 Sep 2023 12:34:03 +0200 Subject: [PATCH 075/252] 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 076/252] 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 Date: Thu, 5 Oct 2023 09:46:54 -0400 Subject: [PATCH 077/252] 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 Date: Sat, 7 Oct 2023 19:45:26 +0100 Subject: [PATCH 078/252] 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 079/252] [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 Date: Thu, 5 Oct 2023 10:54:10 -0400 Subject: [PATCH 080/252] 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 Date: Sun, 11 Dec 2022 13:43:32 +0400 Subject: [PATCH 081/252] 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 Date: Sun, 19 Feb 2023 15:34:51 +0300 Subject: [PATCH 082/252] 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 Date: Fri, 29 Sep 2023 22:12:25 +0300 Subject: [PATCH 083/252] 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 084/252] [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 Date: Sat, 30 Sep 2023 22:19:55 +0300 Subject: [PATCH 085/252] 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 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ⅆ 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 Z5u#E@CS;^2JA{HX83{%4AEHLV{{=AGzgYkP literal 33986 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 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 @@

    {{ application.name }}

    {% trans "Post Logout Redirect Uris" %}

  • + +
  • +

    {% trans "Allowed Origins" %}

    + +
  • From 7282fdee0bd8e71cc008bffdf88f407f65c88b2b Mon Sep 17 00:00:00 2001 From: Aliaksei Kanstantsinau Date: Sun, 1 Oct 2023 09:43:01 +0300 Subject: [PATCH 086/252] 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 Date: Sun, 1 Oct 2023 10:13:19 +0300 Subject: [PATCH 087/252] 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 Date: Tue, 3 Oct 2023 21:18:04 +0300 Subject: [PATCH 088/252] 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 Date: Tue, 3 Oct 2023 21:32:13 +0300 Subject: [PATCH 089/252] 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 Date: Wed, 4 Oct 2023 20:30:19 +0300 Subject: [PATCH 090/252] 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 Date: Fri, 6 Oct 2023 17:23:37 +0300 Subject: [PATCH 091/252] 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 Date: Mon, 1 May 2023 16:43:11 -0400 Subject: [PATCH 092/252] 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----- +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----- +""", + "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 @@ + +

    Log In

    +
    + {% csrf_token %} + {{ form.as_p }} + +
    \ 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 @@ + + + + + + + %sveltekit.head% + + +
    %sveltekit.body%
    + + 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 @@ + + +{#if browser} + + + Login + Logout + RefreshToken
    +
    isLoading: {$isLoading}
    +
    isAuthenticated: {$isAuthenticated}
    +
    authToken: {$accessToken}
    +
    idToken: {$idToken}
    +
    userInfo: {JSON.stringify($userInfo, null, 2)}
    +
    authError: {$authError}
    +
    +{/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 zcmV+;2Hg3HP)Px)-AP12RCwC$UE6KzI1p6{F2N z1VK2vi|pOpn{~#djwYcWXTI_im_u^TJgMZ4JMOsSj!0ma>B?-(Hr@X&W@|R-$}W@Z zgj#$x=!~7LGqHW?IO8+*oE1MyDp!G=L0#^lUx?;!fXv@l^6SvTnf^ac{5OurzC#ZMYc20lI%HhX816AYVs1T3heS1*WaWH z%;x>)-J}YB5#CLzU@GBR6sXYrD>Vw(Fmt#|JP;+}<#6b63Ike{Fuo!?M{yEffez;| zp!PfsuaC)>h>-AdbnwN13g*1LowNjT5?+lFVd#9$!8Z9HA|$*6dQ8EHLu}U|obW6f z2%uGv?vr=KNq7YYa2Roj;|zooo<)lf=&2yxM@e`kM$CmCR#x>gI>I|*Ubr({5Y^rb zghxQU22N}F51}^yfDSt786oMTc!W&V;d?76)9KXX1 z+6Okem(d}YXmmOiZq$!IPk5t8nnS{%?+vDFz3BevmFNgpIod~R{>@#@5x9zJKEHLHv!gHeK~n)Ld!M8DB|Kfe%~123&Hz1Z(86nU7*G5chmyDe ziV7$pB7pJ=96hpxHv9rCR29%bLOXlKU<_13_M8x)6;P8E1Kz6G<&P?$P^%c!M5`2` zfY2zg;VK5~^>TJGQzc+33-n~gKt{{of8GzUkWmU110IgI0DLxRIM>0US|TsM=L|@F z0Bun8U!cRB7-2apz=y-7*UxOxz@Z0)@QM)9wSGki1AZ38ceG7Q72z5`i;i=J`ILzL z@iUO?SBBG-0cQuo+an4TsLy-g-x;8P4UVwk|D8{W@U1Zi z!M)+jqy@nQ$p?5tsHp-6J304Q={v-B>66$P0IDx&YT(`IcZ~bZfmn11#rXd7<5s}y zBi9eim&zQc0Dk|2>$bs0PnLmDfMP5lcXRY&cvJ=zKxI^f0%-d$tD!`LBf9^jMSYUA zI8U?CWdY@}cRq6{5~y+)#h1!*-HcGW@+gZ4B};0OnC~`xQOyH19z*TA!!BJ%9s0V3F?CAJ{hTd#*tf+ur-W9MOURF-@B77_-OshsY}6 zOXRY=5%C^*26z?l)1=$bz30!so5tfABdSYzO+H=CpV~aaUefmjvfZ3Ttu9W&W3Iu6 zROlh0MFA5h;my}8lB0tAV-Rvc2Zs_CCSJnx@d`**$idgy-iMob4dJWWw|21b4NB=LfsYp0Aeh{Ov)yztQi;eL4y5 zMi>8^SzKqk8~k?UiQK^^-5d8c%bV?$F8%X~czyiaKCI2=UH Date: Tue, 17 Oct 2023 12:42:45 -0400 Subject: [PATCH 093/252] 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] 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 094/252] 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] 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 Date: Sun, 11 Dec 2022 13:43:32 +0400 Subject: [PATCH 095/252] 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 Date: Sun, 19 Feb 2023 15:34:51 +0300 Subject: [PATCH 096/252] 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 Date: Fri, 29 Sep 2023 22:12:25 +0300 Subject: [PATCH 097/252] 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 098/252] [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 Date: Sun, 1 Oct 2023 09:43:01 +0300 Subject: [PATCH 099/252] 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 Date: Sun, 1 Oct 2023 10:13:19 +0300 Subject: [PATCH 100/252] 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 Date: Tue, 3 Oct 2023 21:18:04 +0300 Subject: [PATCH 101/252] 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 Date: Wed, 18 Oct 2023 12:50:26 +0300 Subject: [PATCH 102/252] 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 `_ +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://[">', + # 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 Date: Wed, 18 Oct 2023 22:13:34 +0300 Subject: [PATCH 103/252] 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 Date: Wed, 18 Oct 2023 23:01:34 +0300 Subject: [PATCH 104/252] 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 Date: Wed, 18 Oct 2023 23:18:10 +0300 Subject: [PATCH 105/252] 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://[">', # 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 106/252] [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 Date: Fri, 20 Oct 2023 11:32:24 -0400 Subject: [PATCH 107/252] 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 Date: Fri, 20 Oct 2023 14:53:34 -0400 Subject: [PATCH 108/252] 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://[">', + ] + + 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://[">', - # 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 109/252] 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] 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 Date: Sat, 28 Oct 2023 02:25:14 -0400 Subject: [PATCH 110/252] 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 111/252] [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 Date: Fri, 10 Nov 2023 16:38:18 +0000 Subject: [PATCH 112/252] 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 Date: Fri, 10 Nov 2023 17:26:27 +0000 Subject: [PATCH 113/252] 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 =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 Date: Fri, 10 Nov 2023 17:40:57 +0000 Subject: [PATCH 114/252] 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 Date: Fri, 10 Nov 2023 18:25:03 +0000 Subject: [PATCH 115/252] 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 Date: Sat, 11 Nov 2023 02:41:48 +0600 Subject: [PATCH 116/252] Add LOGIN_URL settings in getting_started.rst (#1162) Co-authored-by: Mumin --- 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 Date: Sat, 11 Nov 2023 13:55:08 -0500 Subject: [PATCH 117/252] 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} Date: Sat, 11 Nov 2023 15:57:15 -0500 Subject: [PATCH 118/252] 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 Date: Mon, 13 Nov 2023 10:49:54 -0500 Subject: [PATCH 119/252] 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 ``/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 120/252] [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?= Date: Thu, 16 Nov 2023 20:53:28 +0100 Subject: [PATCH 121/252] 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 Date: Sun, 26 Nov 2023 08:21:13 -0500 Subject: [PATCH 122/252] 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 123/252] [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 124/252] [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 125/252] [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 126/252] Update index.rst requirements (#1375) I simply copy pasted the ones from the README.rst Co-authored-by: Alan Crosswell --- 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 Date: Mon, 25 Dec 2023 15:02:03 -0500 Subject: [PATCH 127/252] [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 128/252] [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 129/252] 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 130/252] 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 131/252] [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 Date: Wed, 31 Jan 2024 13:34:27 +0100 Subject: [PATCH 132/252] 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 133/252] 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 134/252] [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 Date: Wed, 28 Feb 2024 02:41:27 +0900 Subject: [PATCH 135/252] 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 `_ 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 `_ project. By contributing you agree t 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 @@ -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 `_ +* Update ``CHANGELOG.md`` (only for user relevant changes). We use `Keep A Changelog `_ 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 ``. Do not file an issue on the tracker + * ``Security`` in case of vulnerabilities. (Please report any security issues to the + JazzBand security team ````. 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 `_. .. 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 `_ @@ -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 ` or :doc:`first tutorial `. 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 `_. 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=&password=" -u":" 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": "", @@ -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 " 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 " -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=&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: +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": "", @@ -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=&password=&scope=read" -u":" 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": "", @@ -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 " http://localhost:8000/users/ curl -H "Authorization: Bearer " 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 " -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 `_ 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 `_ -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 `_ - For confidential clients, the use of PKCE `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 `_ +When is set to ``False`` (default) the `OpenID Connect RP-Initiated Logout `_ 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 `_. 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 `_. * 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 `_. 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. `_ 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 `, 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 `, 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://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 `. 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 `_. :: @@ -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 Date: Wed, 28 Feb 2024 02:50:13 +0900 Subject: [PATCH 136/252] 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 137/252] 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 138/252] [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 Date: Mon, 6 May 2024 21:32:54 +0300 Subject: [PATCH 139/252] 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 Date: Tue, 7 May 2024 15:39:14 +0200 Subject: [PATCH 140/252] Remove duplicate OAuthLibMixin from base classes (#1191) Co-authored-by: Alan Crosswell --- 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 Date: Tue, 7 May 2024 11:44:43 -0300 Subject: [PATCH 141/252] 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 Co-authored-by: Alan Crosswell --- 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 Date: Tue, 7 May 2024 18:54:37 +0200 Subject: [PATCH 142/252] =?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 --- 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 Date: Tue, 7 May 2024 14:18:16 -0400 Subject: [PATCH 143/252] 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 --- .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 `. 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 Date: Tue, 7 May 2024 11:37:34 -0700 Subject: [PATCH 144/252] 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 Date: Tue, 7 May 2024 12:04:26 -0700 Subject: [PATCH 145/252] 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 --- 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 `_ +(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 `_ - For confidential clients, the use of PKCE `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 Date: Tue, 7 May 2024 12:12:04 -0700 Subject: [PATCH 146/252] 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 Date: Mon, 13 May 2024 17:20:16 +0300 Subject: [PATCH 147/252] 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 Date: Sun, 19 May 2024 01:11:32 -0400 Subject: [PATCH 148/252] 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 `), 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 `), 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 Date: Mon, 20 May 2024 22:20:12 -0400 Subject: [PATCH 149/252] 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 150/252] 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 151/252] [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 Date: Wed, 12 Jun 2024 09:44:53 -0400 Subject: [PATCH 152/252] 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://[">', - ] - - 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://[">', - ] - - 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 Date: Wed, 12 Jun 2024 17:09:48 -0400 Subject: [PATCH 153/252] 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 @@ - + 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 @@ {#if browser} - - - Login - Logout - RefreshToken
    -
    isLoading: {$isLoading}
    -
    isAuthenticated: {$isAuthenticated}
    -
    authToken: {$accessToken}
    -
    idToken: {$idToken}
    -
    userInfo: {JSON.stringify($userInfo, null, 2)}
    -
    authError: {$authError}
    -
    + + Login + Logout + RefreshToken
    +
    isLoading: {$isLoading}
    +
    isAuthenticated: {$isAuthenticated}
    +
    authToken: {$accessToken}
    +
    idToken: {$idToken}
    +
    userInfo: {JSON.stringify($userInfo, null, 2)}
    +
    authError: {$authError}
    +
    {/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 154/252] 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 Date: Thu, 13 Jun 2024 17:31:11 -0400 Subject: [PATCH 155/252] 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 Date: Mon, 17 Jun 2024 09:57:26 -0400 Subject: [PATCH 156/252] 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----- +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 +-----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 157/252] [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 Date: Thu, 20 Jun 2024 21:03:14 +0200 Subject: [PATCH 158/252] 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 Date: Thu, 20 Jun 2024 23:45:18 +0200 Subject: [PATCH 159/252] 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 ` or :doc:`first tutorial `. - From 9cb93ee37db6f0d020f29e14d6e6a42c47d00e76 Mon Sep 17 00:00:00 2001 From: Jaap Roes Date: Fri, 21 Jun 2024 18:57:27 +0200 Subject: [PATCH 160/252] 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//', 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 Date: Mon, 8 Jul 2024 12:22:24 -0300 Subject: [PATCH 161/252] 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 162/252] [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 Date: Tue, 13 Aug 2024 06:16:33 -0700 Subject: [PATCH 163/252] 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 --- 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?= Date: Tue, 13 Aug 2024 16:02:06 +0200 Subject: [PATCH 164/252] 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 165/252] 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 Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Alan Crosswell --- 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 Date: Thu, 15 Aug 2024 01:45:34 +0800 Subject: [PATCH 166/252] 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[\w-]+)/$", views.ApplicationDetail.as_view(), name="detail"), - re_path(r"^applications/(?P[\w-]+)/delete/$", views.ApplicationDelete.as_view(), name="delete"), - re_path(r"^applications/(?P[\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//", views.ApplicationDetail.as_view(), name="detail"), + path("applications//delete/", views.ApplicationDelete.as_view(), name="delete"), + path("applications//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[\w-]+)/delete/$", + path("authorized_tokens/", views.AuthorizedTokensListView.as_view(), name="authorized-token-list"), + path( + "authorized_tokens//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 Date: Thu, 15 Aug 2024 21:03:06 +0800 Subject: [PATCH 167/252] 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 Date: Thu, 15 Aug 2024 17:11:25 +0330 Subject: [PATCH 168/252] 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 --- 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 @@

    {{ application.name }}

    {% 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 %}
    -
    +

    {% block app-form-title %} {% trans "Edit application" %} {{ application.name }} @@ -31,7 +31,7 @@

    - + {% trans "Go Back" %} 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 @@

    {% trans "Your applications" %}

    {% if applications %} 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 Date: Thu, 15 Aug 2024 22:15:14 +0800 Subject: [PATCH 169/252] 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 Date: Thu, 15 Aug 2024 22:26:31 +0800 Subject: [PATCH 170/252] 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 171/252] 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 Date: Fri, 16 Aug 2024 00:32:28 +0800 Subject: [PATCH 172/252] 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 `_ for linting, -`black `_ for formatting the code, -`isort `_ for formatting and sorting imports, +`ruff `_ for formatting the code and sorting imports, and `pre-commit `_ 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 Date: Fri, 16 Aug 2024 00:54:19 +0800 Subject: [PATCH 173/252] 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 Date: Fri, 16 Aug 2024 01:02:28 +0800 Subject: [PATCH 174/252] 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 `_ for linting, -`ruff `_ for formatting the code and sorting imports, -and `pre-commit `_ for checking/fixing commits for -correctness before they are made. +The project uses `ruff `_ for linting, formatting the code and sorting imports, +and `pre-commit `_ 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 `_ 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 Date: Fri, 16 Aug 2024 01:18:40 +0800 Subject: [PATCH 175/252] 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 Date: Tue, 27 Aug 2024 01:08:00 +0800 Subject: [PATCH 176/252] 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 Date: Tue, 27 Aug 2024 01:18:58 +0800 Subject: [PATCH 177/252] 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 Date: Tue, 27 Aug 2024 01:28:54 +0800 Subject: [PATCH 178/252] 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 179/252] [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 Date: Wed, 28 Aug 2024 15:45:04 +0200 Subject: [PATCH 180/252] 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 181/252] 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] 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 182/252] [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 Date: Tue, 3 Sep 2024 15:53:17 +0100 Subject: [PATCH 183/252] 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 Date: Wed, 4 Sep 2024 18:58:27 +0100 Subject: [PATCH 184/252] 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 Date: Wed, 4 Sep 2024 18:29:33 -0700 Subject: [PATCH 185/252] 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 Co-authored-by: Alan Crosswell --- 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 `_ 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 Date: Wed, 4 Sep 2024 23:05:42 -0400 Subject: [PATCH 186/252] add link to new gh discussions (#1480) * add link to new gh discussions * fix typo in link --------- Co-authored-by: Darrel O'Pry --- 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 `__. +Discussions +~~~~~~~~~~~ +Have questions or want to discuss the project? +See `the 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 `__. +If you are interested in stepping up to be a Project Lead, please take a look at +the `discussion about this `__. From 5ce5e7fc8698bdb9956a3b98b1b5b6fb6c5670bf Mon Sep 17 00:00:00 2001 From: Alan Crosswell Date: Thu, 5 Sep 2024 18:23:02 -0400 Subject: [PATCH 187/252] 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 Date: Fri, 6 Sep 2024 12:42:22 -0400 Subject: [PATCH 188/252] 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 Date: Sat, 7 Sep 2024 08:42:59 -0400 Subject: [PATCH 189/252] 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 190/252] [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 Date: Fri, 20 Sep 2024 11:39:20 -0400 Subject: [PATCH 191/252] 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 --- 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 192/252] [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 193/252] 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] 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 Date: Fri, 20 Sep 2024 12:13:38 -0400 Subject: [PATCH 194/252] 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 Date: Sun, 22 Sep 2024 19:56:25 +0200 Subject: [PATCH 195/252] Support for specifying client secret hasher (#1498) Co-authored-by: Matej Spiller Muys --- 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 +--> + ## [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 Date: Mon, 7 Oct 2024 19:20:37 +0200 Subject: [PATCH 200/252] 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 201/252] [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 Date: Mon, 7 Oct 2024 18:03:31 -0400 Subject: [PATCH 202/252] 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 Date: Tue, 8 Oct 2024 06:14:53 +0800 Subject: [PATCH 203/252] 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 Date: Wed, 9 Oct 2024 11:11:17 -0400 Subject: [PATCH 204/252] 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 ### Fixed +* #1517 OP prompts for logout when no OP session +* #1512 client_secret not marked sensitive + 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 205/252] [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 Date: Mon, 4 Nov 2024 16:49:27 +0100 Subject: [PATCH 206/252] 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 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 207/252] [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 208/252] [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 209/252] [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 210/252] 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] 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 211/252] [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 212/252] [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 213/252] [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 Date: Thu, 12 Dec 2024 15:12:35 +0100 Subject: [PATCH 214/252] 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 `), 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 215/252] [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 216/252] [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 217/252] [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 218/252] [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 219/252] [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 220/252] 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 221/252] [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 --- .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 Date: Wed, 29 Jan 2025 17:56:21 +0000 Subject: [PATCH 222/252] 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 --------- Co-authored-by: Alan Crosswell --- 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 Date: Fri, 31 Jan 2025 18:28:11 -0500 Subject: [PATCH 223/252] 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. 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 224/252] [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 225/252] [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 226/252] [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 227/252] [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 228/252] [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 229/252] [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 230/252] [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 231/252] [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 232/252] [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 233/252] 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 Date: Fri, 18 Apr 2025 13:31:16 -0400 Subject: [PATCH 234/252] 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 `_ 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 `_ 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 `_ 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 235/252] [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 236/252] [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 237/252] [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 238/252] [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 239/252] [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 240/252] [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 241/252] [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 242/252] 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 243/252] [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 244/252] [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 Date: Sun, 20 Jul 2025 13:52:50 -0400 Subject: [PATCH 245/252] 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 246/252] 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 247/252] [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 248/252] 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 --- 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 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?= Date: Tue, 12 Aug 2025 20:00:11 +0300 Subject: [PATCH 249/252] 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 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 \n" +"PO-Revision-Date: 2025-07-01 12:35-0300\n" +"Last-Translator: Thales Barbosa Bento \n" "Language-Team: LANGUAGE \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 251/252] 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 `. .. _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 Date: Thu, 14 Aug 2025 04:55:52 +0530 Subject: [PATCH 252/252] 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