django-ninja-aio-crud 0.10.2__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of django-ninja-aio-crud might be problematic. Click here for more details.

@@ -0,0 +1,382 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-ninja-aio-crud
3
+ Version: 2.4.0
4
+ Summary: Django Ninja AIO CRUD - Rest Framework
5
+ Author: Giuseppe Casillo
6
+ Requires-Python: >=3.10, <3.15
7
+ Description-Content-Type: text/markdown
8
+ Classifier: Operating System :: OS Independent
9
+ Classifier: Topic :: Internet
10
+ Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
11
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
12
+ Classifier: Topic :: Software Development :: Libraries
13
+ Classifier: Topic :: Software Development
14
+ Classifier: Environment :: Web Environment
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3 :: Only
23
+ Classifier: Framework :: Django
24
+ Classifier: Framework :: AsyncIO
25
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
26
+ Classifier: Topic :: Internet :: WWW/HTTP
27
+ License-File: LICENSE
28
+ Requires-Dist: django-ninja >=1.3.0, <1.6
29
+ Requires-Dist: joserfc >=1.0.0, <= 1.4.1
30
+ Requires-Dist: orjson >= 3.10.7, <= 3.11.5
31
+ Requires-Dist: coverage ; extra == "test"
32
+ Project-URL: Documentation, https://django-ninja-aio.com
33
+ Project-URL: Repository, https://github.com/caspel26/django-ninja-aio-crud
34
+ Provides-Extra: test
35
+
36
+ # 🥷 django-ninja-aio-crud
37
+
38
+ ![Tests](https://github.com/caspel26/django-ninja-aio-crud/actions/workflows/coverage.yml/badge.svg)
39
+ [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=caspel26_django-ninja-aio-crud&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=caspel26_django-ninja-aio-crud)
40
+ [![codecov](https://codecov.io/gh/caspel26/django-ninja-aio-crud/graph/badge.svg?token=DZ5WDT3S20)](https://codecov.io/gh/caspel26/django-ninja-aio-crud/)
41
+ [![PyPI - Version](https://img.shields.io/pypi/v/django-ninja-aio-crud?color=g&logo=pypi&logoColor=white)](https://pypi.org/project/django-ninja-aio-crud/)
42
+ [![PyPI - License](https://img.shields.io/pypi/l/django-ninja-aio-crud)](LICENSE)
43
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
44
+
45
+ > Lightweight async CRUD layer on top of **[Django Ninja](https://django-ninja.dev/)** with automatic schema generation, filtering, pagination, auth & Many‑to‑Many management.
46
+
47
+ ---
48
+
49
+ ## ✨ Features
50
+
51
+ - Async CRUD ViewSets (create, list, retrieve, update, delete)
52
+ - Automatic Pydantic schemas from `ModelSerializer` (read/create/update)
53
+ - Dynamic query params (runtime schema via `pydantic.create_model`)
54
+ - Per-method authentication (`auth`, `get_auth`, `post_auth`, etc.)
55
+ - Async pagination (customizable)
56
+ - M2M relation endpoints via `M2MRelationSchema` (add/remove/get + filters)
57
+ - Reverse relation serialization
58
+ - Hook methods (`query_params_handler`, `<related>_query_params_handler`, `custom_actions`, lifecycle hooks)
59
+ - ORJSON renderer through `NinjaAIO`
60
+ - Clean, minimal integration
61
+
62
+ ---
63
+
64
+ ## 📦 Installation
65
+
66
+ ```bash
67
+ pip install django-ninja-aio-crud
68
+ ```
69
+
70
+ Add to your project’s dependencies and ensure Django Ninja is installed.
71
+
72
+ ---
73
+
74
+ ## 🚀 Quick Start
75
+
76
+ models.py
77
+
78
+ ```python
79
+ from django.db import models
80
+ from ninja_aio.models import ModelSerializer
81
+
82
+ class Book(ModelSerializer):
83
+ title = models.CharField(max_length=120)
84
+ published = models.BooleanField(default=True)
85
+
86
+ class ReadSerializer:
87
+ fields = ["id", "title", "published"]
88
+
89
+ class CreateSerializer:
90
+ fields = ["title", "published"]
91
+
92
+ class UpdateSerializer:
93
+ optionals = [("title", str), ("published", bool)]
94
+ ```
95
+
96
+ views.py
97
+
98
+ ```python
99
+ from ninja_aio import NinjaAIO
100
+ from ninja_aio.views import APIViewSet
101
+ from .models import Book
102
+
103
+ api = NinjaAIO()
104
+
105
+ @api.viewset(Book)
106
+ class BookViewSet(APIViewSet):
107
+ pass
108
+
109
+ ```
110
+
111
+ Visit `/docs` → CRUD endpoints ready.
112
+
113
+ ---
114
+
115
+ ## 🔄 Query Filtering
116
+
117
+ ```python
118
+ @api.viewset(Book)
119
+ class BookViewSet(APIViewSet):
120
+ query_params = {"published": (bool, None), "title": (str, None)}
121
+
122
+ async def query_params_handler(self, queryset, filters):
123
+ if filters.get("published") is not None:
124
+ queryset = queryset.filter(published=filters["published"])
125
+ if filters.get("title"):
126
+ queryset = queryset.filter(title__icontains=filters["title"])
127
+ return queryset
128
+ ```
129
+
130
+ Request:
131
+
132
+ ```
133
+ GET /book/?published=true&title=python
134
+ ```
135
+
136
+ ---
137
+
138
+ ## 🤝 Many-to-Many Example (with filters)
139
+
140
+ ```python
141
+ from ninja_aio.schemas import M2MRelationSchema
142
+
143
+ class Tag(ModelSerializer):
144
+ name = models.CharField(max_length=50)
145
+ class ReadSerializer:
146
+ fields = ["id", "name"]
147
+
148
+ class Article(ModelSerializer):
149
+ title = models.CharField(max_length=120)
150
+ tags = models.ManyToManyField(Tag, related_name="articles")
151
+
152
+ class ReadSerializer:
153
+ fields = ["id", "title", "tags"]
154
+
155
+ @api.viewset(Article)
156
+ class ArticleViewSet(APIViewSet):
157
+ m2m_relations = [
158
+ M2MRelationSchema(
159
+ model=Tag,
160
+ related_name="tags",
161
+ filters={"name": (str, "")}
162
+ )
163
+ ]
164
+
165
+ async def tags_query_params_handler(self, queryset, filters):
166
+ n = filters.get("name")
167
+ if n:
168
+ queryset = queryset.filter(name__icontains=n)
169
+ return queryset
170
+
171
+ ```
172
+
173
+ Endpoints:
174
+
175
+ - `GET /article/{pk}/tag?name=dev`
176
+ - `POST /article/{pk}/tag/` body: `{"add":[1,2],"remove":[3]}`
177
+
178
+ ---
179
+
180
+ ## 🔐 Authentication (JWT example)
181
+
182
+ ```python
183
+ from ninja_aio.auth import AsyncJwtBearer
184
+ from joserfc import jwk
185
+ from .models import Book
186
+
187
+ PUBLIC_KEY = "-----BEGIN PUBLIC KEY----- ..."
188
+
189
+ class JWTAuth(AsyncJwtBearer):
190
+ jwt_public = jwk.RSAKey.import_key(PUBLIC_KEY)
191
+ jwt_alg = "RS256"
192
+ claims = {"sub": {"essential": True}}
193
+
194
+ async def auth_handler(self, request):
195
+ book_id = self.dcd.claims.get("sub")
196
+ return await Book.objects.aget(id=book_id)
197
+
198
+ @api.viewset(Book)
199
+ class SecureBookViewSet(APIViewSet):
200
+ auth = [JWTAuth()]
201
+ get_auth = None # list/retrieve public
202
+ ```
203
+
204
+ ---
205
+
206
+ ## 📑 Lifecycle Hooks (ModelSerializer)
207
+
208
+ Available on every save/delete:
209
+
210
+ - `on_create_before_save`
211
+ - `on_create_after_save`
212
+ - `before_save`
213
+ - `after_save`
214
+ - `on_delete`
215
+ - `custom_actions(payload)` (create/update custom field logic)
216
+ - `post_create()` (after create commit)
217
+
218
+ ---
219
+
220
+ ## 🧩 Adding Custom Endpoints
221
+
222
+ ```python
223
+ from ninja_aio.decorators import api_get
224
+
225
+ @api.viewset(Book)
226
+ class BookViewSet(APIViewSet):
227
+ @api_get("/stats/")
228
+ async def stats(self, request):
229
+ total = await Book.objects.acount()
230
+ return {"total": total}
231
+ ```
232
+
233
+ Or
234
+
235
+ ```python
236
+ @api.viewset(Book)
237
+ class BookViewSet(APIViewSet):
238
+ def views(self):
239
+ @self.router.get("/stats/")
240
+ async def stats(request):
241
+ total = await Book.objects.acount()
242
+ return {"total": total}
243
+ ```
244
+
245
+ ---
246
+
247
+ ## 📄 Pagination
248
+
249
+ Default: `PageNumberPagination`. Override:
250
+
251
+ ```python
252
+ from ninja.pagination import PageNumberPagination
253
+
254
+ class LargePagination(PageNumberPagination):
255
+ page_size = 50
256
+ max_page_size = 200
257
+
258
+ @api.viewset(Book)
259
+ class BookViewSet(APIViewSet):
260
+ pagination_class = LargePagination
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Meta-driven Serializer (for vanilla Django models)
266
+
267
+ If you already have Django models and don't want to inherit from ModelSerializer, use the Meta-driven Serializer to generate dynamic schemas and integrate with APIViewSet.
268
+
269
+ Example:
270
+
271
+ ```python
272
+ from ninja_aio.models import serializers
273
+ from . import models
274
+
275
+ class BookSerializer(serializers.Serializer):
276
+ class Meta:
277
+ model = models.Book
278
+ schema_in = serializers.SchemaModelConfig(fields=["title", "published"])
279
+ schema_out = serializers.SchemaModelConfig(fields=["id", "title", "published"])
280
+ schema_update = serializers.SchemaModelConfig(optionals=[("title", str), ("published", bool)])
281
+
282
+ @api.viewset(models.Book)
283
+ class BookViewSet(APIViewSet):
284
+ serializer_class = BookSerializer
285
+ ```
286
+
287
+ - Works without modifying existing models
288
+ - Supports nested relations via relations_serializers
289
+ - APIViewSet will auto-generate missing schemas from the serializer
290
+
291
+ ---
292
+
293
+ ## 🛠 Project Structure & Docs
294
+
295
+ Documentation (MkDocs + Material):
296
+
297
+ ```
298
+ docs/
299
+ getting_started/
300
+ tutorial/
301
+ api/
302
+ views/
303
+ models/
304
+ authentication.md
305
+ pagination.md
306
+ ```
307
+
308
+ Browse full reference:
309
+
310
+ - APIViewSet: `docs/api/views/api_view_set.md`
311
+ - APIView: `docs/api/views/api_view.md`
312
+ - ModelSerializer: `docs/api/models/model_serializer.md`
313
+ - Authentication: `docs/api/authentication.md`
314
+ - Example repository: https://github.com/caspel26/ninja-aio-blog-example
315
+
316
+ ---
317
+
318
+ ## 🧪 Tests
319
+
320
+ Use Django test runner + async ORM patterns. Example async pattern:
321
+
322
+ ```python
323
+ obj = await Book.objects.acreate(title="T1", published=True)
324
+ count = await Book.objects.acount()
325
+ ```
326
+
327
+ ---
328
+
329
+ ## 🚫 Disable Operations
330
+
331
+ ```python
332
+ @api.viewset(Book)
333
+ class ReadOnlyBookViewSet(APIViewSet):
334
+ disable = ["update", "delete"]
335
+ ```
336
+
337
+ ---
338
+
339
+ ## 📌 Performance Tips
340
+
341
+ - Use `queryset_request` classmethod to prefetch
342
+ - Index frequently filtered fields
343
+ - Keep pagination enabled
344
+ - Limit slices (`queryset = queryset[:1000]`) for heavy searches
345
+
346
+ ---
347
+
348
+ ## 🤲 Contributing
349
+
350
+ 1. Fork
351
+ 2. Create branch
352
+ 3. Add tests
353
+ 4. Run lint (`ruff check .`)
354
+ 5. Open PR
355
+
356
+ ---
357
+
358
+ ## ⭐ Support
359
+
360
+ Star the repo or donate:
361
+
362
+ - [Buy me a coffee](https://buymeacoffee.com/caspel26)
363
+
364
+ ---
365
+
366
+ ## 📜 License
367
+
368
+ MIT License. See [LICENSE](LICENSE).
369
+
370
+ ---
371
+
372
+ ## 🔗 Quick Links
373
+
374
+ | Item | Link |
375
+ | ------- | -------------------------------------------------------- |
376
+ | PyPI | https://pypi.org/project/django-ninja-aio-crud/ |
377
+ | Docs | https://django-ninja-aio.com |
378
+ | Issues | https://github.com/caspel26/django-ninja-aio-crud/issues |
379
+ | Example | https://github.com/caspel26/ninja-aio-blog-example |
380
+
381
+ ---
382
+
@@ -0,0 +1,29 @@
1
+ ninja_aio/__init__.py,sha256=Ms2GNzuORIvMqPlxF2wzxTHgoQBL_OpaeqByF4LtwhI,119
2
+ ninja_aio/api.py,sha256=tuC7vdvn7s1GkCnSFy9Kn1zv0glZfYptRQVvo8ZRtGQ,2429
3
+ ninja_aio/auth.py,sha256=4sWdFPjKiQgUL1d_CSGDblVjnY5ptP6LQha6XXdluJA,9157
4
+ ninja_aio/exceptions.py,sha256=_3xFqfFCOfrrMhSA0xbMqgXy8R0UQjhXaExrFvaDAjY,3891
5
+ ninja_aio/parsers.py,sha256=e_4lGCPV7zs-HTqtdJTc8yQD2KPAn9njbL8nF_Mmgkc,153
6
+ ninja_aio/renders.py,sha256=89g46NWUT8nmDG-rG0nxUYbAQWhuXcYKrPh7e1r_Fc4,1735
7
+ ninja_aio/types.py,sha256=nFqWEopm7eoEaHRzbi6EyA9WZ5Cneyd602ilFKypeQI,577
8
+ ninja_aio/decorators/__init__.py,sha256=cDDHD_9EI4CP7c5eL1m2mGNl9bR24i8FAkQsT3_RNGM,371
9
+ ninja_aio/decorators/operations.py,sha256=L9yt2ku5oo4CpOLixCADmkcFjLGsWAn-cg-sDcjFhMA,343
10
+ ninja_aio/decorators/views.py,sha256=0RVU4XaM1HvTQ-BOt_NwUtbhwfHau06lh-O8El1LqQk,8139
11
+ ninja_aio/factory/__init__.py,sha256=IdH2z1ZZpv_IqonaDfVo7IsMzkgop6lHqz42RphUYBU,72
12
+ ninja_aio/factory/operations.py,sha256=OgWGqq4WJ4arSQrH9FGAby9kx-HTdS7MOITxHdYMk18,12051
13
+ ninja_aio/helpers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ ninja_aio/helpers/api.py,sha256=YMzuZ4-ZpUrJBQIabE26gb_GYwsH2rVosWRE95YfdPQ,20775
15
+ ninja_aio/helpers/query.py,sha256=YJMdEonCuqx1XjmszCK74mg5hcUPh84ynXrsuoSQdNA,4519
16
+ ninja_aio/models/__init__.py,sha256=L3UQnQAlKoI3F7jinadL-Nn55hkPvnSRPYW0JtnbWFo,114
17
+ ninja_aio/models/serializers.py,sha256=wFEG6QrOPN4TiDEIRkOxExIKkjAQ0I432czaELZZxdI,25110
18
+ ninja_aio/models/utils.py,sha256=PkvdByNuZKnTzQhdkbqZuG6CEIMIZTCO4QkCUgvqBjs,28776
19
+ ninja_aio/schemas/__init__.py,sha256=iLBwHg0pmL9k_UkIui5Q8QIl_gO4fgxSv2JHxDzqnSI,549
20
+ ninja_aio/schemas/api.py,sha256=-VwXhBRhmMsZLIAmWJ-P7tB5klxXS75eukjabeKKYsc,360
21
+ ninja_aio/schemas/generics.py,sha256=frjJsKJMAdM_NdNKv-9ddZNGxYy5PNzjIRGtuycgr-w,112
22
+ ninja_aio/schemas/helpers.py,sha256=W6IeHi5Tmbjh3FXwDYqjqlLBTVj5uTYq3_JVkNUWayo,7355
23
+ ninja_aio/views/__init__.py,sha256=DEzjWA6y3WF0V10nNF8eEurLNEodgxKzyFd09AqVp3s,148
24
+ ninja_aio/views/api.py,sha256=AQWtu8oI9R0blk-PW8BpwXLuejj1Z8vqpoNqlU_WrGs,20953
25
+ ninja_aio/views/mixins.py,sha256=Jh6BG8Cs823nurVlODlzCquTxKrLH7Pmo5udPqUGZek,11378
26
+ django_ninja_aio_crud-2.4.0.dist-info/licenses/LICENSE,sha256=yrDAYcm0gRp_Qyzo3GQa4BjYjWRkAhGC8QRva__RYq0,1073
27
+ django_ninja_aio_crud-2.4.0.dist-info/WHEEL,sha256=G2gURzTEtmeR8nrdXUJfNiB3VYVxigPQ-bEQujpNiNs,82
28
+ django_ninja_aio_crud-2.4.0.dist-info/METADATA,sha256=qRTfA6me4WsclgqfdyVKL7nPOfv3rRasiLNQdVUeZpM,9988
29
+ django_ninja_aio_crud-2.4.0.dist-info/RECORD,,
ninja_aio/__init__.py CHANGED
@@ -1,6 +1,6 @@
1
1
  """Django Ninja AIO CRUD - Rest Framework"""
2
2
 
3
- __version__ = "0.10.2"
3
+ __version__ = "2.4.0"
4
4
 
5
5
  from .api import NinjaAIO
6
6
 
ninja_aio/api.py CHANGED
@@ -5,10 +5,13 @@ from ninja.throttling import BaseThrottle
5
5
  from ninja import NinjaAPI
6
6
  from ninja.openapi.docs import DocsBase, Swagger
7
7
  from ninja.constants import NOT_SET, NOT_SET_TYPE
8
+ from django.db import models
8
9
 
9
10
  from .parsers import ORJSONParser
10
11
  from .renders import ORJSONRenderer
11
12
  from .exceptions import set_api_exception_handlers
13
+ from .views import APIView, APIViewSet
14
+ from .models import ModelSerializer
12
15
 
13
16
 
14
17
  class NinjaAIO(NinjaAPI):
@@ -23,7 +26,6 @@ class NinjaAIO(NinjaAPI):
23
26
  docs_decorator=None,
24
27
  servers: list[dict[str, Any]] | None = None,
25
28
  urls_namespace: str | None = None,
26
- csrf: bool = False,
27
29
  auth: Sequence[Any] | NOT_SET_TYPE = NOT_SET,
28
30
  throttle: BaseThrottle | list[BaseThrottle] | NOT_SET_TYPE = NOT_SET,
29
31
  default_router: Router | None = None,
@@ -39,7 +41,6 @@ class NinjaAIO(NinjaAPI):
39
41
  docs_decorator=docs_decorator,
40
42
  servers=servers,
41
43
  urls_namespace=urls_namespace,
42
- csrf=csrf,
43
44
  auth=auth,
44
45
  throttle=throttle,
45
46
  default_router=default_router,
@@ -51,3 +52,24 @@ class NinjaAIO(NinjaAPI):
51
52
  def set_default_exception_handlers(self):
52
53
  set_api_exception_handlers(self)
53
54
  super().set_default_exception_handlers()
55
+
56
+ def view(self, prefix: str, tags: list[str] = None) -> Any:
57
+ def wrapper(view: type[APIView]):
58
+ instance = view(api=self, prefix=prefix, tags=tags)
59
+ instance.add_views_to_route()
60
+ return instance
61
+
62
+ return wrapper
63
+
64
+ def viewset(
65
+ self,
66
+ model: models.Model | ModelSerializer,
67
+ prefix: str = None,
68
+ tags: list[str] = None,
69
+ ) -> None:
70
+ def wrapper(viewset: type[APIViewSet]):
71
+ instance = viewset(api=self, model=model, prefix=prefix, tags=tags)
72
+ instance.add_views_to_route()
73
+ return instance
74
+
75
+ return wrapper
ninja_aio/auth.py CHANGED
@@ -1,12 +1,88 @@
1
+ import datetime
2
+ from typing import Optional
3
+
1
4
  from joserfc import jwt, jwk, errors
2
5
  from django.http.request import HttpRequest
6
+ from django.utils import timezone
7
+ from django.conf import settings
3
8
  from ninja.security.http import HttpBearer
4
9
 
5
- from .exceptions import AuthError
10
+ from ninja_aio.types import JwtKeys
11
+
12
+ JWT_MANDATORY_CLAIMS = [
13
+ ("iss", "JWT_ISSUER"),
14
+ ("aud", "JWT_AUDIENCE"),
15
+ ]
6
16
 
7
17
 
8
18
  class AsyncJwtBearer(HttpBearer):
9
- jwt_public: jwk.RSAKey
19
+ """
20
+ AsyncJwtBearer provides asynchronous JWT-based authentication for Django Ninja endpoints
21
+ using HTTP Bearer tokens. It decodes and validates JWTs against a configured public key
22
+ and claim registry, then delegates user retrieval to an overridable async handler.
23
+ Attributes:
24
+ jwt_public (jwk.RSAKey | jwk.ECKey):
25
+ The public key (JWK format) used to verify the JWT signature.
26
+ Must be set externally before authentication occurs.
27
+ claims (dict[str, dict]):
28
+ A mapping defining expected JWT claims passed to jwt.JWTClaimsRegistry.
29
+ Each key corresponds to a claim name; values configure validation rules
30
+ (e.g., {'iss': {'value': 'https://issuer.example'}}).
31
+ algorithms (list[str]):
32
+ List of permitted JWT algorithms for signature verification. Defaults to ["RS256"].
33
+ dcd (jwt.Token | None):
34
+ Set after successful decode; holds the decoded token object (assigned dynamically).
35
+ Class Methods:
36
+ get_claims() -> jwt.JWTClaimsRegistry:
37
+ Constructs and returns a claims registry from the class-level claims definition.
38
+ Instance Methods:
39
+ validate_claims(claims: jwt.Claims) -> None:
40
+ Validates the provided claims object against the registry. Raises jose.errors.JoseError
41
+ or ValueError-derived exceptions if validation fails.
42
+ auth_handler(request: HttpRequest) -> Any:
43
+ Asynchronous hook to be overridden by subclasses to implement application-specific
44
+ user resolution (e.g., fetching a user model instance). Must return a user-like object
45
+ on success or raise / return False on failure.
46
+ authenticate(request: HttpRequest, token: str) -> Any | bool:
47
+ Orchestrates authentication:
48
+ 1. Attempts to decode the JWT using the configured public key and algorithms.
49
+ 2. Validates claims via validate_claims.
50
+ 3. Delegates to auth_handler for domain-specific user retrieval.
51
+ Returns the user object on success; returns False if decoding or claim validation fails.
52
+ Usage Notes:
53
+ - You must assign jwt_public (jwk.RSAKey) and populate claims before calling authenticate.
54
+ - Override auth_handler to integrate with your user persistence layer.
55
+ - Token decoding failures (e.g., signature mismatch, malformed token) result in False.
56
+ - Claim validation errors (e.g., expired token, issuer mismatch) result in False.
57
+ - This class does not itself raise HTTP errors; caller may translate False into an HTTP response.
58
+ Example Extension:
59
+ class MyBearer(AsyncJwtBearer):
60
+ jwt_public = jwk.RSAKey.import_key(open("pub.pem").read())
61
+ claims = {
62
+ "iss": {"value": "https://auth.example"},
63
+ "aud": {"value": "my-api"},
64
+ }
65
+ async def auth_handler(self, request):
66
+ sub = self.dcd.claims.get("sub")
67
+ return await get_user_by_id(sub)
68
+ Thread Safety:
69
+ - Instances are not inherently thread-safe if mutable shared state is attached.
70
+ - Prefer per-request instantiation or ensure read-only shared configuration.
71
+ Security Considerations:
72
+ - Ensure jwt_public key rotation strategy is in place.
73
+ - Validate critical claims (exp, nbf, iss, aud) via the claims registry configuration.
74
+ - Avoid logging raw tokens or sensitive claim contents.
75
+ Raises:
76
+ jose.errors.JoseError:
77
+ Propagated from validate_claims if claim checks fail.
78
+ ValueError:
79
+ May occur during token decoding (e.g., invalid structure) but is internally caught
80
+ and converted to a False return value.
81
+ Return Semantics:
82
+ - authenticate -> user object (success) | False (failure)
83
+ """
84
+
85
+ jwt_public: JwtKeys
10
86
  claims: dict[str, dict]
11
87
  algorithms: list[str] = ["RS256"]
12
88
 
@@ -31,13 +107,119 @@ class AsyncJwtBearer(HttpBearer):
31
107
  """
32
108
  try:
33
109
  self.dcd = jwt.decode(token, self.jwt_public, algorithms=self.algorithms)
34
- except ValueError as exc:
110
+ except ValueError:
35
111
  # raise AuthError(", ".join(exc.args), 401)
36
112
  return False
37
113
 
38
114
  try:
39
115
  self.validate_claims(self.dcd.claims)
40
- except errors.JoseError as exc:
116
+ except errors.JoseError:
41
117
  return False
42
118
 
43
119
  return await self.auth_handler(request)
120
+
121
+
122
+ def validate_key(key: Optional[JwtKeys], setting_name: str) -> JwtKeys:
123
+ if key is None:
124
+ key = getattr(settings, setting_name, None)
125
+ if key is None:
126
+ raise ValueError(f"{setting_name} is required")
127
+ if not isinstance(key, (jwk.RSAKey, jwk.ECKey, jwk.OctKey)):
128
+ raise ValueError(
129
+ f"{setting_name} must be an instance of jwk.RSAKey or jwk.ECKey"
130
+ )
131
+ return key
132
+
133
+
134
+ def validate_mandatory_claims(claims: dict) -> dict:
135
+ for claim_key, setting_name in JWT_MANDATORY_CLAIMS:
136
+ if claims.get(claim_key) is not None:
137
+ continue
138
+ value = getattr(settings, setting_name, None)
139
+ if value is None:
140
+ raise ValueError(f"jwt {claim_key} is required")
141
+ claims[claim_key] = value
142
+ return claims
143
+
144
+
145
+ def encode_jwt(
146
+ claims: dict, duration: int, private_key: JwtKeys = None, algorithm: str = None
147
+ ) -> str:
148
+ """
149
+ Encode and sign a JWT.
150
+
151
+ Adds time-based claims and ensures mandatory issuer/audience:
152
+ - iat: current time (timezone-aware)
153
+ - nbf: current time
154
+ - exp: current time + duration (seconds)
155
+ - iss/aud: from claims if provided; otherwise from settings.JWT_ISSUER and settings.JWT_AUDIENCE
156
+
157
+ Parameters:
158
+ - claims (dict): additional claims to merge into the payload (can override defaults)
159
+ - duration (int): token lifetime in seconds
160
+ - private_key (jwk.RSAKey): RSA/EC JWK for signing; defaults to settings.JWT_PRIVATE_KEY
161
+ - algorithm (str): JWS algorithm (default "RS256")
162
+
163
+ Returns:
164
+ - str: JWT compact string
165
+
166
+ Raises:
167
+ - ValueError: if private_key is missing or not jwk.RSAKey/jwk.ECKey
168
+ - ValueError: if mandatory claims (iss, aud) are missing and not in settings
169
+
170
+ Notes:
171
+ - Header includes alg, typ=JWT, and kid from the private key (if available).
172
+ - Uses timezone-aware timestamps from django.utils.timezone.
173
+ """
174
+ now = timezone.now()
175
+ nbf = now
176
+ pkey = validate_key(private_key, "JWT_PRIVATE_KEY")
177
+ algorithm = algorithm or "RS256"
178
+ claims = validate_mandatory_claims(claims)
179
+ kid_h = {"kid": pkey.kid} if pkey.kid else {}
180
+ return jwt.encode(
181
+ header={"alg": algorithm, "typ": "JWT"} | kid_h,
182
+ claims={
183
+ "iat": now,
184
+ "nbf": nbf,
185
+ "exp": now + datetime.timedelta(seconds=duration),
186
+ }
187
+ | claims,
188
+ key=pkey,
189
+ algorithms=[algorithm],
190
+ )
191
+
192
+
193
+ def decode_jwt(
194
+ token: str,
195
+ public_key: JwtKeys = None,
196
+ algorithms: list[str] = None,
197
+ ) -> jwt.Token:
198
+ """
199
+ Decode and verify a JSON Web Token (JWT) using the provided RSA public key.
200
+ This function decodes the JWT, verifies its signature, and returns the decoded token object.
201
+ Parameters:
202
+ - token (str): The JWT string to decode.
203
+ - public_key (jwk.RSAKey, optional): RSA public key used to verify the token's signature.
204
+ If not provided, settings.JWT_PUBLIC_KEY will be used. Must be an instance of jwk.RSAKey.
205
+ - algorithms (list[str], optional): List of permitted algorithms for signature verification.
206
+ Defaults to ["RS256"] if not provided.
207
+ Returns:
208
+ - jwt.Token: The decoded JWT token object containing header and claims.
209
+ Raises:
210
+ - ValueError: If no public key is provided or if the provided key is not an instance of jwk.RSAKey.
211
+ - jose.errors.JoseError: If the token is invalid or fails verification.
212
+ Notes:
213
+ - The function uses the specified algorithms to restrict acceptable signing methods.
214
+ Example:
215
+ decoded_token = decode_jwt(
216
+ token=my_jwt,
217
+ public_key=my_rsa_jwk,
218
+ algorithms=["RS256"],
219
+ )
220
+ """
221
+ return jwt.decode(
222
+ token,
223
+ validate_key(public_key, "JWT_PUBLIC_KEY"),
224
+ algorithms=algorithms or ["RS256"],
225
+ )