@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.2

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
@@ -25,7 +25,7 @@ Consomme [`@nodefony/user`](https://www.npmjs.com/package/@nodefony/user).
25
25
  (cookie opaque BFF, révocable, `HttpOnly; Secure; SameSite`) ; les **API/agents** portent leur
26
26
  preuve à chaque requête (**JWT** signé / clé API). Jamais « full stateless » : la session reste
27
27
  la fondation web (révocation immédiate), le JWT est réservé au sans-état machine-à-machine.
28
- - **Config type-safe** : schéma **Zod** (18 sections, tout `enabled`), **introspectable** (Studio
28
+ - **Config type-safe** : schéma **Zod** (19 sections, la plupart désactivables par `enabled`), **introspectable** (Studio
29
29
  génère son formulaire d'édition). L'app configure via `use("@nodefony/security", { … })`.
30
30
  - **En-têtes natifs** (sans la lib `helmet`) — 0 dépendance, nonce CSP par requête.
31
31
 
@@ -67,7 +67,8 @@ export default defineConfig((ctx) => ({
67
67
 
68
68
  > Une zone se déclare idéalement **par module** (override `module-security`, dans la config du
69
69
  > module) pour vivre au plus près de ses routes. Authenticators fournis : `anonymous`, `session`,
70
- > `userpassword`, `jwt`, `apikey`, `webauthn`, plus les providers OAuth2. La chaîne est validée
70
+ > `userpassword`, `jwt`, `external-jwt`, `apikey`. WebAuthn, OAuth2 et TOTP n'en sont pas : ils
71
+ > ouvrent une session BFF, que l'authenticator `session` identifie ensuite. La chaîne est validée
71
72
  > au boot (`mode: "first"` par défaut = le premier qui reconnaît authentifie ; `"all"` = tous
72
73
  > requis, ex. mTLS + JWT). Config invalide → firewall **fail-closed** (tout rejeté).
73
74
 
@@ -117,7 +118,7 @@ csrf: {
117
118
  > ouvrir la lecture CORS des réponses au JavaScript tiers. Une origine listée dans `cors.origins` est
118
119
  > toutefois aussi acceptée (ce que CORS autorise explicitement n'est pas du CSRF).
119
120
 
120
- Le token synchronizer renforcé (`@CsrfProtect` / `@CsrfExempt`) arrive à l'étape suivante.
121
+ Le token synchronizer renforcé s'active par route avec `@CsrfProtect` ; `@CsrfExempt` en exclut une route.
121
122
 
122
123
  ### CORS — l'inverse du CSRF
123
124
 
@@ -141,8 +142,9 @@ const schema = securityConfigJsonSchema(); // JSON Schema → formulaire d'édit
141
142
  ## Journal d'audit (événements de sécurité)
142
143
 
143
144
  Le journal trace les **transitions d'état** de sécurité (login, refus, jeton émis/révoqué, verrou
144
- WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible ; le
145
- **chemin de succès reste muet** (le volume n'est pas un signal), seul l'échec/refus émet (cold-path)
145
+ WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible, sur les
146
+ chemins froids (login, logout, émission ou révocation de jeton, refus) ; jamais par requête nominale :
147
+ le trafic courant ne passe pas par l'audit (cold-path)
146
148
  → aucun coût ajouté au hot-path nominal. Activé par défaut (`audit.enabled`, OWASP A09), coût nul si
147
149
  désactivé. Lecture : `GET /nodefony/security/api/audit/events` (RBAC `ROLE_NODEFONY_ADMIN`).
148
150
 
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.152.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
77
77
  import SecurityToken from "./nodefony/command/security-token.js";
78
78
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
79
79
  import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
80
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
81
- import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
80
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js";
81
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorate.js";
82
82
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
83
83
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
84
84
  import "./nodefony/errors/index.js";
@@ -20,6 +20,8 @@ const NONCE_PLACEHOLDER = "{{nonce}}";
20
20
  var SecurityHeaders = class {
21
21
  #headers;
22
22
  #cspParts;
23
+ #cspPrefix;
24
+ #cspSuffix;
23
25
  #cspRaw;
24
26
  constructor(o) {
25
27
  const h = {};
@@ -39,6 +41,13 @@ var SecurityHeaders = class {
39
41
  if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
40
42
  this.#headers = Object.freeze(h);
41
43
  this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
44
+ if (cspParts?.length === 2) {
45
+ this.#cspPrefix = cspParts[0] ?? "";
46
+ this.#cspSuffix = cspParts[1] ?? "";
47
+ } else {
48
+ this.#cspPrefix = null;
49
+ this.#cspSuffix = "";
50
+ }
42
51
  this.#cspRaw = o.csp;
43
52
  }
44
53
  /** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
@@ -51,13 +60,15 @@ var SecurityHeaders = class {
51
60
  }
52
61
  /**
53
62
  * Recompose le CSP en injectant le `nonce` de la requête aux emplacements
54
- * `{{nonce}}`. 1 `join` (segments pré-split au boot). Le nonce est base64 (jamais
63
+ * `{{nonce}}` : une concaténation s'il n'y en a qu'un, un `join` sinon (segments
64
+ * pré-split au boot). Le nonce est base64 (jamais
55
65
  * `'`/`;`/espace) → aucune évasion possible du token CSP.
56
66
  *
57
67
  * @param nonce - nonce base64 de la requête (`Context.cspNonce`).
58
68
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
59
69
  */
60
70
  cspFor(nonce) {
71
+ if (this.#cspPrefix !== null) return this.#cspPrefix + nonce + this.#cspSuffix;
61
72
  return this.#cspParts.join(nonce);
62
73
  }
63
74
  /**
@@ -43,7 +43,8 @@ export declare class SecurityHeaders {
43
43
  get hasNonce(): boolean;
44
44
  /**
45
45
  * Recompose le CSP en injectant le `nonce` de la requête aux emplacements
46
- * `{{nonce}}`. 1 `join` (segments pré-split au boot). Le nonce est base64 (jamais
46
+ * `{{nonce}}` : une concaténation s'il n'y en a qu'un, un `join` sinon (segments
47
+ * pré-split au boot). Le nonce est base64 (jamais
47
48
  * `'`/`;`/espace) → aucune évasion possible du token CSP.
48
49
  *
49
50
  * @param nonce - nonce base64 de la requête (`Context.cspNonce`).
package/docs/api-keys.md CHANGED
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
137
137
 
138
138
  ### 1. Déclarer la zone machine et les règles d'émission
139
139
 
140
- Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
140
+ Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
141
141
  dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
142
142
 
143
143
  ```typescript
package/docs/audit.md CHANGED
@@ -135,7 +135,8 @@ lourds s'enregistrent depuis **leur** module. Un
135
135
  ## 🚀 Démarrage rapide
136
136
 
137
137
  Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
138
- défaut, `config.ts:729`) sur un store mémoire. Voici le parcours complet : configurer, émettre,
138
+ défaut, `config.ts:729`) sur le store que résout `auto` : la base SQLite locale de l'application
139
+ générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
139
140
  relire.
140
141
 
141
142
  ### 1. Choisir où le journal est écrit
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
265
266
  | `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
266
267
  | `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
267
268
  | `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
268
- | `limit` | taille de page (défaut 100, **plafonné à 500**) | `limit=200` |
269
+ | `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
269
270
  | `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
270
271
 
271
272
  ```bash
@@ -496,7 +497,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la
496
497
  | Option | Type | Défaut | Effet |
497
498
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
498
499
  | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
499
- | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle` |
500
+ | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
500
501
  | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
501
502
  | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
502
503
  | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
@@ -514,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
514
515
 
515
516
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
516
517
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
517
- 3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
518
+ 3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
519
+ 4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
518
520
 
519
521
  La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
520
522
  de Studio : configuré, résolu, disponible, motif, emplacement physique.
@@ -702,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
702
704
  | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
703
705
  | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
704
706
  | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
705
- | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
707
+ | `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
706
708
  | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
707
- | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
708
- | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
709
+ | `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
710
+ | Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
709
711
  | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
710
712
  | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
711
713
  | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
185
185
 
186
186
  # 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
187
187
  curl -si -c /tmp/jar -H 'Content-Type: application/json' \
188
- -d '{"username":"admin","password":"admin"}' \
188
+ -d '{"username":"admin","password":"nodefony-dev-42"}' \
189
189
  http://localhost:5151/nodefony/security/api/auth/login | head -1
190
190
  # HTTP/1.1 200 OK
191
191
 
@@ -349,7 +349,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
349
349
 
350
350
  > [!NOTE]
351
351
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
352
- > frames lisent un cache O(1)) → une révocation prend effet **à la reconnexion**, pas à la frame
352
+ > frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
353
353
  > suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
354
354
  > immédiate forte passe par le JWT + un canal « token révoqué ».
355
355
 
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
331
331
 
332
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
333
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
334
- (`Resolver.ts:700-715`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:878-885`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
335
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
336
336
  `@IsGranted("doc.edit", { subject: "id" })`.
337
337
 
@@ -364,16 +364,16 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
364
364
  | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
365
365
  | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
366
366
  | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
367
- | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:789`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:923`) |
368
368
  | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
369
369
  | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
370
370
  | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
371
371
 
372
372
  La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
373
- 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:406-408`). Le
373
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:489-491`). Le
374
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
375
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
376
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:719`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:853`).
377
377
 
378
378
  > [!IMPORTANT]
379
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
511
511
 
512
512
  ## 🔌 HTTP et WebSocket — une garde, N transports
513
513
 
514
- - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:372`) est le point unique
514
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:446`) est le point unique
515
515
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
516
516
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
517
517
  `ws-isgranted-jwt`).
@@ -541,7 +541,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
541
541
  ## ⚡ Performance & mémoire
542
542
 
543
543
  - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
544
- alloc (`Resolver.ts:406`) ; l'exigence est **figée une fois** par route et partagée entre
544
+ alloc (`Resolver.ts:486`) ; l'exigence est **figée une fois** par route et partagée entre
545
545
  requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
546
546
  - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
547
547
  instanciés **une seule fois** au boot (`authorization.ts:70`).
package/docs/cors.md CHANGED
@@ -134,7 +134,7 @@ chaque appel du front.
134
134
  ### 1. La config — dans `nodefony.config.ts`
135
135
 
136
136
  ```typescript
137
- // nodefony.config.ts (extrait généré par `nodefony create app`, puis complété)
137
+ // nodefony/config/security.ts (extrait généré par `nodefony create app`, puis complété)
138
138
  use("@nodefony/security", {
139
139
  cors: {
140
140
  // Allowlist EXACTE : scheme + host + port. Un sous-domaine n'est PAS inclus.
@@ -323,7 +323,7 @@ sequenceDiagram
323
323
  ```
324
324
 
325
325
  `Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
- (`http-kernel.ts:1333`), à la ligne `http-kernel.ts:1333` — **avant le routing**. La raison est
326
+ (`http-kernel.ts:1456`), à la ligne `http-kernel.ts:1456` — **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.
@@ -354,7 +354,7 @@ Deux nuances utiles :
354
354
  `Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
355
355
  point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
356
356
  - **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
357
- (`http-kernel.ts:1382`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
357
+ (`http-kernel.ts:1630`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
358
358
  tes routes.
359
359
 
360
360
  Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
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:163-168`).
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:1524`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1747`) 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 —
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
315
315
  | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
316
316
  | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
317
317
  | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
318
- | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:486`). |
318
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:542`). |
319
319
  | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
320
320
  | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
321
321
 
@@ -339,7 +339,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
339
339
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
340
  allouée « au cas où » (`firewall.ts:164`).
341
341
  - Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
342
- marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:162`).
342
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:205`).
343
343
 
344
344
  ## 📡 Observabilité — Studio
345
345
 
@@ -123,11 +123,12 @@ concernent d'après la liste d'émetteurs, puis délègue la vérification au se
123
123
  La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
124
124
 
125
125
  ```ts
126
- firewall: {
126
+ areas: {
127
127
  api: {
128
128
  pattern: "^/api",
129
129
  stateless: true,
130
130
  authenticators: ["external-jwt"],
131
+ resource: "https://api.example.com",
131
132
  },
132
133
  }
133
134
  ```
package/docs/firewall.md CHANGED
@@ -73,11 +73,11 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
73
73
 
74
74
  ### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
75
75
 
76
- Le scaffold déclare deux zones dans `nodefony.config.ts` — c'est la forme canonique (un **objet par
76
+ Le scaffold déclare trois zones (`main`, `secure`, `machine`) dans `nodefony/config/security.ts`, importé par `nodefony.config.ts` ; en voici deux — c'est la forme canonique (un **objet par
77
77
  nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
78
78
 
79
79
  ```typescript
80
- // nodefony.config.ts (extrait généré par `nodefony create app`)
80
+ // nodefony/config/security.ts (extrait généré par `nodefony create app`)
81
81
  use("@nodefony/security", {
82
82
  areas: {
83
83
  // Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
@@ -151,7 +151,7 @@ curl -si http://localhost:5151/api/secure/account/me | head -1
151
151
 
152
152
  # 2) Login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
153
153
  curl -si -c /tmp/jar -H 'Content-Type: application/json' \
154
- -d '{"username":"admin","password":"admin"}' \
154
+ -d '{"username":"admin","password":"nodefony-dev-42"}' \
155
155
  http://localhost:5151/nodefony/security/api/auth/login | head -1
156
156
  # HTTP/1.1 200 OK
157
157
 
@@ -463,12 +463,12 @@ inchangé : la zone n'exige qu'une identité.
463
463
  C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
464
464
  explicite du code — jamais un contournement :
465
465
 
466
- | Ce qui décide à la place | Pourquoi |
467
- | ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------- |
468
- | Une **garde d'action** (`@IsGranted`, `@RequireScope`) | L'action a déjà dit ce qu'elle exige. Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
- | Une route **`selfGuarded`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
- | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
- | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
466
+ | Ce qui décide à la place | Pourquoi |
467
+ | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
468
+ | Une **garde de rôle** (`@IsGranted`) | L'action a déjà dit quel rôle elle exige (un `@RequireScope` seul ne dispense pas du rôle de zone). Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
469
+ | Une route **`areaRoleExempt`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
470
+ | Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
471
+ | L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
472
472
 
473
473
  Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
474
474
  une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
@@ -477,7 +477,7 @@ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rô
477
477
  puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
478
478
  destinataire.
479
479
 
480
- > ⚠️ **`selfGuarded` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
480
+ > ⚠️ **`areaRoleExempt` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
481
481
  > qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
482
482
  > que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
483
483
  > Dans le doute, une garde d'action explicite est toujours préférable.
@@ -505,7 +505,7 @@ registerVoterFactory(
505
505
  // l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
506
506
  ```
507
507
 
508
- Le voter est **découvert automatiquement** par l'`AuthorizationService` — aucun changement dans le
508
+ Le voter est **découvert automatiquement** par le service `authorization` — aucun changement dans le
509
509
  cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
510
510
  `@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
511
511
  **est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
@@ -553,16 +553,16 @@ ne paie quasiment rien.
553
553
 
554
554
  ## ⚠️ Pièges (symptôme → cause → correction)
555
555
 
556
- | Symptôme | Cause (dans le code) | Correction |
557
- | ---------------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------- |
558
- | Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
559
- | Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
560
- | 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
561
- | Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
562
- | API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
563
- | JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
564
- | WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet à la reconnexion ; pour l'immédiat, canal JWT (J4) |
565
- | 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
556
+ | Symptôme | Cause (dans le code) | Correction |
557
+ | ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
558
+ | Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
559
+ | Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
560
+ | 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
561
+ | Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
562
+ | API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
563
+ | JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
564
+ | WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet en une fenêtre (tick du hub + avant chaque `api.request`, fermeture 4001) |
565
+ | 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
566
566
 
567
567
  ## 📡 Observabilité — Studio
568
568
 
package/docs/headers.md CHANGED
@@ -122,10 +122,10 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
122
122
  statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
123
123
  se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
124
124
  - Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
125
- (`http-kernel.ts:348`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
- boot ; `onHttpRequest` (`http-kernel.ts:972`) ne fait plus que trois `setHeader`.
125
+ (`http-kernel.ts:358`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
+ boot ; `onHttpRequest` (`http-kernel.ts:1009`) ne fait plus que trois `setHeader`.
127
127
  - Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
128
- (`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) qu'à la première lecture,
128
+ (`Context.ts:253`) ne tire ses 128 bits (16 octets CSPRNG, en base64) d'un pool amorti (`randomFillSync`, 4 Ko pour 256 nonces) qu'à la première lecture,
129
129
  puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
130
130
 
131
131
  Le second parti pris est la **séparation d'autorité** décrite plus haut : un seul émetteur par
@@ -275,7 +275,7 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
275
275
  victime est capté par ton interface.
276
276
 
277
277
  Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
278
- (`http-kernel.ts:989`) — et configuré côté `@nodefony/http` avec `frameOptions`
278
+ (`http-kernel.ts:1049`) — et configuré côté `@nodefony/http` avec `frameOptions`
279
279
  (`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
280
280
  s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
281
281
  pour un HTML statique servi directement depuis `public/`.
@@ -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:986`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
290
+ (`http-kernel.ts:1046`). 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
 
@@ -297,10 +297,10 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
297
297
  installer un intercepteur.
298
298
 
299
299
  La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
300
- (`http-kernel.ts:348`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
300
+ (`http-kernel.ts:358`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
301
301
 
302
302
  Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
303
- de serveur (`http-kernel.ts:992`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
303
+ de serveur (`http-kernel.ts:1052`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
304
304
  soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
305
305
  inclus.
306
306
 
@@ -397,7 +397,7 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
397
397
  ### Socle transport — `use("@nodefony/http", { securityHeaders })`
398
398
 
399
399
  Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
400
- **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:398`)
400
+ **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:408`)
401
401
  recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
402
402
 
403
403
  | Option | Type | Défaut | Effet |
@@ -446,13 +446,13 @@ Le chemin complet, sans surprise :
446
446
  `<script nonce="…">` portent forcément la même valeur. C'est le motif employé par le contrôleur
447
447
  de Studio (`StudioController.ts:62`).
448
448
 
449
- `SecurityHeaders.hasNonce` (`securityHeaders.ts:88`) est le drapeau qui décide du régime : à `false`,
449
+ `SecurityHeaders.hasNonce` (`securityHeaders.ts:100`) est le drapeau qui décide du régime : à `false`,
450
450
  pas une seule opération crypto. Il n'y a **aucun setter** pour `cspNonce` : un jeton serveur doit
451
451
  rester imprévisible, jamais pilotable par le client — contrairement au `requestId`, qui, lui, accepte
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:1386`). Cet ordre n'est pas cosmétique : il
455
+ le repli statique et le `writeHead` (`http-kernel.ts:1624`). 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
 
@@ -478,7 +478,7 @@ les tests fiables.
478
478
  **Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
479
479
  (`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1101`), jamais par requête. Le merge d'une route
480
480
  `@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
481
- `securityHeaders.ts:115`) ; le cas courant reste le simple `join`.
481
+ `securityHeaders.ts:131`) ; le cas courant reste le simple `join`.
482
482
 
483
483
  ## 🧩 Extension — déclarer un fragment CSP depuis son module
484
484
 
@@ -523,8 +523,8 @@ 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:267`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
- | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:986`) |
527
- | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:348`) |
526
+ | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1046`) |
527
+ | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:358`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
530
530
 
@@ -533,13 +533,13 @@ Le coût est concentré au boot, par construction :
533
533
  - **En-têtes constants** : une seule table, gelée (`securityHeaders.ts:77`). Par requête, une boucle
534
534
  `for…in` sur un objet de 1 à 6 entrées et autant de `setHeader`. Aucune allocation.
535
535
  - **CSP statique** : rien de plus — la chaîne est dans la table.
536
- - **CSP à nonce** : `randomBytes(16)` plus un `join` par requête. C'est le seul coût variable, et il
537
- n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:88`)
536
+ - **CSP à nonce** : un tirage dans le pool plus une concaténation `préfixe + nonce + suffixe` par requête. C'est le seul coût variable, et il
537
+ n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:100`)
538
538
  court-circuite entièrement ce chemin sinon. La paresse de `Context.cspNonce` (`Context.ts:253`)
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:1086`) ; 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:958-966`), avec
542
+ - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:1047-1053`), 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
package/docs/index.md CHANGED
@@ -43,7 +43,7 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
43
43
  **Je découvre la sécurité Nodefony** — comprendre le modèle avant de configurer quoi que ce soit.
44
44
 
45
45
  1. [Firewall](firewall.md) — zones, Zero Trust, la chaîne de décision. **Tout part d'ici.**
46
- 2. [Authenticators](authenticators.md) — les six façons de prouver **qui** appelle.
46
+ 2. [Authenticators](authenticators.md) — les sept façons de prouver **qui** appelle.
47
47
  3. [Autorisation](authorization.md) — rôles, scopes, voters : **ce qu'il a le droit** de faire.
48
48
  4. [Jetons](tokens.md) — ce qui matérialise une identité prouvée, et comment on la révoque.
49
49
 
@@ -98,7 +98,7 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
98
98
  "desc": "Le pare-feu applicatif : trois questions pour chaque requête — est-ce une zone protégée ? qui es-tu ? as-tu le droit ? Le chemin chaud (détecter) est séparé du chemin froid (décider), donc une route publique ne paie rien.",
99
99
  "meta": "à lire en premier — toutes les autres briques s'y branchent" },
100
100
  { "icon": "🪪", "title": "authenticators", "href": "authenticators.md",
101
- "desc": "Prouver qui appelle : six stratégies au même contrat — session, userpassword, jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
101
+ "desc": "Prouver qui appelle : sept stratégies au même contrat — session, userpassword, jwt, external-jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
102
102
  "meta": "commence par « Ordre et modes » : les pièges de configuration sont là" },
103
103
  { "icon": "⚖️", "title": "authorization", "href": "authorization.md",
104
104
  "desc": "Décider des droits : rôles hiérarchisés, scopes, et voters métier qui portent le vrai pouvoir applicatif. Le jury vote, la stratégie tranche.",
@@ -167,7 +167,7 @@ un mot de passe parce qu'il est perdu ou compromis.
167
167
  ## ⚙️ Configuration
168
168
 
169
169
  Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
170
- au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `loginThrottle`
170
+ au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `rateLimit`
171
171
  (backoff NIST), puis un bloc par brique (`jwt`, `apiKeys`, `totp`, `webhooks`, `audit`, `passkeys`,
172
172
  `oauth2`). **Chaque page de brique détaille son bloc**, avec une table dérivée du schéma.
173
173
 
package/docs/lexique.md CHANGED
@@ -126,21 +126,21 @@ source: "src/packages/@nodefony/security/docs/lexique.md"
126
126
  > L'**autorisation** (authz) répond « as-tu le DROIT de faire ça ? » (les voters, `@IsGranted`).
127
127
  > On peut être authentifié ET refusé (un `user` connecté sur une route `ROLE_ADMIN` → **403**, pas 401).
128
128
 
129
- | Sigle / terme | Développé | En clair |
130
- | ----------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
- | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
- | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
- | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
- | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
- | **`AuthorizationService.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
- | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
- | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
- | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
- | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
- | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
- | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
- | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
- | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
129
+ | Sigle / terme | Développé | En clair |
130
+ | ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
131
+ | **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
132
+ | **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
133
+ | **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
134
+ | **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
135
+ | **`Authorization.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
136
+ | **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
137
+ | **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
138
+ | **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
139
+ | **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
140
+ | **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
141
+ | **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
142
+ | **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
143
+ | **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
144
144
 
145
145
  ### Décorateurs sécurité (panoplie J7)
146
146
 
package/docs/oauth2.md CHANGED
@@ -144,12 +144,13 @@ d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewal
144
144
  c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
145
145
 
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
- dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
147
+ dans le module (`OAuth2Client`, `oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
148
148
  l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
149
149
  mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
150
- processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
151
- que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
152
- est **404**, pas « désactivée ».
150
+ processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes sont montées dès
151
+ que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
152
+ `…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
153
+ rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
153
154
 
154
155
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
155
156
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
@@ -215,8 +216,8 @@ export default defineConfig<typeof env>((ctx) => ({
215
216
  ### Les routes sont FOURNIES — tu n'écris aucun controller
216
217
 
217
218
  `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
218
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`), et **seulement si** le service `oauth2`
219
- est présent (`framework/index.ts:460`) :
219
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
220
+ enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
220
221
 
221
222
  | Route | Rôle |
222
223
  | ---------------------------- | --------------------------------------------------------------------- |
@@ -488,14 +489,14 @@ que le mapping du profil. Exemple sans réseau dans le dépôt :
488
489
  Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
489
490
  (`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
490
491
 
491
- | Option | Type | Défaut | Effet |
492
- | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
493
- | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
494
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
495
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
496
- | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
497
- | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
498
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
492
+ | Option | Type | Défaut | Effet |
493
+ | ----------------- | -------------------- | --------------- | ----------------------------------------------------------- |
494
+ | `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
495
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
496
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
497
+ | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
498
+ | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
499
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
499
500
 
500
501
  Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
501
502
 
@@ -608,15 +609,17 @@ session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l
608
609
  provisionné dans l'écran **Users**, avec ses rôles réels.
609
610
 
610
611
  > [!NOTE]
611
- > L'événement d'audit du login social porte la raison par défaut `federated`
612
- > (`authFlow.ts:218`) : le controller n'affine pas le facteur. Pour distinguer OAuth de WebAuthn dans
613
- > un filtre d'audit, s'appuyer sur le contexte de la requête plutôt que sur cette seule valeur.
612
+ > L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
613
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:194`), comme WebAuthn passe `webauthn`.
614
+ > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
615
+ > (`authFlow.ts:215`).
614
616
 
615
617
  ## ⚠️ Pièges (symptôme → cause → correction)
616
618
 
617
619
  | Symptôme | Cause (dans le code) | Correction |
618
620
  | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
619
- | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
621
+ | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
622
+ | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
620
623
  | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
621
624
  | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
622
625
  | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
package/docs/tokens.md CHANGED
@@ -25,7 +25,7 @@ tags:
25
25
  ]
26
26
  version: "doc"
27
27
  status: stable
28
- updated: 2026-07-19
28
+ updated: 2026-10-02
29
29
  source: "src/packages/@nodefony/security/docs/tokens.md"
30
30
  ---
31
31
 
@@ -142,8 +142,9 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
143
143
 
144
144
  > [!IMPORTANT]
145
- > Ces routes n'existent **que si** le service `tokenService` est présent — sinon 404, zéro surface
146
- > (`TokenAuthController.ts:46-48`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
145
+ > Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
146
+ > (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
147
148
  > elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
148
149
  > authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
149
150
 
@@ -479,7 +480,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
479
480
 
480
481
  | Symptôme | Cause (dans le code) | Correction |
481
482
  | --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
482
- | 404 sur `/nodefony/security/api/token` | Routes montées seulement si `tokenService` existe | Charger `@nodefony/security` + `jwt.enabled: true` |
483
+ | 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
483
484
  | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
484
485
  | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
485
486
  | JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
package/docs/totp.md CHANGED
@@ -190,8 +190,9 @@ export default defineConfig<typeof env>((ctx) => ({
190
190
 
191
191
  ### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
192
192
 
193
- `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service, **et seulement si**
194
- le service `totp` existe (security chargé + 2FA activé) — sinon zéro surface, 404 :
193
+ `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service dès que
194
+ `@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
195
+ service non initialisé : elles répondent `503 2FA unavailable` :
195
196
 
196
197
  | Route | Corps | Réponse |
197
198
  | ------------------------------------------ | ---------- | ---------------------------------------------- |
@@ -211,7 +212,7 @@ Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
211
212
  > [!WARNING]
212
213
  > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
213
214
  > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
214
- > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:138`) — jamais un
215
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:142`) — jamais un
215
216
  > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
216
217
 
217
218
  ### 4. Ce qu'on observe
@@ -455,17 +456,17 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
455
456
  La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
456
457
  échoue **au démarrage**, pas au premier login :
457
458
 
458
- | Option | Type | Défaut | Effet |
459
- | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------ |
460
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:101`). |
461
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
463
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
466
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
467
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
468
- | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
459
+ | Option | Type | Défaut | Effet |
460
+ | --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
461
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
462
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
463
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
464
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
465
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
466
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
467
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
468
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
469
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
469
470
 
470
471
  ### Situation 1 — un utilisateur active la 2FA sur son compte
471
472
 
package/docs/webauthn.md CHANGED
@@ -217,7 +217,7 @@ export async function enrollPasskey(): Promise<string> {
217
217
  return out.credentialId;
218
218
  }
219
219
 
220
- /** Se connecter sans mot de passe. `username` omis = passkey découvrable. */
220
+ /** Se connecter sans mot de passe : un anonyme reçoit toujours un défi découvrable. */
221
221
  export async function loginWithPasskey(
222
222
  username?: string,
223
223
  ): Promise<{ username: string }> {
@@ -404,15 +404,16 @@ sans champ à remplir.
404
404
  passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
405
405
  ```
406
406
 
407
- Et côté client, **n'envoie pas** `username` à `login/options` : `allowCredentials` est alors omis et
408
- le navigateur propose les comptes qu'il connaît. C'est aussi la variante la plus sobre côté vie
409
- privée — voir la section suivante.
407
+ Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
408
+ `allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
409
+ jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
410
+ `WebAuthnController.ts:171`).
410
411
 
411
- | Ce que le client envoie à `login/options` | Ce que renvoie le serveur | Conséquence |
412
- | ----------------------------------------- | ----------------------------------------------- | ------------------------------------------------ |
413
- | `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
414
- | `{"username":"alice"}` (alice a 3 clés) | défi + `allowCredentials` de **3** identifiants | ciblage — mais l'anonyme apprend qu'ils existent |
415
- | `{"username":"fantome"}` | défi + `allowCredentials: []` (200, jamais 404) | pas d'erreur révélatrice — mais liste vide |
412
+ | Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
413
+ | --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
414
+ | anonyme, `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
415
+ | anonyme, `{"username":"alice"}` | identique : le `username` est ignoré | aucun ciblage, rien à apprendre sur `alice` |
416
+ | session authentifiée (ré-auth, step-up) | défi + `allowCredentials` des clés **de ce porteur** | ciblage de ses propres passkeys |
416
417
 
417
418
  ## 🛡️ `rpId` et origines — l'ancre anti-phishing
418
419
 
@@ -439,14 +440,16 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
439
440
 
440
441
  ## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
441
442
 
442
- `login/options` est **accessible à un anonyme** (`bypassFirewall`) et accepte un `username`
443
- (`WebAuthnController.ts:168`). C'est nécessaire — on ne peut pas exiger d'être connecté pour se
444
- connecter — mais cela ouvre deux surfaces qu'il faut regarder en face.
443
+ `login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
444
+ pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
445
+ reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
446
+ (`WebAuthnController.loginOptions()`, `WebAuthnController.ts:171`). Deux surfaces restent à
447
+ regarder en face.
445
448
 
446
449
  ### Amplification : bornée par le plafond, pas par une pagination
447
450
 
448
- Avec un `username`, le serveur charge **toutes** les passkeys du porteur pour construire
449
- `allowCredentials` (`webAuthn.ts:399`). Cet appel `findByUser` est **volontairement non paginé** —
451
+ Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
452
+ `allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:389`). Cet appel `findByUser` est **volontairement non paginé** —
450
453
  `allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
451
454
  répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
452
455
 
@@ -460,22 +463,17 @@ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `c
460
463
  (3 comptages, 0 chargement) ;
461
464
  - retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
462
465
 
463
- ### Énumération : le statut est uniforme, la liste ne l'est pas
466
+ ### Énumération : rien ne distingue deux comptes
464
467
 
465
- Un compte inexistant reçoit **200 + un défi**, exactement comme un compte réel — le test d'attaque E3
466
- (`webauthn-attack.test.ts`) verrouille ce point. Aucun 404, aucun message différencié, aucune latence
467
- de recherche de mot de passe.
468
+ Un anonyme reçoit **toujours le même défi découvrable**, sans `allowCredentials`, qu'il poste ou non
469
+ un `username` : aucun statut, aucune liste, aucune latence ne dit si un compte existe ou porte une
470
+ passkey, et l'appel ne touche même pas le store. C'est le remède que la spécification W3C WebAuthn
471
+ propose contre la fuite des identifiants de credentials ; `webauthnLoginOptionsPrivacy.test.ts` le
472
+ verrouille.
468
473
 
469
- Mais `allowCredentials` reflète la réalité : **vide** pour un identifiant sans passkey, **peuplé** (et
470
- portant les identifiants de credentials) pour un porteur enrôlé. Un anonyme peut donc distinguer
471
- « cet identifiant a au moins une passkey » de « il n'en a pas ».
472
-
473
- > [!WARNING]
474
- > C'est le comportement standard d'un `login/options` avec identifiant, et la parade est
475
- > architecturale : **ne transmets pas `username`**. Avec `residentKey: "required"` et un client qui
476
- > poste `{}`, `allowCredentials` est omis, aucun état de compte ne transparaît, et l'appel ne touche
477
- > même pas le store. Si ton UX exige la saisie d'un identifiant, place un rate-limit devant
478
- > `login/options` — le firewall ne le protège pas, c'est une route en `bypassFirewall`.
474
+ > [!NOTE]
475
+ > `login/options` reste une route en `bypassFirewall` : le firewall ne la protège pas. Un rate-limit
476
+ > en amont reste de bonne hygiène, pour borner le coût des défis émis.
479
477
 
480
478
  Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
481
479
 
@@ -680,19 +678,18 @@ requête. Le code en tire trois conséquences.
680
678
 
681
679
  ## ⚠️ Pièges (symptôme → cause → correction)
682
680
 
683
- | Symptôme | Cause (dans le code) | Correction |
684
- | ---------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------- |
685
- | `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
686
- | `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`) |
687
- | `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
688
- | Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>` |
689
- | Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`) |
690
- | Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
691
- | Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait |
692
- | `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
693
- | Un anonyme distingue les comptes à passkey | `allowCredentials` peuplé vs vide sur `login/options` | Ne pas envoyer `username` (usernameless) ; rate-limit la route |
694
- | `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
695
- | Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
681
+ | Symptôme | Cause (dans le code) | Correction |
682
+ | ---------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------- |
683
+ | `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
684
+ | `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`) |
685
+ | `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
686
+ | Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>` |
687
+ | Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`) |
688
+ | Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
689
+ | Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait |
690
+ | `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
691
+ | `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
692
+ | Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
696
693
 
697
694
  ## 🧪 Tests & couverture
698
695
 
package/docs/webhooks.md CHANGED
@@ -251,7 +251,7 @@ import { Buffer } from "node:buffer";
251
251
  // prettier-ignore
252
252
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
253
253
 
254
- const SECRET = (process.env.NODEFONY_HOOK_SECRET ?? "").replace(/^whsec_/, "");
254
+ const SECRET = (process.env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
255
255
 
256
256
  @controller("/hooks")
257
257
  class HookMiniController extends Controller {
@@ -308,7 +308,7 @@ import { Buffer } from "node:buffer";
308
308
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
309
309
 
310
310
  /** Secret `whsec_…` donné par l'émetteur — via l'environnement, jamais en dur. */
311
- const SECRET = process.env.NODEFONY_HOOK_SECRET ?? "";
311
+ const SECRET = process.env.NF_HOOK_SECRET ?? "";
312
312
  /** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
313
313
  const TOLERANCE_S = 300;
314
314
 
@@ -956,21 +956,21 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
956
956
 
957
957
  ## ⚠️ Pièges (symptôme → cause → correction)
958
958
 
959
- | Symptôme | Cause (dans le code) | Correction |
960
- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
961
- | Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
962
- | Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
963
- | `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
964
- | Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
965
- | Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
966
- | Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
967
- | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
968
- | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
969
- | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
970
- | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
971
- | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
972
- | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
973
- | Multi-pod : un endpoint créé ne livre que depuis un pod | Le snapshot n'est chargé qu'au boot (`webhooks.ts:325`) ; les pods qui n'ont pas vu l'écriture gardent l'ancien | Redémarrage tournant après une mutation, ou router l'admin sur tous les pods |
959
+ | Symptôme | Cause (dans le code) | Correction |
960
+ | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
961
+ | Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
962
+ | Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
963
+ | `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
964
+ | Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
965
+ | Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
966
+ | Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
967
+ | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
968
+ | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
969
+ | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
970
+ | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
971
+ | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
972
+ | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
973
+ | Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts:406`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
974
974
 
975
975
  ## 🧪 Tests & couverture
976
976
 
@@ -983,7 +983,7 @@ depuis vitest, jamais figés ici :
983
983
  historique, bornes de perf, arrêt) ; `webhookSignature` (vecteur officiel Standard Webhooks,
984
984
  altération du corps/id/timestamp) ; `webhookDelivery` (pin d'IP, non-suivi des 3xx, timeout,
985
985
  politique de protocole, capture du corps de réponse) ; `webhookStore` (CRUD mémoire + copie
986
- défensive) ; `webhookAdminApi` (les 8 endpoints, rôles, `400`/`404`/`422`/`503`, audit des
986
+ défensive) ; `webhookAdminApi` (les 9 endpoints, rôles, `400`/`404`/`422`/`503`, audit des
987
987
  mutations) ; `webhookPagination` (harnais du banc de contrat sur le store mémoire).
988
988
  - **attaque (red-team)** — `webhookSsrf.attack.test.ts` : matrice threat-first dérivée d'OWASP SSRF
989
989
  et de CAPEC-664 (IPv4-mapped IPv6 sous 7 notations, confusion `userinfo`, IP encodée
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-beta.1",
3
+ "version": "10.0.0-beta.2",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -54,22 +54,22 @@
54
54
  "zod": "^4.6.5"
55
55
  },
56
56
  "devDependencies": {
57
- "@nodefony/framework": "^10.0.0-beta.1",
58
- "@nodefony/http": "^10.0.0-beta.1",
59
- "@nodefony/user": "^10.0.0-beta.1",
57
+ "@nodefony/framework": "^10.0.0-beta.2",
58
+ "@nodefony/http": "^10.0.0-beta.2",
59
+ "@nodefony/user": "^10.0.0-beta.2",
60
60
  "@types/node": "26.6.3",
61
- "@vitest/coverage-v8": "5.0.2",
62
- "nodefony": "^10.0.0-beta.1",
63
- "vitest": "5.0.2"
61
+ "@vitest/coverage-v8": "5.0.3",
62
+ "nodefony": "^10.0.0-beta.2",
63
+ "vitest": "5.0.3"
64
64
  },
65
65
  "license": "Apache-2.0",
66
66
  "readmeFilename": "README.md",
67
67
  "contributors": [],
68
68
  "peerDependencies": {
69
- "@nodefony/framework": "^10.0.0-beta.1",
70
- "@nodefony/http": "^10.0.0-beta.1",
71
- "@nodefony/user": "^10.0.0-beta.1",
72
- "nodefony": "^10.0.0-beta.1"
69
+ "@nodefony/framework": "^10.0.0-beta.2",
70
+ "@nodefony/http": "^10.0.0-beta.2",
71
+ "@nodefony/user": "^10.0.0-beta.2",
72
+ "nodefony": "^10.0.0-beta.2"
73
73
  },
74
74
  "files": [
75
75
  "dist",