swiftpy-framework 0.1.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.
Files changed (211) hide show
  1. swiftpy/.gitignore +0 -0
  2. swiftpy/__init__.py +572 -0
  3. swiftpy/admin/__init__.py +50 -0
  4. swiftpy/admin/auth.py +522 -0
  5. swiftpy/admin/registry.py +567 -0
  6. swiftpy/admin/views.py +1583 -0
  7. swiftpy/ai/__init__.py +199 -0
  8. swiftpy/ai/_deterministic.py +40 -0
  9. swiftpy/ai/_fake.py +604 -0
  10. swiftpy/ai/agents/__init__.py +30 -0
  11. swiftpy/ai/agents/base_agent.py +506 -0
  12. swiftpy/ai/agents/memory.py +864 -0
  13. swiftpy/ai/client.py +1011 -0
  14. swiftpy/ai/conversation.py +171 -0
  15. swiftpy/ai/embeddings.py +321 -0
  16. swiftpy/ai/exceptions.py +75 -0
  17. swiftpy/ai/history_strategies.py +157 -0
  18. swiftpy/ai/middleware.py +918 -0
  19. swiftpy/ai/observability/__init__.py +28 -0
  20. swiftpy/ai/observability/drivers.py +763 -0
  21. swiftpy/ai/observability/observer.py +364 -0
  22. swiftpy/ai/pool.py +102 -0
  23. swiftpy/ai/pricing.py +101 -0
  24. swiftpy/ai/prompts/__init__.py +6 -0
  25. swiftpy/ai/prompts/loader.py +343 -0
  26. swiftpy/ai/providers/__init__.py +22 -0
  27. swiftpy/ai/providers/anthropic_provider.py +353 -0
  28. swiftpy/ai/providers/base.py +273 -0
  29. swiftpy/ai/providers/openai_provider.py +275 -0
  30. swiftpy/ai/rag/__init__.py +53 -0
  31. swiftpy/ai/rag/context_formatter.py +263 -0
  32. swiftpy/ai/rag/pipeline_builder.py +544 -0
  33. swiftpy/ai/rag/reranker.py +484 -0
  34. swiftpy/ai/rag/result.py +78 -0
  35. swiftpy/ai/rag/retriever.py +315 -0
  36. swiftpy/ai/redis_store.py +227 -0
  37. swiftpy/ai/streaming.py +115 -0
  38. swiftpy/ai/tools/__init__.py +22 -0
  39. swiftpy/ai/tools/base_tool.py +489 -0
  40. swiftpy/ai/tools/first_party.py +384 -0
  41. swiftpy/ai/tools/tool_loop.py +417 -0
  42. swiftpy/ai/vectorstore/__init__.py +46 -0
  43. swiftpy/ai/vectorstore/base.py +542 -0
  44. swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
  45. swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
  46. swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
  47. swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
  48. swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
  49. swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
  50. swiftpy/auth/__init__.py +50 -0
  51. swiftpy/auth/api_key_guard.py +544 -0
  52. swiftpy/auth/auth_manager.py +259 -0
  53. swiftpy/auth/exceptions.py +71 -0
  54. swiftpy/auth/jwt_guard.py +230 -0
  55. swiftpy/auth/jwt_issuer.py +658 -0
  56. swiftpy/auth/token_blacklist.py +300 -0
  57. swiftpy/cache/__init__.py +14 -0
  58. swiftpy/cache/cache.py +251 -0
  59. swiftpy/cache/driver.py +216 -0
  60. swiftpy/cache/drivers/memory.py +199 -0
  61. swiftpy/cache/drivers/redis.py +421 -0
  62. swiftpy/console/__init__.py +0 -0
  63. swiftpy/console/cli.py +68 -0
  64. swiftpy/console/commands/__init__.py +0 -0
  65. swiftpy/console/commands/cache.py +164 -0
  66. swiftpy/console/commands/make.py +935 -0
  67. swiftpy/console/commands/migrate.py +280 -0
  68. swiftpy/console/commands/new.py +1806 -0
  69. swiftpy/console/commands/queue.py +306 -0
  70. swiftpy/console/commands/seed.py +252 -0
  71. swiftpy/console/commands/start.py +171 -0
  72. swiftpy/console/commands/storage.py +104 -0
  73. swiftpy/console/loader.py +21 -0
  74. swiftpy/console/stubs/__init__.py +26 -0
  75. swiftpy/console/stubs/controller.stub +14 -0
  76. swiftpy/console/stubs/event.stub +14 -0
  77. swiftpy/console/stubs/job.stub +22 -0
  78. swiftpy/console/stubs/listener.stub +14 -0
  79. swiftpy/console/stubs/mail.stub +20 -0
  80. swiftpy/console/stubs/middleware.stub +16 -0
  81. swiftpy/console/stubs/migration.alter.stub +17 -0
  82. swiftpy/console/stubs/migration.api_keys.stub +22 -0
  83. swiftpy/console/stubs/migration.blank.stub +11 -0
  84. swiftpy/console/stubs/migration.create.stub +15 -0
  85. swiftpy/console/stubs/migration.stub +11 -0
  86. swiftpy/console/stubs/migration.update.stub +17 -0
  87. swiftpy/console/stubs/model.stub +17 -0
  88. swiftpy/console/stubs/provider.stub +18 -0
  89. swiftpy/console/stubs/repository.stub +10 -0
  90. swiftpy/console/stubs/request.stub +16 -0
  91. swiftpy/console/stubs/resource.stub +21 -0
  92. swiftpy/console/stubs/schema.stub +12 -0
  93. swiftpy/console/stubs/seeder.stub +14 -0
  94. swiftpy/console/stubs/service.stub +10 -0
  95. swiftpy/core/__init__.py +0 -0
  96. swiftpy/core/application.py +1306 -0
  97. swiftpy/core/bootstrap.py +30 -0
  98. swiftpy/core/config.py +432 -0
  99. swiftpy/core/container.py +596 -0
  100. swiftpy/core/container_exceptions.py +18 -0
  101. swiftpy/core/context.py +148 -0
  102. swiftpy/core/exceptions.py +352 -0
  103. swiftpy/core/providers/base.py +35 -0
  104. swiftpy/database/__init__.py +41 -0
  105. swiftpy/database/exceptions.py +42 -0
  106. swiftpy/database/identifiers.py +105 -0
  107. swiftpy/database/migrations/__init__.py +36 -0
  108. swiftpy/database/migrations/blueprint.py +431 -0
  109. swiftpy/database/migrations/lock.py +104 -0
  110. swiftpy/database/migrations/migration.py +22 -0
  111. swiftpy/database/migrations/migrator.py +507 -0
  112. swiftpy/database/migrations/schema.py +435 -0
  113. swiftpy/database/orm/__init__.py +33 -0
  114. swiftpy/database/orm/fields.py +92 -0
  115. swiftpy/database/orm/model.py +2034 -0
  116. swiftpy/database/orm/registry.py +33 -0
  117. swiftpy/database/orm/relationships.py +836 -0
  118. swiftpy/database/pagination/__init__.py +16 -0
  119. swiftpy/database/pagination/contracts.py +60 -0
  120. swiftpy/database/pagination/paginator.py +112 -0
  121. swiftpy/database/pagination/simple_paginator.py +97 -0
  122. swiftpy/database/pool.py +218 -0
  123. swiftpy/database/query_builder.py +1803 -0
  124. swiftpy/database/seeder.py +39 -0
  125. swiftpy/database/transaction.py +276 -0
  126. swiftpy/events/__init__.py +10 -0
  127. swiftpy/events/dispatcher.py +308 -0
  128. swiftpy/events/redis_bus.py +552 -0
  129. swiftpy/filesystem/__init__.py +227 -0
  130. swiftpy/filesystem/disk.py +378 -0
  131. swiftpy/filesystem/drivers/__init__.py +11 -0
  132. swiftpy/filesystem/drivers/base.py +156 -0
  133. swiftpy/filesystem/drivers/local.py +559 -0
  134. swiftpy/filesystem/drivers/s3.py +655 -0
  135. swiftpy/filesystem/events.py +37 -0
  136. swiftpy/filesystem/exceptions.py +127 -0
  137. swiftpy/filesystem/manager.py +138 -0
  138. swiftpy/filesystem/metadata.py +22 -0
  139. swiftpy/filesystem/paths.py +99 -0
  140. swiftpy/filesystem/service_provider.py +17 -0
  141. swiftpy/filesystem/uploads/__init__.py +13 -0
  142. swiftpy/filesystem/uploads/handlers.py +362 -0
  143. swiftpy/filesystem/uploads/uploaded_file.py +222 -0
  144. swiftpy/filesystem/uploads/validators.py +186 -0
  145. swiftpy/filesystem/visibility.py +22 -0
  146. swiftpy/helpers/__init__.py +42 -0
  147. swiftpy/helpers/helper.py +180 -0
  148. swiftpy/http/exceptions.py +41 -0
  149. swiftpy/http/middleware.py +307 -0
  150. swiftpy/http/request.py +375 -0
  151. swiftpy/http/response.py +442 -0
  152. swiftpy/http/router.py +1050 -0
  153. swiftpy/mail/__init__.py +58 -0
  154. swiftpy/mail/attachments.py +255 -0
  155. swiftpy/mail/config.py +71 -0
  156. swiftpy/mail/envelope.py +183 -0
  157. swiftpy/mail/exceptions.py +90 -0
  158. swiftpy/mail/facade.py +312 -0
  159. swiftpy/mail/mailable.py +53 -0
  160. swiftpy/mail/manager.py +240 -0
  161. swiftpy/mail/message.py +186 -0
  162. swiftpy/mail/models.py +19 -0
  163. swiftpy/mail/queue/__init__.py +5 -0
  164. swiftpy/mail/queue/job.py +279 -0
  165. swiftpy/mail/service_provider.py +19 -0
  166. swiftpy/mail/templates/__init__.py +5 -0
  167. swiftpy/mail/templates/renderer.py +173 -0
  168. swiftpy/mail/transports/__init__.py +21 -0
  169. swiftpy/mail/transports/api/__init__.py +13 -0
  170. swiftpy/mail/transports/api/base.py +194 -0
  171. swiftpy/mail/transports/api/mailgun.py +158 -0
  172. swiftpy/mail/transports/api/resend.py +156 -0
  173. swiftpy/mail/transports/api/sendgrid.py +172 -0
  174. swiftpy/mail/transports/base.py +24 -0
  175. swiftpy/mail/transports/console.py +91 -0
  176. swiftpy/mail/transports/sendmail.py +128 -0
  177. swiftpy/mail/transports/smtp.py +466 -0
  178. swiftpy/queue/__init__.py +13 -0
  179. swiftpy/queue/dispatcher.py +813 -0
  180. swiftpy/queue/job.py +541 -0
  181. swiftpy/queue/worker.py +556 -0
  182. swiftpy/support/__init__.py +12 -0
  183. swiftpy/support/keys.py +108 -0
  184. swiftpy/support/redis_config.py +223 -0
  185. swiftpy/support/strings.py +114 -0
  186. swiftpy/testing/__init__.py +20 -0
  187. swiftpy/testing/fakes/__init__.py +11 -0
  188. swiftpy/testing/fakes/ai_fake.py +21 -0
  189. swiftpy/testing/fakes/vectorstore_fake.py +194 -0
  190. swiftpy/testing/fixtures.py +124 -0
  191. swiftpy/testing/http_client.py +730 -0
  192. swiftpy/testing/test_case.py +126 -0
  193. swiftpy/validation/__init__.py +97 -0
  194. swiftpy/validation/exceptions.py +26 -0
  195. swiftpy/validation/form_request.py +302 -0
  196. swiftpy/validation/protocols.py +33 -0
  197. swiftpy/validation/rules/__init__.py +89 -0
  198. swiftpy/validation/rules/builtin.py +391 -0
  199. swiftpy/validation/rules/exists.py +86 -0
  200. swiftpy/validation/rules/registry.py +290 -0
  201. swiftpy/validation/rules/required.py +55 -0
  202. swiftpy/validation/rules/unique.py +138 -0
  203. swiftpy/validation/validator.py +612 -0
  204. swiftpy/websockets/__init__.py +19 -0
  205. swiftpy/websockets/connection.py +317 -0
  206. swiftpy/websockets/manager.py +271 -0
  207. swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
  208. swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
  209. swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
  210. swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
  211. swiftpy_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,658 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import logging
