mic-struct 0.0.1__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.
- mic/__init__.py +9 -0
- mic/_lazy.py +80 -0
- mic/auth/__init__.py +43 -0
- mic/auth/revoke_blacklist.py +247 -0
- mic/auth/service_auth.py +317 -0
- mic/authz/__init__.py +60 -0
- mic/authz/base.py +44 -0
- mic/authz/errors.py +27 -0
- mic/authz/in_memory.py +91 -0
- mic/authz/opa.py +131 -0
- mic/authz/openfga.py +167 -0
- mic/cache/__init__.py +31 -0
- mic/cache/backends.py +185 -0
- mic/cache/cluster/__init__.py +35 -0
- mic/cache/cluster/consistent_hash.py +116 -0
- mic/cache/cluster/redis_cluster.py +191 -0
- mic/cli/__init__.py +24 -0
- mic/cli/builder.py +176 -0
- mic/cli/spec.py +68 -0
- mic/client/__init__.py +26 -0
- mic/client/async_service_client.py +210 -0
- mic/client/service_client.py +229 -0
- mic/contracts/__init__.py +15 -0
- mic/contracts/envelope.py +45 -0
- mic/contracts/strict_schema.py +48 -0
- mic/datastorage/__init__.py +98 -0
- mic/datastorage/base.py +316 -0
- mic/datastorage/document/__init__.py +14 -0
- mic/datastorage/document/engine.py +166 -0
- mic/datastorage/document/in_memory.py +104 -0
- mic/datastorage/graph/__init__.py +12 -0
- mic/datastorage/graph/engine.py +131 -0
- mic/datastorage/keyvalue/__init__.py +11 -0
- mic/datastorage/keyvalue/in_memory.py +65 -0
- mic/datastorage/sql/__init__.py +10 -0
- mic/datastorage/sql/engine.py +152 -0
- mic/eventbus/__init__.py +70 -0
- mic/eventbus/base.py +54 -0
- mic/eventbus/in_memory.py +81 -0
- mic/eventbus/redis_streams.py +168 -0
- mic/fastapi/__init__.py +79 -0
- mic/fastapi/_resolve.py +86 -0
- mic/fastapi/middlewares.py +673 -0
- mic/fastapi/router.py +186 -0
- mic/grpc/__init__.py +75 -0
- mic/grpc/client.py +175 -0
- mic/grpc/errors.py +110 -0
- mic/grpc/interceptors.py +183 -0
- mic/grpc/rpc_spec.py +125 -0
- mic/grpc/server.py +56 -0
- mic/grpc/spec.py +42 -0
- mic/healthcheck/__init__.py +40 -0
- mic/healthcheck/readiness.py +239 -0
- mic/http/__init__.py +40 -0
- mic/http/api_key.py +120 -0
- mic/http/auth_gate.py +335 -0
- mic/http/basic_auth.py +114 -0
- mic/http/bearer.py +67 -0
- mic/http/correlation.py +169 -0
- mic/http/csrf.py +173 -0
- mic/http/request_context.py +55 -0
- mic/http/route_spec.py +140 -0
- mic/idempotency/__init__.py +77 -0
- mic/idempotency/store.py +716 -0
- mic/litestar/__init__.py +64 -0
- mic/litestar/_resolve.py +86 -0
- mic/litestar/middlewares.py +503 -0
- mic/litestar/router.py +241 -0
- mic/locking/__init__.py +68 -0
- mic/locking/base.py +51 -0
- mic/locking/in_memory.py +64 -0
- mic/locking/lock_handle.py +26 -0
- mic/locking/redis_backend.py +116 -0
- mic/locking/scope.py +58 -0
- mic/model/__init__.py +21 -0
- mic/model/errors.py +130 -0
- mic/observability/__init__.py +208 -0
- mic/observability/caller.py +102 -0
- mic/observability/circuit_breaker_metrics.py +111 -0
- mic/observability/log_context_middleware.py +140 -0
- mic/observability/logging.py +442 -0
- mic/observability/metrics.py +105 -0
- mic/observability/metrics_backend.py +656 -0
- mic/observability/metrics_guard.py +185 -0
- mic/observability/redact_logs.py +101 -0
- mic/observability/redact_traces.py +115 -0
- mic/observability/sentry.py +98 -0
- mic/observability/tracing.py +207 -0
- mic/observability/tracing_middleware.py +142 -0
- mic/outbox/__init__.py +111 -0
- mic/outbox/base.py +161 -0
- mic/outbox/dispatcher.py +345 -0
- mic/outbox/event.py +117 -0
- mic/outbox/in_memory.py +225 -0
- mic/outbox/sanitize.py +130 -0
- mic/outbox/sql.py +412 -0
- mic/py.typed +0 -0
- mic/queue/__init__.py +71 -0
- mic/queue/base.py +99 -0
- mic/queue/in_memory.py +87 -0
- mic/queue/kafka.py +120 -0
- mic/queue/nats.py +161 -0
- mic/ratelimit/__init__.py +89 -0
- mic/ratelimit/base.py +64 -0
- mic/ratelimit/in_memory.py +97 -0
- mic/ratelimit/middleware.py +138 -0
- mic/ratelimit/redis_backend.py +240 -0
- mic/ratelimit/spec.py +385 -0
- mic/read_models/__init__.py +64 -0
- mic/read_models/counter.py +78 -0
- mic/read_models/leaderboard.py +92 -0
- mic/read_models/membership_set.py +86 -0
- mic/read_models/timeline.py +97 -0
- mic/read_models/unique_count.py +73 -0
- mic/realtime/__init__.py +134 -0
- mic/realtime/auth.py +103 -0
- mic/realtime/backend.py +129 -0
- mic/realtime/endpoint.py +140 -0
- mic/realtime/redis_streams.py +141 -0
- mic/realtime/room.py +64 -0
- mic/resilience/__init__.py +44 -0
- mic/resilience/circuit_breaker.py +301 -0
- mic/response_cache/__init__.py +100 -0
- mic/response_cache/_stampede.py +90 -0
- mic/response_cache/_storage.py +109 -0
- mic/response_cache/middleware.py +289 -0
- mic/response_cache/rule.py +106 -0
- mic/shard/__init__.py +63 -0
- mic/shard/selector.py +145 -0
- mic/shard/session_factory.py +99 -0
- mic_struct-0.0.1.dist-info/METADATA +435 -0
- mic_struct-0.0.1.dist-info/RECORD +134 -0
- mic_struct-0.0.1.dist-info/WHEEL +4 -0
- mic_struct-0.0.1.dist-info/licenses/LICENSE +21 -0
mic/__init__.py
ADDED
mic/_lazy.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Lazy package re-exports (PEP 562) — keeps ``import mic.X`` cheap and
|
|
2
|
+
import-safe even when the optional extra for X isn't installed.
|
|
3
|
+
|
|
4
|
+
Problem
|
|
5
|
+
-------
|
|
6
|
+
A package ``__init__.py`` that eagerly does
|
|
7
|
+
``from mic.X.submodule import Symbol`` forces ``import mic.X`` to import
|
|
8
|
+
``submodule`` at package-load time. If that submodule imports an optional
|
|
9
|
+
dependency at module top-level (``import prometheus_client``,
|
|
10
|
+
``import httpx``, ``import grpc``, …), then ``import mic.X`` **crashes**
|
|
11
|
+
for anyone who ran ``pip install mic-struct`` without the ``[X]`` extra —
|
|
12
|
+
even if they only wanted a stdlib-only symbol from another submodule.
|
|
13
|
+
|
|
14
|
+
Fix
|
|
15
|
+
---
|
|
16
|
+
Defer the submodule import to first attribute access via PEP 562
|
|
17
|
+
``__getattr__``. ``import mic.X`` only runs ``__init__.py`` itself; the
|
|
18
|
+
heavy submodule loads when (and only when) a symbol from it is used.
|
|
19
|
+
If the optional dependency is missing at that point, the ``ImportError``
|
|
20
|
+
is re-raised with a clear ``pip install 'mic-struct[X]'`` hint.
|
|
21
|
+
|
|
22
|
+
This keeps the established "Philosophy B" of the codebase consistent:
|
|
23
|
+
*importing a package never fails for a missing optional dependency —
|
|
24
|
+
only actually using the dependency-backed symbol does.*
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import importlib
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
#: Une entrée d'export lazy : ``(chemin du sous-module, nom de l'extra | None)``.
|
|
34
|
+
#: ``extra=None`` = sous-module sans dépendance optionnelle (stdlib only) —
|
|
35
|
+
#: le lazy reste utile pour ne pas charger inutilement, mais pas de
|
|
36
|
+
#: message "install extra" à émettre.
|
|
37
|
+
LazyExport = tuple[str, str | None]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def make_lazy_getattr(
|
|
41
|
+
*,
|
|
42
|
+
package: str,
|
|
43
|
+
exports: dict[str, LazyExport],
|
|
44
|
+
fallback: Callable[[str], Any] | None = None,
|
|
45
|
+
) -> Callable[[str], Any]:
|
|
46
|
+
"""Construit un ``__getattr__`` PEP 562 pour un ``__init__.py`` de package.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
package: le ``__name__`` du package (pour les messages d'erreur).
|
|
50
|
+
exports: ``{symbole: (sous-module, extra | None)}``. Le sous-module
|
|
51
|
+
est importé à la demande au premier accès du symbole.
|
|
52
|
+
fallback: handler optionnel essayé quand le nom n'est **pas** dans
|
|
53
|
+
``exports`` (ex: alias deprecated). Doit lever ``AttributeError``
|
|
54
|
+
s'il ne sait pas résoudre — sinon la vraie ``AttributeError``
|
|
55
|
+
Python serait masquée.
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Une fonction ``__getattr__(name) -> Any`` à assigner au niveau
|
|
59
|
+
module dans le ``__init__.py`` du package.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
def __getattr__(name: str) -> Any:
|
|
63
|
+
entry = exports.get(name)
|
|
64
|
+
if entry is None:
|
|
65
|
+
if fallback is not None:
|
|
66
|
+
return fallback(name)
|
|
67
|
+
raise AttributeError(f"module {package!r} has no attribute {name!r}")
|
|
68
|
+
module_path, extra = entry
|
|
69
|
+
try:
|
|
70
|
+
module = importlib.import_module(module_path)
|
|
71
|
+
except ImportError as exc:
|
|
72
|
+
if extra is not None:
|
|
73
|
+
raise ImportError(
|
|
74
|
+
f"{package}.{name} requires the optional [{extra}] extra. "
|
|
75
|
+
f"Install it with: pip install 'mic-struct[{extra}]'"
|
|
76
|
+
) from exc
|
|
77
|
+
raise
|
|
78
|
+
return getattr(module, name)
|
|
79
|
+
|
|
80
|
+
return __getattr__
|
mic/auth/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Service-to-service auth — JWT HS256 audience-bound.
|
|
2
|
+
|
|
3
|
+
- Émetteur signe avec un secret partagé (HS256)
|
|
4
|
+
- Audience-bound (chaque token est destiné à UN service)
|
|
5
|
+
- Double-key support pour rotation zéro-downtime : le verifier accepte
|
|
6
|
+
N et N-1 pendant la fenêtre de transition.
|
|
7
|
+
|
|
8
|
+
Import lazy (PEP 562) : ``import mic.auth`` ne charge PAS ``pyjwt``.
|
|
9
|
+
Les symboles tirent ``service_auth`` (donc ``jwt``) au premier accès —
|
|
10
|
+
si l'extra ``[auth]`` n'est pas installé, l'erreur est claire. Cf.
|
|
11
|
+
:mod:`mic._lazy`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
from mic._lazy import LazyExport, make_lazy_getattr
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING: # pragma: no cover — résolution de types only, pas de coût runtime
|
|
21
|
+
from mic.auth.revoke_blacklist import RevokeBlacklist
|
|
22
|
+
from mic.auth.service_auth import (
|
|
23
|
+
ServiceAuthClaims,
|
|
24
|
+
ServiceAuthSigner,
|
|
25
|
+
ServiceAuthVerifier,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_EXPORTS: dict[str, LazyExport] = {
|
|
29
|
+
"ServiceAuthClaims": ("mic.auth.service_auth", "auth"),
|
|
30
|
+
"ServiceAuthSigner": ("mic.auth.service_auth", "auth"),
|
|
31
|
+
"ServiceAuthVerifier": ("mic.auth.service_auth", "auth"),
|
|
32
|
+
# RevokeBlacklist ne tire que mic.cache (core) — pas d'extra requis.
|
|
33
|
+
"RevokeBlacklist": ("mic.auth.revoke_blacklist", None),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
__getattr__ = make_lazy_getattr(package=__name__, exports=_EXPORTS)
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"RevokeBlacklist",
|
|
40
|
+
"ServiceAuthClaims",
|
|
41
|
+
"ServiceAuthSigner",
|
|
42
|
+
"ServiceAuthVerifier",
|
|
43
|
+
]
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""``RevokeBlacklist`` — révocation de JWT court-TTL via :class:`CacheBackend`.
|
|
2
|
+
|
|
3
|
+
Le pattern *JWT stateless* a une faiblesse intrinsèque : un token signé
|
|
4
|
+
reste valide jusqu'à ``exp``, on ne peut pas l'invalider unilatéralement.
|
|
5
|
+
Pour les use cases qui ont besoin de révocation immédiate (logout, vol
|
|
6
|
+
de token, "logout everywhere"), ce module expose deux mécanismes
|
|
7
|
+
complémentaires backed par n'importe quel :class:`CacheBackend` :
|
|
8
|
+
|
|
9
|
+
1. **Per-jti** — :meth:`revoke_jti` / :meth:`is_jti_revoked`. Ajoute un
|
|
10
|
+
``jti`` spécifique à une blacklist. TTL = lifetime restant du token
|
|
11
|
+
au moment de la révocation (auto-cleanup à ``exp``). Usage : logout
|
|
12
|
+
d'un device, rotation refresh token (l'ancien ``jti`` est révoqué).
|
|
13
|
+
|
|
14
|
+
2. **Per-subject (revoke-epoch)** — :meth:`revoke_all_for_subject` /
|
|
15
|
+
:meth:`is_subject_revoked_since`. Stocke un timestamp "tous les
|
|
16
|
+
tokens de ce sujet émis AVANT ce moment sont révoqués". Usage :
|
|
17
|
+
"logout me everywhere", changement de mot de passe, compromission
|
|
18
|
+
compte. TTL = max lifetime des tokens longue durée du sujet
|
|
19
|
+
(typiquement refresh TTL).
|
|
20
|
+
|
|
21
|
+
L'epoch-pattern bat la SET-per-user d'``jti`` actifs :
|
|
22
|
+
|
|
23
|
+
- O(1) clé Redis par sujet (vs N entrées à maintenir et nettoyer).
|
|
24
|
+
- Auto-cleanup natif via TTL (vs scan périodique pour purger les
|
|
25
|
+
jtis expirés d'une SET, Redis ne fait pas TTL par élément).
|
|
26
|
+
- Couvre access + refresh + tokens futurs émis avant le epoch — pas
|
|
27
|
+
besoin d'énumérer.
|
|
28
|
+
|
|
29
|
+
L'API reste sync (cohérent avec :class:`CacheBackend` et
|
|
30
|
+
:class:`IdempotencyStore`). Un consumer async wrap via
|
|
31
|
+
``asyncio.to_thread`` si besoin.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import time
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
|
|
39
|
+
from mic.cache.backends import CacheBackend, CacheError
|
|
40
|
+
|
|
41
|
+
#: Préfixe par défaut pour les clés ``jti`` révoqués individuellement.
|
|
42
|
+
#: Namespace distinct du subject-epoch pour éviter toute collision
|
|
43
|
+
#: si le caller utilise des sub == jti par accident.
|
|
44
|
+
_DEFAULT_JTI_NAMESPACE = "revoke:jti:"
|
|
45
|
+
|
|
46
|
+
#: Préfixe par défaut pour le timestamp d'epoch de révocation par sujet.
|
|
47
|
+
_DEFAULT_SUBJECT_NAMESPACE = "revoke:sub:"
|
|
48
|
+
|
|
49
|
+
#: Valeur sentinel stockée pour les entrées per-jti — seule l'existence
|
|
50
|
+
#: de la clé compte, le payload est ignoré. ``b"1"`` minimise la mémoire
|
|
51
|
+
#: Redis (1 byte vs un timestamp).
|
|
52
|
+
_JTI_PRESENT = b"1"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RevokeBlacklist:
|
|
56
|
+
"""Tracker de révocation pour JWT court-TTL.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
cache: backend cache (typiquement :class:`RedisCache` en prod,
|
|
60
|
+
:class:`InMemoryCache` en tests). Le backend est partagé
|
|
61
|
+
avec d'éventuels autres consommateurs — préfixe-isolé via
|
|
62
|
+
``jti_namespace`` et ``subject_namespace``.
|
|
63
|
+
jti_namespace: préfixe des clés per-jti (default ``"revoke:jti:"``).
|
|
64
|
+
subject_namespace: préfixe des clés per-subject (default
|
|
65
|
+
``"revoke:sub:"``).
|
|
66
|
+
time_fn: source d'horloge en secondes Unix. Injectable pour tests.
|
|
67
|
+
Default ``lambda: int(time.time())`` (wall-clock — l'epoch
|
|
68
|
+
DOIT être comparable à ``iat`` du JWT qui est aussi wall-clock).
|
|
69
|
+
|
|
70
|
+
## Pourquoi pas ``time.monotonic`` ?
|
|
71
|
+
|
|
72
|
+
L'epoch stocké doit être comparable au claim ``iat`` du JWT, qui est
|
|
73
|
+
un timestamp Unix wall-clock (cf. RFC 7519 §4.1.6). Utiliser
|
|
74
|
+
``time.monotonic`` cassserait la comparaison ``iat < epoch`` : les
|
|
75
|
+
deux horloges n'ont pas la même origine. Le coût (clock-skew entre
|
|
76
|
+
replicas) est négligeable ici : un skew de quelques secondes ne change
|
|
77
|
+
pas la sémantique de "logout everywhere".
|
|
78
|
+
|
|
79
|
+
## Cohérence avec :class:`ServiceAuthVerifier`
|
|
80
|
+
|
|
81
|
+
Le verifier `ServiceAuthVerifier` lui-même n'appelle PAS
|
|
82
|
+
``RevokeBlacklist`` — c'est au caller (typiquement un middleware
|
|
83
|
+
d'auth applicatif) de combiner les deux : vérifier la signature
|
|
84
|
+
via le verifier PUIS interroger la blacklist. Garder les
|
|
85
|
+
responsabilités séparées permet :
|
|
86
|
+
|
|
87
|
+
- Au verifier de rester stateless (pas de dep Redis).
|
|
88
|
+
- À la blacklist d'être optionnelle (services qui n'ont pas besoin
|
|
89
|
+
de révocation immédiate).
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
cache: CacheBackend,
|
|
96
|
+
jti_namespace: str = _DEFAULT_JTI_NAMESPACE,
|
|
97
|
+
subject_namespace: str = _DEFAULT_SUBJECT_NAMESPACE,
|
|
98
|
+
time_fn: Callable[[], int] = lambda: int(time.time()),
|
|
99
|
+
) -> None:
|
|
100
|
+
self._cache = cache
|
|
101
|
+
self._jti_namespace = jti_namespace
|
|
102
|
+
self._subject_namespace = subject_namespace
|
|
103
|
+
self._time_fn = time_fn
|
|
104
|
+
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
# Per-jti revocation
|
|
107
|
+
# ------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def revoke_jti(self, jti: str, *, ttl_seconds: int) -> None:
|
|
110
|
+
"""Marque un ``jti`` comme révoqué pour ``ttl_seconds`` secondes.
|
|
111
|
+
|
|
112
|
+
``ttl_seconds`` doit être le **lifetime restant** du token au
|
|
113
|
+
moment de la révocation. Une fois le TTL écoulé, la clé est
|
|
114
|
+
nettoyée par le backend (Redis EXPIRE) et le ``jti`` est
|
|
115
|
+
considéré comme à nouveau "non révoqué" — mais c'est OK : à ce
|
|
116
|
+
moment-là le token lui-même est expiré (``exp`` passée), donc le
|
|
117
|
+
verifier le rejette de toute façon. La blacklist ne sert qu'à
|
|
118
|
+
couvrir l'écart entre "révocation" et "expiration naturelle".
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
jti: identifiant unique du token (claim JWT ``jti``).
|
|
122
|
+
ttl_seconds: durée de vie restante du token (≥1).
|
|
123
|
+
|
|
124
|
+
Raises:
|
|
125
|
+
ValueError: si ``jti`` est vide.
|
|
126
|
+
CacheError: si ``ttl_seconds < 1`` (propagé du backend) ou
|
|
127
|
+
si le backend est indisponible.
|
|
128
|
+
"""
|
|
129
|
+
if not jti:
|
|
130
|
+
raise ValueError("RevokeBlacklist.revoke_jti(jti=...) must be non-empty")
|
|
131
|
+
# ttl_seconds est validé par le backend (>= 1). On laisse le
|
|
132
|
+
# CacheError remonter pour cohérence avec les autres consumers.
|
|
133
|
+
self._cache.set(self._jti_key(jti), _JTI_PRESENT, ttl_seconds=ttl_seconds)
|
|
134
|
+
|
|
135
|
+
def is_jti_revoked(self, jti: str) -> bool:
|
|
136
|
+
"""``True`` si le ``jti`` est dans la blacklist active.
|
|
137
|
+
|
|
138
|
+
Lookup O(1) en Redis. Le caller (middleware d'auth) appelle
|
|
139
|
+
cette méthode APRÈS la vérification de signature du
|
|
140
|
+
:class:`ServiceAuthVerifier` — pas la peine d'interroger Redis
|
|
141
|
+
sur un token invalide cryptographiquement.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
jti: identifiant du token à tester.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
``True`` si révoqué (caller rejette → 401), ``False`` sinon
|
|
148
|
+
(caller accepte).
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ValueError: si ``jti`` est vide.
|
|
152
|
+
CacheError: si le backend est indisponible. Le caller décide
|
|
153
|
+
de la politique : fail-closed (rejeter, sécurité d'abord)
|
|
154
|
+
ou fail-open (accepter, disponibilité d'abord). Recommandé
|
|
155
|
+
fail-closed pour les use cases sécurité-critiques.
|
|
156
|
+
"""
|
|
157
|
+
if not jti:
|
|
158
|
+
raise ValueError("RevokeBlacklist.is_jti_revoked(jti=...) must be non-empty")
|
|
159
|
+
return self._cache.get(self._jti_key(jti)) is not None
|
|
160
|
+
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
# Per-subject mass revocation (revoke-epoch)
|
|
163
|
+
# ------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
def revoke_all_for_subject(self, subject: str, *, ttl_seconds: int) -> None:
|
|
166
|
+
"""Stocke un epoch "tous les tokens du sujet émis AVANT now sont révoqués".
|
|
167
|
+
|
|
168
|
+
Sémantique "logout everywhere" : invalide TOUS les tokens (access
|
|
169
|
+
+ refresh) du sujet émis avant l'instant courant. Les tokens
|
|
170
|
+
émis APRÈS (typiquement après un nouveau login) sont valides.
|
|
171
|
+
|
|
172
|
+
``ttl_seconds`` doit être >= au TTL max des tokens longue durée
|
|
173
|
+
du sujet (typiquement refresh token TTL = ~30j). Au-delà, l'epoch
|
|
174
|
+
est garbage-collected mais c'est OK : à ce moment-là, le token
|
|
175
|
+
le plus ancien encore en circulation est expiré naturellement.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
subject: identifiant du sujet (``sub`` claim JWT, typiquement
|
|
179
|
+
``user_id``). Doit être non-vide.
|
|
180
|
+
ttl_seconds: durée de rétention de l'epoch (≥1, typiquement
|
|
181
|
+
= refresh token TTL).
|
|
182
|
+
|
|
183
|
+
Raises:
|
|
184
|
+
ValueError: si ``subject`` est vide.
|
|
185
|
+
CacheError: si le backend est indisponible.
|
|
186
|
+
|
|
187
|
+
Idempotent : appel répété écrase l'epoch avec ``now`` courant
|
|
188
|
+
(re-clic "logout everywhere" → re-révoque tout token émis
|
|
189
|
+
entre les deux clics).
|
|
190
|
+
"""
|
|
191
|
+
if not subject:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
"RevokeBlacklist.revoke_all_for_subject(subject=...) must be non-empty"
|
|
194
|
+
)
|
|
195
|
+
epoch = self._time_fn()
|
|
196
|
+
payload = str(epoch).encode("ascii")
|
|
197
|
+
self._cache.set(self._subject_key(subject), payload, ttl_seconds=ttl_seconds)
|
|
198
|
+
|
|
199
|
+
def is_subject_revoked_since(self, subject: str, *, issued_at: int) -> bool:
|
|
200
|
+
"""``True`` si le sujet a un epoch de révocation et ``issued_at < epoch``.
|
|
201
|
+
|
|
202
|
+
Le caller (middleware d'auth) appelle cette méthode avec le
|
|
203
|
+
claim ``iat`` du token vérifié. Si l'epoch existe et que
|
|
204
|
+
``iat < epoch`` → token émis avant la révocation → rejeté.
|
|
205
|
+
Si pas d'epoch (clé absente ou expirée TTL) → ``False`` (jamais
|
|
206
|
+
révoqué pour ce sujet, ou révocation trop vieille pour matter).
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
subject: identifiant du sujet (``sub`` claim).
|
|
210
|
+
issued_at: timestamp Unix du claim ``iat`` du token.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
``True`` si le token doit être rejeté (caller → 401).
|
|
214
|
+
|
|
215
|
+
Raises:
|
|
216
|
+
ValueError: si ``subject`` est vide.
|
|
217
|
+
CacheError: si le backend est indisponible (cf. politique
|
|
218
|
+
fail-closed/fail-open documentée sur :meth:`is_jti_revoked`).
|
|
219
|
+
"""
|
|
220
|
+
if not subject:
|
|
221
|
+
raise ValueError(
|
|
222
|
+
"RevokeBlacklist.is_subject_revoked_since(subject=...) must be non-empty"
|
|
223
|
+
)
|
|
224
|
+
raw = self._cache.get(self._subject_key(subject))
|
|
225
|
+
if raw is None:
|
|
226
|
+
return False
|
|
227
|
+
try:
|
|
228
|
+
epoch = int(raw.decode("ascii"))
|
|
229
|
+
except (UnicodeDecodeError, ValueError) as exc:
|
|
230
|
+
# Payload corrompu (cache empoisonné, collision de
|
|
231
|
+
# namespace). Fail-closed : on traite comme révoqué. La
|
|
232
|
+
# corruption est suffisamment anormale pour qu'on préfère
|
|
233
|
+
# forcer un re-login plutôt que d'accepter aveuglément.
|
|
234
|
+
raise CacheError(
|
|
235
|
+
f"RevokeBlacklist: corrupted subject epoch payload for {subject!r}: {raw!r}"
|
|
236
|
+
) from exc
|
|
237
|
+
return issued_at < epoch
|
|
238
|
+
|
|
239
|
+
# ------------------------------------------------------------------
|
|
240
|
+
# Helpers internes
|
|
241
|
+
# ------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def _jti_key(self, jti: str) -> str:
|
|
244
|
+
return f"{self._jti_namespace}{jti}"
|
|
245
|
+
|
|
246
|
+
def _subject_key(self, subject: str) -> str:
|
|
247
|
+
return f"{self._subject_namespace}{subject}"
|