django-bolt 0.1.0__cp310-abi3-win_amd64.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-bolt might be problematic. Click here for more details.
- django_bolt/__init__.py +147 -0
- django_bolt/_core.pyd +0 -0
- django_bolt/admin/__init__.py +25 -0
- django_bolt/admin/admin_detection.py +179 -0
- django_bolt/admin/asgi_bridge.py +267 -0
- django_bolt/admin/routes.py +91 -0
- django_bolt/admin/static.py +155 -0
- django_bolt/admin/static_routes.py +111 -0
- django_bolt/api.py +1011 -0
- django_bolt/apps.py +7 -0
- django_bolt/async_collector.py +228 -0
- django_bolt/auth/README.md +464 -0
- django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
- django_bolt/auth/__init__.py +84 -0
- django_bolt/auth/backends.py +236 -0
- django_bolt/auth/guards.py +224 -0
- django_bolt/auth/jwt_utils.py +212 -0
- django_bolt/auth/revocation.py +286 -0
- django_bolt/auth/token.py +335 -0
- django_bolt/binding.py +363 -0
- django_bolt/bootstrap.py +77 -0
- django_bolt/cli.py +133 -0
- django_bolt/compression.py +104 -0
- django_bolt/decorators.py +159 -0
- django_bolt/dependencies.py +128 -0
- django_bolt/error_handlers.py +305 -0
- django_bolt/exceptions.py +294 -0
- django_bolt/health.py +129 -0
- django_bolt/logging/__init__.py +6 -0
- django_bolt/logging/config.py +357 -0
- django_bolt/logging/middleware.py +296 -0
- django_bolt/management/__init__.py +1 -0
- django_bolt/management/commands/__init__.py +0 -0
- django_bolt/management/commands/runbolt.py +427 -0
- django_bolt/middleware/__init__.py +32 -0
- django_bolt/middleware/compiler.py +131 -0
- django_bolt/middleware/middleware.py +247 -0
- django_bolt/openapi/__init__.py +23 -0
- django_bolt/openapi/config.py +196 -0
- django_bolt/openapi/plugins.py +439 -0
- django_bolt/openapi/routes.py +152 -0
- django_bolt/openapi/schema_generator.py +581 -0
- django_bolt/openapi/spec/__init__.py +68 -0
- django_bolt/openapi/spec/base.py +74 -0
- django_bolt/openapi/spec/callback.py +24 -0
- django_bolt/openapi/spec/components.py +72 -0
- django_bolt/openapi/spec/contact.py +21 -0
- django_bolt/openapi/spec/discriminator.py +25 -0
- django_bolt/openapi/spec/encoding.py +67 -0
- django_bolt/openapi/spec/enums.py +41 -0
- django_bolt/openapi/spec/example.py +36 -0
- django_bolt/openapi/spec/external_documentation.py +21 -0
- django_bolt/openapi/spec/header.py +132 -0
- django_bolt/openapi/spec/info.py +50 -0
- django_bolt/openapi/spec/license.py +28 -0
- django_bolt/openapi/spec/link.py +66 -0
- django_bolt/openapi/spec/media_type.py +51 -0
- django_bolt/openapi/spec/oauth_flow.py +36 -0
- django_bolt/openapi/spec/oauth_flows.py +28 -0
- django_bolt/openapi/spec/open_api.py +87 -0
- django_bolt/openapi/spec/operation.py +105 -0
- django_bolt/openapi/spec/parameter.py +147 -0
- django_bolt/openapi/spec/path_item.py +78 -0
- django_bolt/openapi/spec/paths.py +27 -0
- django_bolt/openapi/spec/reference.py +38 -0
- django_bolt/openapi/spec/request_body.py +38 -0
- django_bolt/openapi/spec/response.py +48 -0
- django_bolt/openapi/spec/responses.py +44 -0
- django_bolt/openapi/spec/schema.py +678 -0
- django_bolt/openapi/spec/security_requirement.py +28 -0
- django_bolt/openapi/spec/security_scheme.py +69 -0
- django_bolt/openapi/spec/server.py +34 -0
- django_bolt/openapi/spec/server_variable.py +32 -0
- django_bolt/openapi/spec/tag.py +32 -0
- django_bolt/openapi/spec/xml.py +44 -0
- django_bolt/pagination.py +669 -0
- django_bolt/param_functions.py +49 -0
- django_bolt/params.py +337 -0
- django_bolt/request_parsing.py +128 -0
- django_bolt/responses.py +214 -0
- django_bolt/router.py +48 -0
- django_bolt/serialization.py +193 -0
- django_bolt/status_codes.py +321 -0
- django_bolt/testing/__init__.py +10 -0
- django_bolt/testing/client.py +274 -0
- django_bolt/testing/helpers.py +93 -0
- django_bolt/tests/__init__.py +0 -0
- django_bolt/tests/admin_tests/__init__.py +1 -0
- django_bolt/tests/admin_tests/conftest.py +6 -0
- django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
- django_bolt/tests/admin_tests/urls.py +9 -0
- django_bolt/tests/cbv/__init__.py +0 -0
- django_bolt/tests/cbv/test_class_views.py +570 -0
- django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
- django_bolt/tests/cbv/test_class_views_features.py +1173 -0
- django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
- django_bolt/tests/conftest.py +165 -0
- django_bolt/tests/test_action_decorator.py +399 -0
- django_bolt/tests/test_auth_secret_key.py +83 -0
- django_bolt/tests/test_decorator_syntax.py +159 -0
- django_bolt/tests/test_error_handling.py +481 -0
- django_bolt/tests/test_file_response.py +192 -0
- django_bolt/tests/test_global_cors.py +172 -0
- django_bolt/tests/test_guards_auth.py +441 -0
- django_bolt/tests/test_guards_integration.py +303 -0
- django_bolt/tests/test_health.py +283 -0
- django_bolt/tests/test_integration_validation.py +400 -0
- django_bolt/tests/test_json_validation.py +536 -0
- django_bolt/tests/test_jwt_auth.py +327 -0
- django_bolt/tests/test_jwt_token.py +458 -0
- django_bolt/tests/test_logging.py +837 -0
- django_bolt/tests/test_logging_merge.py +419 -0
- django_bolt/tests/test_middleware.py +492 -0
- django_bolt/tests/test_middleware_server.py +230 -0
- django_bolt/tests/test_model_viewset.py +323 -0
- django_bolt/tests/test_models.py +24 -0
- django_bolt/tests/test_pagination.py +1258 -0
- django_bolt/tests/test_parameter_validation.py +178 -0
- django_bolt/tests/test_syntax.py +626 -0
- django_bolt/tests/test_testing_utilities.py +163 -0
- django_bolt/tests/test_testing_utilities_simple.py +123 -0
- django_bolt/tests/test_viewset_unified.py +346 -0
- django_bolt/typing.py +273 -0
- django_bolt/views.py +1110 -0
- django_bolt-0.1.0.dist-info/METADATA +629 -0
- django_bolt-0.1.0.dist-info/RECORD +128 -0
- django_bolt-0.1.0.dist-info/WHEEL +4 -0
- django_bolt-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Token revocation support for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides flexible revocation strategies that users can choose based on their needs.
|
|
5
|
+
Revocation is OPTIONAL - only checked if user provides a handler.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from abc import ABC, abstractmethod
|
|
9
|
+
from typing import Optional, Set
|
|
10
|
+
from datetime import timedelta
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class RevocationStore(ABC):
|
|
14
|
+
"""
|
|
15
|
+
Base class for token revocation storage.
|
|
16
|
+
|
|
17
|
+
Implementations can use in-memory, Django cache, database, Redis, etc.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def is_revoked(self, jti: str) -> bool:
|
|
22
|
+
"""
|
|
23
|
+
Check if a token (by JTI) is revoked.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
jti: JWT ID (unique token identifier)
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
True if token is revoked, False otherwise
|
|
30
|
+
"""
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
@abstractmethod
|
|
34
|
+
async def revoke(self, jti: str, ttl: Optional[int] = None) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Revoke a token by its JTI.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
jti: JWT ID to revoke
|
|
40
|
+
ttl: Time-to-live in seconds (optional, for cleanup)
|
|
41
|
+
"""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
async def revoke_all(self, user_id: str) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Revoke all tokens for a user (optional, not all stores support this).
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
user_id: User identifier
|
|
50
|
+
"""
|
|
51
|
+
raise NotImplementedError("This revocation store does not support revoke_all")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InMemoryRevocation(RevocationStore):
|
|
55
|
+
"""
|
|
56
|
+
Simple in-memory revocation store.
|
|
57
|
+
|
|
58
|
+
⚠️ WARNING: Only works in single-process mode. Not suitable for production
|
|
59
|
+
with multiple workers. Use DjangoCacheRevocation for multi-process setups.
|
|
60
|
+
|
|
61
|
+
Good for:
|
|
62
|
+
- Development
|
|
63
|
+
- Testing
|
|
64
|
+
- Single-process deployments
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
```python
|
|
68
|
+
from django_bolt.auth import JWTAuthentication
|
|
69
|
+
from django_bolt.auth.revocation import InMemoryRevocation
|
|
70
|
+
|
|
71
|
+
revocation = InMemoryRevocation()
|
|
72
|
+
|
|
73
|
+
auth = JWTAuthentication(
|
|
74
|
+
secret=settings.SECRET_KEY,
|
|
75
|
+
revocation_store=revocation,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# In logout endpoint
|
|
79
|
+
@api.post("/logout")
|
|
80
|
+
async def logout(request):
|
|
81
|
+
jti = request["context"]["auth_claims"]["jti"]
|
|
82
|
+
await revocation.revoke(jti)
|
|
83
|
+
return {"message": "Logged out"}
|
|
84
|
+
```
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(self):
|
|
88
|
+
self._revoked: Set[str] = set()
|
|
89
|
+
|
|
90
|
+
async def is_revoked(self, jti: str) -> bool:
|
|
91
|
+
return jti in self._revoked
|
|
92
|
+
|
|
93
|
+
async def revoke(self, jti: str, ttl: Optional[int] = None) -> None:
|
|
94
|
+
self._revoked.add(jti)
|
|
95
|
+
# TTL not supported in memory (would need background cleanup)
|
|
96
|
+
|
|
97
|
+
def clear(self) -> None:
|
|
98
|
+
"""Clear all revoked tokens (useful for testing)."""
|
|
99
|
+
self._revoked.clear()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DjangoCacheRevocation(RevocationStore):
|
|
103
|
+
"""
|
|
104
|
+
Django cache-based revocation store.
|
|
105
|
+
|
|
106
|
+
Works with ANY Django cache backend:
|
|
107
|
+
- Redis (django.core.cache.backends.redis.RedisCache)
|
|
108
|
+
- Memcached (django.core.cache.backends.memcached.PyMemcacheCache)
|
|
109
|
+
- Database (django.core.cache.backends.db.DatabaseCache)
|
|
110
|
+
- File-based (django.core.cache.backends.filebased.FileBasedCache)
|
|
111
|
+
- Local memory (django.core.cache.backends.locmem.LocMemCache)
|
|
112
|
+
|
|
113
|
+
✅ Production-ready: Works across multiple processes/workers
|
|
114
|
+
✅ Fast: Uses Django's cache framework (Redis ~50k ops/sec)
|
|
115
|
+
✅ Automatic cleanup: TTL handled by cache backend
|
|
116
|
+
|
|
117
|
+
Example:
|
|
118
|
+
```python
|
|
119
|
+
# settings.py
|
|
120
|
+
CACHES = {
|
|
121
|
+
'default': {
|
|
122
|
+
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
|
|
123
|
+
'LOCATION': 'redis://127.0.0.1:6379/1',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# api.py
|
|
128
|
+
from django_bolt.auth import JWTAuthentication
|
|
129
|
+
from django_bolt.auth.revocation import DjangoCacheRevocation
|
|
130
|
+
|
|
131
|
+
auth = JWTAuthentication(
|
|
132
|
+
secret=settings.SECRET_KEY,
|
|
133
|
+
revocation_store=DjangoCacheRevocation(cache_alias='default'),
|
|
134
|
+
)
|
|
135
|
+
```
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, cache_alias: str = 'default', key_prefix: str = 'revoked:'):
|
|
139
|
+
"""
|
|
140
|
+
Initialize Django cache-based revocation.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
cache_alias: Django cache alias to use (default: 'default')
|
|
144
|
+
key_prefix: Prefix for cache keys (default: 'revoked:')
|
|
145
|
+
"""
|
|
146
|
+
self.cache_alias = cache_alias
|
|
147
|
+
self.key_prefix = key_prefix
|
|
148
|
+
self._cache = None
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def cache(self):
|
|
152
|
+
"""Lazy-load cache to avoid import issues."""
|
|
153
|
+
if self._cache is None:
|
|
154
|
+
from django.core.cache import caches
|
|
155
|
+
self._cache = caches[self.cache_alias]
|
|
156
|
+
return self._cache
|
|
157
|
+
|
|
158
|
+
async def is_revoked(self, jti: str) -> bool:
|
|
159
|
+
key = f"{self.key_prefix}{jti}"
|
|
160
|
+
# Django cache get is sync, but fast
|
|
161
|
+
return self.cache.get(key) is not None
|
|
162
|
+
|
|
163
|
+
async def revoke(self, jti: str, ttl: Optional[int] = None) -> None:
|
|
164
|
+
key = f"{self.key_prefix}{jti}"
|
|
165
|
+
# TTL defaults to 30 days (longer than most refresh tokens)
|
|
166
|
+
timeout = ttl or (86400 * 30)
|
|
167
|
+
self.cache.set(key, "1", timeout=timeout)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class DjangoORMRevocation(RevocationStore):
|
|
171
|
+
"""
|
|
172
|
+
Database-based revocation store using Django ORM.
|
|
173
|
+
|
|
174
|
+
⚠️ Slower than cache-based solutions (~1k-5k ops/sec vs 50k ops/sec).
|
|
175
|
+
Only use if you don't have cache infrastructure.
|
|
176
|
+
|
|
177
|
+
Requires creating a model:
|
|
178
|
+
```python
|
|
179
|
+
# myapp/models.py
|
|
180
|
+
from django.db import models
|
|
181
|
+
|
|
182
|
+
class RevokedToken(models.Model):
|
|
183
|
+
jti = models.CharField(max_length=255, unique=True, db_index=True)
|
|
184
|
+
revoked_at = models.DateTimeField(auto_now_add=True)
|
|
185
|
+
expires_at = models.DateTimeField(db_index=True)
|
|
186
|
+
|
|
187
|
+
class Meta:
|
|
188
|
+
indexes = [
|
|
189
|
+
models.Index(fields=['jti']),
|
|
190
|
+
models.Index(fields=['expires_at']),
|
|
191
|
+
]
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
Then run migrations:
|
|
195
|
+
```bash
|
|
196
|
+
python manage.py makemigrations
|
|
197
|
+
python manage.py migrate
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Usage:
|
|
201
|
+
```python
|
|
202
|
+
from django_bolt.auth.revocation import DjangoORMRevocation
|
|
203
|
+
|
|
204
|
+
revocation = DjangoORMRevocation(model='myapp.RevokedToken')
|
|
205
|
+
|
|
206
|
+
auth = JWTAuthentication(
|
|
207
|
+
secret=settings.SECRET_KEY,
|
|
208
|
+
revocation_store=revocation,
|
|
209
|
+
)
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
⚠️ Remember to add a cleanup task to delete expired tokens:
|
|
213
|
+
```python
|
|
214
|
+
# Periodic task (celery, cron, etc.)
|
|
215
|
+
from datetime import datetime, timezone
|
|
216
|
+
|
|
217
|
+
async def cleanup_expired_tokens():
|
|
218
|
+
await RevokedToken.objects.filter(
|
|
219
|
+
expires_at__lt=datetime.now(timezone.utc)
|
|
220
|
+
).adelete()
|
|
221
|
+
```
|
|
222
|
+
"""
|
|
223
|
+
|
|
224
|
+
def __init__(self, model: str):
|
|
225
|
+
"""
|
|
226
|
+
Initialize ORM-based revocation.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
model: String path to model (e.g., 'myapp.RevokedToken')
|
|
230
|
+
"""
|
|
231
|
+
self.model_path = model
|
|
232
|
+
self._model = None
|
|
233
|
+
|
|
234
|
+
@property
|
|
235
|
+
def model(self):
|
|
236
|
+
"""Lazy-load model to avoid import issues."""
|
|
237
|
+
if self._model is None:
|
|
238
|
+
from django.apps import apps
|
|
239
|
+
app_label, model_name = self.model_path.split('.')
|
|
240
|
+
self._model = apps.get_model(app_label, model_name)
|
|
241
|
+
return self._model
|
|
242
|
+
|
|
243
|
+
async def is_revoked(self, jti: str) -> bool:
|
|
244
|
+
return await self.model.objects.filter(jti=jti).aexists()
|
|
245
|
+
|
|
246
|
+
async def revoke(self, jti: str, ttl: Optional[int] = None) -> None:
|
|
247
|
+
from datetime import datetime, timezone, timedelta
|
|
248
|
+
|
|
249
|
+
expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl or 86400 * 30)
|
|
250
|
+
|
|
251
|
+
await self.model.objects.aupdate_or_create(
|
|
252
|
+
jti=jti,
|
|
253
|
+
defaults={'expires_at': expires_at}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def create_revocation_handler(store: RevocationStore):
|
|
258
|
+
"""
|
|
259
|
+
Create a revoked_token_handler from a RevocationStore.
|
|
260
|
+
|
|
261
|
+
This is a convenience function to convert a RevocationStore into
|
|
262
|
+
a callable that can be passed to JWTAuthentication.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
store: RevocationStore instance
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Async callable that checks if token is revoked
|
|
269
|
+
|
|
270
|
+
Example:
|
|
271
|
+
```python
|
|
272
|
+
from django_bolt.auth.revocation import InMemoryRevocation, create_revocation_handler
|
|
273
|
+
|
|
274
|
+
store = InMemoryRevocation()
|
|
275
|
+
handler = create_revocation_handler(store)
|
|
276
|
+
|
|
277
|
+
auth = JWTAuthentication(
|
|
278
|
+
secret=settings.SECRET_KEY,
|
|
279
|
+
revoked_token_handler=handler,
|
|
280
|
+
)
|
|
281
|
+
```
|
|
282
|
+
"""
|
|
283
|
+
async def handler(jti: str) -> bool:
|
|
284
|
+
return await store.is_revoked(jti)
|
|
285
|
+
|
|
286
|
+
return handler
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
"""
|
|
2
|
+
JWT Token handling for Django-Bolt.
|
|
3
|
+
|
|
4
|
+
Provides a Token dataclass for encoding/decoding JWTs. The actual validation
|
|
5
|
+
happens in Rust for performance, but this provides Python-side utilities for
|
|
6
|
+
token creation and inspection.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field, asdict
|
|
10
|
+
from datetime import datetime, timedelta, timezone
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
import msgspec
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _normalize_datetime(value: datetime) -> datetime:
|
|
16
|
+
"""
|
|
17
|
+
Convert datetime to UTC and strip microseconds for consistent JWT claims.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
value: A datetime instance
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
A normalized datetime in UTC without microseconds
|
|
24
|
+
"""
|
|
25
|
+
if value.tzinfo is not None:
|
|
26
|
+
value = value.astimezone(timezone.utc)
|
|
27
|
+
return value.replace(microsecond=0, tzinfo=timezone.utc)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Token:
|
|
32
|
+
"""
|
|
33
|
+
JWT Token data structure.
|
|
34
|
+
|
|
35
|
+
This class represents a JWT token with standard claims (exp, sub, iat, etc.)
|
|
36
|
+
and optional custom claims. The actual encoding/decoding happens in Rust
|
|
37
|
+
for performance, but you can use this class to construct tokens in Python.
|
|
38
|
+
|
|
39
|
+
Standard Claims:
|
|
40
|
+
exp: Expiration time (required, must be in the future)
|
|
41
|
+
sub: Subject - usually user ID (required)
|
|
42
|
+
iat: Issued at time (auto-generated if not provided)
|
|
43
|
+
iss: Issuer - identifies who issued the token
|
|
44
|
+
aud: Audience - intended recipient(s)
|
|
45
|
+
jti: JWT ID - unique identifier for this token
|
|
46
|
+
|
|
47
|
+
Django-Bolt Custom Claims:
|
|
48
|
+
is_staff: Whether user is staff
|
|
49
|
+
is_superuser/is_admin: Whether user is admin
|
|
50
|
+
permissions: List of permission strings
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
>>> token = Token(
|
|
54
|
+
... sub="user123",
|
|
55
|
+
... exp=datetime.now(timezone.utc) + timedelta(hours=1),
|
|
56
|
+
... is_staff=True,
|
|
57
|
+
... permissions=["users.view", "users.edit"]
|
|
58
|
+
... )
|
|
59
|
+
>>> encoded = token.encode(secret="my-secret", algorithm="HS256")
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
exp: datetime
|
|
63
|
+
"""Expiration time - when the token expires (required, must be future)"""
|
|
64
|
+
|
|
65
|
+
sub: str
|
|
66
|
+
"""Subject - typically the user ID or identifier (required)"""
|
|
67
|
+
|
|
68
|
+
iat: datetime = field(default_factory=lambda: _normalize_datetime(datetime.now(timezone.utc)))
|
|
69
|
+
"""Issued at - timestamp when token was created"""
|
|
70
|
+
|
|
71
|
+
iss: Optional[str] = None
|
|
72
|
+
"""Issuer - who issued the token (e.g., "my-auth-service")"""
|
|
73
|
+
|
|
74
|
+
aud: Optional[str] = None
|
|
75
|
+
"""Audience - intended recipient (e.g., "my-api")"""
|
|
76
|
+
|
|
77
|
+
jti: Optional[str] = None
|
|
78
|
+
"""JWT ID - unique identifier for this token (useful for revocation)"""
|
|
79
|
+
|
|
80
|
+
nbf: Optional[datetime] = None
|
|
81
|
+
"""Not before - token is not valid before this time"""
|
|
82
|
+
|
|
83
|
+
# Django-Bolt specific claims
|
|
84
|
+
is_staff: Optional[bool] = None
|
|
85
|
+
"""Whether the user is staff"""
|
|
86
|
+
|
|
87
|
+
is_superuser: Optional[bool] = None
|
|
88
|
+
"""Whether the user is a superuser/admin"""
|
|
89
|
+
|
|
90
|
+
is_admin: Optional[bool] = None
|
|
91
|
+
"""Alternative admin flag (checked along with is_superuser)"""
|
|
92
|
+
|
|
93
|
+
permissions: Optional[List[str]] = None
|
|
94
|
+
"""List of permission strings (e.g., ["users.view", "posts.create"])"""
|
|
95
|
+
|
|
96
|
+
# Extra custom claims
|
|
97
|
+
extras: Dict[str, Any] = field(default_factory=dict)
|
|
98
|
+
"""Any additional custom claims not covered by standard fields"""
|
|
99
|
+
|
|
100
|
+
# Internal flag to skip validation (used during decoding)
|
|
101
|
+
_skip_validation: bool = field(default=False, repr=False, compare=False)
|
|
102
|
+
|
|
103
|
+
def __post_init__(self):
|
|
104
|
+
"""Validate token fields after initialization."""
|
|
105
|
+
# Validate sub is non-empty
|
|
106
|
+
if not self.sub or len(self.sub) < 1:
|
|
107
|
+
raise ValueError("sub (subject) must be a non-empty string")
|
|
108
|
+
|
|
109
|
+
# Normalize and validate exp
|
|
110
|
+
if isinstance(self.exp, datetime):
|
|
111
|
+
self.exp = _normalize_datetime(self.exp)
|
|
112
|
+
if not self._skip_validation:
|
|
113
|
+
now = _normalize_datetime(datetime.now(timezone.utc))
|
|
114
|
+
if self.exp.timestamp() <= now.timestamp():
|
|
115
|
+
raise ValueError("exp (expiration) must be in the future")
|
|
116
|
+
else:
|
|
117
|
+
raise ValueError("exp must be a datetime object")
|
|
118
|
+
|
|
119
|
+
# Normalize iat
|
|
120
|
+
if isinstance(self.iat, datetime):
|
|
121
|
+
self.iat = _normalize_datetime(self.iat)
|
|
122
|
+
if not self._skip_validation:
|
|
123
|
+
now = _normalize_datetime(datetime.now(timezone.utc))
|
|
124
|
+
if self.iat.timestamp() > now.timestamp():
|
|
125
|
+
raise ValueError("iat (issued at) must be current or past time")
|
|
126
|
+
|
|
127
|
+
# Normalize nbf if provided
|
|
128
|
+
if self.nbf is not None and isinstance(self.nbf, datetime):
|
|
129
|
+
self.nbf = _normalize_datetime(self.nbf)
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
132
|
+
"""
|
|
133
|
+
Convert token to dictionary for encoding.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
Dictionary with all claims, converting datetimes to Unix timestamps
|
|
137
|
+
"""
|
|
138
|
+
result = {}
|
|
139
|
+
|
|
140
|
+
# Standard claims
|
|
141
|
+
result["exp"] = int(self.exp.timestamp())
|
|
142
|
+
result["sub"] = self.sub
|
|
143
|
+
result["iat"] = int(self.iat.timestamp())
|
|
144
|
+
|
|
145
|
+
if self.iss is not None:
|
|
146
|
+
result["iss"] = self.iss
|
|
147
|
+
if self.aud is not None:
|
|
148
|
+
result["aud"] = self.aud
|
|
149
|
+
if self.jti is not None:
|
|
150
|
+
result["jti"] = self.jti
|
|
151
|
+
if self.nbf is not None:
|
|
152
|
+
result["nbf"] = int(self.nbf.timestamp())
|
|
153
|
+
|
|
154
|
+
# Django-Bolt custom claims
|
|
155
|
+
if self.is_staff is not None:
|
|
156
|
+
result["is_staff"] = self.is_staff
|
|
157
|
+
if self.is_superuser is not None:
|
|
158
|
+
result["is_superuser"] = self.is_superuser
|
|
159
|
+
if self.is_admin is not None:
|
|
160
|
+
result["is_admin"] = self.is_admin
|
|
161
|
+
if self.permissions is not None:
|
|
162
|
+
result["permissions"] = self.permissions
|
|
163
|
+
|
|
164
|
+
# Extra claims
|
|
165
|
+
result.update(self.extras)
|
|
166
|
+
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
def encode(
|
|
170
|
+
self,
|
|
171
|
+
secret: str,
|
|
172
|
+
algorithm: str = "HS256",
|
|
173
|
+
headers: Optional[Dict[str, Any]] = None,
|
|
174
|
+
) -> str:
|
|
175
|
+
"""
|
|
176
|
+
Encode the token into a JWT string.
|
|
177
|
+
|
|
178
|
+
Note: This uses Python's jwt library for convenience. For production
|
|
179
|
+
token generation at scale, consider using Rust directly via PyO3.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
secret: Secret key for signing
|
|
183
|
+
algorithm: JWT algorithm (HS256, HS384, HS512, RS256, etc.)
|
|
184
|
+
headers: Optional additional headers (e.g., {"kid": "key-id"})
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
Encoded JWT string
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
ValueError: If encoding fails
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
import jwt
|
|
194
|
+
return jwt.encode(
|
|
195
|
+
payload=self.to_dict(),
|
|
196
|
+
key=secret,
|
|
197
|
+
algorithm=algorithm,
|
|
198
|
+
headers=headers,
|
|
199
|
+
)
|
|
200
|
+
except Exception as e:
|
|
201
|
+
raise ValueError(f"Failed to encode token: {e}") from e
|
|
202
|
+
|
|
203
|
+
@classmethod
|
|
204
|
+
def decode(
|
|
205
|
+
cls,
|
|
206
|
+
encoded_token: str,
|
|
207
|
+
secret: str,
|
|
208
|
+
algorithm: str = "HS256",
|
|
209
|
+
audience: Optional[str] = None,
|
|
210
|
+
issuer: Optional[str] = None,
|
|
211
|
+
verify_exp: bool = True,
|
|
212
|
+
verify_nbf: bool = True,
|
|
213
|
+
) -> "Token":
|
|
214
|
+
"""
|
|
215
|
+
Decode and validate a JWT token.
|
|
216
|
+
|
|
217
|
+
Note: In production, JWT validation happens in Rust for performance.
|
|
218
|
+
This method is provided for testing and Python-side token inspection.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
encoded_token: The JWT string to decode
|
|
222
|
+
secret: Secret key for validation
|
|
223
|
+
algorithm: Expected algorithm
|
|
224
|
+
audience: Expected audience (if any)
|
|
225
|
+
issuer: Expected issuer (if any)
|
|
226
|
+
verify_exp: Verify expiration time
|
|
227
|
+
verify_nbf: Verify not-before time
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
Decoded Token instance
|
|
231
|
+
|
|
232
|
+
Raises:
|
|
233
|
+
ValueError: If token is invalid or verification fails
|
|
234
|
+
"""
|
|
235
|
+
try:
|
|
236
|
+
import jwt
|
|
237
|
+
|
|
238
|
+
options = {
|
|
239
|
+
"verify_signature": True,
|
|
240
|
+
"verify_exp": verify_exp,
|
|
241
|
+
"verify_nbf": verify_nbf,
|
|
242
|
+
"verify_aud": audience is not None,
|
|
243
|
+
"verify_iss": issuer is not None,
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
payload = jwt.decode(
|
|
247
|
+
jwt=encoded_token,
|
|
248
|
+
key=secret,
|
|
249
|
+
algorithms=[algorithm],
|
|
250
|
+
audience=audience if audience else None,
|
|
251
|
+
issuer=issuer if issuer else None,
|
|
252
|
+
options=options,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Convert timestamps back to datetime
|
|
256
|
+
exp = datetime.fromtimestamp(payload["exp"], tz=timezone.utc)
|
|
257
|
+
iat = datetime.fromtimestamp(payload.get("iat", payload["exp"] - 3600), tz=timezone.utc)
|
|
258
|
+
nbf = None
|
|
259
|
+
if "nbf" in payload:
|
|
260
|
+
nbf = datetime.fromtimestamp(payload["nbf"], tz=timezone.utc)
|
|
261
|
+
|
|
262
|
+
# Extract known fields
|
|
263
|
+
known_fields = {
|
|
264
|
+
"exp", "sub", "iat", "iss", "aud", "jti", "nbf",
|
|
265
|
+
"is_staff", "is_superuser", "is_admin", "permissions"
|
|
266
|
+
}
|
|
267
|
+
extras = {k: v for k, v in payload.items() if k not in known_fields}
|
|
268
|
+
|
|
269
|
+
return cls(
|
|
270
|
+
exp=exp,
|
|
271
|
+
sub=payload["sub"],
|
|
272
|
+
iat=iat,
|
|
273
|
+
iss=payload.get("iss"),
|
|
274
|
+
aud=payload.get("aud"),
|
|
275
|
+
jti=payload.get("jti"),
|
|
276
|
+
nbf=nbf,
|
|
277
|
+
is_staff=payload.get("is_staff"),
|
|
278
|
+
is_superuser=payload.get("is_superuser"),
|
|
279
|
+
is_admin=payload.get("is_admin"),
|
|
280
|
+
permissions=payload.get("permissions"),
|
|
281
|
+
extras=extras,
|
|
282
|
+
_skip_validation=True, # Skip validation when decoding
|
|
283
|
+
)
|
|
284
|
+
except Exception as e:
|
|
285
|
+
raise ValueError(f"Failed to decode token: {e}") from e
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def create(
|
|
289
|
+
cls,
|
|
290
|
+
sub: str,
|
|
291
|
+
expires_delta: Optional[timedelta] = None,
|
|
292
|
+
issuer: Optional[str] = None,
|
|
293
|
+
audience: Optional[str] = None,
|
|
294
|
+
is_staff: bool = False,
|
|
295
|
+
is_admin: bool = False,
|
|
296
|
+
permissions: Optional[List[str]] = None,
|
|
297
|
+
**extra_claims: Any,
|
|
298
|
+
) -> "Token":
|
|
299
|
+
"""
|
|
300
|
+
Convenient factory method to create a token.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
sub: Subject (user ID)
|
|
304
|
+
expires_delta: How long until expiration (default: 1 hour)
|
|
305
|
+
issuer: Token issuer
|
|
306
|
+
audience: Token audience
|
|
307
|
+
is_staff: Staff flag
|
|
308
|
+
is_admin: Admin flag
|
|
309
|
+
permissions: List of permissions
|
|
310
|
+
**extra_claims: Any additional claims
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
New Token instance
|
|
314
|
+
"""
|
|
315
|
+
if expires_delta is None:
|
|
316
|
+
expires_delta = timedelta(hours=1)
|
|
317
|
+
|
|
318
|
+
now = datetime.now(timezone.utc)
|
|
319
|
+
exp = now + expires_delta
|
|
320
|
+
|
|
321
|
+
return cls(
|
|
322
|
+
exp=exp,
|
|
323
|
+
sub=sub,
|
|
324
|
+
iat=now,
|
|
325
|
+
iss=issuer,
|
|
326
|
+
aud=audience,
|
|
327
|
+
is_staff=is_staff,
|
|
328
|
+
is_admin=is_admin or is_staff, # Admin implies staff
|
|
329
|
+
permissions=permissions,
|
|
330
|
+
extras=extra_claims,
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# Alias for backwards compatibility with Litestar-style naming
|
|
335
|
+
JWTToken = Token
|