@nodefony/security 10.0.0-alpha.3 → 10.0.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/LICENSE +201 -543
  2. package/README.md +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
  4. package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
  5. package/dist/index.js +4 -2
  6. package/dist/nodefony/command/security-secrets.js +7 -3
  7. package/dist/nodefony/command/security-token.js +5 -5
  8. package/dist/nodefony/command/security-user-add.js +22 -6
  9. package/dist/nodefony/command/security-user-password.js +111 -0
  10. package/dist/nodefony/config/config.js +3 -1
  11. package/dist/nodefony/service/auditService.js +3 -3
  12. package/dist/nodefony/service/oauth2.js +79 -2
  13. package/dist/nodefony/service/tokenService.js +3 -3
  14. package/dist/nodefony/service/totp.js +3 -3
  15. package/dist/nodefony/service/webAuthn.js +3 -3
  16. package/dist/nodefony/service/webhooks.js +3 -3
  17. package/dist/nodefony/src/token/JwtKeystore.js +37 -1
  18. package/dist/types/nodefony/command/security-user-add.d.ts +16 -0
  19. package/dist/types/nodefony/command/security-user-password.d.ts +31 -0
  20. package/dist/types/nodefony/config/config.d.ts +2 -0
  21. package/dist/types/nodefony/service/oauth2.d.ts +41 -1
  22. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +20 -0
  23. package/docs/audit.md +5 -5
  24. package/docs/authenticators.md +10 -10
  25. package/docs/authorization.md +2 -2
  26. package/docs/cors.md +4 -4
  27. package/docs/csrf.md +5 -5
  28. package/docs/firewall.md +7 -7
  29. package/docs/headers.md +7 -7
  30. package/docs/index.md +24 -0
  31. package/docs/oauth2.md +54 -32
  32. package/docs/tokens.md +10 -9
  33. package/docs/totp.md +1 -1
  34. package/docs/webauthn.md +3 -3
  35. package/docs/webhooks.md +1 -1
  36. package/package.json +12 -12
@@ -238,6 +238,8 @@ export declare const securityConfigSchema: z.ZodObject<{
238
238
  successRedirect: z.ZodOptional<z.ZodString>;
239
239
  failureRedirect: z.ZodOptional<z.ZodString>;
240
240
  defaultRoles: z.ZodOptional<z.ZodArray<z.ZodString>>;
241
+ label: z.ZodOptional<z.ZodString>;
242
+ hidden: z.ZodDefault<z.ZodBoolean>;
241
243
  }, z.core.$strict>>>;
242
244
  }, z.core.$strict>>;
