diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4b13db2f0..29ceecfbe 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
### Added
* #1637 Support for Django 6.0
+* #1373 Integration and docs for Django Ninja authentication
### Removed
* #1636 Remove support for Python 3.8 and 3.9
diff --git a/docs/conf.py b/docs/conf.py
index c32af9c03..2a7d40b2d 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -45,6 +45,9 @@
"sphinx_rtd_theme",
]
+# Optional integrations that may not be installed in the docs build environment
+autodoc_mock_imports = ["ninja"]
+
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
diff --git a/docs/index.rst b/docs/index.rst
index 60934726f..f709b73b1 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -35,6 +35,7 @@ Index
getting_started
tutorial/tutorial
rest-framework/rest-framework
+ ninja
views/views
templates
views/details
diff --git a/docs/ninja.rst b/docs/ninja.rst
new file mode 100644
index 000000000..b131338b7
--- /dev/null
+++ b/docs/ninja.rst
@@ -0,0 +1,123 @@
+Django Ninja
+============
+
+Django OAuth Toolkit provides a support layer for
+`Django Ninja `_.
+
+This consists of a ``HttpOAuth2`` class, which will determine whether the
+incoming HTTP request contains a valid OAuth2 access token issued
+by Django OAuth Toolkit. Optionally, ``HttpOAuth2`` can also ensure that the
+OAuth2 Access Token contains a defined set of scopes.
+
+Import ``HttpOAuth2`` as:
+
+.. code-block:: python
+
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+
+
+Basic Usage
+-----------
+``HttpOAuth2`` can be used anywhere that
+`Django Ninja expects an authentication callable `_.
+
+For example, to ensure all requests are authenticated with OAuth2:
+
+.. code-block:: python
+
+ from ninja import NinjaAPI
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+
+ api = NinjaAPI(auth=HttpOAuth2())
+
+
+To require authentication on only a single endpoint:
+
+.. code-block:: python
+
+ from ninja import NinjaAPI
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+
+ api = NinjaAPI()
+
+ @api.get("/private", auth=HttpOAuth2())
+ def private_endpoint(request):
+ return {"message": "This is a private endpoint"}
+
+
+Optional Authentication
+-----------------------
+``HttpOAuth2`` will always fail if the request is not authenticated.
+However, many use cases require optional authentication (for example, where
+additional private content is returned for authenticated users).
+
+Django Ninja's support for
+`multiple authenticators `_
+can be used for optional authentication. Simply place ``HttpOAuth2`` at the
+beginning of a list of authenticators (where it will be run first), with more
+permissive authenticator functions near the end (as a fall-back).
+
+For example, to attempt OAuth2 authentication on all requests,
+but allow access even for unauthenticated requests:
+
+.. code-block:: python
+
+ from ninja import NinjaAPI
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+
+ # Stricter authenticators must be placed first,
+ # as the first success terminates the chain
+ api = NinjaAPI(auth=[HttpOAuth2(), lambda _request: True])
+
+
+Scope Enforcement
+-----------------
+``HttpOAuth2`` can optionally enforce that the OAuth2 access token has certain
+scopes (defined by the application).
+
+If a ``scopes`` argument is passed to ``HttpOAuth2``, then incoming access
+tokens must contain all of the specified scopes to be considered valid.
+
+For example:
+
+.. code-block:: python
+
+ from ninja import NinjaAPI
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+
+ api = NinjaAPI()
+
+ @api.post("/thing", auth=HttpOAuth2(scopes=["read", "write"]))
+ def create_endpoint(request):
+ ...
+
+
+Custom Authorization Behavior
+-----------------------------
+``HttpOAuth2`` can be extended to provide custom authorization behaviors.
+
+Simply subclass it and override its ``authenticate`` method.
+
+.. autoclass:: oauth2_provider.contrib.ninja.HttpOAuth2
+ :members: authenticate
+
+For example:
+
+.. code-block:: python
+
+ from typing import Any
+
+ from django.http import HttpRequest
+ from ninja import NinjaAPI
+ from oauth2_provider.contrib.ninja import HttpOAuth2
+ from oauth2_provider.models import AbstractAccessToken
+
+ class StaffOnlyOAuth2(HttpOAuth2):
+ def authenticate(self, request: HttpRequest, access_token: AbstractAccessToken) -> Any | None:
+ if not access_token.user.is_staff:
+ return None
+
+ # Anything truthy can be returned, and will be available as `request.auth`
+ return access_token
+
+ api = NinjaAPI(auth=StaffOnlyOAuth2())
diff --git a/oauth2_provider/contrib/ninja/__init__.py b/oauth2_provider/contrib/ninja/__init__.py
new file mode 100644
index 000000000..4cb1659c8
--- /dev/null
+++ b/oauth2_provider/contrib/ninja/__init__.py
@@ -0,0 +1,4 @@
+from .security import HttpOAuth2
+
+
+__all__ = ["HttpOAuth2"]
diff --git a/oauth2_provider/contrib/ninja/security.py b/oauth2_provider/contrib/ninja/security.py
new file mode 100644
index 000000000..24f19814c
--- /dev/null
+++ b/oauth2_provider/contrib/ninja/security.py
@@ -0,0 +1,45 @@
+from typing import Any
+
+from django.http import HttpRequest
+from ninja.security.http import HttpAuthBase
+
+from ...models import AbstractAccessToken
+from ...oauth2_backends import get_oauthlib_core
+
+
+# Don't inherit from `HttpBearer`, since we have our own header extraction logic
+class HttpOAuth2(HttpAuthBase):
+ """Perform OAuth2 authentication, for use with Django Ninja."""
+
+ openapi_scheme: str = "bearer"
+
+ def __init__(self, *, scopes: list[str] | None = None) -> None:
+ super().__init__()
+ # Copy list, since it's mutable
+ self.scopes = list(scopes) if scopes is not None else []
+
+ def __call__(self, request: HttpRequest) -> Any | None:
+ oauthlib_core = get_oauthlib_core()
+ valid, r = oauthlib_core.verify_request(request, scopes=self.scopes)
+
+ if not valid:
+ return None
+
+ # Ninja doesn't automatically set `request.user`: https://github.com/vitalik/django-ninja/issues/76
+ # However, Django's AuthenticationMiddleware (which does set this from a session cookie) is
+ # ubiquitous, and even Ninja's own tutorials assume that `request.user` will somehow be set,
+ # so ensure that authentication via OAuth2 doesn't violate expectations.
+ request.user = r.user
+
+ return self.authenticate(request, r.access_token)
+
+ def authenticate(self, request: HttpRequest, access_token: AbstractAccessToken) -> Any | None:
+ """
+ Determine whether authentication succeeds.
+
+ If this returns a truthy value, authentication will succeed.
+ Django Ninja will set the return value as `request.auth`.
+
+ Subclasses may override this to implement additional authorization logic.
+ """
+ return access_token
diff --git a/tests/test_ninja.py b/tests/test_ninja.py
new file mode 100644
index 000000000..e791a674a
--- /dev/null
+++ b/tests/test_ninja.py
@@ -0,0 +1,111 @@
+from datetime import timedelta
+
+import pytest
+from django.contrib.auth import get_user_model
+from django.test.utils import override_settings
+from django.urls import include, path
+from django.utils import timezone
+from ninja import NinjaAPI
+
+from oauth2_provider.contrib.ninja import HttpOAuth2
+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()
+UserModel = get_user_model()
+
+api = NinjaAPI()
+
+
+@api.get("/private", auth=HttpOAuth2())
+def private_endpoint(request):
+ return {"message": "This is a private endpoint"}
+
+
+@api.get("/write", auth=HttpOAuth2(scopes=["write"]))
+def scoped_endpoint(request):
+ return {"message": "This requires 'write' scope"}
+
+
+@api.get("/impossible", auth=HttpOAuth2(scopes=["impossible"]))
+def scoped_impossible_endpoint(request):
+ return {"message": "This requires 'impossible' scope"}
+
+
+@api.get("/request-attributes", auth=HttpOAuth2())
+def request_attributes_endpoint(request):
+ return {
+ "request_user_username": request.user.username,
+ "request_auth_token": request.auth.token,
+ }
+
+
+urlpatterns = [
+ path("oauth2/", include("oauth2_provider.urls")),
+ path("api/", api.urls),
+]
+
+
+@override_settings(ROOT_URLCONF=__name__)
+@pytest.mark.nologinrequiredmiddleware
+@pytest.mark.usefixtures("oauth2_settings")
+class TestNinja(TestCase):
+ @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=cls.dev_user,
+ client_type=Application.CLIENT_CONFIDENTIAL,
+ authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
+ )
+
+ 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=cls.application,
+ )
+
+ def _create_authorization_header(self, token):
+ return "Bearer {0}".format(token)
+
+ def test_valid_token(self):
+ auth = self._create_authorization_header(self.access_token.token)
+ response = self.client.get("/api/private", HTTP_AUTHORIZATION=auth)
+ self.assertEqual(response.status_code, 200)
+
+ def test_missing_token(self):
+ response = self.client.get("/api/private")
+ self.assertEqual(response.status_code, 401)
+
+ def test_invalid_token(self):
+ auth = self._create_authorization_header("invalid")
+ response = self.client.get("/api/private", HTTP_AUTHORIZATION=auth)
+ self.assertEqual(response.status_code, 401)
+
+ def test_valid_token_with_scope(self):
+ auth = self._create_authorization_header(self.access_token.token)
+ response = self.client.get("/api/write", HTTP_AUTHORIZATION=auth)
+ self.assertEqual(response.status_code, 200)
+
+ def test_valid_token_absent_scope(self):
+ auth = self._create_authorization_header(self.access_token.token)
+ response = self.client.get("/api/impossible", HTTP_AUTHORIZATION=auth)
+ self.assertEqual(response.status_code, 401)
+
+ def test_request_attributes(self):
+ auth = self._create_authorization_header(self.access_token.token)
+ response = self.client.get("/api/request-attributes", HTTP_AUTHORIZATION=auth)
+
+ self.assertEqual(response.status_code, 200)
+ response_data = response.json()
+ self.assertEqual(response_data["request_user_username"], self.test_user.username)
+ self.assertEqual(response_data["request_auth_token"], self.access_token.token)
diff --git a/tox.ini b/tox.ini
index 02eaba07e..d7d8302bf 100644
--- a/tox.ini
+++ b/tox.ini
@@ -47,6 +47,7 @@ deps =
dj60: Django>=6.0,<6.1
djmain: https://github.com/django/django/archive/main.tar.gz
djangorestframework
+ django-ninja
oauthlib>=3.3.0
jwcrypto
coverage