From f2bb6c9725afa1426e9469751096f7afb02b69e3 Mon Sep 17 00:00:00 2001 From: Paul Gessinger Date: Mon, 26 Jan 2026 09:29:36 +0100 Subject: [PATCH] Enhancement: Add support for app oidc (#11756) --------- Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com> --- docs/api.md | 10 +++- src/paperless/adapter.py | 11 +++++ src/paperless/settings.py | 2 + src/paperless/tests/test_adapter.py | 75 +++++++++++++++++++++++++++++ src/paperless/urls.py | 1 + 5 files changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/api.md b/docs/api.md index 1ac634162..ced8eb5b3 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,7 +8,7 @@ Further documentation is provided here for some endpoints and features. ## Authorization -The REST api provides four different forms of authentication. +The REST api provides five different forms of authentication. 1. Basic authentication @@ -52,6 +52,14 @@ The REST api provides four different forms of authentication. [configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)), you can authenticate against the API using Remote User auth. +5. Headless OIDC via [`django-allauth`](https://codeberg.org/allauth/django-allauth) + + `django-allauth` exposes API endpoints under `api/auth/` which enable tools + like third-party apps to authenticate with social accounts that are + configured. See + [here](advanced_usage.md#openid-connect-and-social-authentication) for more + information on social accounts. + ## Searching for documents Full text searching is available on the `/api/documents/` endpoint. Two diff --git a/src/paperless/adapter.py b/src/paperless/adapter.py index a4506275e..f1f478141 100644 --- a/src/paperless/adapter.py +++ b/src/paperless/adapter.py @@ -3,12 +3,15 @@ from urllib.parse import quote from allauth.account.adapter import DefaultAccountAdapter from allauth.core import context +from allauth.headless.tokens.sessions import SessionTokenStrategy from allauth.socialaccount.adapter import DefaultSocialAccountAdapter from django.conf import settings from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError +from django.http import HttpRequest from django.urls import reverse +from rest_framework.authtoken.models import Token from documents.models import Document from paperless.signals import handle_social_account_updated @@ -159,3 +162,11 @@ class CustomSocialAccountAdapter(DefaultSocialAccountAdapter): exception, extra_context, ) + + +class DrfTokenStrategy(SessionTokenStrategy): + def create_access_token(self, request: HttpRequest) -> str | None: + if not request.user.is_authenticated: + return None + token, _ = Token.objects.get_or_create(user=request.user) + return token.key diff --git a/src/paperless/settings.py b/src/paperless/settings.py index 30ee213d1..532a2bc36 100644 --- a/src/paperless/settings.py +++ b/src/paperless/settings.py @@ -345,6 +345,7 @@ INSTALLED_APPS = [ "allauth.account", "allauth.socialaccount", "allauth.mfa", + "allauth.headless", "drf_spectacular", "drf_spectacular_sidecar", "treenode", @@ -539,6 +540,7 @@ SOCIALACCOUNT_PROVIDERS = json.loads( ) SOCIAL_ACCOUNT_DEFAULT_GROUPS = __get_list("PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS") SOCIAL_ACCOUNT_SYNC_GROUPS = __get_boolean("PAPERLESS_SOCIAL_ACCOUNT_SYNC_GROUPS") +HEADLESS_TOKEN_STRATEGY = "paperless.adapter.DrfTokenStrategy" MFA_TOTP_ISSUER = "Paperless-ngx" diff --git a/src/paperless/tests/test_adapter.py b/src/paperless/tests/test_adapter.py index 37b8aaa3b..dbef3fde7 100644 --- a/src/paperless/tests/test_adapter.py +++ b/src/paperless/tests/test_adapter.py @@ -4,6 +4,7 @@ from allauth.account.adapter import get_adapter from allauth.core import context from allauth.socialaccount.adapter import get_adapter as get_social_adapter from django.conf import settings +from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import Group from django.contrib.auth.models import User from django.forms import ValidationError @@ -11,6 +12,9 @@ from django.http import HttpRequest from django.test import TestCase from django.test import override_settings from django.urls import reverse +from rest_framework.authtoken.models import Token + +from paperless.adapter import DrfTokenStrategy class TestCustomAccountAdapter(TestCase): @@ -181,3 +185,74 @@ class TestCustomSocialAccountAdapter(TestCase): self.assertTrue( any("Test authentication error" in message for message in log_cm.output), ) + + +class TestDrfTokenStrategy(TestCase): + def test_create_access_token_creates_new_token(self): + """ + GIVEN: + - A user with no existing DRF token + WHEN: + - create_access_token is called + THEN: + - A new token is created and its key is returned + """ + + user = User.objects.create_user("testuser") + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify a token was created + self.assertIsNotNone(token_key) + self.assertTrue(Token.objects.filter(user=user).exists()) + + # Verify the returned key matches the created token + token = Token.objects.get(user=user) + self.assertEqual(token_key, token.key) + + def test_create_access_token_returns_existing_token(self): + """ + GIVEN: + - A user with an existing DRF token + WHEN: + - create_access_token is called again + THEN: + - The same token key is returned (no new token created) + """ + + user = User.objects.create_user("testuser") + existing_token = Token.objects.create(user=user) + + request = HttpRequest() + request.user = user + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + # Verify the existing token key is returned + self.assertEqual(token_key, existing_token.key) + + # Verify only one token exists (no duplicate created) + self.assertEqual(Token.objects.filter(user=user).count(), 1) + + def test_create_access_token_returns_none_for_unauthenticated_user(self): + """ + GIVEN: + - An unauthenticated request + WHEN: + - create_access_token is called + THEN: + - None is returned and no token is created + """ + + request = HttpRequest() + request.user = AnonymousUser() + + strategy = DrfTokenStrategy() + token_key = strategy.create_access_token(request) + + self.assertIsNone(token_key) + self.assertEqual(Token.objects.count(), 0) diff --git a/src/paperless/urls.py b/src/paperless/urls.py index 179af14e0..ce5c68494 100644 --- a/src/paperless/urls.py +++ b/src/paperless/urls.py @@ -228,6 +228,7 @@ urlpatterns = [ ], ), ), + re_path("^auth/headless/", include("allauth.headless.urls")), re_path( "^$", # Redirect to the API swagger view RedirectView.as_view(url="schema/view/"),