243
245
  apiKeys: z.ZodDefault<z.ZodObject<{
@@ -1,4 +1,26 @@
1
1
  import { Service, Module } from "nodefony";
2
+ /**
3
+ * Libellé affichable d'un fournisseur, quand sa configuration n'en donne pas.
4
+ *
5
+ * Un écran de connexion ne doit JAMAIS montrer un identifiant technique brut :
6
+ * `mon-idp-interne` sur un bouton ne dit rien à qui doit cliquer. À défaut de
7
+ * marque connue, le nom de la clé de configuration est ce qui s'en rapproche le
8
+ * plus — mais rendu lisible : séparateurs en espaces, initiales en capitales,
9
+ * sigles préservés.
10
+ *
11
+ * Fonction PURE, donc éprouvable sans boot ni réseau.
12
+ *
13
+ * @param name - nom du fournisseur, tel qu'il est écrit dans la configuration
14
+ * @returns le libellé à afficher sur le bouton
15
+ */
16
+ export declare function oauthDisplayLabel(name: string): string;
17
+ /** Un fournisseur tel que l'écran de connexion doit le présenter. */
18
+ export interface IOAuthDisplayProvider {
19
+ /** Nom technique — celui que l'URL `/authorize` attend. */
20
+ readonly name: string;
21
+ /** Libellé du bouton : celui de la config, sinon dérivé du nom. */
22
+ readonly label: string;
23
+ }
2
24
  /** Données à porter en session entre `authorize` et `callback` (anti-replay). */
3
25
  export interface IOAuthAuthorization {
4
26
  /** URL d'autorisation vers laquelle rediriger l'utilisateur. */
@@ -33,8 +55,26 @@ declare class OAuth2Service extends Service {
33
55
  constructor(module: Module);
34
56
  /** `true` si le social login est opérationnel (activé + boot OK). */
35
57
  isEnabled(): boolean;
36
- /** Noms des fournisseurs configurés ET connus du registre (UI : boutons à afficher). */
58
+ /**
59
+ * Noms des fournisseurs OPÉRATIONNELS — configurés ET connus du registre.
60
+ *
61
+ * 🔴 C'est la **garde d'autorisation** : `/authorize` refuse en 404 tout nom
62
+ * absent de cette liste. Elle répond donc à « ce flux peut-il s'ouvrir ? »,
63
+ * jamais à « ce bouton doit-il s'afficher ? » — pour l'écran, voir
64
+ * {@link listDisplayProviders}. Confondre les deux ferait d'un masquage une
65
+ * désactivation, et couperait les bancs qui exercent une fixture masquée.
66
+ */
37
67
  listProviders(): string[];
68
+ /**
69
+ * Fournisseurs à MONTRER sur l'écran de connexion, libellés compris.
70
+ *
71
+ * Rend TOUT fournisseur opérationnel — y compris ceux dont le framework ne
72
+ * connaît pas la marque, qui sont précisément ceux qu'une application
73
+ * enregistre elle-même. Le seul retrait possible est explicite et se lit dans
74
+ * la configuration du fournisseur (`hidden: true`), à côté de la raison qui
75
+ * l'a motivé ; il ne désactive rien.
76
+ */
77
+ listDisplayProviders(): IOAuthDisplayProvider[];
38
78
  /**
39
79
  * Redirections post-login (succès / échec) — lues par le controller.
40
80
  * Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
@@ -9,6 +9,26 @@ interface KeystoreSource {
9
9
  /** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
10
10
  readonly dir?: string;
11
11
  }
12
+ /**
13
+ * Le trousseau va-t-il partir dans l'image de conteneur ?
14
+ *
15
+ * ⭐ **Pourquoi cet avertissement existe.** Le trousseau est une clé privée
16
+ * Ed25519. Elle ne sort pas de l'image aujourd'hui pour UNE seule raison : le
17
+ * gabarit a choisi `var/keys`, et le `Dockerfile` généré efface `var/`. Rien
18
+ * n'attache cette sécurité à la configuration — un utilisateur qui écrit
19
+ * `keystore: { dir: "nodefony/config/keys" }`, chemin parfaitement raisonnable,
20
+ * publie sa clé privée sans qu'aucun signal n'existe. C'est exactement le
21
+ * chemin par lequel une clé TLS est déjà partie dans une image publiée.
22
+ *
23
+ * ⚠️ **Les chemins ABSOLUS ne sont pas jugés**, et c'est délibéré : `/etc/…` ou
24
+ * un point de montage sont des choix d'exploitation qui sortent du contexte de
25
+ * construction, et prétendre les évaluer ferait crier ce contrôle sur la
26
+ * pratique la plus saine. Un contrôle qui crie faux apprend à passer outre.
27
+ *
28
+ * @param dir - la valeur de `jwt.keystore.dir`, telle que configurée.
29
+ * @returns le message à journaliser, ou `null` quand le dossier est nettoyé.
30
+ */
31
+ export declare function keystoreLeaksIntoImage(dir: string): string | null;
12
32
  /**
13
33
  * Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
14
34
  *
package/docs/audit.md CHANGED
@@ -383,7 +383,7 @@ Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recor
383
383
  - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
384
384
  - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
385
385
  (`firewall.ts:811`) ;
386
- - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:638`).
386
+ - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
387
387
 
388
388
  Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
389
389
  `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
@@ -458,7 +458,7 @@ Quatre mécanismes, tous prouvés par les tests.
458
458
 
459
459
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
460
460
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
461
- (`firewall.ts:884`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
461
+ (`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
462
462
  Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
463
463
 
464
464
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
@@ -484,7 +484,7 @@ jamais faire tomber ce qu'on supervise.
484
484
  ## ⚙️ Configuration
485
485
 
486
486
  Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
487
- (`config.ts:1121`).
487
+ (`config.ts:1152`).
488
488
 
489
489
  | Option | Type | Défaut | Effet |
490
490
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
@@ -503,7 +503,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
503
503
  ### Comment `store: "auto"` décide
504
504
 
505
505
  Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
506
- enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:241`).
506
+ enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:289`).
507
507
 
508
508
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
509
509
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
@@ -618,7 +618,7 @@ pour les migrations de production ; en dev et en test, le DDL dérivé les ignor
618
618
  filtrage, jamais de sémantique.
619
619
 
620
620
  L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
621
- (`registerStores.ts:241`, entité via `registerAuditEntities()`, `auditEventEntity.ts:141`). Côté
621
+ (`registerStores.ts:276`, entité via `registerAuditEntities()`, `auditEventEntity.ts:141`). Côté
622
622
  implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts:64`) résout son handle de base **à chaque
623
623
  appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
624
624
  `onTerminate` avant le drain des serveurs.
@@ -49,7 +49,7 @@ flowchart TD
49
49
  S --> CTRL["→ autorisation → contrôleur"]
50
50
  ```
51
51
 
52
- C'est `Firewall.#authenticate()` (`firewall.ts:1112`) qui déroule ce cycle pour chaque maillon de la
52
+ C'est `Firewall.#authenticate()` (`firewall.ts:1128`) qui déroule ce cycle pour chaque maillon de la
53
53
  zone, dans l'ordre déclaré. Le succès pose l'identité dans l'ALS ; l'échec remonte au firewall qui
54
54
  pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la réponse.
55
55
 
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
105
105
  ### Le registre pluggable
106
106
 
107
107
  Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
108
- (`firewall.ts:402`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
108
+ (`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
109
109
  un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
110
110
 
111
111
  - Les **cinq builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `apikey`)
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
116
116
  - La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
117
117
  `tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
118
118
  - Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
119
- (`firewall.ts:419`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
119
+ (`firewall.ts:582`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
120
120
  faute de frappe.
121
121
 
122
122
  ## 🚀 Démarrage rapide
@@ -261,10 +261,10 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
261
261
  d'un utilisateur (`SessionAuthenticator.ts:43-46`) — le pipeline http démarre la session _avant_
262
262
  le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
263
263
  - **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
264
- (`SessionAuthenticator.ts:70`) → rôles frais, révocation immédiate. Les contrôles d'état sont
264
+ (`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
265
265
  partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts:40`).
266
266
  - `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
267
- principal courant (`SessionAuthenticator.ts:78-80`).
267
+ principal courant (`SessionAuthenticator.ts:110-116`).
268
268
  - **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login,
269
269
  jamais une popup Basic (`SessionAuthenticator.ts:25-27`).
270
270
 
@@ -353,12 +353,12 @@ Deux preuves différentes, mêmes routes — c'est la config du Démarrage rapid
353
353
  de lecture :
354
354
 
355
355
  - un maillon dont `supports()` est faux est simplement **sauté** en mode `first`
356
- (`firewall.ts:1128`) ;
356
+ (`firewall.ts:1114`) ;
357
357
  - un credential **présenté mais invalide échoue immédiatement** — l'échec d'`authenticate()`
358
- remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1112`). Une clé
358
+ remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1128`). Une clé
359
359
  API révoquée donne un 401 direct, même si un autre maillon aurait pu réussir.
360
360
  - aucune preuve présentée sur toute la chaîne → `handleSecurity()` lève l'`AuthenticationError`
361
- Zero Trust (`firewall.ts:738`).
361
+ Zero Trust (`firewall.ts:754`).
362
362
 
363
363
  ### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
364
364
 
@@ -397,7 +397,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
397
397
  Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
398
398
  invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
399
399
  client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
400
- en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1191`).
400
+ en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
401
401
 
402
402
  ## 🧩 Ajouter un authenticator maison
403
403
 
@@ -423,7 +423,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
423
423
  <!-- prettier-ignore -->
424
424
  | Domaine | Norme | Ancrage |
425
425
  | --- | --- | --- |
426
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1191`) |
426
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
427
427
  | Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
428
428
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
429
429
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
@@ -302,7 +302,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
302
302
 
303
303
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
304
304
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
305
- (`Resolver.ts:592-600`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
305
+ (`Resolver.ts:587-607`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
306
306
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
307
307
  `@IsGranted("doc.edit", { subject: "id" })`.
308
308
 
@@ -428,7 +428,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
428
428
  et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
429
429
  - **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
430
430
  hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
431
- (`firewall.ts:466`) — les rôles exigés par un canal héritent comme partout ailleurs.
431
+ (`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
432
432
 
433
433
  ## 📜 Normes appliquées
434
434
 
package/docs/cors.md CHANGED
@@ -323,7 +323,7 @@ sequenceDiagram
323
323
  ```
324
324
 
325
325
  `Firewall.handleCors()` (`firewall.ts:991`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
- (`http-kernel.ts:1258`), à la ligne `http-kernel.ts:1258` — **avant le routing**. La raison est
326
+ (`http-kernel.ts:1301`), à la ligne `http-kernel.ts:1301` — **avant le routing**. La raison est
327
327
  concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
328
328
  router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
329
329
  credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
@@ -338,7 +338,7 @@ Quatre sorties en no-op, dans cet ordre (`firewall.ts:797`) :
338
338
  court-circuité en 204 (`firewall.ts:822`).
339
339
 
340
340
  **La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
341
- `Access-Control-Request-Method` (`firewall.ts:808`). Un `OPTIONS` nu — celui d'un client qui interroge
341
+ `Access-Control-Request-Method` (`firewall.ts:999`). Un `OPTIONS` nu — celui d'un client qui interroge
342
342
  les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
343
343
 
344
344
  ### Ce que chaque moment pose
@@ -384,7 +384,7 @@ origine (`config.ts:180`). Ajouter une origine à `cors.origins` est **plus** pe
384
384
  **Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
385
385
  `new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
386
386
  victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
387
- (`firewall.ts:991`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
387
+ (`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
388
388
 
389
389
  La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
390
390
  (`http-kernel.ts:599`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
@@ -428,7 +428,7 @@ Le coût par requête est donc :
428
428
  ## 📡 Observabilité — Studio
429
429
 
430
430
  La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
431
- `Firewall.describe()` (`firewall.ts:505`), qui délègue à `Firewall.#describeDefenses()`
431
+ `Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
432
432
  (`firewall.ts:575`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
433
  `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:594`) — aucun secret ne transite par
434
434
  cette surface.
package/docs/csrf.md CHANGED
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
93
93
  - **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
94
94
  couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:151-156`).
95
95
  - **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
96
- branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1427`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1470`) arrive **après** le
97
97
  resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
98
98
  attaquant ne coûte ni lecture de session ni authentification).
99
99
  - **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
@@ -153,7 +153,7 @@ le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les ac
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
155
155
  1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
156
- (`firewall.ts:753-757`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
156
+ (`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
157
157
  exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
158
158
  2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
159
159
  `x-csrf-token` sur chaque mutation.
@@ -298,10 +298,10 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
298
298
  2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
299
299
  (`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
300
300
  (`Context.ts:181-183`).
301
- 3. `Firewall.enforceCsrf()` (`firewall.ts:932`) fait les trois rôles : **émission** du token sur
301
+ 3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
302
302
  requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
303
303
  `@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
304
- (`firewall.ts:743-745`), les `@CsrfExempt` sortent après la barrière méthode sûre
304
+ (`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
305
305
  (`firewall.ts:951`).
306
306
  4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
307
307
  avec le cookie de session (`HttpContext.ts:419-432`).
@@ -346,7 +346,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
346
346
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
347
347
  `Firewall.tsx:313-314`). La projection est **sans secret par construction** :
348
348
  `Firewall.#describeDefenses()` (`firewall.ts:575`) publie la config résolue, et `synchronizerToken`
349
- n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:557`).
349
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
350
350
 
351
351
  ## ⚠️ Pièges (symptôme → cause → correction)
352
352
 
package/docs/firewall.md CHANGED
@@ -342,7 +342,7 @@ Ce qui se passe, requête par requête :
342
342
  | --- | --- | --- |
343
343
  | le cookie de session | `session` | identifié, `apikey` jamais consulté |
344
344
  | `Authorization: Bearer nf_…` | `apikey` | identifié (session ne matche pas, on passe) |
345
- | une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1112`) |
345
+ | une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1128`) |
346
346
  | rien | aucun | **401** (Zero Trust) |
347
347
 
348
348
  ### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
@@ -377,7 +377,7 @@ Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte
377
377
 
378
378
  > [!TIP]
379
379
  > Un nom d'authenticator inconnu en config **fait échouer le boot** —
380
- > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:402`) : jamais de zone
380
+ > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:429`) : jamais de zone
381
381
  > « protégée » silencieusement ouverte à cause d'une faute de frappe.
382
382
 
383
383
  ## 🧑‍⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
@@ -470,10 +470,10 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
470
470
 
471
471
  ## 🛡️ En-têtes de sécurité, CSRF, CORS
472
472
 
473
- - **`Firewall.applySecurityHeaders()`** (`firewall.ts:1029`) : CSP, Referrer-Policy, COOP/COEP/CORP
474
- au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:855`) :
473
+ - **`Firewall.applySecurityHeaders()`** (`firewall.ts:1045`) : CSP, Referrer-Policy, COOP/COEP/CORP
474
+ au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
475
475
  alloué seulement si une directive en a besoin.
476
- - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:932`) : Fetch Metadata
476
+ - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
477
477
  (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:764`), puis double-submit `x-csrf-token` ≡
478
478
  cookie + HMAC (`firewall.ts:778`).
479
479
  - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
@@ -482,13 +482,13 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
482
482
 
483
483
  | Domaine | Norme | Ancrage |
484
484
  | ---------------------- | --------------- | ------------------------------------------------------ |
485
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1191`) |
485
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
486
486
  | Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
487
487
  | JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
488
488
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
489
489
  | Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:764`) |
490
490
  | Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
491
- | CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:932`) |
491
+ | CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
492
492
  | Modèle | Zero Trust | `firewall.ts:611` (aucune preuve → 401) |
493
493
 
494
494
  ## ⚡ Performance & mémoire
package/docs/headers.md CHANGED
@@ -39,7 +39,7 @@ source: "src/packages/@nodefony/security/docs/headers.md"
39
39
  > (`@nodefony/http`, dès l'entrée brute — couvre aussi les fichiers statiques et les erreurs) et la
40
40
  > couche **applicative** (`@nodefony/security`, dans le pipeline — CSP, Referrer-Policy, isolation
41
41
  > cross-origin). Ancré sur `SecurityHeaders` (`securityHeaders.ts:42`) et
42
- > `Firewall.applySecurityHeaders()` (`firewall.ts:1029`).
42
+ > `Firewall.applySecurityHeaders()` (`firewall.ts:1045`).
43
43
 
44
44
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **En-têtes de sécurité**
45
45
 
@@ -287,7 +287,7 @@ pour un HTML statique servi directement depuis `public/`.
287
287
  avec tes cookies.
288
288
 
289
289
  Valeur unique reconnue : `nosniff`, posée depuis le cache `secContentTypeOptions`
290
- (`http-kernel.ts:1334`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
290
+ (`http-kernel.ts:963`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
291
291
  vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404
292
292
  (`security-headers.test.ts:38`).
293
293
 
@@ -452,7 +452,7 @@ rester imprévisible, jamais pilotable par le client — contrairement au `reque
452
452
  une corrélation entrante.
453
453
 
454
454
  **Placement dans le pipeline** : `applySecurityHeaders` est appelé **après le resolve** et **avant**
455
- le repli statique et le `writeHead` (`http-kernel.ts:1334`). Cet ordre n'est pas cosmétique : il
455
+ le repli statique et le `writeHead` (`http-kernel.ts:1372`). Cet ordre n'est pas cosmétique : il
456
456
  faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
457
457
  être avant l'écriture des en-têtes pour pouvoir en poser.
458
458
 
@@ -510,7 +510,7 @@ Trois propriétés à retenir :
510
510
 
511
511
  L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
512
512
  origines du serveur Vite et `'unsafe-eval'` (exigé par le Fast Refresh de React) via
513
- `FrontendService.#viteCspFragment()` (`FrontendService.ts:909`) — ce qui explique qu'un CSP observé
513
+ `FrontendService.#viteCspFragment()` (`FrontendService.ts:1012`) — ce qui explique qu'un CSP observé
514
514
  en dev soit plus large qu'en production, où ce fragment n'existe pas.
515
515
 
516
516
  ## 📜 Normes appliquées
@@ -523,7 +523,7 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
523
523
  | Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
524
524
  | Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:239`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
- | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1334`) |
526
+ | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
527
527
  | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:330`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
@@ -539,7 +539,7 @@ Le coût est concentré au boot, par construction :
539
539
  protège en plus les chemins internes qui n'atteignent jamais le firewall.
540
540
  - **Merge CSP** : jamais dans le chemin chaud. Le fragment d'un module est fusionné à
541
541
  l'enregistrement (`firewall.ts:1067`) ; celui d'une route ne coûte que sur les routes `@Csp`.
542
- - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:1334`), avec
542
+ - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:958-966`), avec
543
543
  un test `!== null` qui annule le coût des en-têtes désactivés.
544
544
 
545
545
  Le module n'attache aucun écouteur d'événement et ne conserve aucun état par requête : il n'entre pas
@@ -550,7 +550,7 @@ dans le périmètre du gate mémoire, qu'il ne peut structurellement pas dégrad
550
550
  L'écran **Firewall** de Studio affiche la section « En-têtes de sécurité » — pilotée par
551
551
  `headers.enabled` (`FirewallDefenses.tsx:219`) — avec le CSP effectif, l'état du nonce par requête, la
552
552
  Referrer-Policy et les valeurs d'isolation. Les données
553
- viennent de `Firewall.describe()` (`firewall.ts:505`), qui projette la config **sans aucun secret**,
553
+ viennent de `Firewall.describe()` (`firewall.ts:549`), qui projette la config **sans aucun secret**,
554
554
  exposée par `GET /nodefony/security/api/firewall`.
555
555
 
556
556
  L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Zod — chaque champ y
package/docs/index.md CHANGED
@@ -138,6 +138,30 @@ Services `Firewall`, `AuthFlow`, `TokenService`, `ApiKeyService`, `Authorization
138
138
  Les signatures exactes vivent dans le graphe généré — `jq '.symbols.Firewall' .ai/symbols.json` —
139
139
  jamais recopiées ici (elles divergeraient).
140
140
 
141
+ ## 🛠️ Gérer les comptes en exploitation
142
+
143
+ Sur un serveur, la console d'administration suppose d'y être déjà entré — ce qui est précisément
144
+ impossible quand on a perdu le mot de passe. Ces commandes sont l'autre porte, et elles s'exécutent
145
+ sans ouvrir de port (profil console) :
146
+
147
+ <!-- prettier-ignore -->
148
+ | Commande | Ce qu'elle fait |
149
+ | --- | --- |
150
+ | `nodefony security:user:add <id> [--admin]` | crée un compte, puis dit comment s'authentifier |
151
+ | `nodefony security:user:list [-q <motif>]` | identifiant, rôles, état |
152
+ | `nodefony security:user:password <id>` | change le mot de passe — **et révoque sessions et jetons** |
153
+ | `nodefony security:user:delete <id>` | supprime, après confirmation ; refuse le dernier administrateur |
154
+ | `nodefony security:secrets [--write]` | engendre les clés attendues et guide leur câblage |
155
+ | `nodefony security:token` | émet un jeton d'accès pour la porte MCP |
156
+
157
+ En terminal, le mot de passe est **demandé masqué** et confirmé ; pour un script, `--password <pwd>`
158
+ l'accepte en argument — au prix de l'historique du shell, que la commande rappelle.
159
+
160
+ Deux garde-fous qui se constatent plutôt qu'ils ne se supposent : le **dernier administrateur actif
161
+ ne se supprime pas** (`security-user-delete.ts:101`), et un changement de mot de passe **éjecte les
162
+ accès en cours** par la même cascade que la suppression (`userRevocationCascade.ts:37`) — on change
163
+ un mot de passe parce qu'il est perdu ou compromis.
164
+
141
165
  ## ⚙️ Configuration
142
166
 
143
167
  Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod