@nodefony/security 10.0.0-alpha.4 → 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.
package/README.md CHANGED
@@ -179,4 +179,4 @@ Un **secret n'entre jamais** dans un événement — seule sa _présence_ est tr
179
179
 
180
180
  ## Licence
181
181
 
182
- CeCILL-B — Christophe CAMENSULI.
182
+ Apache 2.0 — Christophe CAMENSULI.
package/dist/index.js CHANGED
@@ -73,6 +73,7 @@ import SecuritySecrets from "./nodefony/command/security-secrets.js";
73
73
  import SecurityUserAdd from "./nodefony/command/security-user-add.js";
74
74
  import SecurityUserList from "./nodefony/command/security-user-list.js";
75
75
  import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
76
+ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
76
77
  import SecurityToken from "./nodefony/command/security-token.js";
77
78
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
78
79
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
@@ -108,6 +109,7 @@ let Security = class Security extends Module {
108
109
  this.addCommand(SecurityUserAdd);
109
110
  this.addCommand(SecurityUserList);
110
111
  this.addCommand(SecurityUserDelete);
112
+ this.addCommand(SecurityUserPassword);
111
113
  this.addCommand(SecurityToken);
112
114
  }
113
115
  /**
@@ -16,6 +16,22 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
16
16
  /** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
17
17
  const ROLE_BASE = "ROLE_USER";
18
18
  /**
19
+ * La route qui échange un compte contre une SESSION — le geste qui manquait.
20
+ *
21
+ * Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
22
+ * 33 minutes d'un essai réel se sont passées entre un premier refus et
23
+ * l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
24
+ * suivante au moment où elle a un sens.
25
+ *
26
+ * ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
27
+ * framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
28
+ * conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
29
+ * exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
30
+ * controller : deux copies qui divergent en silence, c'est précisément ce
31
+ * qu'on refuse.
32
+ */
33
+ const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
34
+ /**
19
35
  * `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
20
36
  * service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
21
37
  * jamais de mot de passe stocké en clair).
@@ -80,7 +96,7 @@ var SecurityUserAdd = class extends Command {
80
96
  return this;
81
97
  }
82
98
  if (await users.findByIdentifier(identifier)) {
83
- this.log(`le compte « ${identifier} » existe déjà.\n · le voir : nodefony security:user:list -q ${identifier}\n · le supprimer : nodefony security:user:delete ${identifier}\n · mot de passe oublié : par Studio (/nodefony) la commande n'existe pas encore.`, "ERROR");
99
+ this.log(`le compte « ${identifier} » existe déjà.\n · le voir : nodefony security:user:list -q ${identifier}\n · le supprimer : nodefony security:user:delete ${identifier}\n · mot de passe oublié : nodefony security:user:password ${identifier}`, "ERROR");
84
100
  process.exitCode = 1;
85
101
  return this;
86
102
  }
@@ -124,9 +140,9 @@ var SecurityUserAdd = class extends Command {
124
140
  plainPassword: password,
125
141
  roles
126
142
  });
127
- process.stdout.write(`\n${GREEN}✓ compte créé${RESET} — ${BOLD}${user.identifier}${RESET} ${DIM}(id ${user.id})${RESET}\n rôles : ${roles.join(" · ")}\n` + (opts.admin ? ` ${DIM}accès console Studio : /nodefony${RESET}\n` : "") + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : ""));
143
+ process.stdout.write(`\n${GREEN}✓ compte créé${RESET} — ${BOLD}${user.identifier}${RESET} ${DIM}(id ${user.id})${RESET}\n rôles : ${roles.join(" · ")}\n` + (opts.admin ? ` ${DIM}accès console Studio : /nodefony${RESET}\n` : "") + ` ${DIM}pour t'authentifier : POST ${AUTH_LOGIN_PATH}${RESET}\n ${DIM} body JSON {"username","password"} → cookie de session ; l'identité courante se relit sur GET /nodefony/security/api/auth/me${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : ""));
128
144
  return this;
129
145
  }
130
146
  };
131
147
  //#endregion
132
- export { SecurityUserAdd as default };
148
+ export { AUTH_LOGIN_PATH, SecurityUserAdd as default };
@@ -0,0 +1,111 @@
1
+ import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
+ import { USER_REVOKED_EVENT } from "@nodefony/user";
3
+ //#region nodefony/command/security-user-password.ts
4
+ const options = {
5
+ runProfile: CONSOLE_DATA_RUN_PROFILE,
6
+ helpGroup: "COMPTES ET SECRETS",
7
+ showBanner: false,
8
+ kernelEvent: "onPostReady",
9
+ quietBoot: true
10
+ };
11
+ const GREEN = "\x1B[32m";
12
+ const YELLOW = "\x1B[33m";
13
+ const DIM = "\x1B[2m";
14
+ const BOLD = "\x1B[1m";
15
+ const RESET = "\x1B[0m";
16
+ /**
17
+ * `nodefony security:user:password [identifiant]` — change le mot de passe d'un
18
+ * compte.
19
+ *
20
+ * Le geste d'exploitation qui manquait : `user:add`, `user:list` et
21
+ * `user:delete` existaient, mais un mot de passe perdu n'avait aucun recours en
22
+ * ligne de commande — seule la console d'administration savait le faire, ce qui
23
+ * suppose d'y être déjà entré. Sur un serveur, c'est précisément le cas où l'on
24
+ * ne peut pas.
25
+ *
26
+ * Mot de passe : `--password` (visible dans l'historique shell — accepté pour
27
+ * les scripts) ou PROMPT MASQUÉ en TTY, demandé deux fois. Le hachage est celui
28
+ * du service (`UserService.changePassword` → Argon2id) : jamais de clair
29
+ * persisté, et la liste de mots de passe interdits s'applique comme à la
30
+ * création.
31
+ *
32
+ * 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
33
+ * option : on change un mot de passe parce qu'il est compromis ou perdu, et
34
+ * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
35
+ * révocation passe par l'événement que la suppression emploie déjà — une
36
+ * cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
37
+ * rien changer ici.
38
+ */
39
+ var SecurityUserPassword = class extends Command {
40
+ constructor(cli) {
41
+ super("security:user:password", "change le mot de passe d'un compte", cli, options);
42
+ this.addArgument("[identifier]", "identifiant (login) du compte");
43
+ this.addOption("-p, --password <password>", "nouveau mot de passe (sinon : prompt masqué en TTY)");
44
+ }
45
+ async generate(identifierArg, opts) {
46
+ const users = this.kernel?.container?.get("users");
47
+ if (!users) {
48
+ this.log("service « users » absent — l'application ne provisionne pas son annuaire utilisateurs (cf nodefony/security/provisionUsers.ts d'une app générée).", "ERROR");
49
+ process.exitCode = 1;
50
+ return this;
51
+ }
52
+ let identifier;
53
+ try {
54
+ identifier = await this.askArgument(identifierArg, {
55
+ name: "identifier",
56
+ message: "Compte dont changer le mot de passe :"
57
+ });
58
+ } catch (e) {
59
+ this.log(e.message, "ERROR");
60
+ process.exitCode = 1;
61
+ return this;
62
+ }
63
+ const user = await users.findByIdentifier(identifier);
64
+ if (!user) {
65
+ this.log(`aucun compte « ${identifier} » — nodefony security:user:list`, "ERROR");
66
+ process.exitCode = 1;
67
+ return this;
68
+ }
69
+ let password = opts.password;
70
+ if (!password) {
71
+ if (!process.stdin.isTTY) {
72
+ this.log("mot de passe requis : --password <pwd> (pas de prompt hors terminal).", "ERROR");
73
+ process.exitCode = 1;
74
+ return this;
75
+ }
76
+ password = await askPasswordMasked(`${BOLD}Nouveau mot de passe de « ${identifier} »${RESET} ${DIM}(frappe masquée)${RESET} : `);
77
+ const confirmed = await askPasswordMasked(`${BOLD}Confirme le mot de passe${RESET} : `);
78
+ if (password !== confirmed) {
79
+ this.log("les deux saisies diffèrent — rien n'a changé.", "ERROR");
80
+ process.exitCode = 1;
81
+ return this;
82
+ }
83
+ }
84
+ if (!password) {
85
+ this.log("mot de passe vide — rien n'a changé.", "ERROR");
86
+ process.exitCode = 1;
87
+ return this;
88
+ }
89
+ try {
90
+ if (await users.changePassword(user.id, password) === null) {
91
+ this.log(`aucune ligne modifiée pour « ${identifier} » — le compte a-t-il disparu entre-temps ?`, "ERROR");
92
+ process.exitCode = 1;
93
+ return this;
94
+ }
95
+ } catch (e) {
96
+ this.log(e.message, "ERROR");
97
+ process.exitCode = 1;
98
+ return this;
99
+ }
100
+ this.kernel?.fire(USER_REVOKED_EVENT, {
101
+ id: user.id,
102
+ identifier: user.identifier,
103
+ tenantId: null,
104
+ reason: "password_changed"
105
+ });
106
+ process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST /nodefony/security/api/auth/login${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
107
+ return this;
108
+ }
109
+ };
110
+ //#endregion
111
+ export { SecurityUserPassword as default };
@@ -2,6 +2,40 @@ import { messageNonRestreint, modeNonRestreintAsync, readIfPresent, writeSecret
2
2
  import { join } from "node:path";
3
3
  //#region nodefony/src/token/JwtKeystore.ts
4
4
  /**
5
+ * Dossiers que le `Dockerfile` généré EFFACE à l'étage de construction.
6
+ *
7
+ * La liste est ici parce que c'est ici qu'on s'en sert ; elle est tenue honnête
8
+ * par le test qui la confronte au gabarit — deux copies d'une même règle
9
+ * divergent en silence, et c'est une clé privée qui paierait la dérive.
10
+ */
11
+ const CLEANED_DIRS = ["var", "tmp"];
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
+ function keystoreLeaksIntoImage(dir) {
32
+ const normalized = dir.replace(/\\/gu, "/").replace(/^\.\//u, "");
33
+ if (normalized.startsWith("/") || /^[A-Za-z]:/u.test(normalized)) return null;
34
+ const first = normalized.split("/")[0];
35
+ if (CLEANED_DIRS.includes(first)) return null;
36
+ return `JWT keystore: « ${dir} » n'est PAS sous ${CLEANED_DIRS.map((d) => `\`${d}/\``).join(" ni ")}, les seuls dossiers que le Dockerfile généré efface avant de fabriquer l'image. La clé privée de signature partira donc dans ton image de conteneur, où elle reste lisible par quiconque la télécharge — même effacée par une couche suivante. Deux issues : déplacer le dossier sous \`var/\` (ex. \`var/keys\`), ou injecter la clé par jwt.keystore.keySetJson depuis l'environnement, ce qui est la voie de production.`;
37
+ }
38
+ /**
5
39
  * Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
6
40
  *
7
41
  * Résout la clé de signature selon une **priorité** (jamais d'auto-génération en
@@ -61,6 +95,8 @@ var JwtKeystore = class {
61
95
  return;
62
96
  }
63
97
  if (this.#source.dir) {
98
+ const risk = keystoreLeaksIntoImage(this.#source.dir);
99
+ if (risk) this.#log(risk, "WARNING");
64
100
  const file = join(this.#source.dir, "keyset.json");
65
101
  const existing = await this.#readFile(file);
66
102
  if (existing) {
@@ -157,4 +193,4 @@ var JwtKeystore = class {
157
193
  }
158
194
  };
159
195
  //#endregion
160
- export { JwtKeystore, JwtKeystore as default };
196
+ export { JwtKeystore, JwtKeystore as default, keystoreLeaksIntoImage };
@@ -1,4 +1,20 @@
1
1
  import { CliKernel, Command } from "nodefony";
2
+ /**
3
+ * La route qui échange un compte contre une SESSION — le geste qui manquait.
4
+ *
5
+ * Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
6
+ * 33 minutes d'un essai réel se sont passées entre un premier refus et
7
+ * l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
8
+ * suivante au moment où elle a un sens.
9
+ *
10
+ * ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
11
+ * framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
12
+ * conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
13
+ * exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
14
+ * controller : deux copies qui divergent en silence, c'est précisément ce
15
+ * qu'on refuse.
16
+ */
17
+ export declare const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
2
18
  /**
3
19
  * `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
4
20
  * service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
@@ -0,0 +1,31 @@
1
+ import { CliKernel, Command } from "nodefony";
2
+ /**
3
+ * `nodefony security:user:password [identifiant]` — change le mot de passe d'un
4
+ * compte.
5
+ *
6
+ * Le geste d'exploitation qui manquait : `user:add`, `user:list` et
7
+ * `user:delete` existaient, mais un mot de passe perdu n'avait aucun recours en
8
+ * ligne de commande — seule la console d'administration savait le faire, ce qui
9
+ * suppose d'y être déjà entré. Sur un serveur, c'est précisément le cas où l'on
10
+ * ne peut pas.
11
+ *
12
+ * Mot de passe : `--password` (visible dans l'historique shell — accepté pour
13
+ * les scripts) ou PROMPT MASQUÉ en TTY, demandé deux fois. Le hachage est celui
14
+ * du service (`UserService.changePassword` → Argon2id) : jamais de clair
15
+ * persisté, et la liste de mots de passe interdits s'applique comme à la
16
+ * création.
17
+ *
18
+ * 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
19
+ * option : on change un mot de passe parce qu'il est compromis ou perdu, et
20
+ * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
21
+ * révocation passe par l'événement que la suppression emploie déjà — une
22
+ * cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
23
+ * rien changer ici.
24
+ */
25
+ declare class SecurityUserPassword extends Command {
26
+ constructor(cli: CliKernel);
27
+ generate(identifierArg: string | undefined, opts: {
28
+ password?: string;
29
+ }): Promise<this>;
30
+ }
31
+ export default SecurityUserPassword;
@@ -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