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,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}'")
|