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.

Files changed (128) hide show
  1. django_bolt/__init__.py +147 -0
  2. django_bolt/_core.pyd +0 -0
  3. django_bolt/admin/__init__.py +25 -0
  4. django_bolt/admin/admin_detection.py +179 -0
  5. django_bolt/admin/asgi_bridge.py +267 -0
  6. django_bolt/admin/routes.py +91 -0
  7. django_bolt/admin/static.py +155 -0
  8. django_bolt/admin/static_routes.py +111 -0
  9. django_bolt/api.py +1011 -0
  10. django_bolt/apps.py +7 -0
  11. django_bolt/async_collector.py +228 -0
  12. django_bolt/auth/README.md +464 -0
  13. django_bolt/auth/REVOCATION_EXAMPLE.md +391 -0
  14. django_bolt/auth/__init__.py +84 -0
  15. django_bolt/auth/backends.py +236 -0
  16. django_bolt/auth/guards.py +224 -0
  17. django_bolt/auth/jwt_utils.py +212 -0
  18. django_bolt/auth/revocation.py +286 -0
  19. django_bolt/auth/token.py +335 -0
  20. django_bolt/binding.py +363 -0
  21. django_bolt/bootstrap.py +77 -0
  22. django_bolt/cli.py +133 -0
  23. django_bolt/compression.py +104 -0
  24. django_bolt/decorators.py +159 -0
  25. django_bolt/dependencies.py +128 -0
  26. django_bolt/error_handlers.py +305 -0
  27. django_bolt/exceptions.py +294 -0
  28. django_bolt/health.py +129 -0
  29. django_bolt/logging/__init__.py +6 -0
  30. django_bolt/logging/config.py +357 -0
  31. django_bolt/logging/middleware.py +296 -0
  32. django_bolt/management/__init__.py +1 -0
  33. django_bolt/management/commands/__init__.py +0 -0
  34. django_bolt/management/commands/runbolt.py +427 -0
  35. django_bolt/middleware/__init__.py +32 -0
  36. django_bolt/middleware/compiler.py +131 -0
  37. django_bolt/middleware/middleware.py +247 -0
  38. django_bolt/openapi/__init__.py +23 -0
  39. django_bolt/openapi/config.py +196 -0
  40. django_bolt/openapi/plugins.py +439 -0
  41. django_bolt/openapi/routes.py +152 -0
  42. django_bolt/openapi/schema_generator.py +581 -0
  43. django_bolt/openapi/spec/__init__.py +68 -0
  44. django_bolt/openapi/spec/base.py +74 -0
  45. django_bolt/openapi/spec/callback.py +24 -0
  46. django_bolt/openapi/spec/components.py +72 -0
  47. django_bolt/openapi/spec/contact.py +21 -0
  48. django_bolt/openapi/spec/discriminator.py +25 -0
  49. django_bolt/openapi/spec/encoding.py +67 -0
  50. django_bolt/openapi/spec/enums.py +41 -0
  51. django_bolt/openapi/spec/example.py +36 -0
  52. django_bolt/openapi/spec/external_documentation.py +21 -0
  53. django_bolt/openapi/spec/header.py +132 -0
  54. django_bolt/openapi/spec/info.py +50 -0
  55. django_bolt/openapi/spec/license.py +28 -0
  56. django_bolt/openapi/spec/link.py +66 -0
  57. django_bolt/openapi/spec/media_type.py +51 -0
  58. django_bolt/openapi/spec/oauth_flow.py +36 -0
  59. django_bolt/openapi/spec/oauth_flows.py +28 -0
  60. django_bolt/openapi/spec/open_api.py +87 -0
  61. django_bolt/openapi/spec/operation.py +105 -0
  62. django_bolt/openapi/spec/parameter.py +147 -0
  63. django_bolt/openapi/spec/path_item.py +78 -0
  64. django_bolt/openapi/spec/paths.py +27 -0
  65. django_bolt/openapi/spec/reference.py +38 -0
  66. django_bolt/openapi/spec/request_body.py +38 -0
  67. django_bolt/openapi/spec/response.py +48 -0
  68. django_bolt/openapi/spec/responses.py +44 -0
  69. django_bolt/openapi/spec/schema.py +678 -0
  70. django_bolt/openapi/spec/security_requirement.py +28 -0
  71. django_bolt/openapi/spec/security_scheme.py +69 -0
  72. django_bolt/openapi/spec/server.py +34 -0
  73. django_bolt/openapi/spec/server_variable.py +32 -0
  74. django_bolt/openapi/spec/tag.py +32 -0
  75. django_bolt/openapi/spec/xml.py +44 -0
  76. django_bolt/pagination.py +669 -0
  77. django_bolt/param_functions.py +49 -0
  78. django_bolt/params.py +337 -0
  79. django_bolt/request_parsing.py +128 -0
  80. django_bolt/responses.py +214 -0
  81. django_bolt/router.py +48 -0
  82. django_bolt/serialization.py +193 -0
  83. django_bolt/status_codes.py +321 -0
  84. django_bolt/testing/__init__.py +10 -0
  85. django_bolt/testing/client.py +274 -0
  86. django_bolt/testing/helpers.py +93 -0
  87. django_bolt/tests/__init__.py +0 -0
  88. django_bolt/tests/admin_tests/__init__.py +1 -0
  89. django_bolt/tests/admin_tests/conftest.py +6 -0
  90. django_bolt/tests/admin_tests/test_admin_with_django.py +278 -0
  91. django_bolt/tests/admin_tests/urls.py +9 -0
  92. django_bolt/tests/cbv/__init__.py +0 -0
  93. django_bolt/tests/cbv/test_class_views.py +570 -0
  94. django_bolt/tests/cbv/test_class_views_django_orm.py +703 -0
  95. django_bolt/tests/cbv/test_class_views_features.py +1173 -0
  96. django_bolt/tests/cbv/test_class_views_with_client.py +622 -0
  97. django_bolt/tests/conftest.py +165 -0
  98. django_bolt/tests/test_action_decorator.py +399 -0
  99. django_bolt/tests/test_auth_secret_key.py +83 -0
  100. django_bolt/tests/test_decorator_syntax.py +159 -0
  101. django_bolt/tests/test_error_handling.py +481 -0
  102. django_bolt/tests/test_file_response.py +192 -0
  103. django_bolt/tests/test_global_cors.py +172 -0
  104. django_bolt/tests/test_guards_auth.py +441 -0
  105. django_bolt/tests/test_guards_integration.py +303 -0
  106. django_bolt/tests/test_health.py +283 -0
  107. django_bolt/tests/test_integration_validation.py +400 -0
  108. django_bolt/tests/test_json_validation.py +536 -0
  109. django_bolt/tests/test_jwt_auth.py +327 -0
  110. django_bolt/tests/test_jwt_token.py +458 -0
  111. django_bolt/tests/test_logging.py +837 -0
  112. django_bolt/tests/test_logging_merge.py +419 -0
  113. django_bolt/tests/test_middleware.py +492 -0
  114. django_bolt/tests/test_middleware_server.py +230 -0
  115. django_bolt/tests/test_model_viewset.py +323 -0
  116. django_bolt/tests/test_models.py +24 -0
  117. django_bolt/tests/test_pagination.py +1258 -0
  118. django_bolt/tests/test_parameter_validation.py +178 -0
  119. django_bolt/tests/test_syntax.py +626 -0
  120. django_bolt/tests/test_testing_utilities.py +163 -0
  121. django_bolt/tests/test_testing_utilities_simple.py +123 -0
  122. django_bolt/tests/test_viewset_unified.py +346 -0
  123. django_bolt/typing.py +273 -0
  124. django_bolt/views.py +1110 -0
  125. django_bolt-0.1.0.dist-info/METADATA +629 -0
  126. django_bolt-0.1.0.dist-info/RECORD +128 -0
  127. django_bolt-0.1.0.dist-info/WHEEL +4 -0
  128. 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