From c5d756bfb25f694bbca1b88ea7f1e7e9247195dc Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Tue, 22 Jul 2025 15:13:13 -0400 Subject: [PATCH 1/4] Add idp-oidc dependency --- pyproject.toml | 2 +- uv.lock | 34 +++++++++++++++++++++++++++------- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ecdf4e97..35cc1571 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ "celery", "deepdiff", "django[argon2]", - "django-allauth", + "django-allauth[idp-oidc]>=65.10.0", "django-auth-style", "django-cachalot", "django-click", diff --git a/uv.lock b/uv.lock index ed75a3db..12ddffc1 100644 --- a/uv.lock +++ b/uv.lock @@ -523,13 +523,19 @@ argon2 = [ [[package]] name = "django-allauth" -version = "65.9.0" +version = "65.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, { name = "django" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/a3/00aa9d5bb5df4f7464495675074dc11107c08b3eea3462fb3edc059d71e1/django_allauth-65.9.0.tar.gz", hash = "sha256:a06bca9974df44321e94c33bcf770bb6f924d1a44b57defbce4d7ec54a55483e", size = 1710514, upload-time = "2025-06-01T19:21:07.771Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/9e/271e3b8ea27c089ddf3431140cf4aa86df86556ec102e360da5af62c3a99/django_allauth-65.10.0.tar.gz", hash = "sha256:47daa3b0e11a1d75724ea32995de37bd2b8963e9e4cce2b3a7fd64eb6d3b3c48", size = 1897777, upload-time = "2025-07-10T11:32:44.098Z" } + +[package.optional-dependencies] +idp-oidc = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, +] [[package]] name = "django-auth-style" @@ -1155,7 +1161,7 @@ dependencies = [ { name = "celery" }, { name = "deepdiff" }, { name = "django", extra = ["argon2"] }, - { name = "django-allauth" }, + { name = "django-allauth", extra = ["idp-oidc"] }, { name = "django-auth-style" }, { name = "django-cachalot" }, { name = "django-click" }, @@ -1256,7 +1262,7 @@ requires-dist = [ { name = "celery" }, { name = "deepdiff" }, { name = "django", extras = ["argon2"] }, - { name = "django-allauth" }, + { name = "django-allauth", extras = ["idp-oidc"], specifier = ">=65.10.0" }, { name = "django-auth-style" }, { name = "django-browser-reload", marker = "extra == 'development'" }, { name = "django-cachalot" }, @@ -1650,11 +1656,11 @@ wheels = [ [[package]] name = "oauthlib" -version = "3.2.2" +version = "3.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6d/fa/fbf4001037904031639e6bfbfc02badfc7e12f137a8afa254df6c4c8a670/oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918", size = 177352, upload-time = "2022-10-17T20:04:27.471Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/80/cab10959dc1faead58dc8384a781dfbf93cb4d33d50988f7a69f1b7c9bbe/oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca", size = 151688, upload-time = "2022-10-17T20:04:24.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] [[package]] @@ -2041,6 +2047,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/63/14f5c6253e8c85c758485c7717f542346a0d4487818afc28721912a1574b/pyinstrument-5.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:971c974c061019fa6177a021882255e639399bc15bf71b0a17979830702ad8d3", size = 124287, upload-time = "2025-05-24T15:46:34.333Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.2.3" From c73cbcdcf3d9fdda6ec7d9e0efe0b149768c0593 Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Tue, 22 Jul 2025 15:13:52 -0400 Subject: [PATCH 2/4] Add the allauth.idp.oidc app --- dev/private_key_development.pem | 28 ++++++++++++++++++++++++++++ isic/settings/base.py | 11 +++++++++++ isic/settings/development.py | 5 +++++ 3 files changed, 44 insertions(+) create mode 100644 dev/private_key_development.pem diff --git a/dev/private_key_development.pem b/dev/private_key_development.pem new file mode 100644 index 00000000..cfb36a76 --- /dev/null +++ b/dev/private_key_development.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCe0X6Mm6YHNsXb +355ujaeVRqkmJGZNNl3920BVM3NN9b5NBSOF+bmUZeMej6Gh0Zpv+qMIq2ddtgXJ +oVTF0PyHvxZpcj8wE6iHxETkiBo61Kt8Vll8RpWXuXkwIiY1camDNWch1vzLZpyZ +sG4QcI2nmhLInoWfREWrWwg9mA9Uh11THLa7aP4zdq53e1qb80gH64coCHW6UuJT +j0oNmwsXCVzyOywAhZyy7H60MsbWHZc/pdby9/NrNuhd+OooUXtbnbN9eUj2HmGx +dHIVHbxuEWCwsAafdSUGIDXVwnO8w6ZDVTdcMKoBfll3l//bJqpaguPXcVBrtbvf +vp1xxFRxAgMBAAECggEAE03NGc53b+wJQPryw7xFu4ANt5xNWk2P6iG4Cu3Ih5gl +loYCiy/POJ7pFsIIODuFD98BPwtacopZNL3+fawzwv+H0VmjcuFeJuEagJlHGucr +pYl3XK2AVE36fCPCd3+GcPN7mCI3XYpuNsNk9WxA2OIs2KQQA3ZQbji6jTC04vtU +iNezp3hFW3MVVehKX+5le1NpZylTyqg0+LL5j/+BHmqxiu12qvZbxanZ7diRgwqI +fjRfTUJ50gMUarYYaXuno6ZAON9SRtgueKmWfmcgKjUlJXX40LR1CbPoEQ8A72fd +YngS4ffHjo2zdqUn683qw5gznRFg36qJzh2f8TgRcwKBgQDdH/U72RLE9uAFQNMU +lyCHaqyd9+j+U3npiNkGfHnnLaYK6O6hHBw9XoiYfAnBDfcOYeWFmWDBnLw/N95y +H+r8pV+oGhVeP4VGpwycCRuvMxp7cVbq2kkXgtjLfhYx1Asuf/DdvlQ84ENuSMCu +2Kq90KLaHqDZ+w10XH0cNgpXhwKBgQC33eAzHxmc6JxFPtqG8I6u4ibEudWYV8qR ++iHYf0Zqbb70rddua8c6H+10WGu3oZhXnnNWkAsqJGgakAREzxIanbFqfpZ1ZtA1 ++2alupWeUF+7XoR1RwkKysUjbWlWUbKvO20MRunxzvdwzG14Y8ungITaQHzB+M3l +nMJQfdECRwKBgQDBYiiTbZVnokxq67VuZXjyVQ2fnWcrvQ96eM7sSEJINnjnQ60m +QzJDTYCCcsAJEVCGSIF1ZJzk1lEfrJmjD1zwFSTiG+WiJkVFc+SoNaL7huLbIFUW +UU7o++rjlGKOs1YQFZ4uHz0GfE8cjQ3OG/i+xk8WGQEtgczTfeuAl5ZV0wKBgE72 +QV+S/pvtJZdzW8PRsWUXiFC6AinvofY49qoUVrhEM1q/AaLRNHkY1xA9HN16z4Lp +cFz/dVv+0Jp/uOWYDA1UJao3fQQkSEy2j6mizLh1ifdcqwP2osJ4vFrvlOpWIaex +nK5GEhgfqxJNKMIoEYD455UXVryyzjHKtYR90/HnAoGBAMDZPN0V7pW7CCzSpC2F +8bfZmpm75XZDakgHWHPzj/uUMQ0EFR871+v8eI4F27gfoxerfwKOvudFPpCRrg7M +3FqvZFduVdm51nd7QfqdmsJiRZC6BgqZkU9F0cTw3kc1DTPTD9J/vd1fl1Np4/Dy +BEfjGnMsRoqoJ2JEXyyDhJiw +-----END PRIVATE KEY----- diff --git a/isic/settings/base.py b/isic/settings/base.py index 21256871..c3964ba0 100644 --- a/isic/settings/base.py +++ b/isic/settings/base.py @@ -42,6 +42,7 @@ # Everything else "allauth", "allauth.account", + "allauth.idp.oidc", "allauth.socialaccount", "cachalot", "corsheaders", @@ -279,3 +280,13 @@ "isic.core.context_processors.citation_styles", ] ISIC_JS_SENTRY = False + + +# Django can persist logins for longer than this via cookies, +# but non-refreshing clients will need to redirect to Django's auth every 24 hours. +IDP_OIDC_ACCESS_TOKEN_EXPIRES_IN = timedelta(days=1).total_seconds() + +# Allow 5 minutes for a flow to exchange an auth code for a token. This is typically +# 60 seconds but out-of-band flows may take a bit longer. A maximum of 10 minutes is +# recommended: https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2. +IDP_OIDC_AUTHORIZATION_CODE_EXPIRES_IN = timedelta(minutes=5).total_seconds() diff --git a/isic/settings/development.py b/isic/settings/development.py index 50461141..688bdac2 100644 --- a/isic/settings/development.py +++ b/isic/settings/development.py @@ -98,3 +98,8 @@ # suppress noisy cache invalidation log messages logging.getLogger("isic.core.signals").setLevel(logging.ERROR) + +# Set the OIDC private key for the IDP to the (insecure) development key. +IDP_OIDC_PRIVATE_KEY = ( + Path(__file__).parents[2] / "dev" / "private_key_development.pem" +).read_text() From fb33812cb6fb2d0ded92ff7a5e84f9dc52f7d54e Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Thu, 24 Jul 2025 18:06:20 -0400 Subject: [PATCH 3/4] Add PermissionedTokenAuth --- isic/auth.py | 36 +++++++++++++++++++++++--- isic/core/tests/test_isic_oauth_app.py | 32 +++++++++++++---------- isic/settings/testing.py | 6 +++++ 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/isic/auth.py b/isic/auth.py index b3588086..b82e2664 100644 --- a/isic/auth.py +++ b/isic/auth.py @@ -1,5 +1,7 @@ from collections.abc import Callable +from typing import Literal +from django.http import HttpRequest from ninja.security import HttpBearer, django_auth from oauth2_provider.oauth2_backends import get_oauthlib_core @@ -7,6 +9,34 @@ ACCESS_PERMS = ["any", "is_authenticated", "is_staff"] +from allauth.idp.oidc.contrib.ninja.security import TokenAuth # noqa: E402 + + +class PermissionedTokenAuth(TokenAuth): + def __init__( + self, permission: Literal["any", "is_authenticated", "is_staff"], scope: str | list | dict + ): + if permission not in ACCESS_PERMS: + raise ValueError(f"Invalid permission: {permission}") + + super().__init__(scope) + self.permission = permission + + def __call__(self, request: HttpRequest): + result = super().__call__(request) + if result is not None: + if self.permission == "any": + return result + if self.permission == "is_authenticated" and request.user.is_authenticated: + return result + if ( + self.permission == "is_staff" + and request.user.is_authenticated + and request.user.is_staff + ): + return result + return None + class OAuth2AuthBearer(HttpBearer): def __init__(self, perm: str): @@ -39,6 +69,6 @@ def authenticate(self, request, token): # The lambda _: True is to handle the case where a user doesn't pass any authentication. -allow_any: list[Callable] = [django_auth, OAuth2AuthBearer("any"), lambda _: True] -is_authenticated = [django_auth, OAuth2AuthBearer("is_authenticated")] -is_staff = [SessionAuthStaffUser(), OAuth2AuthBearer("is_staff")] +allow_any: list[Callable] = [django_auth, PermissionedTokenAuth("any", scope=[]), lambda _: True] +is_authenticated = [django_auth, PermissionedTokenAuth("is_authenticated", scope=[])] +is_staff = [SessionAuthStaffUser(), PermissionedTokenAuth("is_staff", scope=[])] diff --git a/isic/core/tests/test_isic_oauth_app.py b/isic/core/tests/test_isic_oauth_app.py index ae07edc1..edd3d80e 100644 --- a/isic/core/tests/test_isic_oauth_app.py +++ b/isic/core/tests/test_isic_oauth_app.py @@ -1,10 +1,13 @@ from datetime import timedelta +import secrets +from allauth.idp.oidc.adapter import get_adapter +from allauth.idp.oidc.models import Client, Token from django.test import RequestFactory from django.urls import path from django.utils import timezone from ninja import NinjaAPI -from oauth2_provider.models import get_access_token_model, get_application_model +from oauth2_provider.models import get_application_model import pytest from isic import auth @@ -13,25 +16,29 @@ @pytest.fixture def oauth_app(user_factory): - user = user_factory() - return get_application_model().objects.create( + return Client.objects.create( name="Test Application", - redirect_uris="http://localhost", - user=user, - client_type=get_application_model().CLIENT_CONFIDENTIAL, - authorization_grant_type=get_application_model().GRANT_AUTHORIZATION_CODE, + scopes="openid", + type=Client.Type.PUBLIC, + grant_types=Client.GrantType.DEVICE_CODE, + redirect_uris="http://foo.com", + response_types="code", + skip_consent=False, ) @pytest.fixture def oauth_token_factory(oauth_app): def f(user): - return get_access_token_model().objects.create( + token = secrets.token_hex(8) + oauth_app.token_set.create( user=user, - expires=timezone.now() + timedelta(seconds=300), - token="some-token", - application=oauth_app, + expires_at=timezone.now() + timedelta(seconds=300), + hash=get_adapter().hash_token(token), + type=Token.Type.ACCESS_TOKEN, + scopes=["openid"], ) + return token return f @@ -89,8 +96,7 @@ def is_staff_view(request): def get_bearer_token(user, oauth_token_factory): - token = oauth_token_factory(user) - return token.token + return oauth_token_factory(user) @pytest.mark.django_db diff --git a/isic/settings/testing.py b/isic/settings/testing.py index ce29da97..4bee9069 100644 --- a/isic/settings/testing.py +++ b/isic/settings/testing.py @@ -66,3 +66,9 @@ # suppress noisy cache invalidation log messages logging.getLogger("isic.core.signals").setLevel(logging.ERROR) + + +# Set the OIDC private key for the IDP to the (insecure) development key. +IDP_OIDC_PRIVATE_KEY = ( + Path(__file__).parents[2] / "dev" / "private_key_development.pem" +).read_text() From b19839035119dd5ef6437b1b9a59767cac8c5b9f Mon Sep 17 00:00:00 2001 From: Dan LaManna Date: Tue, 22 Jul 2025 15:14:16 -0400 Subject: [PATCH 4/4] Migrate from django-oauth-toolkit -> django-allauth --- isic/auth.py | 35 +------------- isic/core/migrations/0001_initial.py | 5 +- .../0027_delete_isicoauthapplication.py | 15 ++++++ isic/core/models/__init__.py | 3 +- isic/core/models/base.py | 16 ------- isic/core/tasks.py | 6 --- isic/core/tests/test_isic_oauth_app.py | 48 +++++++++---------- isic/settings/base.py | 22 --------- isic/settings/development.py | 3 -- isic/urls.py | 2 +- pyproject.toml | 2 - uv.lock | 30 ------------ 12 files changed, 44 insertions(+), 143 deletions(-) create mode 100644 isic/core/migrations/0027_delete_isicoauthapplication.py diff --git a/isic/auth.py b/isic/auth.py index b82e2664..72e18ba0 100644 --- a/isic/auth.py +++ b/isic/auth.py @@ -2,8 +2,7 @@ from typing import Literal from django.http import HttpRequest -from ninja.security import HttpBearer, django_auth -from oauth2_provider.oauth2_backends import get_oauthlib_core +from ninja.security import django_auth from isic.core.permissions import SessionAuthStaffUser @@ -35,37 +34,7 @@ def __call__(self, request: HttpRequest): and request.user.is_staff ): return result - return None - - -class OAuth2AuthBearer(HttpBearer): - def __init__(self, perm: str): - if perm not in ACCESS_PERMS: - raise ValueError(f"Invalid permission: {perm}") - self.perm = perm - super().__init__() - - # This is a reimplementation of the django-oauth-toolkit authentication backend for DRF. - # See https://github.com/jazzband/django-oauth-toolkit/blob/a4ae1d4716bcabe45d80a787f4064022f11e584f/oauth2_provider/contrib/rest_framework/authentication.py#L8 # noqa: E501 - def authenticate(self, request, token): - oauthlib_core = get_oauthlib_core() - valid, r = oauthlib_core.verify_request(request, scopes=[]) - - if valid: - # See https://github.com/vitalik/django-ninja/issues/76 for why we have to manually set - # request.user here. - request.user = r.user - - if self.perm == "any": - return r.user, token - if self.perm == "is_authenticated" and r.user.is_authenticated: - return r.user, token - if self.perm == "is_staff" and r.user.is_authenticated and r.user.is_staff: - return r.user, token - elif self.perm == "any": - return True - else: - request.oauth2_error = getattr(r, "oauth2_error", {}) + return self.permission == "any" # The lambda _: True is to handle the case where a user doesn't pass any authentication. diff --git a/isic/core/migrations/0001_initial.py b/isic/core/migrations/0001_initial.py index f43d44ad..522b3f49 100644 --- a/isic/core/migrations/0001_initial.py +++ b/isic/core/migrations/0001_initial.py @@ -6,7 +6,6 @@ from django.db import migrations, models import django.db.models.deletion import django_extensions.db.fields -import oauth2_provider.generators import s3_file_field.fields @@ -376,7 +375,7 @@ class Migration(migrations.Migration): "client_id", models.CharField( db_index=True, - default=oauth2_provider.generators.generate_client_id, + default="placeholder", max_length=100, unique=True, ), @@ -413,7 +412,7 @@ class Migration(migrations.Migration): models.CharField( blank=True, db_index=True, - default=oauth2_provider.generators.generate_client_secret, + default="", max_length=255, ), ), diff --git a/isic/core/migrations/0027_delete_isicoauthapplication.py b/isic/core/migrations/0027_delete_isicoauthapplication.py new file mode 100644 index 00000000..ed0f5f66 --- /dev/null +++ b/isic/core/migrations/0027_delete_isicoauthapplication.py @@ -0,0 +1,15 @@ +# Generated by Django 5.2.3 on 2025-07-24 22:20 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0026_invert_doi_fk"), + ] + + operations = [ + migrations.DeleteModel( + name="IsicOAuthApplication", + ), + ] diff --git a/isic/core/models/__init__.py b/isic/core/models/__init__.py index d87e7da7..68d92fb2 100644 --- a/isic/core/models/__init__.py +++ b/isic/core/models/__init__.py @@ -2,7 +2,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver -from .base import CopyrightLicense, CreationSortedTimeStampedModel, IsicOAuthApplication +from .base import CopyrightLicense, CreationSortedTimeStampedModel from .collection import Collection from .collection_count import CollectionCount from .doi import Doi @@ -24,7 +24,6 @@ "Image", "ImageAlias", "IsicId", - "IsicOAuthApplication", "Segmentation", "SegmentationReview", "SupplementalFile", diff --git a/isic/core/models/base.py b/isic/core/models/base.py index d66159ac..46fe71cd 100644 --- a/isic/core/models/base.py +++ b/isic/core/models/base.py @@ -1,9 +1,6 @@ -import re - from django.db import models from django_extensions.db.fields import CreationDateTimeField from django_extensions.db.models import TimeStampedModel -from oauth2_provider.models import AbstractApplication class CreationSortedTimeStampedModel(TimeStampedModel): @@ -23,16 +20,3 @@ class CopyrightLicense(models.TextChoices): # These 2 require attribution CC_BY = "CC-BY", "CC-BY" CC_BY_NC = "CC-BY-NC", "CC-BY-NC" - - -class IsicOAuthApplication(AbstractApplication): - class Meta: - verbose_name = "ISIC OAuth application" - - def redirect_uri_allowed(self, uri): - """Allow regex matching, in addition to the normal behavior.""" - for redirect_uri in self.redirect_uris.split(): - if redirect_uri.startswith("^") and re.match(redirect_uri, uri): - return True - - return super().redirect_uri_allowed(uri) diff --git a/isic/core/tasks.py b/isic/core/tasks.py index 0779174f..372e9061 100644 --- a/isic/core/tasks.py +++ b/isic/core/tasks.py @@ -14,7 +14,6 @@ from django.db import connection, transaction from django.db.models import Prefetch from django.template.loader import render_to_string -from oauth2_provider.models import clear_expired as clear_expired_oauth_tokens import requests from resonant_utils.storages import expiring_url from urllib3.exceptions import ConnectionError as Urllib3ConnectionError @@ -187,11 +186,6 @@ def generate_archive_snapshot_task() -> None: Path(metadata_filename).unlink() -@shared_task(soft_time_limit=10, time_limit=15) -def prune_expired_oauth_tokens(): - clear_expired_oauth_tokens() - - @shared_task(soft_time_limit=90, time_limit=120) def refresh_materialized_view_collection_counts_task(): with connection.cursor() as cursor: diff --git a/isic/core/tests/test_isic_oauth_app.py b/isic/core/tests/test_isic_oauth_app.py index edd3d80e..0faa697d 100644 --- a/isic/core/tests/test_isic_oauth_app.py +++ b/isic/core/tests/test_isic_oauth_app.py @@ -1,17 +1,17 @@ +from base64 import b64encode from datetime import timedelta import secrets +from allauth.core import context from allauth.idp.oidc.adapter import get_adapter from allauth.idp.oidc.models import Client, Token from django.test import RequestFactory from django.urls import path from django.utils import timezone from ninja import NinjaAPI -from oauth2_provider.models import get_application_model import pytest from isic import auth -from isic.core.models.base import IsicOAuthApplication @pytest.fixture @@ -43,6 +43,7 @@ def f(user): return f +@pytest.mark.skip(reason="TODO: needs to be ported to allauth") @pytest.mark.django_db @pytest.mark.parametrize( ("uri", "allowed_uris", "allowed"), @@ -54,22 +55,11 @@ def f(user): ], ) def test_redirect_uri_allowed(user, uri, allowed_uris, allowed): - app = IsicOAuthApplication.objects.create( - name="Test Application", - redirect_uris=allowed_uris, - user=user, - client_type=get_application_model().CLIENT_CONFIDENTIAL, - authorization_grant_type=get_application_model().GRANT_AUTHORIZATION_CODE, - ) - - assert app.redirect_uri_allowed(uri) == allowed + pass @pytest.fixture def test_oauth_api_endpoints(request): - # this is pretty gross, but DOT requires a "more" real request object be created, meaning the - # ninja test client can't be used since it mocks it. using the django test client means we have - # to add real routes and then remove them. api = NinjaAPI(urls_namespace=request.function.__name__, auth=auth.allow_any) @api.get("/allow-any") @@ -186,16 +176,24 @@ def test_is_staff_with_nonstaff_bearer_token(client, nonstaff_user, oauth_token_ assert response.status_code == 401 -def test_oauth2authbearer_any_accepts_invalid_token(): - bearer = auth.OAuth2AuthBearer("any") - request = RequestFactory().get("/") - result = bearer.authenticate(request, "invalidtoken") - assert result is True +@pytest.mark.django_db +@pytest.mark.usefixtures("test_oauth_api_endpoints") +def test_permissioned_token_auth_invalid_token(): + request = RequestFactory( + headers={"Authorization": f"Bearer {b64encode(b'invalidtoken').decode()}"} + ).get("/test-oauth/allow-any") + + token_auth = auth.PermissionedTokenAuth("any", scope=[]) + + # allauth APIs assume a global request context, so we need to set it up manually + with context.request_context(request): + result = token_auth(request) + assert result is True - bearer = auth.OAuth2AuthBearer("is_authenticated") - result = bearer.authenticate(request, "invalidtoken") - assert result is None + token_auth = auth.PermissionedTokenAuth("is_authenticated", scope=[]) + result = token_auth(request) + assert result is False - bearer = auth.OAuth2AuthBearer("is_staff") - result = bearer.authenticate(request, "invalidtoken") - assert result is None + token_auth = auth.PermissionedTokenAuth("is_staff", scope=[]) + result = token_auth(request) + assert result is False diff --git a/isic/settings/base.py b/isic/settings/base.py index c3964ba0..7bfd59a0 100644 --- a/isic/settings/base.py +++ b/isic/settings/base.py @@ -13,7 +13,6 @@ from resonant_settings.django import * from resonant_settings.django_extensions import * from resonant_settings.logging import * -from resonant_settings.oauth_toolkit import * if TYPE_CHECKING: from urllib.parse import ParseResult @@ -61,7 +60,6 @@ "markdownify", # Install "ninja" to force Swagger to be served locally, so it can be overridden "ninja", - "oauth2_provider", "resonant_utils", "s3_file_field", "widget_tweaks", @@ -148,10 +146,6 @@ "task": "isic.core.tasks.sync_elasticsearch_indices_task", "schedule": crontab(minute="0", hour="0"), }, - "prune-expired-oauth-tokens": { - "task": "isic.core.tasks.prune_expired_oauth_tokens", - "schedule": crontab(minute="0", hour="0"), - }, "refresh-materialized-view-collection-counts": { "task": "isic.core.tasks.refresh_materialized_view_collection_counts_task", "schedule": crontab(minute="*/15", hour="*"), @@ -215,22 +209,6 @@ "from isic.studies.tasks import *", ] -OAUTH2_PROVIDER.update( - { - # PKCE_REQUIRED is on by default in oauth-toolkit >= 2.0 - "PKCE_REQUIRED": True, - # Normally, "http" would only be allowed in development, but local developers - # of the Gallery are allowed to authenticate against the production site - "ALLOWED_REDIRECT_URI_SCHEMES": ["http", "https"], - "SCOPES": { - "identity": "Access to your basic profile information", - "image:read": "Read access to images", - "image:write": "Write access to images", - }, - "DEFAULT_SCOPES": ["identity"], - } -) -OAUTH2_PROVIDER_APPLICATION_MODEL = "core.IsicOAuthApplication" ISIC_ELASTICSEARCH_URL: ParseResult = env.url("DJANGO_ISIC_ELASTICSEARCH_URL") ISIC_ELASTICSEARCH_IMAGES_INDEX = "isic" diff --git a/isic/settings/development.py b/isic/settings/development.py index 688bdac2..c48ed99c 100644 --- a/isic/settings/development.py +++ b/isic/settings/development.py @@ -91,9 +91,6 @@ # cachalot sets its own expiration time, so it needs to be set to 0 as well CACHALOT_TIMEOUT = 0 -# In development, always present the approval dialog -OAUTH2_PROVIDER["REQUEST_APPROVAL_PROMPT"] = "force" - ISIC_ZIP_DOWNLOAD_WILDCARD_URLS = False # suppress noisy cache invalidation log messages diff --git a/isic/urls.py b/isic/urls.py index fcd0f189..61f6bb0a 100644 --- a/isic/urls.py +++ b/isic/urls.py @@ -79,7 +79,7 @@ def handle_image_search_parse_error(request, exc: ImageSearchParseError): urlpatterns = [ path("accounts/", include("allauth.urls")), - path("oauth/", include("oauth2_provider.urls")), + path("oauth/", include("allauth.idp.urls")), path("admin/", admin.site.urls), path("api/v2/s3-upload/", include("s3_file_field.urls")), path("api/v2/", api.urls), diff --git a/pyproject.toml b/pyproject.toml index 35cc1571..2ca000d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,6 @@ dependencies = [ "django-json-widget", "django-markdownify", "django-ninja", - # v2 removed OOB support: https://github.com/jazzband/django-oauth-toolkit/pull/1124 - "django-oauth-toolkit<2.0.0", "django-redis", "django-resonant-settings[allauth,celery]", "django-resonant-utils[s3_storage]", diff --git a/uv.lock b/uv.lock index 12ddffc1..ba124330 100644 --- a/uv.lock +++ b/uv.lock @@ -691,21 +691,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/ec/0cfa9b817f048cdec354354ae0569d7c0fd63907e5b1f927a7ee04a18635/django_ninja-1.4.3-py3-none-any.whl", hash = "sha256:f3204137a059437b95677049474220611f1cf9efedba9213556474b75168fa01", size = 2426185, upload-time = "2025-06-04T15:11:11.314Z" }, ] -[[package]] -name = "django-oauth-toolkit" -version = "1.7.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "django" }, - { name = "jwcrypto" }, - { name = "oauthlib" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/84/11/7841f88b6e200e49533307425271c356399319b35738aedb3fcb487afc3b/django-oauth-toolkit-1.7.1.tar.gz", hash = "sha256:37b690fa53f340c7391bdbc0fdbb32fd9ef8a7c012e72ee8754c331a2d7b4adb", size = 46685, upload-time = "2022-03-19T22:35:09.076Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/1a/3a8332b8c507ae41491f6b049ff4521225eabca83032d2e67372d63d973b/django_oauth_toolkit-1.7.1-py3-none-any.whl", hash = "sha256:756e44421d0993f27705736b6c33a3d89018393859a31ac926296950f76e4433", size = 63199, upload-time = "2022-03-19T22:35:36.988Z" }, -] - [[package]] name = "django-redis" version = "6.0.0" @@ -1172,7 +1157,6 @@ dependencies = [ { name = "django-json-widget" }, { name = "django-markdownify" }, { name = "django-ninja" }, - { name = "django-oauth-toolkit" }, { name = "django-redis" }, { name = "django-resonant-settings", extra = ["allauth", "celery"] }, { name = "django-resonant-utils", extra = ["s3-storage"] }, @@ -1276,7 +1260,6 @@ requires-dist = [ { name = "django-markdownify" }, { name = "django-minio-storage", marker = "extra == 'development'" }, { name = "django-ninja" }, - { name = "django-oauth-toolkit", specifier = "<2.0.0" }, { name = "django-redis" }, { name = "django-resonant-settings", extras = ["allauth", "celery"] }, { name = "django-resonant-utils", extras = ["minio-storage"], marker = "extra == 'development'" }, @@ -1434,19 +1417,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/0e/b27cdbaccf30b890c40ed1da9fd4a3593a5cf94dae54fb34f8a4b74fcd3f/jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af", size = 18437, upload-time = "2025-04-23T12:34:05.422Z" }, ] -[[package]] -name = "jwcrypto" -version = "1.5.6" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cryptography" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e1/db/870e5d5fb311b0bcf049630b5ba3abca2d339fd5e13ba175b4c13b456d08/jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039", size = 87168, upload-time = "2024-03-06T19:58:31.831Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/58/4a1880ea64032185e9ae9f63940c9327c6952d5584ea544a8f66972f2fda/jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789", size = 92520, upload-time = "2024-03-06T19:58:29.765Z" }, -] - [[package]] name = "kombu" version = "5.5.2"