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.
- swiftpy/.gitignore +0 -0
- swiftpy/__init__.py +572 -0
- swiftpy/admin/__init__.py +50 -0
- swiftpy/admin/auth.py +522 -0
- swiftpy/admin/registry.py +567 -0
- swiftpy/admin/views.py +1583 -0
- swiftpy/ai/__init__.py +199 -0
- swiftpy/ai/_deterministic.py +40 -0
- swiftpy/ai/_fake.py +604 -0
- swiftpy/ai/agents/__init__.py +30 -0
- swiftpy/ai/agents/base_agent.py +506 -0
- swiftpy/ai/agents/memory.py +864 -0
- swiftpy/ai/client.py +1011 -0
- swiftpy/ai/conversation.py +171 -0
- swiftpy/ai/embeddings.py +321 -0
- swiftpy/ai/exceptions.py +75 -0
- swiftpy/ai/history_strategies.py +157 -0
- swiftpy/ai/middleware.py +918 -0
- swiftpy/ai/observability/__init__.py +28 -0
- swiftpy/ai/observability/drivers.py +763 -0
- swiftpy/ai/observability/observer.py +364 -0
- swiftpy/ai/pool.py +102 -0
- swiftpy/ai/pricing.py +101 -0
- swiftpy/ai/prompts/__init__.py +6 -0
- swiftpy/ai/prompts/loader.py +343 -0
- swiftpy/ai/providers/__init__.py +22 -0
- swiftpy/ai/providers/anthropic_provider.py +353 -0
- swiftpy/ai/providers/base.py +273 -0
- swiftpy/ai/providers/openai_provider.py +275 -0
- swiftpy/ai/rag/__init__.py +53 -0
- swiftpy/ai/rag/context_formatter.py +263 -0
- swiftpy/ai/rag/pipeline_builder.py +544 -0
- swiftpy/ai/rag/reranker.py +484 -0
- swiftpy/ai/rag/result.py +78 -0
- swiftpy/ai/rag/retriever.py +315 -0
- swiftpy/ai/redis_store.py +227 -0
- swiftpy/ai/streaming.py +115 -0
- swiftpy/ai/tools/__init__.py +22 -0
- swiftpy/ai/tools/base_tool.py +489 -0
- swiftpy/ai/tools/first_party.py +384 -0
- swiftpy/ai/tools/tool_loop.py +417 -0
- swiftpy/ai/vectorstore/__init__.py +46 -0
- swiftpy/ai/vectorstore/base.py +542 -0
- swiftpy/ai/vectorstore/chroma_adapter.py +435 -0
- swiftpy/ai/vectorstore/in_memory_adapter.py +321 -0
- swiftpy/ai/vectorstore/lancedb_adapter.py +370 -0
- swiftpy/ai/vectorstore/pgvector_adapter.py +496 -0
- swiftpy/ai/vectorstore/pinecone_adapter.py +445 -0
- swiftpy/ai/vectorstore/qdrant_adapter.py +418 -0
- swiftpy/auth/__init__.py +50 -0
- swiftpy/auth/api_key_guard.py +544 -0
- swiftpy/auth/auth_manager.py +259 -0
- swiftpy/auth/exceptions.py +71 -0
- swiftpy/auth/jwt_guard.py +230 -0
- swiftpy/auth/jwt_issuer.py +658 -0
- swiftpy/auth/token_blacklist.py +300 -0
- swiftpy/cache/__init__.py +14 -0
- swiftpy/cache/cache.py +251 -0
- swiftpy/cache/driver.py +216 -0
- swiftpy/cache/drivers/memory.py +199 -0
- swiftpy/cache/drivers/redis.py +421 -0
- swiftpy/console/__init__.py +0 -0
- swiftpy/console/cli.py +68 -0
- swiftpy/console/commands/__init__.py +0 -0
- swiftpy/console/commands/cache.py +164 -0
- swiftpy/console/commands/make.py +935 -0
- swiftpy/console/commands/migrate.py +280 -0
- swiftpy/console/commands/new.py +1806 -0
- swiftpy/console/commands/queue.py +306 -0
- swiftpy/console/commands/seed.py +252 -0
- swiftpy/console/commands/start.py +171 -0
- swiftpy/console/commands/storage.py +104 -0
- swiftpy/console/loader.py +21 -0
- swiftpy/console/stubs/__init__.py +26 -0
- swiftpy/console/stubs/controller.stub +14 -0
- swiftpy/console/stubs/event.stub +14 -0
- swiftpy/console/stubs/job.stub +22 -0
- swiftpy/console/stubs/listener.stub +14 -0
- swiftpy/console/stubs/mail.stub +20 -0
- swiftpy/console/stubs/middleware.stub +16 -0
- swiftpy/console/stubs/migration.alter.stub +17 -0
- swiftpy/console/stubs/migration.api_keys.stub +22 -0
- swiftpy/console/stubs/migration.blank.stub +11 -0
- swiftpy/console/stubs/migration.create.stub +15 -0
- swiftpy/console/stubs/migration.stub +11 -0
- swiftpy/console/stubs/migration.update.stub +17 -0
- swiftpy/console/stubs/model.stub +17 -0
- swiftpy/console/stubs/provider.stub +18 -0
- swiftpy/console/stubs/repository.stub +10 -0
- swiftpy/console/stubs/request.stub +16 -0
- swiftpy/console/stubs/resource.stub +21 -0
- swiftpy/console/stubs/schema.stub +12 -0
- swiftpy/console/stubs/seeder.stub +14 -0
- swiftpy/console/stubs/service.stub +10 -0
- swiftpy/core/__init__.py +0 -0
- swiftpy/core/application.py +1306 -0
- swiftpy/core/bootstrap.py +30 -0
- swiftpy/core/config.py +432 -0
- swiftpy/core/container.py +596 -0
- swiftpy/core/container_exceptions.py +18 -0
- swiftpy/core/context.py +148 -0
- swiftpy/core/exceptions.py +352 -0
- swiftpy/core/providers/base.py +35 -0
- swiftpy/database/__init__.py +41 -0
- swiftpy/database/exceptions.py +42 -0
- swiftpy/database/identifiers.py +105 -0
- swiftpy/database/migrations/__init__.py +36 -0
- swiftpy/database/migrations/blueprint.py +431 -0
- swiftpy/database/migrations/lock.py +104 -0
- swiftpy/database/migrations/migration.py +22 -0
- swiftpy/database/migrations/migrator.py +507 -0
- swiftpy/database/migrations/schema.py +435 -0
- swiftpy/database/orm/__init__.py +33 -0
- swiftpy/database/orm/fields.py +92 -0
- swiftpy/database/orm/model.py +2034 -0
- swiftpy/database/orm/registry.py +33 -0
- swiftpy/database/orm/relationships.py +836 -0
- swiftpy/database/pagination/__init__.py +16 -0
- swiftpy/database/pagination/contracts.py +60 -0
- swiftpy/database/pagination/paginator.py +112 -0
- swiftpy/database/pagination/simple_paginator.py +97 -0
- swiftpy/database/pool.py +218 -0
- swiftpy/database/query_builder.py +1803 -0
- swiftpy/database/seeder.py +39 -0
- swiftpy/database/transaction.py +276 -0
- swiftpy/events/__init__.py +10 -0
- swiftpy/events/dispatcher.py +308 -0
- swiftpy/events/redis_bus.py +552 -0
- swiftpy/filesystem/__init__.py +227 -0
- swiftpy/filesystem/disk.py +378 -0
- swiftpy/filesystem/drivers/__init__.py +11 -0
- swiftpy/filesystem/drivers/base.py +156 -0
- swiftpy/filesystem/drivers/local.py +559 -0
- swiftpy/filesystem/drivers/s3.py +655 -0
- swiftpy/filesystem/events.py +37 -0
- swiftpy/filesystem/exceptions.py +127 -0
- swiftpy/filesystem/manager.py +138 -0
- swiftpy/filesystem/metadata.py +22 -0
- swiftpy/filesystem/paths.py +99 -0
- swiftpy/filesystem/service_provider.py +17 -0
- swiftpy/filesystem/uploads/__init__.py +13 -0
- swiftpy/filesystem/uploads/handlers.py +362 -0
- swiftpy/filesystem/uploads/uploaded_file.py +222 -0
- swiftpy/filesystem/uploads/validators.py +186 -0
- swiftpy/filesystem/visibility.py +22 -0
- swiftpy/helpers/__init__.py +42 -0
- swiftpy/helpers/helper.py +180 -0
- swiftpy/http/exceptions.py +41 -0
- swiftpy/http/middleware.py +307 -0
- swiftpy/http/request.py +375 -0
- swiftpy/http/response.py +442 -0
- swiftpy/http/router.py +1050 -0
- swiftpy/mail/__init__.py +58 -0
- swiftpy/mail/attachments.py +255 -0
- swiftpy/mail/config.py +71 -0
- swiftpy/mail/envelope.py +183 -0
- swiftpy/mail/exceptions.py +90 -0
- swiftpy/mail/facade.py +312 -0
- swiftpy/mail/mailable.py +53 -0
- swiftpy/mail/manager.py +240 -0
- swiftpy/mail/message.py +186 -0
- swiftpy/mail/models.py +19 -0
- swiftpy/mail/queue/__init__.py +5 -0
- swiftpy/mail/queue/job.py +279 -0
- swiftpy/mail/service_provider.py +19 -0
- swiftpy/mail/templates/__init__.py +5 -0
- swiftpy/mail/templates/renderer.py +173 -0
- swiftpy/mail/transports/__init__.py +21 -0
- swiftpy/mail/transports/api/__init__.py +13 -0
- swiftpy/mail/transports/api/base.py +194 -0
- swiftpy/mail/transports/api/mailgun.py +158 -0
- swiftpy/mail/transports/api/resend.py +156 -0
- swiftpy/mail/transports/api/sendgrid.py +172 -0
- swiftpy/mail/transports/base.py +24 -0
- swiftpy/mail/transports/console.py +91 -0
- swiftpy/mail/transports/sendmail.py +128 -0
- swiftpy/mail/transports/smtp.py +466 -0
- swiftpy/queue/__init__.py +13 -0
- swiftpy/queue/dispatcher.py +813 -0
- swiftpy/queue/job.py +541 -0
- swiftpy/queue/worker.py +556 -0
- swiftpy/support/__init__.py +12 -0
- swiftpy/support/keys.py +108 -0
- swiftpy/support/redis_config.py +223 -0
- swiftpy/support/strings.py +114 -0
- swiftpy/testing/__init__.py +20 -0
- swiftpy/testing/fakes/__init__.py +11 -0
- swiftpy/testing/fakes/ai_fake.py +21 -0
- swiftpy/testing/fakes/vectorstore_fake.py +194 -0
- swiftpy/testing/fixtures.py +124 -0
- swiftpy/testing/http_client.py +730 -0
- swiftpy/testing/test_case.py +126 -0
- swiftpy/validation/__init__.py +97 -0
- swiftpy/validation/exceptions.py +26 -0
- swiftpy/validation/form_request.py +302 -0
- swiftpy/validation/protocols.py +33 -0
- swiftpy/validation/rules/__init__.py +89 -0
- swiftpy/validation/rules/builtin.py +391 -0
- swiftpy/validation/rules/exists.py +86 -0
- swiftpy/validation/rules/registry.py +290 -0
- swiftpy/validation/rules/required.py +55 -0
- swiftpy/validation/rules/unique.py +138 -0
- swiftpy/validation/validator.py +612 -0
- swiftpy/websockets/__init__.py +19 -0
- swiftpy/websockets/connection.py +317 -0
- swiftpy/websockets/manager.py +271 -0
- swiftpy_framework-0.1.0.dist-info/METADATA +603 -0
- swiftpy_framework-0.1.0.dist-info/RECORD +211 -0
- swiftpy_framework-0.1.0.dist-info/WHEEL +4 -0
- swiftpy_framework-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|