8
+ import math
9
+ import secrets
10
+ import time
11
+ import uuid
12
+ from datetime import timedelta
13
+ from typing import Any, ClassVar
14
+
15
+ from cryptography.exceptions import InvalidSignature
16
+ from cryptography.hazmat.primitives import hashes, serialization
17
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
18
+ from cryptography.hazmat.primitives.asymmetric.rsa import (
19
+ RSAPrivateKey,
20
+ RSAPublicKey,
21
+ )
22
+
23
+ from swiftpy.core.config import Config, ConfigurationError
24
+
25
+ logger = logging.getLogger("swiftpy.auth.jwt")
26
+
27
+
28
+ class JWTError(Exception):
29
+ """Base exception for all JWT-related errors."""
30
+
31
+
32
+ class InvalidTokenError(JWTError):
33
+ """Raised when a token is structurally malformed or invalid."""
34
+
35
+
36
+ class TokenExpiredError(JWTError):
37
+ """Raised when a token's exp claim is in the past."""
38
+
39
+
40
+ class InvalidSignatureError(InvalidTokenError):
41
+ """Raised when a token's signature cannot be verified."""
42
+
43
+
44
+ class InvalidAlgorithmError(InvalidTokenError):
45
+ """Raised when an algorithm is disallowed, missing, or mismatched."""
46
+
47
+
48
+ def _base64url_encode(data: bytes) -> str:
49
+ """Encode bytes into base64url string without trailing padding."""
50
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
51
+
52
+
53
+ def _base64url_decode(data: str) -> bytes:
54
+ """Decode a canonical base64url string, rejecting non-canonical padding bits."""
55
+ unpadded = data.rstrip("=")
56
+ rem = len(unpadded) % 4
57
+ if rem == 1:
58
+ raise InvalidTokenError("Invalid base64url length")
59
+ padded = unpadded + ("=" * (4 - rem) if rem > 0 else "")
60
+ try:
61
+ decoded = base64.urlsafe_b64decode(padded.encode("ascii"))
62
+ except Exception as exc:
63
+ raise InvalidTokenError(f"Base64 decoding failed: {exc}") from exc
64
+ if _base64url_encode(decoded) != unpadded:
65
+ raise InvalidSignatureError("Non-canonical base64url encoding")
66
+ return decoded
67
+
68
+
69
+ def _is_valid_timestamp(value: Any) -> bool:
70
+ """Return True if value is a finite int or float (excluding bool)."""
71
+ return (
72
+ not isinstance(value, bool)
73
+ and isinstance(value, (int, float))
74
+ and math.isfinite(value)
75
+ )
76
+
77
+
78
+ class JWTIssuer:
79
+ """
80
+ High-performance, secure JWT engine supporting RS256 and HS256 algorithms
81
+ with built-in access/refresh token pair lifecycle and claim validation.
82
+ """
83
+
84
+ SUPPORTED_ALGORITHMS: ClassVar[set[str]] = {"HS256", "RS256"}
85
+ RESERVED_CLAIMS: ClassVar[set[str]] = {
86
+ "iat",
87
+ "exp",
88
+ "jti",
89
+ "type",
90
+ "token_type",
91
+ "typ",
92
+ "sub",
93
+ "iss",
94
+ "aud",
95
+ "nbf",
96
+ }
97
+
98
+ def __init__(
99
+ self,
100
+ secret: str | bytes | None = None,
101
+ private_key: str | bytes | RSAPrivateKey | None = None,
102
+ public_key: str | bytes | RSAPublicKey | None = None,
103
+ algorithm: str = "HS256",
104
+ access_token_ttl: int = 900, # 15 minutes
105
+ refresh_token_ttl: int = 604800, # 7 days
106
+ issuer: str | None = None,
107
+ audience: str | list[str] | set[str] | tuple[str, ...] | None = None,
108
+ leeway: int = 0,
109
+ ) -> None:
110
+ if not isinstance(algorithm, str) or algorithm not in self.SUPPORTED_ALGORITHMS:
111
+ raise InvalidAlgorithmError(
112
+ f"Unsupported algorithm '{algorithm}'. Must be one of {self.SUPPORTED_ALGORITHMS}."
113
+ )
114
+ self.algorithm = algorithm
115
+
116
+ if isinstance(leeway, bool) or not isinstance(leeway, (int, float)) or not math.isfinite(leeway) or leeway < 0:
117
+ raise ConfigurationError("JWT leeway must be a non-negative number.")
118
+
119
+ self.access_token_ttl = access_token_ttl
120
+ self.refresh_token_ttl = refresh_token_ttl
121
+ self.issuer = issuer
122
+ self.audience = audience
123
+ self.leeway = int(leeway)
124
+
125
+ # Symmetric key setup
126
+ self._secret: bytes | None = None
127
+ if self.algorithm == "HS256":
128
+ if secret is None:
129
+ raise ConfigurationError(
130
+ "JWT secret must be provided for symmetric algorithm HS256."
131
+ )
132
+ raw_secret = secret.encode("utf-8") if isinstance(secret, str) else secret
133
+ if len(raw_secret) < 32:
134
+ raise ConfigurationError(
135
+ f"JWT secret must be at least 32 bytes (got {len(raw_secret)} bytes) for HS256."
136
+ )
137
+ self._secret = raw_secret
138
+ elif secret is not None:
139
+ self._secret = secret.encode("utf-8") if isinstance(secret, str) else secret
140
+
141
+ # Asymmetric key setup
142
+ self._private_key: RSAPrivateKey | None = None
143
+ self._public_key: RSAPublicKey | None = None
144
+
145
+ if private_key is not None:
146
+ if isinstance(private_key, RSAPrivateKey):
147
+ self._private_key = private_key
148
+ else:
149
+ raw_bytes = (
150
+ private_key.encode("utf-8")
151
+ if isinstance(private_key, str)
152
+ else private_key
153
+ )
154
+ loaded_key = serialization.load_pem_private_key(
155
+ raw_bytes,
156
+ password=None,
157
+ )
158
+ if not isinstance(loaded_key, RSAPrivateKey):
159
+ raise TypeError("Loaded private key is not an RSAPrivateKey")
160
+ self._private_key = loaded_key
161
+
162
+ if public_key is None and self._private_key is not None:
163
+ self._public_key = self._private_key.public_key()
164
+
165
+ if public_key is not None:
166
+ if isinstance(public_key, RSAPublicKey):
167
+ self._public_key = public_key
168
+ else:
169
+ raw_pub_bytes = (
170
+ public_key.encode("utf-8")
171
+ if isinstance(public_key, str)
172
+ else public_key
173
+ )
174
+ loaded_pub = serialization.load_pem_public_key(raw_pub_bytes)
175
+ if not isinstance(loaded_pub, RSAPublicKey):
176
+ raise TypeError("Loaded public key is not an RSAPublicKey")
177
+ self._public_key = loaded_pub
178
+
179
+ if self.algorithm == "RS256" and self._private_key is None and self._public_key is None:
180
+ raise ConfigurationError(
181
+ "RS256 algorithm requires a private_key (for signing) or public_key (for verification)."
182
+ )
183
+
184
+ @classmethod
185
+ def from_config(cls, config: Config | None = None) -> JWTIssuer:
186
+ """
187
+ Build and configure a JWTIssuer from application Config.
188
+
189
+ Reads settings from 'auth.guards.jwt.*' or 'auth.jwt.*'.
190
+ In production (debug=False), fails fast if JWT_SECRET is missing or <32 bytes.
191
+ In debug mode (debug=True), if JWT_SECRET is absent, generates a fresh ephemeral
192
+ random secret per call (cached per application via the container singleton)
193
+ and logs a loud warning.
194
+ """
195
+ if config is None:
196
+ try:
197
+ cfg = Config.get_instance()
198
+ except (RuntimeError, ValueError):
199
+ cfg = Config()
200
+ else:
201
+ cfg = config
202
+
203
+ # Look up jwt guard config dict
204
+ jwt_cfg: dict[str, Any] = {}
205
+ guards = cfg.get("auth.guards")
206
+ if isinstance(guards, dict) and isinstance(guards.get("jwt"), dict):
207
+ jwt_cfg = guards["jwt"]
208
+ elif isinstance(cfg.get("auth.jwt"), dict):
209
+ jwt_cfg = cfg.get("auth.jwt")
210
+
211
+ algorithm = str(jwt_cfg.get("algorithm") or cfg.get("auth.jwt.algorithm") or "HS256")
212
+ raw_secret = jwt_cfg.get("secret") if "secret" in jwt_cfg else cfg.get("auth.jwt.secret")
213
+ if raw_secret is None:
214
+ raw_secret = cfg.get("jwt_secret")
215
+
216
+ app_env = str(cfg.get("app.env") or cfg.get("env") or "development").lower()
217
+ debug_val = cfg.get("app.debug") if cfg.get("app.debug") is not None else cfg.get("debug")
218
+ if debug_val is not None:
219
+ is_debug = bool(debug_val)
220
+ else:
221
+ is_debug = app_env not in ("production", "prod")
222
+
223
+ secret: str | bytes | None = None
224
+ if algorithm == "HS256":
225
+ if raw_secret is not None and str(raw_secret).strip():
226
+ secret = str(raw_secret)
227
+ elif is_debug:
228
+ secret = secrets.token_urlsafe(48)
229
+ logger.warning(
230
+ "SECURITY WARNING: No JWT_SECRET configured. Generated a random ephemeral "
231
+ "secret for debug mode. All tokens will be invalidated on server restart. "
232
+ "Set JWT_SECRET in your environment for persistence."
233
+ )
234
+ else:
235
+ raise ConfigurationError(
236
+ "JWT_SECRET is not configured. A secret of at least 32 bytes is required in production."
237
+ )
238
+
239
+ access_ttl = int(jwt_cfg.get("access_token_ttl") or cfg.get("auth.jwt.access_token_ttl") or 900)
240
+ refresh_ttl = int(jwt_cfg.get("refresh_token_ttl") or cfg.get("auth.jwt.refresh_token_ttl") or 604800)
241
+ raw_leeway = jwt_cfg.get("leeway") if "leeway" in jwt_cfg else cfg.get("auth.jwt.leeway")
242
+ leeway = int(raw_leeway) if raw_leeway is not None else 0
243
+ issuer = jwt_cfg.get("issuer") or cfg.get("auth.jwt.issuer")
244
+ raw_audience = jwt_cfg.get("audience") or cfg.get("auth.jwt.audience")
245
+ audience: str | list[str] | None
246
+ if isinstance(raw_audience, (list, tuple, set)):
247
+ audience = [str(a) for a in raw_audience]
248
+ elif raw_audience:
249
+ audience = str(raw_audience)
250
+ else:
251
+ audience = None
252
+
253
+ private_key = jwt_cfg.get("private_key") or cfg.get("auth.jwt.private_key")
254
+ public_key = jwt_cfg.get("public_key") or cfg.get("auth.jwt.public_key")
255
+
256
+ return cls(
257
+ secret=secret,
258
+ private_key=private_key,
259
+ public_key=public_key,
260
+ algorithm=algorithm,
261
+ access_token_ttl=access_ttl,
262
+ refresh_token_ttl=refresh_ttl,
263
+ issuer=str(issuer) if issuer else None,
264
+ audience=audience,
265
+ leeway=leeway,
266
+ )
267
+
268
+ @classmethod
269
+ def generate_rsa_key_pair(cls, key_size: int = 2048) -> tuple[str, str]:
270
+ """
271
+ Generate a fresh RSA private and public key pair in PEM format.
272
+ """
273
+ private_key = rsa.generate_private_key(
274
+ public_exponent=65537,
275
+ key_size=key_size,
276
+ )
277
+ private_pem = private_key.private_bytes(
278
+ encoding=serialization.Encoding.PEM,
279
+ format=serialization.PrivateFormat.PKCS8,
280
+ encryption_algorithm=serialization.NoEncryption(),
281
+ ).decode("ascii")
282
+
283
+ public_pem = (
284
+ private_key.public_key()
285
+ .public_bytes(
286
+ encoding=serialization.Encoding.PEM,
287
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
288
+ )
289
+ .decode("ascii")
290
+ )
291
+
292
+ return private_pem, public_pem
293
+
294
+ def issue_token(
295
+ self,
296
+ subject: str | int,
297
+ token_type: str = "access",
298
+ claims: dict[str, Any] | None = None,
299
+ expires_in: int | timedelta | None = None,
300
+ algorithm: str | None = None,
301
+ not_before: int | timedelta | None = None,
302
+ ) -> str:
303
+ """
304
+ Issue a signed JSON Web Token with standard and custom claims.
305
+ """
306
+ alg = self.algorithm if algorithm is None else algorithm
307
+ if not isinstance(alg, str) or alg not in self.SUPPORTED_ALGORITHMS:
308
+ raise InvalidAlgorithmError(f"Unsupported algorithm '{alg}'.")
309
+
310
+ if claims:
311
+ conflicting = set(claims.keys()) & self.RESERVED_CLAIMS
312
+ if conflicting:
313
+ raise ValueError(
314
+ f"Cannot override reserved JWT claim(s): {', '.join(sorted(conflicting))}"
315
+ )
316
+
317
+ now = int(time.time())
318
+ ttl_seconds: int
319
+ if expires_in is not None:
320
+ if isinstance(expires_in, timedelta):
321
+ total_sec = expires_in.total_seconds()
322
+ if not math.isfinite(total_sec):
323
+ raise ValueError("expires_in must be a finite duration")
324
+ ttl_seconds = int(total_sec)
325
+ elif _is_valid_timestamp(expires_in):
326
+ ttl_seconds = int(expires_in)
327
+ else:
328
+ raise ValueError("expires_in must be a finite integer or timedelta")
329
+ else:
330
+ ttl_seconds = (
331
+ self.refresh_token_ttl
332
+ if token_type == "refresh"
333
+ else self.access_token_ttl
334
+ )
335
+
336
+ payload: dict[str, Any] = {
337
+ "sub": str(subject),
338
+ "type": token_type,
339
+ "token_type": token_type,
340
+ "typ": token_type,
341
+ "iat": now,
342
+ "exp": now + ttl_seconds,
343
+ "jti": str(uuid.uuid4()),
344
+ }
345
+
346
+ if not_before is not None:
347
+ if isinstance(not_before, timedelta):
348
+ payload["nbf"] = now + int(not_before.total_seconds())
349
+ elif _is_valid_timestamp(not_before):
350
+ payload["nbf"] = int(not_before)
351
+ else:
352
+ raise ValueError("not_before must be a finite integer or timedelta")
353
+
354
+ if self.issuer:
355
+ payload["iss"] = self.issuer
356
+ if self.audience:
357
+ if isinstance(self.audience, (set, tuple)):
358
+ payload["aud"] = list(self.audience)
359
+ else:
360
+ payload["aud"] = self.audience
361
+
362
+ if claims:
363
+ for key, val in claims.items():
364
+ payload[key] = val
365
+
366
+ header = {
367
+ "alg": alg,
368
+ "typ": token_type if token_type in {"access", "refresh"} else "JWT",
369
+ }
370
+
371
+ header_bytes = json.dumps(
372
+ header, separators=(",", ":"), allow_nan=False
373
+ ).encode("utf-8")
374
+ payload_bytes = json.dumps(
375
+ payload, separators=(",", ":"), allow_nan=False
376
+ ).encode("utf-8")
377
+
378
+ header_b64 = _base64url_encode(header_bytes)
379
+ payload_b64 = _base64url_encode(payload_bytes)
380
+
381
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
382
+ signature_bytes = self._sign(signing_input, alg)
383
+ signature_b64 = _base64url_encode(signature_bytes)
384
+
385
+ return f"{header_b64}.{payload_b64}.{signature_b64}"
386
+
387
+ def issue_access_token(
388
+ self,
389
+ subject: str | int,
390
+ claims: dict[str, Any] | None = None,
391
+ expires_in: int | timedelta | None = None,
392
+ ) -> str:
393
+ """Issue a short-lived access token."""
394
+ return self.issue_token(
395
+ subject=subject,
396
+ token_type="access",
397
+ claims=claims,
398
+ expires_in=self.access_token_ttl if expires_in is None else expires_in,
399
+ )
400
+
401
+ def issue_refresh_token(
402
+ self,
403
+ subject: str | int,
404
+ claims: dict[str, Any] | None = None,
405
+ expires_in: int | timedelta | None = None,
406
+ ) -> str:
407
+ """Issue a long-lived refresh token."""
408
+ return self.issue_token(
409
+ subject=subject,
410
+ token_type="refresh",
411
+ claims=claims,
412
+ expires_in=self.refresh_token_ttl if expires_in is None else expires_in,
413
+ )
414
+
415
+ def issue_token_pair(
416
+ self,
417
+ subject: str | int,
418
+ claims: dict[str, Any] | None = None,
419
+ access_expires_in: int | timedelta | None = None,
420
+ refresh_expires_in: int | timedelta | None = None,
421
+ refresh_claims: dict[str, Any] | None = None,
422
+ ) -> dict[str, Any]:
423
+ """
424
+ Issue both an access token and a refresh token.
425
+ Access token carries caller claims; refresh token does not inherit
426
+ access claims unless explicitly provided in refresh_claims.
427
+ """
428
+ access_token = self.issue_access_token(
429
+ subject=subject,
430
+ claims=claims,
431
+ expires_in=access_expires_in,
432
+ )
433
+ refresh_token = self.issue_refresh_token(
434
+ subject=subject,
435
+ claims=refresh_claims,
436
+ expires_in=refresh_expires_in,
437
+ )
438
+ ttl = self.access_token_ttl if access_expires_in is None else access_expires_in
439
+ ttl_seconds = int(ttl.total_seconds() if isinstance(ttl, timedelta) else ttl)
440
+
441
+ return {
442
+ "access_token": access_token,
443
+ "refresh_token": refresh_token,
444
+ "token_type": "Bearer",
445
+ "expires_in": ttl_seconds,
446
+ }
447
+
448
+ def decode_token(
449
+ self,
450
+ token: str,
451
+ *,
452
+ verify_exp: bool = True,
453
+ verify_nbf: bool = True,
454
+ expected_type: str | None = None,
455
+ allowed_algorithms: list[str] | set[str] | None = None,
456
+ leeway: int | None = None,
457
+ ) -> dict[str, Any]:
458
+ """
459
+ Decode and cryptographically verify a JWT.
460
+ """
461
+ if not isinstance(token, str) or not token:
462
+ raise InvalidTokenError("Token must be a non-empty string")
463
+
464
+ active_leeway = self.leeway if leeway is None else leeway
465
+ if not _is_valid_timestamp(active_leeway) or active_leeway < 0:
466
+ raise ValueError("leeway must be a non-negative finite number")
467
+
468
+ parts = token.strip().split(".")
469
+ if len(parts) != 3:
470
+ raise InvalidTokenError(
471
+ f"Invalid token format: expected 3 segments separated by dots, got {len(parts)}"
472
+ )
473
+
474
+ header_b64, payload_b64, signature_b64 = parts
475
+
476
+ try:
477
+ header_json = _base64url_decode(header_b64).decode("utf-8")
478
+ header = json.loads(header_json)
479
+ except Exception as exc:
480
+ raise InvalidTokenError(f"Malformed token header: {exc}") from exc
481
+
482
+ if not isinstance(header, dict):
483
+ raise InvalidTokenError("Token header must be a JSON object")
484
+
485
+ alg = header.get("alg")
486
+ if not alg or not isinstance(alg, str):
487
+ raise InvalidAlgorithmError("Missing or invalid 'alg' in token header")
488
+
489
+ # Strict security protection: reject "none" algorithm attacks across all case variations
490
+ if alg.upper() == "NONE":
491
+ raise InvalidAlgorithmError("The 'none' algorithm is strictly disallowed.")
492
+
493
+ # Exact-case matching for allowed and supported algorithms (P2-A5)
494
+ allowed = (
495
+ set(allowed_algorithms)
496
+ if allowed_algorithms is not None
497
+ else {self.algorithm}
498
+ )
499
+
500
+ if alg not in allowed:
501
+ raise InvalidAlgorithmError(
502
+ f"Algorithm '{alg}' is not allowed for this issuer. Allowed: {allowed}"
503
+ )
504
+
505
+ if alg not in self.SUPPORTED_ALGORITHMS:
506
+ raise InvalidAlgorithmError(
507
+ f"Algorithm '{alg}' is not supported. Supported: {self.SUPPORTED_ALGORITHMS}"
508
+ )
509
+
510
+ # Verify signature
511
+ signing_input = f"{header_b64}.{payload_b64}".encode("ascii")
512
+ signature_bytes = _base64url_decode(signature_b64)
513
+
514
+ self._verify_signature(signing_input, signature_bytes, alg)
515
+
516
+ # Parse payload
517
+ try:
518
+ payload_json = _base64url_decode(payload_b64).decode("utf-8")
519
+ payload = json.loads(payload_json)
520
+ except Exception as exc:
521
+ raise InvalidTokenError(f"Malformed token payload: {exc}") from exc
522
+
523
+ if not isinstance(payload, dict):
524
+ raise InvalidTokenError("Token payload must be a JSON object")
525
+
526
+ now = int(time.time())
527
+
528
+ # Subject check
529
+ if "sub" not in payload or not payload["sub"]:
530
+ raise InvalidTokenError("Missing or empty 'sub' claim in token")
531
+
532
+ # Issued At (iat) check (including future check with leeway)
533
+ iat = payload.get("iat")
534
+ if iat is not None:
535
+ if not _is_valid_timestamp(iat):
536
+ raise InvalidTokenError("Claim 'iat' must be a numeric timestamp")
537
+ if iat > now + active_leeway:
538
+ raise InvalidTokenError("Token 'iat' claim is in the future")
539
+
540
+ # Expiration check (always validate finiteness if present; check expiry when verify_exp=True)
541
+ exp = payload.get("exp")
542
+ if exp is not None and not _is_valid_timestamp(exp):
543
+ raise InvalidTokenError("Claim 'exp' must be a numeric timestamp")
544
+ if verify_exp:
545
+ if exp is None:
546
+ raise InvalidTokenError("Missing 'exp' claim in token")
547
+ if now - active_leeway >= exp:
548
+ raise TokenExpiredError("Token has expired")
549
+
550
+ # Not Before (nbf) check with leeway
551
+ nbf = payload.get("nbf")
552
+ if nbf is not None:
553
+ if not _is_valid_timestamp(nbf):
554
+ raise InvalidTokenError("Claim 'nbf' must be a numeric timestamp")
555
+ if verify_nbf and now + active_leeway < nbf:
556
+ raise InvalidTokenError("Token is not active yet (nbf constraint)")
557
+
558
+ # Issuer check
559
+ if self.issuer is not None:
560
+ token_iss = payload.get("iss")
561
+ if token_iss != self.issuer:
562
+ raise InvalidTokenError(
563
+ f"Invalid issuer: expected '{self.issuer}', got '{token_iss}'"
564
+ )
565
+
566
+ # Audience check (supports str or list[str] on both token and issuer, P2-A5)
567
+ if self.audience is not None:
568
+ token_aud = payload.get("aud")
569
+ if isinstance(token_aud, str) and token_aud:
570
+ token_aud_set = {token_aud}
571
+ elif (
572
+ isinstance(token_aud, list)
573
+ and token_aud
574
+ and all(isinstance(item, str) and bool(item) for item in token_aud)
575
+ ):
576
+ token_aud_set = set(token_aud)
577
+ else:
578
+ raise InvalidTokenError(
579
+ f"Invalid audience: expected '{self.audience}', got '{token_aud}'"
580
+ )
581
+
582
+ expected_aud_set = (
583
+ {self.audience}
584
+ if isinstance(self.audience, str)
585
+ else set(self.audience)
586
+ )
587
+ if expected_aud_set.isdisjoint(token_aud_set):
588
+ raise InvalidTokenError(
589
+ f"Invalid audience: expected '{self.audience}', got '{token_aud}'"
590
+ )
591
+
592
+ # Token type check
593
+ if expected_type is not None:
594
+ token_type = (
595
+ payload.get("type")
596
+ or payload.get("token_type")
597
+ or payload.get("typ")
598
+ )
599
+ if token_type != expected_type:
600
+ raise InvalidTokenError(
601
+ f"Invalid token type: expected '{expected_type}', got '{token_type}'"
602
+ )
603
+ header_typ = str(header.get("typ") or "").lower()
604
+ if header_typ and header_typ not in {"jwt", expected_type.lower()}:
605
+ raise InvalidTokenError(
606
+ f"Invalid token type in header: expected '{expected_type}', got '{header.get('typ')}'"
607
+ )
608
+
609
+ return payload
610
+
611
+ def _sign(self, data: bytes, algorithm: str) -> bytes:
612
+ if algorithm == "HS256":
613
+ if self._secret is None:
614
+ raise ValueError("Secret key must be provided for HS256 signing")
615
+ return hmac.new(self._secret, data, hashlib.sha256).digest()
616
+
617
+ if algorithm == "RS256":
618
+ if self._private_key is None:
619
+ raise ValueError("RSAPrivateKey must be provided for RS256 signing")
620
+ return self._private_key.sign(
621
+ data,
622
+ padding.PKCS1v15(),
623
+ hashes.SHA256(),
624
+ )
625
+
626
+ raise InvalidAlgorithmError(f"Unsupported algorithm '{algorithm}'")
627
+
628
+ def _verify_signature(
629
+ self,
630
+ signing_input: bytes,
631
+ signature: bytes,
632
+ algorithm: str,
633
+ ) -> None:
634
+ if algorithm == "HS256":
635
+ if self._secret is None:
636
+ raise ValueError("Secret key must be provided for HS256 verification")
637
+ expected_signature = hmac.new(
638
+ self._secret, signing_input, hashlib.sha256
639
+ ).digest()
640
+ if not hmac.compare_digest(expected_signature, signature):
641
+ raise InvalidSignatureError("Signature verification failed")
642
+ return
643
+
644
+ if algorithm == "RS256":
645
+ if self._public_key is None:
646
+ raise ValueError("RSAPublicKey must be provided for RS256 verification")
647
+ try:
648
+ self._public_key.verify(
649
+ signature,
650
+ signing_input,
651
+ padding.PKCS1v15(),
652
+ hashes.SHA256(),
653
+ )
654
+ except InvalidSignature as exc:
655
+ raise InvalidSignatureError("Signature verification failed") from exc
656
+ return
657
+
658
+ raise InvalidAlgorithmError(f"Unsupported algorithm '{algorithm}'")