Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Index
getting_started
tutorial/tutorial
rest-framework/rest-framework
ninja
views/views
templates
views/details
Expand Down
123 changes: 123 additions & 0 deletions docs/ninja.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
Django Ninja
============

Django OAuth Toolkit provides a support layer for
`Django Ninja <https://django-ninja.dev/>`_.

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 <https://django-ninja.dev/guides/authentication/>`_.

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 <https://django-ninja.dev/guides/authentication/#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())
4 changes: 4 additions & 0 deletions oauth2_provider/contrib/ninja/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .security import HttpOAuth2


__all__ = ["HttpOAuth2"]
45 changes: 45 additions & 0 deletions oauth2_provider/contrib/ninja/security.py
Original file line number Diff line number Diff line change
@@ -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

Comment on lines +28 to +33
Copy link
Copy Markdown
Contributor Author

@brianhelba brianhelba Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting consideration, but Copilot's interpretation is incorrect.

To restate my code comment, as Copilot doesn't seem to understand... At this point in the code valid is True. We should assume that our HttpOAuth2 handler is responsible for authenticating the entirety of this request (unfortunately, there's no specification to follow, but this seems like the only behavior that makes sense). It's possible that a session cookie was also passed with this request, in addition to OAuth2 Authorization: Bearer ... headers; in that case, an AuthenticationMiddleware would have pre-set request.user to whatever the session cookie resolves, but we want to always overwrite that to whatever the OAuth2 token resolves. We have to do this unconditionally, or else we'd have conflicting sources of truth for the request user.

However, r is an AbstractAccessToken, and r.user is nullable. What should we do if r.user is None? The answer should not depend on whether an AuthenticationMiddleware has already set request.user, because we now "own" the request's authentication and should unconditionally set the user.

I don't think we should set request.user = AnonymousUser(), because this isn't an anonymous request. It's an authenticated request, but the token simply doesn't have an associated user (which OAuth-Toolkit apparently allows).

However, while Request.user isn't a part of pure Django (it's not defined on the Request model), practically it's always patched on by AuthenticationMiddleware (which is typically enabled). So, most sensible projects (and django-stubs) assume that request.user is always set to something: either an actual user or AnonymousUser (it is not expected to be None, so people don't guard against calling something like request.user.is_authenticated).

So, what do we do? If you force me to resolve the compromise, I'd say perhaps we should set it to AnonymousUser(), so at least we won't break people's type expectations, plus I'm hoping that having AbstractAccessToken.user be None is pretty rare. However, I'd really appreciate some additional feedback @dopry.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for taking the time to look into this. I'll try to find the time to consider it in more depth in the next few days.

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
111 changes: 111 additions & 0 deletions tests/test_ninja.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tox.ini
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading