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,259 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import weakref
5
+ from collections.abc import Callable, Iterator
6
+ from typing import Any
7
+
8
+ from swiftpy.auth.api_key_guard import ApiKeyGuard
9
+ from swiftpy.auth.jwt_guard import JWTGuard
10
+ from swiftpy.auth.jwt_issuer import JWTIssuer
11
+ from swiftpy.auth.token_blacklist import TokenBlacklist
12
+ from swiftpy.core.config import Config
13
+ from swiftpy.core.container import Container, get_container
14
+ from swiftpy.core.context import Context
15
+ from swiftpy.database.pool import DatabasePool
16
+ from swiftpy.http.request import Request
17
+
18
+
19
+ class CurrentUser(dict[str, Any]):
20
+ """
21
+ Task-scoped authenticated user context resolvable through the DI container.
22
+
23
+ Resolves lazily from `Context` at access time when constructed without explicit
24
+ payload data, stays synchronized with context updates so C-level readers like
25
+ `dict(user)` and `json.dumps(user)` see live data, and is never cached in
26
+ the container scope while unauthenticated.
27
+ """
28
+
29
+ def __init__(self, data: dict[str, Any] | None = None) -> None:
30
+ self._lazy = data is None
31
+ self._last_synced: dict[str, Any] = {}
32
+ if data is None:
33
+ ctx_user = Context.get("__swiftpy_current_user__")
34
+ initial = dict(ctx_user) if isinstance(ctx_user, dict) else {}
35
+ super().__init__(initial)
36
+ self._last_synced = dict(initial)
37
+ Context.watch(
38
+ "__swiftpy_current_user__",
39
+ weakref.WeakMethod(self._apply_context_payload),
40
+ )
41
+ else:
42
+ super().__init__(data)
43
+
44
+ def _apply_context_payload(self, payload: Any) -> None:
45
+ if not self._lazy:
46
+ return
47
+ target = dict(payload) if isinstance(payload, dict) else {}
48
+ if target != self._last_synced or dict.keys(self) != target.keys():
49
+ super().clear()
50
+ super().update(target)
51
+ self._last_synced = target
52
+
53
+ def _sync(self) -> None:
54
+ if not self._lazy:
55
+ return
56
+ ctx_user = Context.get("__swiftpy_current_user__")
57
+ target = ctx_user if isinstance(ctx_user, dict) else {}
58
+ if target != self._last_synced:
59
+ self._apply_context_payload(target)
60
+
61
+ @property
62
+ def _cache_in_scope(self) -> bool:
63
+ self._sync()
64
+ return super().__len__() > 0
65
+
66
+ def __getitem__(self, key: str) -> Any:
67
+ self._sync()
68
+ return super().__getitem__(key)
69
+
70
+ def get(self, key: str, default: Any = None) -> Any:
71
+ self._sync()
72
+ return super().get(key, default)
73
+
74
+ def __contains__(self, key: object) -> bool:
75
+ self._sync()
76
+ return super().__contains__(key)
77
+
78
+ def __len__(self) -> int:
79
+ self._sync()
80
+ return super().__len__()
81
+
82
+ def __bool__(self) -> bool:
83
+ self._sync()
84
+ return super().__len__() > 0
85
+
86
+ def __iter__(self) -> Iterator[str]:
87
+ self._sync()
88
+ return super().__iter__()
89
+
90
+ def keys(self) -> Any:
91
+ self._sync()
92
+ return super().keys()
93
+
94
+ def values(self) -> Any:
95
+ self._sync()
96
+ return super().values()
97
+
98
+ def items(self) -> Any:
99
+ self._sync()
100
+ return super().items()
101
+
102
+ def copy(self) -> dict[str, Any]:
103
+ self._sync()
104
+ return super().copy()
105
+
106
+ def to_dict(self) -> dict[str, Any]:
107
+ self._sync()
108
+ return super().copy()
109
+
110
+ def __repr__(self) -> str:
111
+ self._sync()
112
+ return super().__repr__()
113
+
114
+ def __eq__(self, other: object) -> bool:
115
+ self._sync()
116
+ return super().__eq__(other)
117
+
118
+ def __ne__(self, other: object) -> bool:
119
+ self._sync()
120
+ return super().__ne__(other)
121
+
122
+ @property
123
+ def id(self) -> Any:
124
+ self._sync()
125
+ return super().get("id") or super().get("sub")
126
+
127
+ @property
128
+ def email(self) -> Any:
129
+ self._sync()
130
+ return super().get("email")
131
+
132
+ @property
133
+ def is_authenticated(self) -> bool:
134
+ self._sync()
135
+ return super().__len__() > 0
136
+
137
+
138
+ def current_user() -> Any:
139
+ """
140
+ Retrieve the authenticated user payload/record for the current request task.
141
+ Returns None if unauthenticated.
142
+ """
143
+ return Context.get("__swiftpy_current_user__")
144
+
145
+
146
+ class AuthManager:
147
+ """
148
+ Central authentication coordinator.
149
+
150
+ Resolves and caches configured guards ('jwt', 'api_key', or custom drivers),
151
+ and exposes the authenticated current_user() through the DI container
152
+ with strict task-scoped isolation.
153
+ """
154
+
155
+ def __init__(self, container: Container | None = None) -> None:
156
+ self._container = container
157
+ self._guards: dict[str, Any] = {}
158
+ self._custom_creators: dict[str, Callable[[Container], Any]] = {
159
+ "jwt": self._create_default_jwt_guard,
160
+ "api_key": self._create_default_api_key_guard,
161
+ }
162
+
163
+ @staticmethod
164
+ def _create_default_jwt_guard(c: Container) -> JWTGuard:
165
+ issuer = c.resolve(JWTIssuer) if c.has(JWTIssuer) else None
166
+ blacklist = c.resolve(TokenBlacklist) if c.has(TokenBlacklist) else None
167
+ return JWTGuard(issuer=issuer, blacklist=blacklist)
168
+
169
+ @staticmethod
170
+ def _create_default_api_key_guard(c: Container) -> ApiKeyGuard:
171
+ cfg = c.resolve(Config) if c.has(Config) else None
172
+ pool = c.resolve(DatabasePool) if c.has(DatabasePool) else None
173
+ return ApiKeyGuard.from_config(config=cfg, pool=pool)
174
+
175
+ @property
176
+ def container(self) -> Container:
177
+ if self._container is None:
178
+ self._container = get_container()
179
+ return self._container
180
+
181
+ def default_guard_name(self) -> str:
182
+ """Retrieve the name of the default guard from configuration."""
183
+ try:
184
+ if self.container.has(Config):
185
+ cfg = self.container.resolve(Config)
186
+ guard_name = cfg.get("auth.defaults.guard") or cfg.get("auth.default")
187
+ if guard_name and isinstance(guard_name, str):
188
+ return guard_name
189
+ except RuntimeError:
190
+ pass
191
+
192
+ return "jwt"
193
+
194
+ def guard(self, name: str | None = None) -> Any:
195
+ """
196
+ Resolve a guard driver instance by name (e.g. 'jwt', 'api_key').
197
+ If name is None, resolves the default configured guard.
198
+ """
199
+ guard_name = name or self.default_guard_name()
200
+
201
+ if guard_name in self._guards:
202
+ return self._guards[guard_name]
203
+
204
+ guard_instance = self._resolve(guard_name)
205
+ self._guards[guard_name] = guard_instance
206
+ return guard_instance
207
+
208
+ def extend(self, name: str, creator: Callable[[Container], Any]) -> None:
209
+ """Register or override a guard creator factory and invalidate any cached guard."""
210
+ self._custom_creators[name] = creator
211
+ self._guards.pop(name, None)
212
+
213
+ def _resolve(self, name: str) -> Any:
214
+ if name in self._custom_creators:
215
+ return self._custom_creators[name](self.container)
216
+
217
+ raise ValueError(f"Auth guard [{name}] is not defined.")
218
+
219
+ async def authenticate(
220
+ self,
221
+ request: Request | None = None,
222
+ guard_name: str | None = None,
223
+ ) -> Any:
224
+ """
225
+ Authenticate the request by dispatching to the guard instance registered
226
+ for `guard_name` (or the default configured guard).
227
+ """
228
+ resolved_name = guard_name or self.default_guard_name()
229
+ g = self.guard(resolved_name)
230
+
231
+ if hasattr(g, "authenticate") and callable(g.authenticate):
232
+ res = g.authenticate(request)
233
+ if inspect.isawaitable(res):
234
+ return await res
235
+ return res
236
+
237
+ if callable(g):
238
+ res = g(request)
239
+ if inspect.isawaitable(res):
240
+ return await res
241
+ return res
242
+
243
+ raise TypeError(f"Guard [{resolved_name}] does not support authenticate()")
244
+
245
+ def current_user(self) -> Any:
246
+ """Return the current user from task-scoped context."""
247
+ return current_user()
248
+
249
+ def register_container_bindings(self, container: Container | None = None) -> None:
250
+ """
251
+ Register AuthManager as a singleton and CurrentUser as a task-scoped binding
252
+ in the dependency injection container.
253
+ """
254
+ c = container or self.container
255
+ c.instance(AuthManager, self)
256
+ c.scoped(
257
+ CurrentUser,
258
+ lambda _: CurrentUser(),
259
+ )
@@ -0,0 +1,71 @@
1
+ from swiftpy.auth.jwt_issuer import (
2
+ InvalidAlgorithmError,
3
+ InvalidSignatureError,
4
+ InvalidTokenError,
5
+ JWTError,
6
+ TokenExpiredError,
7
+ )
8
+ from swiftpy.http.exceptions import HttpException
9
+
10
+
11
+ class AuthenticationError(HttpException):
12
+ """Base exception for authentication failures resulting in HTTP 401."""
13
+
14
+ def __init__(
15
+ self,
16
+ message: str = "Unauthorized",
17
+ status_code: int = 401,
18
+ headers: dict[str, str] | None = None,
19
+ ) -> None:
20
+ super().__init__(
21
+ message=message,
22
+ status_code=status_code,
23
+ headers=headers or {"WWW-Authenticate": "Bearer"},
24
+ )
25
+
26
+
27
+ class TokenRevokedError(AuthenticationError):
28
+ """Raised when a valid token has been revoked in the blacklist."""
29
+
30
+ def __init__(self, message: str = "Invalid or expired token") -> None:
31
+ super().__init__(message=message, status_code=401)
32
+
33
+
34
+ class InvalidApiKeyError(AuthenticationError):
35
+ """Raised when an API key is invalid, inactive, or expired."""
36
+
37
+ def __init__(self, message: str = "Invalid API key") -> None:
38
+ super().__init__(
39
+ message=message,
40
+ status_code=401,
41
+ headers={"WWW-Authenticate": "ApiKey"},
42
+ )
43
+
44
+
45
+ class AuthenticationServiceUnavailableError(HttpException):
46
+ """Raised when authentication infrastructure (e.g. Redis, Database) is unavailable, resulting in HTTP 503."""
47
+
48
+ def __init__(
49
+ self,
50
+ message: str = "Authentication service unavailable",
51
+ status_code: int = 503,
52
+ headers: dict[str, str] | None = None,
53
+ ) -> None:
54
+ super().__init__(
55
+ message=message,
56
+ status_code=status_code,
57
+ headers=headers or {"Retry-After": "30"},
58
+ )
59
+
60
+
61
+ __all__ = [
62
+ "AuthenticationError",
63
+ "AuthenticationServiceUnavailableError",
64
+ "InvalidAlgorithmError",
65
+ "InvalidApiKeyError",
66
+ "InvalidSignatureError",
67
+ "InvalidTokenError",
68
+ "JWTError",
69
+ "TokenExpiredError",
70
+ "TokenRevokedError",
71
+ ]
@@ -0,0 +1,230 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from swiftpy.auth.exceptions import (
7
+ AuthenticationError,
8
+ AuthenticationServiceUnavailableError,
9
+ InvalidAlgorithmError,
10
+ InvalidSignatureError,
11
+ InvalidTokenError,
12
+ JWTError,
13
+ TokenExpiredError,
14
+ TokenRevokedError,
15
+ )
16
+ from swiftpy.auth.jwt_issuer import JWTIssuer
17
+ from swiftpy.auth.token_blacklist import TokenBlacklist, is_token_blacklist_enabled
18
+ from swiftpy.core.config import Config
19
+ from swiftpy.core.container import get_container
20
+ from swiftpy.core.context import Context
21
+ from swiftpy.http.middleware import Handler, Middleware
22
+ from swiftpy.http.request import Request
23
+ from swiftpy.http.response import Response, json_response, unauthorized
24
+
25
+ logger = logging.getLogger("swiftpy.auth.jwt")
26
+
27
+
28
+ async def require_jwt(
29
+ request: Request | None = None,
30
+ *,
31
+ issuer: JWTIssuer | None = None,
32
+ blacklist: TokenBlacklist | None = None,
33
+ token_type: str = "access",
34
+ ) -> dict[str, Any]:
35
+ """
36
+ Parse and validate a JWT from the HTTP Authorization header or WebSocket credentials.
37
+
38
+ Verifies:
39
+ 1. Presence of Authorization: Bearer <token>, or for WebSockets, Sec-WebSocket-Protocol
40
+ or URL query parameter ('token' / 'access_token').
41
+ (Note: query parameter tokens carry log-leak risks as query strings are frequently
42
+ recorded in server access logs and proxy logs.)
43
+ 2. Valid cryptographic signature (RS256 or HS256)
44
+ 3. Token expiration (exp claim)
45
+ 4. Algorithm authenticity (rejects 'none' or unauthorized algs)
46
+ 5. Expected token type (default 'access')
47
+ 6. Token revocation state in Redis blacklist (jti claim)
48
+
49
+ Attaches the authenticated payload to request.state and task Context.
50
+ """
51
+ # 1. Resolve request from argument or task Context
52
+ req = request or Context.get("request")
53
+ if req is None or not isinstance(req, Request):
54
+ raise RuntimeError(
55
+ "No active HTTP Request found. Pass 'request' explicitly or call within request pipeline."
56
+ )
57
+
58
+ # 2. Extract token from Authorization header or WebSocket handshake
59
+ token: str | None = None
60
+ raw_auth_headers = [
61
+ v.decode("latin1")
62
+ for k, v in req.scope.get("headers", [])
63
+ if k.lower() == b"authorization"
64
+ ]
65
+ if len(raw_auth_headers) > 1:
66
+ raise AuthenticationError("Multiple Authorization headers provided")
67
+ if raw_auth_headers:
68
+ auth_header = raw_auth_headers[0]
69
+ parts = auth_header.strip().split(" ", 1)
70
+ if len(parts) != 2 or parts[0].lower() != "bearer":
71
+ raise AuthenticationError(
72
+ "Invalid Authorization header format. Expected 'Bearer <token>'"
73
+ )
74
+ token = parts[1].strip()
75
+ if not token:
76
+ raise AuthenticationError("Bearer token is empty")
77
+ elif req.scope.get("type") == "websocket":
78
+ # Check Sec-WebSocket-Protocol (subprotocols)
79
+ subprotocols = req.scope.get("subprotocols", [])
80
+ if not subprotocols and "sec-websocket-protocol" in req.headers:
81
+ subprotocols = [p.strip() for p in req.headers["sec-websocket-protocol"].split(",") if p.strip()]
82
+
83
+ for i, proto in enumerate(subprotocols):
84
+ proto_clean = proto.strip()
85
+ if proto_clean.lower() in ("bearer", "access_token") and i + 1 < len(subprotocols):
86
+ token = subprotocols[i + 1].strip()
87
+ break
88
+ if proto_clean.lower().startswith("bearer."):
89
+ token = proto_clean[7:].strip()
90
+ break
91
+ if proto_clean.count(".") == 2:
92
+ token = proto_clean
93
+ break
94
+
95
+ # Check query parameters as fallback (with log-leak warning)
96
+ if not token:
97
+ token = req.query_params.get("token") or req.query_params.get("access_token")
98
+ if token:
99
+ logger.debug(
100
+ "WebSocket authenticated via query parameter token. Note: Query parameter tokens "
101
+ "carry log-leak risks as query strings are frequently logged in server and proxy logs."
102
+ )
103
+
104
+ if not token:
105
+ raise AuthenticationError("Missing Authorization header or WebSocket authentication credentials")
106
+ else:
107
+ raise AuthenticationError("Missing Authorization header")
108
+
109
+ # 3. Resolve JWTIssuer from parameters, container, or default
110
+ active_issuer = issuer
111
+ if active_issuer is None:
112
+ try:
113
+ container = get_container()
114
+ if container.has(JWTIssuer):
115
+ active_issuer = container.resolve(JWTIssuer)
116
+ elif container.has(Config):
117
+ active_issuer = JWTIssuer.from_config(container.resolve(Config))
118
+ except RuntimeError:
119
+ pass
120
+
121
+ if active_issuer is None:
122
+ active_issuer = JWTIssuer.from_config()
123
+
124
+ # 4. Resolve TokenBlacklist from parameters or container unless disabled in config
125
+ active_blacklist = blacklist
126
+ if active_blacklist is None:
127
+ try:
128
+ container = get_container()
129
+ cfg = container.resolve(Config) if container.has(Config) else None
130
+ if is_token_blacklist_enabled(cfg) and container.has(TokenBlacklist):
131
+ active_blacklist = container.resolve(TokenBlacklist)
132
+ except RuntimeError:
133
+ pass
134
+
135
+ # 5. Decode and cryptographically verify token (P2-A4: log detail server-side, return generic 401 message)
136
+ try:
137
+ payload = active_issuer.decode_token(
138
+ token,
139
+ verify_exp=True,
140
+ expected_type=token_type,
141
+ )
142
+ except (
143
+ TokenExpiredError,
144
+ InvalidSignatureError,
145
+ InvalidAlgorithmError,
146
+ InvalidTokenError,
147
+ JWTError,
148
+ ) as exc:
149
+ logger.warning("JWT authentication failed: %s", exc)
150
+ raise AuthenticationError("Invalid or expired token") from exc
151
+
152
+ # 6. Check Redis revocation blacklist if jti or subject is present
153
+ jti = payload.get("jti")
154
+ if active_blacklist is not None:
155
+ sub = payload.get("sub")
156
+ raw_iat = payload.get("iat")
157
+ iat = int(raw_iat) if isinstance(raw_iat, (int, float)) else None
158
+ try:
159
+ try:
160
+ is_rev = await active_blacklist.is_revoked(
161
+ jti or "", subject=sub, issued_at=iat
162
+ )
163
+ except TypeError:
164
+ is_rev = bool(jti and await active_blacklist.is_revoked(jti))
165
+
166
+ if is_rev:
167
+ logger.warning(
168
+ "JWT authentication failed: Token has been revoked (jti=%s, sub=%s)",
169
+ jti,
170
+ sub,
171
+ )
172
+ raise TokenRevokedError("Invalid or expired token")
173
+ except TokenRevokedError:
174
+ raise
175
+ except Exception as exc:
176
+ # If Redis connection fails unexpectedly, log server-side and fail closed with generic 503 (P2-A4)
177
+ logger.exception(
178
+ "Authentication service unavailable: Redis blacklist check failed"
179
+ )
180
+ raise AuthenticationServiceUnavailableError(
181
+ "Authentication service unavailable"
182
+ ) from exc
183
+
184
+ # 7. Attach authenticated user payload to request state and task context
185
+ req.state["user"] = payload
186
+ req.state["auth_guard"] = "jwt"
187
+ Context.set("__swiftpy_current_user__", payload)
188
+
189
+ return payload
190
+
191
+
192
+ class JWTGuard(Middleware):
193
+ """
194
+ Middleware adapter for JWT authentication.
195
+ Applies require_jwt() and handles AuthenticationError (401) or
196
+ AuthenticationServiceUnavailableError (503).
197
+ """
198
+
199
+ def __init__(
200
+ self,
201
+ issuer: JWTIssuer | None = None,
202
+ blacklist: TokenBlacklist | None = None,
203
+ token_type: str = "access",
204
+ ) -> None:
205
+ self.issuer = issuer
206
+ self.blacklist = blacklist
207
+ self.token_type = token_type
208
+
209
+ async def authenticate(self, request: Request | None = None) -> dict[str, Any]:
210
+ """Authenticate the request using this guard's configured JWTIssuer and TokenBlacklist."""
211
+ return await require_jwt(
212
+ request,
213
+ issuer=self.issuer,
214
+ blacklist=self.blacklist,
215
+ token_type=self.token_type,
216
+ )
217
+
218
+ async def handle(self, request: Request, next_handler: Handler) -> Response:
219
+ try:
220
+ await self.authenticate(request)
221
+ except AuthenticationError as exc:
222
+ return unauthorized(exc.message, headers=exc.headers)
223
+ except AuthenticationServiceUnavailableError as exc:
224
+ return json_response(
225
+ {"detail": exc.message},
226
+ status_code=exc.status_code,
227
+ headers=exc.headers,
228
+ )
229
+
230
+ return await next_handler(request)