@nodefony/http 10.0.0-alpha.1
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.
- package/LICENSE +544 -0
- package/README.md +77 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +108 -0
- package/dist/nodefony/command/assetsPublishCommand.js +102 -0
- package/dist/nodefony/command/certificatesCommand.js +47 -0
- package/dist/nodefony/command/networkCommand.js +27 -0
- package/dist/nodefony/command/proxyGenerateCommand.js +66 -0
- package/dist/nodefony/config/config.js +335 -0
- package/dist/nodefony/config/defineModuleConfig.js +93 -0
- package/dist/nodefony/interfaces/IContext.js +1 -0
- package/dist/nodefony/interfaces/ICookie.js +1 -0
- package/dist/nodefony/interfaces/IErrorRenderer.js +1 -0
- package/dist/nodefony/interfaces/IHttpConfig.js +1 -0
- package/dist/nodefony/interfaces/IHttpKernel.js +1 -0
- package/dist/nodefony/interfaces/IRequest.js +1 -0
- package/dist/nodefony/interfaces/IRequestLogger.js +1 -0
- package/dist/nodefony/interfaces/IResponse.js +1 -0
- package/dist/nodefony/interfaces/ISession.js +1 -0
- package/dist/nodefony/interfaces/IUpload.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/HttpAdminApi.js +376 -0
- package/dist/nodefony/service/ProfilerAdminApi.js +73 -0
- package/dist/nodefony/service/audit-logger.js +159 -0
- package/dist/nodefony/service/certificates.js +545 -0
- package/dist/nodefony/service/error-renderer.js +320 -0
- package/dist/nodefony/service/http-kernel.js +948 -0
- package/dist/nodefony/service/pretty-request-logger.js +72 -0
- package/dist/nodefony/service/request-logger.js +54 -0
- package/dist/nodefony/service/servers/clientError.js +20 -0
- package/dist/nodefony/service/servers/server-http.js +135 -0
- package/dist/nodefony/service/servers/server-https.js +204 -0
- package/dist/nodefony/service/servers/server-static.js +192 -0
- package/dist/nodefony/service/servers/server-websocket-secure.js +104 -0
- package/dist/nodefony/service/servers/server-websocket.js +104 -0
- package/dist/nodefony/service/servers/serverShutdown.js +31 -0
- package/dist/nodefony/service/servers/wsHeartbeat.js +64 -0
- package/dist/nodefony/service/sessions/sessions-service.js +580 -0
- package/dist/nodefony/service/trace.js +72 -0
- package/dist/nodefony/service/upload/upload-service.js +171 -0
- package/dist/nodefony/src/assets/collectAssets.js +34 -0
- package/dist/nodefony/src/assets/prebuiltUi.js +125 -0
- package/dist/nodefony/src/context/Context.js +415 -0
- package/dist/nodefony/src/context/domainMatcher.js +88 -0
- package/dist/nodefony/src/context/forwarded.js +185 -0
- package/dist/nodefony/src/context/http/HttpContext.js +309 -0
- package/dist/nodefony/src/context/http/Request.js +543 -0
- package/dist/nodefony/src/context/http/Response.js +368 -0
- package/dist/nodefony/src/context/http/parser.js +188 -0
- package/dist/nodefony/src/context/http/urlFastPath.js +103 -0
- package/dist/nodefony/src/context/http2/Request.js +29 -0
- package/dist/nodefony/src/context/http2/Response.js +97 -0
- package/dist/nodefony/src/context/metaData.js +47 -0
- package/dist/nodefony/src/context/requestId.js +41 -0
- package/dist/nodefony/src/context/trustProxy.js +167 -0
- package/dist/nodefony/src/context/websocket/Response.js +181 -0
- package/dist/nodefony/src/context/websocket/WebsocketContext.js +389 -0
- package/dist/nodefony/src/context/websocket/wsBackpressure.js +56 -0
- package/dist/nodefony/src/context/websocket/wsLogContent.js +68 -0
- package/dist/nodefony/src/cookies/cookie.js +258 -0
- package/dist/nodefony/src/errors/httpError.js +69 -0
- package/dist/nodefony/src/profiler/FrameProfile.js +95 -0
- package/dist/nodefony/src/profiler/Profiler.js +139 -0
- package/dist/nodefony/src/proxy/generateProxyConfig.js +157 -0
- package/dist/nodefony/src/rateLimit/IRateLimitStore.js +1 -0
- package/dist/nodefony/src/rateLimit/MemoryRateLimitStore.js +146 -0
- package/dist/nodefony/src/rateLimit/WsConnectionCounter.js +64 -0
- package/dist/nodefony/src/rateLimit/rateLimitFilters.js +20 -0
- package/dist/nodefony/src/servers/portBinder.js +114 -0
- package/dist/nodefony/src/session/session.js +390 -0
- package/dist/nodefony/src/session/storage/MemorySessionStorage.js +185 -0
- package/dist/nodefony/src/session/storage/RevocationGuardStorage.js +137 -0
- package/dist/nodefony/src/session/storage/sessionFilters.js +83 -0
- package/dist/nodefony/src/session/storage/sessionSort.js +53 -0
- package/dist/types/index.d.ts +83 -0
- package/dist/types/nodefony/command/assetsPublishCommand.d.ts +23 -0
- package/dist/types/nodefony/command/certificatesCommand.d.ts +17 -0
- package/dist/types/nodefony/command/networkCommand.d.ts +8 -0
- package/dist/types/nodefony/command/proxyGenerateCommand.d.ts +19 -0
- package/dist/types/nodefony/config/config.d.ts +197 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IContext.d.ts +138 -0
- package/dist/types/nodefony/interfaces/ICookie.d.ts +47 -0
- package/dist/types/nodefony/interfaces/IErrorRenderer.d.ts +55 -0
- package/dist/types/nodefony/interfaces/IHttpConfig.d.ts +12 -0
- package/dist/types/nodefony/interfaces/IHttpKernel.d.ts +10 -0
- package/dist/types/nodefony/interfaces/IRequest.d.ts +35 -0
- package/dist/types/nodefony/interfaces/IRequestLogger.d.ts +31 -0
- package/dist/types/nodefony/interfaces/IResponse.d.ts +39 -0
- package/dist/types/nodefony/interfaces/ISession.d.ts +283 -0
- package/dist/types/nodefony/interfaces/IUpload.d.ts +66 -0
- package/dist/types/nodefony/interfaces/index.d.ts +7 -0
- package/dist/types/nodefony/service/HttpAdminApi.d.ts +18 -0
- package/dist/types/nodefony/service/ProfilerAdminApi.d.ts +23 -0
- package/dist/types/nodefony/service/audit-logger.d.ts +143 -0
- package/dist/types/nodefony/service/certificates.d.ts +246 -0
- package/dist/types/nodefony/service/error-renderer.d.ts +74 -0
- package/dist/types/nodefony/service/http-kernel.d.ts +377 -0
- package/dist/types/nodefony/service/pretty-request-logger.d.ts +25 -0
- package/dist/types/nodefony/service/request-logger.d.ts +18 -0
- package/dist/types/nodefony/service/servers/clientError.d.ts +14 -0
- package/dist/types/nodefony/service/servers/server-http.d.ts +42 -0
- package/dist/types/nodefony/service/servers/server-https.d.ts +41 -0
- package/dist/types/nodefony/service/servers/server-static.d.ts +62 -0
- package/dist/types/nodefony/service/servers/server-websocket-secure.d.ts +29 -0
- package/dist/types/nodefony/service/servers/server-websocket.d.ts +29 -0
- package/dist/types/nodefony/service/servers/serverShutdown.d.ts +27 -0
- package/dist/types/nodefony/service/servers/wsHeartbeat.d.ts +46 -0
- package/dist/types/nodefony/service/sessions/sessions-service.d.ts +218 -0
- package/dist/types/nodefony/service/trace.d.ts +39 -0
- package/dist/types/nodefony/service/upload/upload-service.d.ts +61 -0
- package/dist/types/nodefony/src/assets/collectAssets.d.ts +35 -0
- package/dist/types/nodefony/src/assets/prebuiltUi.d.ts +99 -0
- package/dist/types/nodefony/src/context/Context.d.ts +195 -0
- package/dist/types/nodefony/src/context/domainMatcher.d.ts +67 -0
- package/dist/types/nodefony/src/context/forwarded.d.ts +95 -0
- package/dist/types/nodefony/src/context/http/HttpContext.d.ts +85 -0
- package/dist/types/nodefony/src/context/http/Request.d.ts +203 -0
- package/dist/types/nodefony/src/context/http/Response.d.ts +68 -0
- package/dist/types/nodefony/src/context/http/parser.d.ts +65 -0
- package/dist/types/nodefony/src/context/http/urlFastPath.d.ts +52 -0
- package/dist/types/nodefony/src/context/http2/Request.d.ts +14 -0
- package/dist/types/nodefony/src/context/http2/Response.d.ts +20 -0
- package/dist/types/nodefony/src/context/metaData.d.ts +58 -0
- package/dist/types/nodefony/src/context/requestId.d.ts +28 -0
- package/dist/types/nodefony/src/context/trustProxy.d.ts +77 -0
- package/dist/types/nodefony/src/context/websocket/Response.d.ts +53 -0
- package/dist/types/nodefony/src/context/websocket/WebsocketContext.d.ts +125 -0
- package/dist/types/nodefony/src/context/websocket/wsBackpressure.d.ts +73 -0
- package/dist/types/nodefony/src/context/websocket/wsLogContent.d.ts +37 -0
- package/dist/types/nodefony/src/cookies/cookie.d.ts +88 -0
- package/dist/types/nodefony/src/errors/httpError.d.ts +15 -0
- package/dist/types/nodefony/src/profiler/FrameProfile.d.ts +110 -0
- package/dist/types/nodefony/src/profiler/Profiler.d.ts +192 -0
- package/dist/types/nodefony/src/proxy/generateProxyConfig.d.ts +76 -0
- package/dist/types/nodefony/src/rateLimit/IRateLimitStore.d.ts +98 -0
- package/dist/types/nodefony/src/rateLimit/MemoryRateLimitStore.d.ts +40 -0
- package/dist/types/nodefony/src/rateLimit/WsConnectionCounter.d.ts +37 -0
- package/dist/types/nodefony/src/rateLimit/rateLimitFilters.d.ts +18 -0
- package/dist/types/nodefony/src/servers/portBinder.d.ts +102 -0
- package/dist/types/nodefony/src/session/session.d.ts +171 -0
- package/dist/types/nodefony/src/session/storage/MemorySessionStorage.d.ts +77 -0
- package/dist/types/nodefony/src/session/storage/RevocationGuardStorage.d.ts +81 -0
- package/dist/types/nodefony/src/session/storage/sessionFilters.d.ts +102 -0
- package/dist/types/nodefony/src/session/storage/sessionSort.d.ts +45 -0
- package/docs/cookies.md +365 -0
- package/docs/index.md +163 -0
- package/docs/observabilite.md +460 -0
- package/docs/rate-limit.md +372 -0
- package/docs/servers.md +935 -0
- package/docs/session.md +768 -0
- package/docs/upload.md +460 -0
- package/package.json +101 -0
package/docs/session.md
ADDED
|
@@ -0,0 +1,768 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Sessions — l'état serveur qui recolle les requêtes"
|
|
3
|
+
navTitle: Sessions
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/http"
|
|
6
|
+
topic: session
|
|
7
|
+
section: "Cœur runtime"
|
|
8
|
+
audience: [developer]
|
|
9
|
+
tags:
|
|
10
|
+
[
|
|
11
|
+
session,
|
|
12
|
+
cookie,
|
|
13
|
+
securite,
|
|
14
|
+
http,
|
|
15
|
+
websocket,
|
|
16
|
+
store,
|
|
17
|
+
nist,
|
|
18
|
+
owasp,
|
|
19
|
+
revocation,
|
|
20
|
+
pagination,
|
|
21
|
+
]
|
|
22
|
+
version: "doc"
|
|
23
|
+
status: stable
|
|
24
|
+
updated: 2026-07-19
|
|
25
|
+
source: "src/packages/@nodefony/http/docs/session.md"
|
|
26
|
+
coverageModule: http
|
|
27
|
+
coverageFiles: session/session.ts,sessions-service.ts
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
# Sessions — l'état serveur qui recolle les requêtes
|
|
31
|
+
|
|
32
|
+
> HTTP n'a pas de mémoire : chaque requête arrive anonyme. Une session recolle ces requêtes à un même
|
|
33
|
+
> utilisateur au moyen d'un **identifiant opaque** porté par un cookie, tout l'état restant côté
|
|
34
|
+
> serveur. Nodefony fait vivre **la même session en HTTP et en WebSocket**, ne l'ouvre que si une route
|
|
35
|
+
> la demande, et applique par défaut les deux bornes de temps NIST/OWASP. Chaque fait de cette page est
|
|
36
|
+
> ancré sur le code.
|
|
37
|
+
|
|
38
|
+
📍 [Documentation](../../../../../docs/index.md) › [@nodefony/http](index.md) › **Sessions**
|
|
39
|
+
|
|
40
|
+
## 🧠 Le modèle mental — un ticket de vestiaire, pas un coffre
|
|
41
|
+
|
|
42
|
+
Le cookie de session est un **ticket de vestiaire** : un numéro, rien d'autre. Il ne contient pas ton
|
|
43
|
+
manteau, il permet juste de le retrouver. Le vestiaire — le _store_ — est côté serveur.
|
|
44
|
+
|
|
45
|
+
Trois conséquences que tout le reste de la page décline :
|
|
46
|
+
|
|
47
|
+
1. **Voler le ticket suffit** pour repartir avec le manteau → le ticket se protège (`HttpOnly`,
|
|
48
|
+
`Secure`, `__Host-`) et se **périme** (idle + absolute).
|
|
49
|
+
2. **Le vestiaire peut déchirer un ticket** à tout moment → la révocation est immédiate et centrale,
|
|
50
|
+
pas une négociation avec le client.
|
|
51
|
+
3. **Le contenu ne voyage jamais** → un cookie Nodefony ne porte ni données, ni jeton signé, ni JWT.
|
|
52
|
+
|
|
53
|
+
```mermaid
|
|
54
|
+
flowchart TD
|
|
55
|
+
R["Requête HTTP ou WS"] --> I{"intent de route ?<br/>@UseSession / @Session<br/>ou cookie déjà présent"}
|
|
56
|
+
I -->|non| SKIP["aucune session<br/>0 lecture, 0 Set-Cookie"]
|
|
57
|
+
I -->|oui| C{"cookie<br/>présent ?"}
|
|
58
|
+
C -->|oui| RS["resume() → lit le store"]
|
|
59
|
+
C -->|non| CR["create() → id CSPRNG + cookie"]
|
|
60
|
+
RS --> V{"valide ?<br/>idle · absolute · strictMode"}
|
|
61
|
+
V -->|non| INV["invalidate()<br/>détruit + session neuve"]
|
|
62
|
+
V -->|oui| CTX["context.session"]
|
|
63
|
+
CR --> CTX
|
|
64
|
+
INV --> CTX
|
|
65
|
+
CTX --> W["controller lit / écrit"]
|
|
66
|
+
W --> S{"mutée ?"}
|
|
67
|
+
S -->|oui| WR["save() → write store"]
|
|
68
|
+
S -->|non| TO["touchIfNeeded()<br/>prolonge sans réécrire"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Le point d'activation est **unique et commun aux deux transports** : `HttpKernel.startSession()`
|
|
72
|
+
(`http-kernel.ts:1131`). Il commence par la garde paresseuse `if (!intent && !context.hasSession())`
|
|
73
|
+
(`http-kernel.ts:1137`) — sans intent de route ni cookie entrant, **aucune session n'est ouverte**.
|
|
74
|
+
|
|
75
|
+
## 📖 Lexique
|
|
76
|
+
|
|
77
|
+
| Terme | Sens |
|
|
78
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------ |
|
|
79
|
+
| Session | État serveur associé à un visiteur, retrouvé de requête en requête via un identifiant. |
|
|
80
|
+
| ID de session | Chaîne opaque aléatoire (32 octets CSPRNG → base64url, 43 caractères) qui indexe la session. |
|
|
81
|
+
| Store | Le backend qui persiste les sessions : `memory`, `drizzle` (SQL), `redis`, `mongoose` (MongoDB). |
|
|
82
|
+
| Intent | Déclaration d'une route qui veut une session (`@UseSession` ou un paramètre `@Session`). |
|
|
83
|
+
| CSPRNG | Générateur d'aléa **cryptographiquement sûr** (`node:crypto` `randomBytes`) — non devinable. |
|
|
84
|
+
| HMAC | Code d'authentification de message à clé — ici pour dériver une référence publique non réversible. |
|
|
85
|
+
| `ref` | Pseudonyme public d'une session, `HMAC-SHA256(secret, id)` tronqué, préfixé `sess_`. Jamais l'ID brut. |
|
|
86
|
+
| Cookie `HttpOnly` | Inaccessible à `document.cookie` → hors de portée d'un script injecté (XSS). |
|
|
87
|
+
| Cookie `Secure` | Envoyé uniquement sur HTTPS/WSS. |
|
|
88
|
+
| Préfixe `__Host-` | Préfixe de nom imposant `Secure` + `Path=/` + interdisant `Domain` (RFC 6265bis §4.1.3). |
|
|
89
|
+
| `SameSite` | Attribut limitant l'envoi du cookie depuis un site tiers (défaut Nodefony : `Lax`). |
|
|
90
|
+
| Session fixation | Attaque : forcer la victime à utiliser un identifiant de session connu de l'attaquant. |
|
|
91
|
+
| Session hijacking | Vol de l'identifiant/cookie pour usurper la session. |
|
|
92
|
+
| Idle timeout | Expiration après une période d'**inactivité** (glissante). |
|
|
93
|
+
| Absolute timeout | Âge **maximum** depuis la création, jamais prolongé — borne un identifiant volé. |
|
|
94
|
+
| Touch | Prolongation de la fenêtre d'inactivité **sans réécrire** les données. |
|
|
95
|
+
| Dirty-tracking | Suivi « la session a-t-elle été mutée ? » — décide s'il faut écrire dans le store. |
|
|
96
|
+
| GC | _Garbage collection_ : purge périodique des sessions expirées, hors chemin de requête. |
|
|
97
|
+
| TTL | _Time To Live_ : durée de vie native d'une clé (Redis `SET … EX`). |
|
|
98
|
+
| UPSERT | `INSERT … ON CONFLICT DO UPDATE` — écriture atomique « crée ou met à jour ». |
|
|
99
|
+
| BFF | _Backend-For-Frontend_ : le serveur gère session et jetons pour le front web. |
|
|
100
|
+
| ALS | `AsyncLocalStorage` — propage l'identité/le contexte à travers les appels asynchrones. |
|
|
101
|
+
| IDOR | _Insecure Direct Object Reference_ : accéder à l'objet d'autrui en changeant un identifiant. |
|
|
102
|
+
| XSS | _Cross-Site Scripting_ : exécution de script injecté dans la page de la victime. |
|
|
103
|
+
| NIST SP 800-63B | Référentiel d'identité numérique du NIST — impose des bornes de session. |
|
|
104
|
+
| OWASP | Fondation de sécurité applicative ; ici le _Session Management Cheat Sheet_. |
|
|
105
|
+
|
|
106
|
+
## Qu'est-ce qu'une session — et quelles failles elle encadre
|
|
107
|
+
|
|
108
|
+
Sans session, un utilisateur devrait re-prouver son identité à **chaque** requête (retaper son mot de
|
|
109
|
+
passe pour chaque clic). La session résout ça : après connexion, le serveur garde l'état et le client
|
|
110
|
+
ne présente plus qu'un **identifiant opaque**.
|
|
111
|
+
|
|
112
|
+
Cet identifiant devient donc une cible. Trois attaques classiques, trois garde-fous **actifs par
|
|
113
|
+
défaut** dans Nodefony :
|
|
114
|
+
|
|
115
|
+
- **Vol du cookie (hijacking).** Un script injecté (XSS) ou un réseau en clair capte le cookie et
|
|
116
|
+
rejoue la session. → `HttpOnly` et `Secure` sont à `true` par défaut (`sessionCookieSchema`,
|
|
117
|
+
`config.ts:718-725`), et le nom du cookie prend le préfixe `__Host-` dès que le transport est TLS
|
|
118
|
+
(`Context.getSessionCookieName()`, `Context.ts:714`).
|
|
119
|
+
- **Fixation.** L'attaquant pose lui-même un identifiant dans le navigateur de la victime, attend
|
|
120
|
+
qu'elle se connecte, puis réutilise **le même** identifiant. → double défense : `strictMode` rejette
|
|
121
|
+
tout identifiant inconnu du store (`Session.resume()`, `session.ts:189`), et le login **régénère**
|
|
122
|
+
l'identifiant (`AuthFlow` — voir plus bas).
|
|
123
|
+
- **Exploitation prolongée d'un identifiant volé.** Une session maintenue artificiellement vivante
|
|
124
|
+
resterait exploitable indéfiniment. → l'**absolute timeout** borne l'âge depuis la création et n'est
|
|
125
|
+
**jamais** prolongé (`Session.isValidSession()`, `session.ts:366`), en plus de l'idle timeout.
|
|
126
|
+
|
|
127
|
+
> [!IMPORTANT]
|
|
128
|
+
> Le cookie **ne chiffre rien** et n'a pas à le faire : il ne porte qu'un numéro. La sécurité repose
|
|
129
|
+
> sur la protection du cookie (les attributs ci-dessus), sur l'imprévisibilité de l'identifiant
|
|
130
|
+
> (32 octets CSPRNG) et sur le store — jamais sur un secret embarqué côté client.
|
|
131
|
+
|
|
132
|
+
## La vision Nodefony
|
|
133
|
+
|
|
134
|
+
Quatre partis pris, chacun vérifiable dans le code.
|
|
135
|
+
|
|
136
|
+
**1. Le cookie ne transporte que l'identifiant.** `Session.getSession()` lit la **valeur brute** du
|
|
137
|
+
cookie, sans déchiffrement (`session.ts:160`) ; l'identifiant vient de `Session.generateId()`
|
|
138
|
+
(`session.ts:226`). Modèle BFF : le web reste sur un cookie opaque, le JWT est réservé aux API et aux
|
|
139
|
+
agents (voir [Firewall](../../security/docs/firewall.md)).
|
|
140
|
+
|
|
141
|
+
**2. La session est paresseuse.** Elle n'existe que si une route la demande — `@UseSession`, ou la
|
|
142
|
+
seule présence d'un paramètre `@Session` — ou si un cookie arrive déjà : c'est la garde de
|
|
143
|
+
`HttpKernel.startSession()` (`http-kernel.ts:1131`). Une route publique ne paie **ni lecture de store,
|
|
144
|
+
ni `Set-Cookie`**.
|
|
145
|
+
|
|
146
|
+
**3. Un seul modèle d'état pour le web et le temps réel.** Le même `startSession()` sert
|
|
147
|
+
`HttpKernel.onRequestEnd()` (`http-kernel.ts:1391`) et `HttpKernel.onConnect()` (`http-kernel.ts:1659`) ;
|
|
148
|
+
l'activité HTTP **ou** WS prolonge la même session (`Session.touchIfNeeded()`, `session.ts:421`).
|
|
149
|
+
|
|
150
|
+
**4. L'administration ne voit jamais un identifiant.** Un opérateur manipule une `ref`, HMAC tronqué
|
|
151
|
+
non réversible produit par `computeSessionRef()` (`sessions-service.ts:100`) — comme la liste
|
|
152
|
+
« appareils connectés » de GitHub ou Google montre une référence, jamais le jeton.
|
|
153
|
+
|
|
154
|
+
Compromis assumé : l'état serveur suppose un store **partagé** dès qu'on passe à plusieurs pods
|
|
155
|
+
(`redis`/`drizzle`/`mongoose`) ; `memory` reste per-pod.
|
|
156
|
+
|
|
157
|
+
## 🚀 Démarrage rapide
|
|
158
|
+
|
|
159
|
+
Dans une app générée par `nodefony create app`, la session est déjà configurée avec des défauts sûrs.
|
|
160
|
+
Voici le chemin complet : configurer, écrire un contrôleur, observer.
|
|
161
|
+
|
|
162
|
+
### 1. Déclarer le store (facultatif — `auto` fait déjà le bon choix)
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// nodefony.config.ts — extrait
|
|
166
|
+
export default defineConfig(() => ({
|
|
167
|
+
modules: [
|
|
168
|
+
use("@nodefony/http", {
|
|
169
|
+
session: {
|
|
170
|
+
// "auto" (défaut) suit l'infra déclarée. On peut nommer le store :
|
|
171
|
+
store: "drizzle",
|
|
172
|
+
name: "monapp", // nom du cookie (préfixé __Host- sur TLS)
|
|
173
|
+
idleTimeoutS: 1800, // 30 min d'inactivité (NIST/OWASP)
|
|
174
|
+
absoluteTimeoutS: 43200, // 12 h d'âge max, jamais prolongé
|
|
175
|
+
cookie: { httpOnly: true, secure: true, hostPrefix: "auto" },
|
|
176
|
+
},
|
|
177
|
+
}),
|
|
178
|
+
"@nodefony/framework",
|
|
179
|
+
"@nodefony/drizzle", // fournit le store `drizzle` (il s'auto-enregistre)
|
|
180
|
+
],
|
|
181
|
+
}));
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 2. Écrire le contrôleur qui lit et écrit la session
|
|
185
|
+
|
|
186
|
+
```typescript
|
|
187
|
+
// nodefony/controllers/CartController.ts — complet, compile tel quel
|
|
188
|
+
import {
|
|
189
|
+
Controller,
|
|
190
|
+
controller,
|
|
191
|
+
Get,
|
|
192
|
+
Post,
|
|
193
|
+
Delete,
|
|
194
|
+
Session,
|
|
195
|
+
Body,
|
|
196
|
+
UseSession,
|
|
197
|
+
} from "@nodefony/framework";
|
|
198
|
+
import type { Session as HttpSession } from "@nodefony/http";
|
|
199
|
+
|
|
200
|
+
@controller("/panier")
|
|
201
|
+
class CartController extends Controller {
|
|
202
|
+
// Lecture seule : la session est reprise mais JAMAIS réécrite (0 write store).
|
|
203
|
+
@UseSession({ readOnly: true })
|
|
204
|
+
@Get("/")
|
|
205
|
+
async show(@Session() session: HttpSession) {
|
|
206
|
+
return this.renderJson({ items: session.get("items") ?? [] });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Le paramètre @Session suffit à déclarer l'intent : pas besoin de @UseSession.
|
|
210
|
+
@Post("/ajouter")
|
|
211
|
+
async add(@Session() session: HttpSession, @Body() body: { sku: string }) {
|
|
212
|
+
const items = (session.get("items") as string[] | null) ?? [];
|
|
213
|
+
items.push(body.sku);
|
|
214
|
+
session.set("items", items); // marque la session « mutée » (dirty)
|
|
215
|
+
session.setFlashBag("notice", `${body.sku} ajouté`); // lu UNE fois, puis effacé
|
|
216
|
+
return this.renderJson({ count: items.length }); // save() écrit en fin de requête
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
@Delete("/")
|
|
220
|
+
async clear(@Session() session: HttpSession) {
|
|
221
|
+
await session.destroy(true); // détruit l'entrée store + efface le cookie
|
|
222
|
+
return this.renderJson({ ok: true });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default CartController;
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### 3. La même session en WebSocket
|
|
230
|
+
|
|
231
|
+
Aucune API différente : le même décorateur, sur une route WebSocket.
|
|
232
|
+
|
|
233
|
+
```typescript
|
|
234
|
+
// nodefony/controllers/LiveController.ts — complet, compile tel quel
|
|
235
|
+
import { Controller, controller, route, UseSession } from "@nodefony/framework";
|
|
236
|
+
import type { WebsocketContext } from "@nodefony/http";
|
|
237
|
+
|
|
238
|
+
@controller("/live")
|
|
239
|
+
class LiveController extends Controller {
|
|
240
|
+
@route("live-panier", {
|
|
241
|
+
path: "/panier",
|
|
242
|
+
requirements: { methods: ["WEBSOCKET"] },
|
|
243
|
+
})
|
|
244
|
+
@UseSession() // session ouverte AU HANDSHAKE, réutilisée par toutes les frames
|
|
245
|
+
async panier(message: string | Buffer | null) {
|
|
246
|
+
const ws = this.context as WebsocketContext | undefined;
|
|
247
|
+
const items = (this.session?.get("items") as string[] | null) ?? [];
|
|
248
|
+
ws?.send(JSON.stringify({ items, echo: message?.toString() ?? null }));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export default LiveController;
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 4. Ce qu'on observe
|
|
256
|
+
|
|
257
|
+
```bash
|
|
258
|
+
# 1) Route SANS intent de session → aucun Set-Cookie (activation paresseuse)
|
|
259
|
+
curl -si https://localhost:5152/ -k | grep -ci 'set-cookie'
|
|
260
|
+
# 0
|
|
261
|
+
|
|
262
|
+
# 2) Première écriture → création de la session + cookie durci
|
|
263
|
+
curl -sik -c /tmp/jar -H 'Content-Type: application/json' \
|
|
264
|
+
-d '{"sku":"NF-1"}' https://localhost:5152/panier/ajouter | grep -i set-cookie
|
|
265
|
+
# Set-Cookie: __Host-monapp=Yk9t…43-caracteres-base64url…; Path=/; HttpOnly; Secure; SameSite=Lax
|
|
266
|
+
|
|
267
|
+
# 3) Rejouer avec le cookie → l'état est retrouvé
|
|
268
|
+
curl -sk -b /tmp/jar https://localhost:5152/panier
|
|
269
|
+
# {"items":["NF-1"]}
|
|
270
|
+
|
|
271
|
+
# 4) Une simple lecture n'écrit RIEN dans le store (dirty-tracking + touch throttlé)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
Le cookie obtenu porte `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/` et **aucun** `Domain` :
|
|
275
|
+
c'est exactement ce qu'assert le banc d'intégration `session-runtime` (« Set-Cookie de session sur TLS »).
|
|
276
|
+
Sur un transport en clair (port 5151), le préfixe `__Host-` est omis — le navigateur le rejetterait
|
|
277
|
+
faute de `Secure` (`Context.getSessionCookieName()`, `Context.ts:714`).
|
|
278
|
+
|
|
279
|
+
## ⚙️ Configuration
|
|
280
|
+
|
|
281
|
+
Source unique des défauts : le schéma Zod `sessionSchema` (`config.ts:761`) et son sous-schéma
|
|
282
|
+
`sessionCookieSchema` (`config.ts:727`).
|
|
283
|
+
|
|
284
|
+
| Option | Type | Défaut | Effet |
|
|
285
|
+
| ------------------- | ------- | ------------ | --------------------------------------------------------------------------------- |
|
|
286
|
+
| `store` | string | `"auto"` | Backend de persistance — voir la résolution ci-dessous (`config.ts:755`). |
|
|
287
|
+
| `name` | string | `"nodefony"` | Nom du cookie, préfixé `__Host-` selon `cookie.hostPrefix` (`config.ts:750`). |
|
|
288
|
+
| `strictMode` | bool | `true` | Un identifiant inconnu du store est rejeté → session neuve (anti-fixation). |
|
|
289
|
+
| `idleTimeoutS` | int ≥ 0 | `1800` | Inactivité max (30 min). `0` = pas d'expiration par inactivité (`config.ts:796`). |
|
|
290
|
+
| `absoluteTimeoutS` | int ≥ 0 | `43200` | Âge max depuis la création (12 h), **jamais** prolongé. `0` = désactivé. |
|
|
291
|
+
| `gcIntervalS` | int ≥ 0 | `600` | Période de purge des sessions expirées, hors requête. `0` = timer désarmé. |
|
|
292
|
+
| `gcJitter` | bool | `true` | Décale le départ du GC par process (anti _thundering herd_ sur un store partagé). |
|
|
293
|
+
| `refererCheck` | bool | `false` | Lie la session à l'hôte de création (défense en profondeur, `session.ts:366`). |
|
|
294
|
+
| `cookie.maxAge` | int ≥ 0 | `0` | `0` = cookie de session (effacé à la fermeture du navigateur). |
|
|
295
|
+
| `cookie.httpOnly` | bool | `true` | Inaccessible depuis JavaScript — anti-XSS. |
|
|
296
|
+
| `cookie.secure` | bool | `true` | Envoyé sur TLS uniquement. |
|
|
297
|
+
| `cookie.signed` | bool | `false` | Signe le cookie avec le secret HMAC du kernel. |
|
|
298
|
+
| `cookie.hostPrefix` | enum | `"auto"` | `__Host-` : `auto` (sur TLS) \| `true` (toujours) \| `false` (jamais). |
|
|
299
|
+
|
|
300
|
+
`SameSite` n'est pas dans ce bloc : il vient des options de cookie génériques, dont le défaut est
|
|
301
|
+
`Lax` (`defaultCookieOptions`, `cookie.ts:48`).
|
|
302
|
+
|
|
303
|
+
> [!WARNING]
|
|
304
|
+
> `idleTimeoutS: 0` **et** `absoluteTimeoutS: 0` désactivent les deux bornes : une session ne meurt
|
|
305
|
+
> alors plus jamais côté serveur. Le banc d'attaque `session-timeout.attack.test.ts` verrouille les
|
|
306
|
+
> défauts NIST (« défauts NIST actifs — idle 1800, absolute 43200 ») justement pour qu'un changement
|
|
307
|
+
> silencieux se voie.
|
|
308
|
+
|
|
309
|
+
### Comment `store: "auto"` se résout au boot
|
|
310
|
+
|
|
311
|
+
`auto` n'est pas un store : c'est une sentinelle résolue une fois, au boot, par `resolveAutoStore()`
|
|
312
|
+
(`config/infra.ts:241`), puis journalisée. Elle suit **l'infra que tu as déclarée**, bornée aux stores
|
|
313
|
+
réellement enregistrés (`SessionsService.initializeStorage()`, `sessions-service.ts:231`).
|
|
314
|
+
|
|
315
|
+
```mermaid
|
|
316
|
+
flowchart TD
|
|
317
|
+
A(["session.store = auto"]) --> F{"NF_STORE posé<br/>et enregistré ?"}
|
|
318
|
+
F -->|oui| FO["ce store<br/>(override global, bancs)"]
|
|
319
|
+
F -->|non| C{"infra cache<br/>NF_REDIS_URL ?"}
|
|
320
|
+
C -->|oui| RE["redis"]
|
|
321
|
+
C -->|non| D{"infra database<br/>NF_DATABASE_URL ?"}
|
|
322
|
+
D -->|mongo| MO["mongoose"]
|
|
323
|
+
D -->|sql| DZ["drizzle"]
|
|
324
|
+
D -->|aucune| L{"backend local<br/>persistant chargé ?"}
|
|
325
|
+
L -->|drizzle| SQ["drizzle (SQLite local)"]
|
|
326
|
+
L -->|mongoose| MG["mongoose"]
|
|
327
|
+
L -->|aucun| ME["memory (volatil)"]
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
Deux comportements à connaître :
|
|
331
|
+
|
|
332
|
+
- **Sans aucune infra déclarée, on ne tombe pas en `memory`** : si `@nodefony/drizzle` est chargé, la
|
|
333
|
+
session persiste en SQLite local — tes données survivent au redémarrage sans une ligne de config
|
|
334
|
+
(`infra.ts:288`).
|
|
335
|
+
- **Un `store` explicite inconnu ne dégrade pas en silence** : en production le boot est **avorté**, en
|
|
336
|
+
développement il y a repli `memory` **annoncé** en WARNING (`sessions-service.ts:252-273`).
|
|
337
|
+
|
|
338
|
+
## 🗂️ Choisir son store
|
|
339
|
+
|
|
340
|
+
Le contrat est unique — `ISessionStorage` (`ISession.ts:127`) — et **tous** les backends le portent.
|
|
341
|
+
Ce qui change, c'est la topologie et la façon d'expirer.
|
|
342
|
+
|
|
343
|
+
| Store | Où vit l'état | Multi-pod | Expiration idle | `total` admin | Quand le choisir |
|
|
344
|
+
| ---------- | --------------------- | :-------: | -------------------------- | :-----------: | -------------------------------------------- |
|
|
345
|
+
| `memory` | RAM du process | non | GC applicatif | exact | tests, CI, bancs de charge |
|
|
346
|
+
| `drizzle` | SQL (SQLite/PG/MySQL) | oui¹ | GC applicatif (2 DELETE) | exact | défaut persistant, mono ou multi-nœud |
|
|
347
|
+
| `redis` | Redis | oui | **TTL natif** (`SET … EX`) | inconnu (-1) | forte charge, cluster, sessions volumineuses |
|
|
348
|
+
| `mongoose` | MongoDB | oui | GC applicatif (`$lt`) | exact | pile déjà MongoDB |
|
|
349
|
+
|
|
350
|
+
¹ multi-pod dès que la base est partagée (PostgreSQL/MySQL) ; en SQLite local, mono-nœud.
|
|
351
|
+
|
|
352
|
+
### `memory` — l'implémentation de référence
|
|
353
|
+
|
|
354
|
+
Store built-in de `@nodefony/http`, enregistré d'office (`sessions-service.ts:860`). Les sessions vivent
|
|
355
|
+
dans une `Map` du process : elles **disparaissent au redémarrage** et ne sont **pas partagées** entre
|
|
356
|
+
pods — c'est un choix (mesurer le framework sans le goulot disque/SQL), pas une limite.
|
|
357
|
+
|
|
358
|
+
Il porte quand même **toute** la sémantique du contrat : `createdAt` figé à la création, `updatedAt`
|
|
359
|
+
rafraîchi par `MemorySessionStorage.touch()` (`MemorySessionStorage.ts:87`), purge sur les deux bornes
|
|
360
|
+
par `gc(idleSeconds, absoluteSeconds)` (`MemorySessionStorage.ts:99`), pagination offset à `total` exact
|
|
361
|
+
et tri déterministe (`MemorySessionStorage.listPage()`, `MemorySessionStorage.ts:161`).
|
|
362
|
+
|
|
363
|
+
### `drizzle` — SQL, le défaut persistant
|
|
364
|
+
|
|
365
|
+
Une table `session`, une ligne par session, écrite en **UPSERT atomique** (`INSERT … ON CONFLICT DO
|
|
366
|
+
UPDATE … RETURNING`) : une seule requête, aucune course entre insertion et mise à jour
|
|
367
|
+
(`@nodefony/drizzle/nodefony/src/SessionStorage.ts:124`). Le `touch` est un simple
|
|
368
|
+
`UPDATE updatedAt` sur la clé primaire, sans réécrire le blob
|
|
369
|
+
(`@nodefony/drizzle/nodefony/src/SessionStorage.ts:196`).
|
|
370
|
+
|
|
371
|
+
Le GC supprime en **deux `DELETE` distincts** — idle puis absolute — plutôt qu'un `$or`, pour rester
|
|
372
|
+
portable sur tous les adaptateurs `orm-core`
|
|
373
|
+
(`@nodefony/drizzle/nodefony/src/SessionStorage.ts:166-188`). La pagination est native
|
|
374
|
+
(`LIMIT`/`OFFSET` + `COUNT`), ordonnée `updatedAt DESC` puis `session_id ASC` pour rester déterministe
|
|
375
|
+
à horodatage égal (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:252`).
|
|
376
|
+
|
|
377
|
+
Détail à connaître : une session anonyme est stockée `user = NULL`, pas chaîne vide — le filtre le
|
|
378
|
+
traduit (`$null`) au lieu de chercher `""` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:258-261`).
|
|
379
|
+
|
|
380
|
+
### `redis` — TTL natif, zéro balayage
|
|
381
|
+
|
|
382
|
+
Ici l'expiration **idle** est portée par Redis lui-même : `SET … EX` pose le TTL à chaque écriture
|
|
383
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:137`) et `touch` le repositionne par un `EXPIRE` O(1)
|
|
384
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:166`). Conséquence : `gc()` est un **no-op assumé**
|
|
385
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:168`) — aucun balayage périodique.
|
|
386
|
+
|
|
387
|
+
L'absolute timeout, lui, n'est pas exprimable par un TTL glissant : il reste honoré **à la lecture**
|
|
388
|
+
par `Session.isValidSession()` (`session.ts:366`). Une entrée trop vieille peut donc survivre côté
|
|
389
|
+
Redis jusqu'à son TTL idle, mais elle est **refusée à la reprise**.
|
|
390
|
+
|
|
391
|
+
Capacités réduites, annoncées et non simulées : la pagination est **par curseur** (pas de `total`, pas
|
|
392
|
+
d'ordre global) et `countSessions()` renvoie **`-1`** = « je ne sais pas »
|
|
393
|
+
(`@nodefony/redis/nodefony/src/SessionStorage.ts:318`). L'appelant affiche l'inconnu, il ne l'invente pas.
|
|
394
|
+
|
|
395
|
+
### `mongoose` — MongoDB, parité de comportement
|
|
396
|
+
|
|
397
|
+
Même sémantique que le store SQL : `findOneAndUpdate({ upsert: true })` en une passe
|
|
398
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:102`), `touch` en `updateOne`
|
|
399
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:174`), GC en deux suppressions `$lt`
|
|
400
|
+
(`@nodefony/mongoose/nodefony/src/SessionStorage.ts:144-166`). Les horodatages sont des **nombres**
|
|
401
|
+
(epoch ms) et non des `Date` Mongo, précisément pour que le store reste interchangeable avec Drizzle.
|
|
402
|
+
|
|
403
|
+
> [!TIP]
|
|
404
|
+
> Ces quatre backends ne sont pas « à peu près » compatibles : leurs invariants communs sont exécutés
|
|
405
|
+
> par un **banc de contrat partagé** (`sessionStoreContract.ts` et `sessionPaginationContract.ts`),
|
|
406
|
+
> importé par chaque adaptateur. Un écart de comportement devient un test rouge, pas une surprise en
|
|
407
|
+
> production.
|
|
408
|
+
|
|
409
|
+
## 🏗️ Architecture interne — le cycle de vie
|
|
410
|
+
|
|
411
|
+
```mermaid
|
|
412
|
+
sequenceDiagram
|
|
413
|
+
participant K as HttpKernel
|
|
414
|
+
participant S as SessionsService
|
|
415
|
+
participant Se as Session
|
|
416
|
+
participant G as RevocationGuardStorage
|
|
417
|
+
participant St as Store réel
|
|
418
|
+
K->>K: intent de route ? cookie ?
|
|
419
|
+
K->>S: start(context, readOnly)
|
|
420
|
+
S->>Se: new Session + readOnly
|
|
421
|
+
Se->>G: start(id)
|
|
422
|
+
G->>St: start(id)
|
|
423
|
+
St-->>Se: blob sérialisé (ou vide)
|
|
424
|
+
Se->>Se: isValidSession (idle · absolute · referer)
|
|
425
|
+
Se-->>K: context.session
|
|
426
|
+
K->>K: contrôleur lit / écrit
|
|
427
|
+
K->>S: saveSession(context)
|
|
428
|
+
alt session mutée
|
|
429
|
+
S->>Se: save(user)
|
|
430
|
+
Se->>G: write(id, blob)
|
|
431
|
+
G->>St: write (refusé si pierre tombale)
|
|
432
|
+
else non mutée
|
|
433
|
+
S->>Se: touchIfNeeded()
|
|
434
|
+
Se->>G: touch(id, idle)
|
|
435
|
+
end
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**Reprise ou création.** `Session.start()` (`session.ts:144`) délègue à `getSession()` : cookie présent
|
|
439
|
+
→ `resume()` (`session.ts:177`), sinon `create()` (`session.ts:204`) qui tire un identifiant CSPRNG,
|
|
440
|
+
pose le cookie et marque la session à persister.
|
|
441
|
+
|
|
442
|
+
**Validation à la reprise.** `Session.isValidSession()` (`session.ts:365`) applique dans l'ordre le
|
|
443
|
+
`refererCheck` (si activé), l'**absolute** (âge depuis `created`, `session.ts:381`), puis l'**idle**
|
|
444
|
+
(depuis `updated`, `session.ts:394`). Échec → `invalidate()` (`session.ts:284`) détruit l'entrée et
|
|
445
|
+
recrée une session vierge.
|
|
446
|
+
|
|
447
|
+
**Écriture minimale.** `SessionsService.saveSession()` (`sessions-service.ts:414`) n'écrit **que** si la
|
|
448
|
+
session est `dirty` et non `readOnly` ; sinon il appelle `Session.touchIfNeeded()` (`session.ts:421`),
|
|
449
|
+
qui prolonge l'idle **sans réécrire le blob**, et seulement au-delà d'une demi-vie d'idle
|
|
450
|
+
(`session.ts:445`). Une requête de lecture coûte donc au pire un `UPDATE` d'horodatage toutes les
|
|
451
|
+
15 minutes (défaut).
|
|
452
|
+
|
|
453
|
+
**Anti-résurrection**, sur deux niveaux. `Session.destroy()` remet `mutated = false` (`session.ts:307`)
|
|
454
|
+
pour que la sauvegarde de fin de requête ne réécrive pas ce qu'on vient de supprimer. Surtout, **tout**
|
|
455
|
+
store est décoré par `RevocationGuardStorage` (`sessions-service.ts:279`) : `destroy()` pose une
|
|
456
|
+
**pierre tombale** de 5 minutes (`RevocationGuardStorage.ts:144`) qui refuse ensuite tout `write`
|
|
457
|
+
(`RevocationGuardStorage.ts:128`) **et tout `touch`** (`RevocationGuardStorage.ts:151`) du même
|
|
458
|
+
identifiant — ce qui couvre la requête « en vol » d'un autre client.
|
|
459
|
+
|
|
460
|
+
**Purge hors requête.** Un `GcScheduler` est armé au `onReady`, désarmé au `onTerminate`
|
|
461
|
+
(`sessions-service.ts:284-313`). La passe métier nue, `SessionsService.runGc()`
|
|
462
|
+
(`sessions-service.ts:470`), est publique exprès : un CronJob Kubernetes peut l'appeler à la place du
|
|
463
|
+
timer (`gcIntervalS: 0`).
|
|
464
|
+
|
|
465
|
+
## Entités de persistance
|
|
466
|
+
|
|
467
|
+
**Drizzle (SQL).** La table est décrite une seule fois en spec logique (`SESSION_TABLE_SPEC`,
|
|
468
|
+
`sessionEntity.ts:25`) et déclinée par dialecte par `buildFrameworkTable()` (`colKit.ts:543`) — mêmes **noms** de
|
|
469
|
+
colonnes partout, donc un store dialect-agnostique.
|
|
470
|
+
|
|
471
|
+
| Colonne | Type logique | SQLite | PostgreSQL | MySQL / MariaDB | Rôle |
|
|
472
|
+
| ------------ | ------------ | ------------------- | ---------- | --------------- | -------------------------------- |
|
|
473
|
+
| `session_id` | text (PK) | `text` | `text` | `varchar(512)` | Identifiant opaque. |
|
|
474
|
+
| `Attributes` | json | `text mode:json` | `jsonb` | `json` (compat) | Données applicatives. |
|
|
475
|
+
| `flashBag` | json | `text mode:json` | `jsonb` | `json` (compat) | Messages « une seule lecture ». |
|
|
476
|
+
| `metaBag` | json | `text mode:json` | `jsonb` | `json` (compat) | Métadonnées (ip, ua, host…). |
|
|
477
|
+
| `user` | text (null) | `text` | `text` | `text` | Propriétaire, `NULL` si anonyme. |
|
|
478
|
+
| `createdAt` | epoch ms | `integer` (64 bits) | `bigint` | `bigint` | Création — borne absolute. |
|
|
479
|
+
| `updatedAt` | epoch ms | `integer` (64 bits) | `bigint` | `bigint` | Dernière activité — borne idle. |
|
|
480
|
+
|
|
481
|
+
En MySQL/MariaDB, une colonne texte indexée devient `varchar` (un `TEXT` InnoDB n'est pas indexable
|
|
482
|
+
sans préfixe) et le type JSON passe par un type compatible qui tolère MariaDB, laquelle stocke le JSON
|
|
483
|
+
en `LONGTEXT` (`colKit.ts:466-469`).
|
|
484
|
+
|
|
485
|
+
**Mongoose (MongoDB).** Schéma équivalent (`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:16`) :
|
|
486
|
+
`session_id` (String, index **unique**), `Attributes`/`flashBag`/`metaBag` (Object, défaut `{}`), `user`
|
|
487
|
+
(String, défaut `null`), `createdAt`/`updatedAt` (Number, ms).
|
|
488
|
+
|
|
489
|
+
Le connecteur diffère volontairement entre les deux adaptateurs — `"default"` pour Drizzle
|
|
490
|
+
(`sessionEntity.ts:11`), `"nodefony"` pour Mongoose
|
|
491
|
+
(`@nodefony/mongoose/nodefony/entity/sessionEntity.ts:5`) — parce que le registre d'entités est
|
|
492
|
+
partagé par processus : deux noms distincts évitent la collision quand les deux ORM cohabitent.
|
|
493
|
+
|
|
494
|
+
## 🔌 HTTP et WebSocket — la même session
|
|
495
|
+
|
|
496
|
+
C'est le différenciateur du framework appliqué à l'état de session : un seul modèle, deux transports.
|
|
497
|
+
|
|
498
|
+
<!-- prettier-ignore -->
|
|
499
|
+
| Aspect | HTTP | WebSocket |
|
|
500
|
+
| --- | --- | --- |
|
|
501
|
+
| Ouverture | à chaque requête — `startSession()` dans `onRequestEnd()` (`http-kernel.ts:1391`) | **une fois** au handshake — `startSession()` dans `onConnect()` (`http-kernel.ts:1659`) |
|
|
502
|
+
| Lecture du cookie | constructeur du contexte | constructeur, même nom effectif (`WebsocketContext.ts:172`) |
|
|
503
|
+
| Sauvegarde | fin de requête | après **chaque frame** traitée (`WebsocketContext.ts:302`) |
|
|
504
|
+
| Filet de fermeture | — | `once("onFinish")` sauve si non déjà fait (`http-kernel.ts:1185`) |
|
|
505
|
+
| Portée ALS | une requête | **handshake + toutes les frames** (`http-kernel.ts:1495`) |
|
|
506
|
+
|
|
507
|
+
La conséquence pratique la plus utile : côté WebSocket, la bulle `AsyncLocalStorage` ouverte au
|
|
508
|
+
handshake par `RequestContext.run()` **enveloppe aussi les messages** (`http-kernel.ts:431`). L'identité résolue une fois est donc
|
|
509
|
+
disponible à chaque frame sans relire la base — c'est ce dont profite
|
|
510
|
+
`FirewallRealtimeAuthenticator.supports()` (`FirewallRealtimeAuthenticator.ts:80`), câblé automatiquement
|
|
511
|
+
par le firewall sur les zones temps réel protégées (`firewall.ts:300`).
|
|
512
|
+
|
|
513
|
+
> [!WARNING]
|
|
514
|
+
> Rien à écrire dans `initialize()` : il n'existe **pas** de `Controller.startSession()`. La session WS
|
|
515
|
+
> se déclare comme en HTTP, par `@UseSession()` **sur la route concernée**. La poser globalement ferait
|
|
516
|
+
> persister une session pour chaque connexion, y compris les routes qui n'en ont aucun besoin — sous
|
|
517
|
+
> charge (broadcast), c'est une tempête d'écritures.
|
|
518
|
+
|
|
519
|
+
## 🔐 Sécurité
|
|
520
|
+
|
|
521
|
+
### Régénération d'identifiant à la connexion (anti-fixation)
|
|
522
|
+
|
|
523
|
+
C'est la défense la plus importante et elle est **active**. `AuthFlow.#openSession()`
|
|
524
|
+
(`authFlow.ts:378`) : reprise ou ouverture de la session, mémorisation de l'ancien identifiant, puis
|
|
525
|
+
appel **inconditionnel** de `Session.regenerateId()` (`authFlow.ts:388`), et enfin destruction de
|
|
526
|
+
l'ancienne entrée du store (`authFlow.ts:390`). Un cookie pré-posé par un attaquant **ne survit donc pas
|
|
527
|
+
au login**. Le nouvel identifiant est un CSPRNG frais, l'état applicatif est conservé
|
|
528
|
+
(`Session.regenerateId()`, `session.ts:236`).
|
|
529
|
+
|
|
530
|
+
Au passage, la provenance est capturée dans le `metaBag` : `ip` (`authFlow.ts:405`) et `ua`
|
|
531
|
+
(`authFlow.ts:407`), en mode « au mieux » — ce sont ces deux champs que la console d'administration
|
|
532
|
+
affiche.
|
|
533
|
+
|
|
534
|
+
### Révocation — immédiate et par construction
|
|
535
|
+
|
|
536
|
+
| Surface | Méthode | Portée |
|
|
537
|
+
| ------------------------ | ----------------------------------------------- | ------------------------------------------- |
|
|
538
|
+
| Déconnexion locale | `Session.destroy()` (`session.ts:300`) | la session courante + pierre tombale |
|
|
539
|
+
| Révocation par un admin | `destroyByRef()` (`sessions-service.ts:707`) | une session désignée par sa `ref` publique |
|
|
540
|
+
| « Déconnecter partout » | `destroyByUser()` (`sessions-service.ts:737`) | toutes les sessions d'un utilisateur |
|
|
541
|
+
| « Mes appareils » (self) | `destroyOwnByRef()` (`sessions-service.ts:834`) | une session, **restreinte au propriétaire** |
|
|
542
|
+
|
|
543
|
+
Deux finesses valent d'être connues.
|
|
544
|
+
|
|
545
|
+
`destroyByUser()` ne fait pas un seul passage : il **repasse jusqu'à ce qu'un passage complet ne
|
|
546
|
+
détruise plus rien** (`sessions-service.ts:737`), car supprimer en parcourant décale les rangs sous un
|
|
547
|
+
curseur offset. Une révocation « partout » qui en laisserait une n'est pas une imprécision, c'est une
|
|
548
|
+
faille — on rend donc la main avec la preuve, pas l'espoir.
|
|
549
|
+
|
|
550
|
+
`destroyOwnByRef()` ferme l'IDOR **par construction** : parcours restreint aux sessions du demandeur,
|
|
551
|
+
et appartenance **re-vérifiée** avant même de comparer la `ref` (`sessions-service.ts:836`). Une
|
|
552
|
+
`ref` d'autrui est structurellement introuvable.
|
|
553
|
+
|
|
554
|
+
### Redaction — l'identifiant ne sort jamais du process
|
|
555
|
+
|
|
556
|
+
Trois barrières superposées :
|
|
557
|
+
|
|
558
|
+
1. Le contrat impose que `listPage()` rende `Attributes` et `flashBag` **vides**
|
|
559
|
+
(`ISession.ts:203`) — les stores SQL/NoSQL ne les sélectionnent même pas.
|
|
560
|
+
2. La projection vers l'extérieur passe par `toSessionSummary()` (`sessions-service.ts:112`), bâtie en
|
|
561
|
+
**liste blanche** : `ref`, `user`, `authenticated`, `ip`, `ua`, dates. Jamais un `delete` après coup.
|
|
562
|
+
3. La `ref` elle-même est un HMAC tronqué non réversible (`computeSessionRef()`,
|
|
563
|
+
`sessions-service.ts:100`) ; la clé est dérivée du certificat au boot et n'est jamais sérialisée
|
|
564
|
+
(`SessionsService.sessionRef()`, `sessions-service.ts:511`).
|
|
565
|
+
|
|
566
|
+
### Récapitulatif des défenses actives par défaut
|
|
567
|
+
|
|
568
|
+
| Menace | Défense | Ancrage |
|
|
569
|
+
| --------------------------------- | ------------------------------------------------- | -------------------------------------------------- |
|
|
570
|
+
| Vol par script injecté (XSS) | `HttpOnly` | `sessionCookieSchema` (`config.ts:718`) |
|
|
571
|
+
| Interception réseau | `Secure` + `__Host-` sur TLS | `getSessionCookieName()` (`Context.ts:714`) |
|
|
572
|
+
| Requête inter-sites | `SameSite=Lax` par défaut | `defaultCookieOptions` (`cookie.ts:48`) |
|
|
573
|
+
| Fixation (cookie pré-posé) | `strictMode` + régénération au login | `Session.resume()` (`session.ts:189`) |
|
|
574
|
+
| Identifiant deviné | 32 octets CSPRNG (43 caractères base64url) | `Session.generateId()` (`session.ts:226`) |
|
|
575
|
+
| Session volée exploitée longtemps | absolute timeout, jamais prolongé | `absoluteTimeoutS` à la reprise (`session.ts:381`) |
|
|
576
|
+
| Session oubliée ouverte | idle timeout glissant | `idleTimeoutS` à la reprise (`session.ts:394`) |
|
|
577
|
+
| Résurrection après révocation | pierre tombale 5 min sur `write` **et** `touch` | `RevocationGuardStorage.ts:121` |
|
|
578
|
+
| Fuite d'identifiant en admin | `ref` HMAC + projection en liste blanche | `toSessionSummary()` (`sessions-service.ts:112`) |
|
|
579
|
+
| IDOR sur « mes sessions » | périmètre depuis l'identité ALS, jamais du client | `destroyOwnByRef()` (`sessions-service.ts:834`) |
|
|
580
|
+
|
|
581
|
+
## 🧰 API publique
|
|
582
|
+
|
|
583
|
+
Les signatures vivent dans `.ai/symbols.json` (jamais recopiées ici). Voici les usages réels.
|
|
584
|
+
|
|
585
|
+
**Depuis un contrôleur** — `this.session` est un getter sur le contexte (`Controller.ts:229`) ; un
|
|
586
|
+
paramètre `@Session()` suffit à déclarer l'intent.
|
|
587
|
+
|
|
588
|
+
| Besoin | Appel | Effet |
|
|
589
|
+
| -------------------------------- | ------------------------------------ | ------------------------------------------------------- |
|
|
590
|
+
| Lire une valeur | `session.get("panier")` | `null` si absente — jamais `undefined`. |
|
|
591
|
+
| Écrire une valeur | `session.set("panier", items)` | Marque la session `dirty` → écriture en fin de requête. |
|
|
592
|
+
| Message « une seule lecture » | `session.setFlashBag("notice", "…")` | Consommé (et effacé) au premier `getFlashBag`. |
|
|
593
|
+
| Lire ce message | `session.getFlashBag("notice")` | Rend la valeur puis la supprime (`session.ts:518`). |
|
|
594
|
+
| Métadonnée technique | `session.getMetaBag("ip")` | ip / ua / host / remoteAddress posés à la création. |
|
|
595
|
+
| Se déconnecter | `await session.destroy(true)` | Détruit l'entrée store **et** efface le cookie. |
|
|
596
|
+
| Renouveler l'identifiant | `session.regenerateId()` | Nouvel identifiant, état conservé (`session.ts:236`). |
|
|
597
|
+
| Savoir si une écriture aura lieu | `session.dirty` | Le drapeau de dirty-tracking (`session.ts:128`). |
|
|
598
|
+
|
|
599
|
+
**Intent de route** — `UseSession(options)` (`routerDecorators.ts:761`) s'applique à une classe **ou** à
|
|
600
|
+
une méthode ; la méthode l'emporte, par fusion et non par remplacement
|
|
601
|
+
(`resolveSessionIntent()`, `routerDecorators.ts:819`). **Une seule** option (`SessionIntent`,
|
|
602
|
+
`ISession.ts:17`) :
|
|
603
|
+
|
|
604
|
+
- `readOnly: true` — la session est reprise et lue mais **jamais** persistée ; une mutation tentée est
|
|
605
|
+
journalisée en WARNING sans écriture (`Session.save()`, `session.ts:255-264`). C'est le seul champ
|
|
606
|
+
propagé par le kernel (`http-kernel.ts:1482`).
|
|
607
|
+
|
|
608
|
+
En décorateur de **classe**, `@UseSession` se place **sous** `@controller` (`routerDecorators.ts:761`).
|
|
609
|
+
|
|
610
|
+
## 🧩 Extension — brancher son propre store
|
|
611
|
+
|
|
612
|
+
Le registre est une inversion de contrôle complète : `@nodefony/http` ne connaît **aucun** backend.
|
|
613
|
+
Chaque module fournisseur se déclare lui-même au chargement, par
|
|
614
|
+
`SessionsService.registerStorage(nom, ctor)` (`sessions-service.ts:174`) — exactement ce que fait la
|
|
615
|
+
dernière ligne de chaque adaptateur (`@nodefony/redis/nodefony/src/SessionStorage.ts:323`).
|
|
616
|
+
|
|
617
|
+
Pour ajouter un backend :
|
|
618
|
+
|
|
619
|
+
1. Implémenter `ISessionStorage` (`ISession.ts:127`). Le **noyau obligatoire** est court :
|
|
620
|
+
`read`/`start`/`write`/`open`/`close`/`destroy`/`gc`.
|
|
621
|
+
2. Ajouter les capacités **optionnelles** utiles : `touch` (idle glissant sans réécriture, `ISession.ts:158`),
|
|
622
|
+
`listPage` + `countSessions` (administration paginée, `ISession.ts:203`), `listAll` (dump).
|
|
623
|
+
3. Appeler `SessionsService.registerStorage("mon-store", MonStore)` au chargement du module.
|
|
624
|
+
4. Exécuter les bancs de contrat partagés (`sessionStoreContract.ts`, `sessionPaginationContract.ts`)
|
|
625
|
+
contre l'implémentation — c'est ce qui garantit la parité.
|
|
626
|
+
|
|
627
|
+
Trois règles de conception se dégagent du contrat, et méritent d'être respectées :
|
|
628
|
+
|
|
629
|
+
- **Une capacité absente s'annonce.** Ne pas implémenter `listPage` fait répondre **501** à l'endpoint
|
|
630
|
+
d'administration (refus honnête) plutôt qu'une liste vide trompeuse (`ISession.ts:221`).
|
|
631
|
+
- **On n'invente pas ce qu'on ignore.** `countSessions()` renvoie `-1` quand compter coûterait trop
|
|
632
|
+
cher — Redis le fait (`ISession.ts:236`).
|
|
633
|
+
- **Une page ne matérialise jamais plus qu'une page.** C'est ce qui rend le coût d'une requête
|
|
634
|
+
d'administration indépendant du nombre de sessions.
|
|
635
|
+
|
|
636
|
+
## 📜 Normes appliquées
|
|
637
|
+
|
|
638
|
+
| Domaine | Norme | Comment le code s'y conforme |
|
|
639
|
+
| ----------------------------- | ------------------------- | ----------------------------------------------------------------------------- |
|
|
640
|
+
| Attributs et préfixes cookie | RFC 6265bis §4.1.3 | `__Host-` impose `Secure` + `Path=/`, interdit `Domain` (`cookie.ts:386-403`) |
|
|
641
|
+
| Nom du cookie selon transport | RFC 6265bis / OWASP | `getSessionCookieName()` (`Context.ts:714`) |
|
|
642
|
+
| Idle timeout | NIST SP 800-63B-4 / OWASP | défaut 1800 s, glissant par `touch` (`config.ts:796`) |
|
|
643
|
+
| Absolute timeout | NIST SP 800-63B-4 / OWASP | défaut 43200 s, jamais prolongé (`config.ts:808`) |
|
|
644
|
+
| Identifiant de session | OWASP Session Management | 32 octets CSPRNG, opaque (`session.ts:226`) |
|
|
645
|
+
| Identifiant hors URL | OWASP Session Management | cookie uniquement — jamais de réécriture d'URL (`session.ts:20-26`) |
|
|
646
|
+
| Renouvellement après auth | OWASP (anti-fixation) | `regenerateId()` inconditionnel au login (`authFlow.ts:388`) |
|
|
647
|
+
| Révocation côté serveur | OWASP | pierre tombale générique (`RevocationGuardStorage.ts:121`) |
|
|
648
|
+
|
|
649
|
+
## ⚡ Performance & mémoire
|
|
650
|
+
|
|
651
|
+
Le coût d'une session est **payé seulement quand elle sert** :
|
|
652
|
+
|
|
653
|
+
- **Zéro par défaut** — sans intent ni cookie, `startSession()` sort immédiatement
|
|
654
|
+
(`http-kernel.ts:1131`) : ni objet `Session`, ni lecture de store.
|
|
655
|
+
- **Objet léger** — trois sacs `{}` à plat, pas de container DI par session (`session.ts:100-104`).
|
|
656
|
+
- **Zéro écriture en lecture** — le dirty-tracking court-circuite `save()` (`session.ts:266`) ; le
|
|
657
|
+
`touch` est throttlé à une écriture par demi-vie d'idle (`session.ts:445`).
|
|
658
|
+
- **GC hors requête** — timer déterministe avec jitter par process, à la place du tirage
|
|
659
|
+
probabiliste hérité de PHP (`sessions-service.ts:306-312`).
|
|
660
|
+
- **Révocation quasi gratuite** — la `Map` de pierres tombales est **paresseuse** : sans révocation,
|
|
661
|
+
`write` ne paie qu'une comparaison `=== null`, sans même un `Date.now()`
|
|
662
|
+
(`RevocationGuardStorage.ts:146-151`).
|
|
663
|
+
- **Administration bornée** — jamais plus de `SCAN_PAGE = 200` enregistrements en mémoire
|
|
664
|
+
(`sessions-service.ts:75`), garde-fou à 5 000 pages (`sessions-service.ts:83`), parcours interrompu
|
|
665
|
+
**journalisé**.
|
|
666
|
+
|
|
667
|
+
Le banc `session-load.test.ts` verrouille ces propriétés sur serveur réel (200 sessions HTTP, 100
|
|
668
|
+
ouvertures/fermetures WebSocket) en mesurant le **drainage des scopes DI** — immunisé au bruit du GC —
|
|
669
|
+
plus un plafond de croissance du tas.
|
|
670
|
+
|
|
671
|
+
> [!NOTE]
|
|
672
|
+
> Les trois sacs sont des objets **littéraux** et non `Object.create(null)`. C'est délibéré :
|
|
673
|
+
> `drizzle-orm` déréférence le prototype via `is()`, et un objet sans prototype ferait échouer
|
|
674
|
+
> l'écriture (`session.ts:95-98`).
|
|
675
|
+
|
|
676
|
+
## 📡 Observabilité — Studio
|
|
677
|
+
|
|
678
|
+
**Data plane** — `createHttpAdminApi()` (`HttpAdminApi.ts:141`) expose la surface d'administration sous
|
|
679
|
+
`/nodefony/http/api/` :
|
|
680
|
+
|
|
681
|
+
| Route | Verbe | Accès | Rôle |
|
|
682
|
+
| ----------------------------------- | ----- | ----------------------- | -------------------------------------------------- |
|
|
683
|
+
| `sessions` | GET | — | état du sous-système (`HttpAdminApi.ts:280`) |
|
|
684
|
+
| `sessions/list` | GET | `ROLE_NODEFONY_ADMIN` | page de sessions redactées (`HttpAdminApi.ts:324`) |
|
|
685
|
+
| `sessions/{ref}/revoke` | POST | `ROLE_NODEFONY_ADMIN` | révoquer une session (`HttpAdminApi.ts:380`) |
|
|
686
|
+
| `sessions/revoke-user/{identifier}` | POST | `ROLE_NODEFONY_ADMIN` | déconnecter partout (`HttpAdminApi.ts:415`) |
|
|
687
|
+
| `sessions/mine` | GET | utilisateur authentifié | « mes appareils » (`HttpAdminApi.ts:458`) |
|
|
688
|
+
| `sessions/mine/{ref}/revoke` | POST | utilisateur authentifié | fermer une de mes sessions (`HttpAdminApi.ts:514`) |
|
|
689
|
+
|
|
690
|
+
Les deux routes `mine` ne demandent pas de rôle, mais **ne sont pas anonymes** : la zone firewall des
|
|
691
|
+
API d'administration n'accepte que l'authenticator `session` (pas d'`anonymous`), et le périmètre est
|
|
692
|
+
pris sur l'identité ALS, jamais sur un paramètre client (`HttpAdminApi.ts:451-457`).
|
|
693
|
+
|
|
694
|
+
Chaque ligne rendue porte **`current`** — vrai pour LA session qui a émis la requête, et pour elle
|
|
695
|
+
seule. C'est le « cet appareil » des consoles d'appareils connectés, et le client ne peut pas le
|
|
696
|
+
déduire : la référence est un HMAC du cookie, que le navigateur ne sait pas calculer. Sans lui, aucune
|
|
697
|
+
ligne n'est désignable — ni celle qu'on ferme, ni celle qu'il ne faut pas fermer. Comparer les
|
|
698
|
+
utilisateurs ne le remplace pas : dans `sessions/mine`, toutes les lignes portent le même. La
|
|
699
|
+
dérivation est faite une fois par page (`sessions-service.ts` `currentSessionRef`) ; `false` quand la
|
|
700
|
+
requête ne porte pas de session (appel interne, invocation CLI).
|
|
701
|
+
|
|
702
|
+
Codes de réponse à connaître : **501** si le store courant ne sait pas s'énumérer
|
|
703
|
+
(`supportsEnumeration()` faux — `HttpAdminApi.ts:352`), **503** si le service de session est absent, **404** pour une `ref` inconnue ou
|
|
704
|
+
une révocation sans effet, **401** sur `mine` sans identité.
|
|
705
|
+
|
|
706
|
+
**Écrans** — la page **Sessions** (`/nodefony/sessions`) de Studio liste les sessions vivantes par `ref` et permet la
|
|
707
|
+
révocation unitaire ou en masse (`@nodefony/studio/frontend/src/routes/sessions/`). L'écran **Stores**
|
|
708
|
+
affiche le backend réellement résolu, sa provenance et son emplacement physique : ces informations sont
|
|
709
|
+
publiées au boot par `registerStoreResolution()` (`sessions-service.ts:290`), avec le chemin du fichier
|
|
710
|
+
SQLite quand c'est pertinent (`SessionStorage.location`,
|
|
711
|
+
`@nodefony/drizzle/nodefony/src/SessionStorage.ts:43`).
|
|
712
|
+
|
|
713
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
714
|
+
|
|
715
|
+
| Symptôme | Cause | Correction |
|
|
716
|
+
| ------------------------------------------------------ | ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
717
|
+
| Aucun `Set-Cookie`, `session` toujours vide | La route n'a **aucun intent** : ni `@UseSession`, ni paramètre `@Session` | Ajouter l'un des deux — l'activation est paresseuse (`http-kernel.ts:1142`) |
|
|
718
|
+
| `context.session` est `null` dans un contrôleur WS | Intent posé sur la classe au lieu de la route, ou absent | `@UseSession()` **sur la route** WebSocket ; `Controller.startSession()` n'existe plus |
|
|
719
|
+
| Mutation ignorée, WARNING « READONLY SESSION mutated » | La route est en `@UseSession({ readOnly: true })` | Retirer `readOnly` sur les routes qui écrivent (`session.ts:255-264`) |
|
|
720
|
+
| Session détruite qui « revient » après logout | Une requête en vol réécrit le blob supprimé | Déjà couvert : pierre tombale 5 min (`RevocationGuardStorage.ts:121`) |
|
|
721
|
+
| Sessions perdues à chaque redémarrage ou entre pods | Store `memory` (volatil, per-pod) | Déclarer une infra (`NF_DATABASE_URL`/`NF_REDIS_URL`) ou nommer le store |
|
|
722
|
+
| Le boot s'arrête sur « session store … inconnu » | Nom de store explicite non enregistré, en production | Charger le module fournisseur, ou corriger le nom (`sessions-service.ts:258-262`) |
|
|
723
|
+
| Total des sessions affiché « inconnu » en admin | Store Redis : compter coûterait un `SCAN` complet | Comportement voulu — `countSessions()` rend `-1`, on n'invente pas |
|
|
724
|
+
| Liste admin en 501 | Le store n'implémente pas `listPage` | Refus honnête ; utiliser un store énumérable pour l'administration |
|
|
725
|
+
| Cookie sans `__Host-` en développement | Transport en clair : le navigateur rejetterait le préfixe | Normal en `http://` ; forcer avec `cookie.hostPrefix: true` derrière un proxy TLS |
|
|
726
|
+
| Session qui n'expire jamais malgré l'inactivité | `idleTimeoutS: 0` (et/ou `absoluteTimeoutS: 0`) | Garder les défauts NIST ; l'absolute borne l'âge même sous activité |
|
|
727
|
+
| 500 pendant l'arrêt du serveur, requête en vol | L'ORM se déconnecte avant le drain des serveurs | Dégradé gracieusement : le repository rend `null` (`@nodefony/drizzle/nodefony/src/SessionStorage.ts:65`) |
|
|
728
|
+
|
|
729
|
+
## 🧪 Tests & couverture
|
|
730
|
+
|
|
731
|
+
Les six familles sont présentes — les **chiffres exacts vivent dans la carte de l'aperçu**, régénérée
|
|
732
|
+
depuis vitest, jamais figés ici.
|
|
733
|
+
|
|
734
|
+
<!-- prettier-ignore -->
|
|
735
|
+
| Type | Où | Ce qui est prouvé |
|
|
736
|
+
| --- | --- | --- |
|
|
737
|
+
| Unitaires | `unit/Session.test.ts`, `unit/MemorySessionStorage.test.ts`, `unit/SessionsAdmin.test.ts` | cycle de vie, sacs, sérialisation, surface admin |
|
|
738
|
+
| Unitaires (intent) | `@nodefony/framework` `unit/UseSession.test.ts` | précédence classe/méthode, intent implicite par `@Session` |
|
|
739
|
+
| **Tests d'attaque** | `unit/session-timeout.attack.test.ts` | absolute non contournable par `touch`, touch d'une session révoquée refusé, défauts NIST verrouillés |
|
|
740
|
+
| Intégration (serveur) | `http/session.test.ts`, `http/session-runtime.test.ts`, `http/session-bff.test.ts` | activation paresseuse, cookie RFC sur TLS, flashBag, `regenerateId` |
|
|
741
|
+
| Intégration (révocation) | `integration/session-revocation.test.ts`, `integration/stores-location.test.ts` | anti-résurrection, store réellement résolu |
|
|
742
|
+
| WebSocket | `websockets/websocket-session.test.ts` | session au handshake |
|
|
743
|
+
| Stores | `@nodefony/drizzle`, `@nodefony/mongoose`, `@nodefony/redis` (dont pagination et résilience) | comportement de chaque backend |
|
|
744
|
+
| **E2E (base réelle)** | `@nodefony/drizzle` `session-store-postgres.e2e.test.ts`, `session-store-mysql.e2e.test.ts` | dialectes réels — gatés par `NF_PG_URL` / `NF_MYSQL_URL` |
|
|
745
|
+
| **Charge / mémoire** | `load/session-load.test.ts` | scopes DI drainés + tas borné (serveur live requis) |
|
|
746
|
+
| **Bancs de contrat** | `tests/support/sessionStoreContract.ts`, `sessionPaginationContract.ts` | invariants tenus par **tous** les stores |
|
|
747
|
+
|
|
748
|
+
> [!CAUTION]
|
|
749
|
+
> Les suites E2E se **skippent** sans leurs variables d'infra, et un skip compte comme vert. Avant de
|
|
750
|
+
> conclure « tout passe » sur les dialectes PostgreSQL/MySQL, vérifier que `NF_PG_URL`/`NF_MYSQL_URL`
|
|
751
|
+
> étaient bien posées (source unique : `vitest.gates.ts` à la racine).
|
|
752
|
+
|
|
753
|
+
Skills utiles : `nodefony-load-test` (rejouer ou étendre la charge), `nodefony-check-memory-health`
|
|
754
|
+
(gate mémoire), `nodefony-security-review` (fixation, timeouts, révocation).
|
|
755
|
+
|
|
756
|
+
**Couverture** : `npm run coverage` dans `@nodefony/http` (vitest, reporter `json-summary`). Le
|
|
757
|
+
pourcentage vit dans le rapport, **jamais figé** dans ce Markdown.
|
|
758
|
+
|
|
759
|
+
## 🔗 Pour aller plus loin
|
|
760
|
+
|
|
761
|
+
- ⬆️ **Retour au hub** : [@nodefony/http — vue du module](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
762
|
+
- 🧭 **Pages sœurs** : qui authentifie la session → [Firewall](../../security/docs/firewall.md) ·
|
|
763
|
+
[Authenticators](../../security/docs/authenticators.md) · rejeu de mutation →
|
|
764
|
+
[Idempotence](../../framework/docs/idempotence.md)
|
|
765
|
+
- 🗄️ **Les stores en détail** : [@nodefony/drizzle](../../drizzle/docs/index.md) ·
|
|
766
|
+
[@nodefony/redis](../../redis/docs/index.md) · [@nodefony/mongoose](../../mongoose/docs/index.md)
|
|
767
|
+
- 🧰 **Guide pratique** : [choisir et configurer son stockage de session](../../../../../docs/guides/session-storage.md)
|
|
768
|
+
- 🏗️ **Où la session s'insère** : [pipeline de requête](../../../../../docs/architecture/pipeline-requete.md)
|