Repository: vitalik/django-ninja
Pull request #1387 (2025-01-09 22:22:51)

Fix of TypeError when returning None in AuthBase.__call__ from async code #1386
# The root cause
The function `_run_authentication` is made to run `AuthBase.__call__` and execute what if returned. But for an example, the [HttpBearer](https://github.com/vitalik/django-ninja/blob/39f40b30ebcc368471a8c1449355990fa63ec0e2/ninja/security/http.py#L22) returns None in cases when a token is misplaced in the request:
```
    def __call__(self, request: HttpRequest) -> Optional[Any]:
        headers = request.headers
        auth_value = headers.get(self.header)
        if not auth_value:
            return None
        parts = auth_value.split(" ")

        if parts[0].lower() != self.openapi_scheme:
            if settings.DEBUG:
                logger.error(f"Unexpected auth - '{auth_value}'")
            return None
        token = " ".join(parts[1:])
        return self.authenticate(request, token)
```
Even though None is returned, the function `_run_authentication` tries to call `async_to_sync` without checking if the coroutine is None.

Because of the call to async_to_sync with None, instead of a coroutine, this TypeError is raised:
```
Traceback (most recent call last):
  File "C:\Users\Marat\Desktop\csmbackend\.venv\Lib\site-packages\ninja\operation.py", line 190, in _run_authentication
    result = async_to_sync(callback)(request)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Marat\Desktop\csmbackend\.venv\Lib\site-packages\asgiref\sync.py", line 254, in __call__
    return call_result.result()
           ^^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Marat\AppData\Local\Programs\Python\Python312\Lib\concurrent\futures\_base.py", line 449, in result
    return self.__get_result()
           ^^^^^^^^^^^^^^^^^^^
  File "C:\Users\Marat\AppData\Local\Programs\Python\Python312\Lib\concurrent\futures\_base.py", line 401, in __get_result
    raise self._exception
  File "C:\Users\Marat\Desktop\csmbackend\.venv\Lib\site-packages\asgiref\sync.py", line 331, in main_wrap
    result = await self.awaitable(*args, **kwargs)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: object NoneType can't be used in 'await' expression
```

# Fix
Because we know that the coroutine is async:
```
if is_async_callable(callback) or getattr(callback, "is_async", False):
```
We can assume that calling it without putting the coroutine into the eventloop won't trigger the coroutine body. So we can safely call the coroutine without putting it into the eventloop, and check if the result is a coroutine, not a function:
```
if inspect.iscoroutine(initial_result):
    # If it's a coroutine, use async_to_sync to resolve it
    resolved_result = async_to_sync(callback)(request)
else:
    # If it's not a coroutine, use the initial result
    resolved_result = initial_result
```
That way we won't call `async_to_sync` with None, thus triggering TypeError.


Patch:
diff --git a/ninja/operation.py b/ninja/operation.py
index 623c0b7..0864390 100644
--- a/ninja/operation.py
+++ b/ninja/operation.py
@@ -1,3 +1,4 @@
+import inspect
 from typing import (
     TYPE_CHECKING,
     Any,
@@ -187,7 +188,9 @@ class Operation:
         for callback in self.auth_callbacks:
             try:
                 if is_async_callable(callback) or getattr(callback, "is_async", False):
-                    result = async_to_sync(callback)(request)
+                    result = callback(request)
+                    if inspect.iscoroutine(result):
+                        result = async_to_sync(callback)(request)
                 else:
                     result = callback(request)
             except Exception as exc:


Test patch:
diff --git a/tests/test_auth.py b/tests/test_auth.py
index 01fb648..5715013 100644
--- a/tests/test_auth.py
+++ b/tests/test_auth.py
@@ -64,6 +64,14 @@ class BearerAuth(HttpBearer):
             raise AuthorizationError
 
 
+class AsyncBearerAuth(HttpBearer):
+    async def authenticate(self, request, token):
+        if token == "bearertoken":
+            return token
+        if token == "nottherightone":
+            raise AuthorizationError
+
+
 def demo_operation(request):
     return {"auth": request.auth}
 
@@ -85,6 +93,7 @@ for path, auth in [
     ("apikeycookie", KeyCookie()),
     ("basic", BasicAuth()),
     ("bearer", BearerAuth()),
+    ("async_bearer", AsyncBearerAuth()),
     ("customexception", KeyHeaderCustomException()),
 ]:
     api.get(f"/{path}", auth=auth, operation_id=path)(demo_operation)
@@ -187,6 +196,18 @@ BODY_FORBIDDEN_DEFAULT = dict(detail="Forbidden")
             401,
             BODY_UNAUTHORIZED_DEFAULT,
         ),
+        (
+            "/async_bearer",
+            dict(headers={"Authorization": "Bearer nonexistingtoken"}),
+            401,
+            BODY_UNAUTHORIZED_DEFAULT,
+        ),
+        (
+            "/async_bearer",
+            dict(headers={}),
+            401,
+            BODY_UNAUTHORIZED_DEFAULT,
+        ),
         (
             "/bearer",
             dict(headers={"Authorization": "Bearer nottherightone"}),
@@ -215,6 +236,7 @@ def test_schema():
     assert schema["components"]["securitySchemes"] == {
         "BasicAuth": {"scheme": "basic", "type": "http"},
         "BearerAuth": {"scheme": "bearer", "type": "http"},
+        "AsyncBearerAuth": {"scheme": "bearer", "type": "http"},
         "KeyCookie": {"in": "cookie", "name": "key", "type": "apiKey"},
         "KeyHeader": {"in": "header", "name": "key", "type": "apiKey"},
         "KeyHeaderCustomException": {"in": "header", "name": "key", "type": "apiKey"},