@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -3
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/SecuredArea.js +7 -0
  13. package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
  14. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  15. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  16. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  17. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  18. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  19. package/dist/types/nodefony/config/config.d.ts +2 -0
  20. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  21. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
  22. package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
  23. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
  24. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  25. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  26. package/docs/api-keys.md +62 -60
  27. package/docs/audit.md +77 -78
  28. package/docs/authenticators.md +55 -43
  29. package/docs/authorization.md +128 -23
  30. package/docs/cors.md +29 -29
  31. package/docs/csrf.md +26 -26
  32. package/docs/external-jwt.md +4 -4
  33. package/docs/firewall.md +80 -29
  34. package/docs/headers.md +26 -26
  35. package/docs/oauth2.md +69 -62
  36. package/docs/obtenir-un-jeton.md +2 -2
  37. package/docs/tokens.md +55 -55
  38. package/docs/totp.md +73 -72
  39. package/docs/webauthn.md +81 -81
  40. package/docs/webhooks.md +43 -37
  41. package/package.json +16 -13
package/docs/audit.md CHANGED
@@ -30,8 +30,8 @@ source: "src/packages/@nodefony/security/docs/audit.md"
30
30
  > Chaque fois qu'une **décision de sécurité** est prise — un login réussit, un accès est refusé, une
31
31
  > clé d'API est révoquée, un jeton volé resurgit — Nodefony écrit une ligne dans un journal
32
32
  > **append-only** : qui, quoi, quand, d'où, avec quel verdict. Pas le trafic (ça, c'est le log HTTP) :
33
- > les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:48`), le contrat
34
- > `IAuditEvent` (`IAuditEvent.ts:53`) et les stores de `nodefony/src/audit/`.
33
+ > les **transitions d'état**. Ancré sur `AuditService` (`auditService.ts:51`), le contrat
34
+ > `IAuditEvent` (`IAuditEvent.ts:61`) et les stores de `nodefony/src/audit/`.
35
35
 
36
36
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Journal d'audit**
37
37
 
@@ -70,7 +70,7 @@ flowchart LR
70
70
  API --> UI["Studio — Journal d'audit"]
71
71
  ```
72
72
 
73
- `AuditService.record()` (`auditService.ts:180`) est le point de passage unique : il pose l'identité et
73
+ `AuditService.record()` (`auditService.ts:187`) est le point de passage unique : il pose l'identité et
74
74
  l'horodatage, écrit **sans attendre**, et notifie les abonnés live. Tout le reste — stores, pont WS,
75
75
  webhooks, console — se branche autour de lui.
76
76
 
@@ -123,7 +123,7 @@ requête authentifiée qui passe n'écrit rien. Prouvé par les tests « SUCCÈS
123
123
  signal, pas du bruit à filtrer.
124
124
 
125
125
  **2. L'audit ne peut jamais casser le métier.** `record()` est synchrone, sans `await`, et l'écriture
126
- part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:192`). Un
126
+ part en fire-and-forget avec un `.catch()` qui se contente de logger (`auditService.ts:199`). Un
127
127
  store en panne dégrade la traçabilité, il ne renvoie pas 500 à l'utilisateur.
128
128
 
129
129
  **3. Le store est pluggable, jamais câblé en dur.** Le service résout un **nom** via un registre
@@ -196,7 +196,7 @@ export default ExportController;
196
196
  ```
197
197
 
198
198
  > [!IMPORTANT]
199
- > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:16`) : il n'existe
199
+ > `category` est une **union fermée** de sous-systèmes de sécurité (`IAuditEvent.ts:24`) : il n'existe
200
200
  > pas de catégorie « métier ». C'est volontaire — ce journal est celui de la sécurité. Range ton
201
201
  > événement dans la catégorie de sécurité qu'il concerne (ici `authz` : un accès privilégié à des
202
202
  > données) et laisse `action` porter le vocabulaire métier.
@@ -254,7 +254,7 @@ et ratées, sur une fenêtre précise.
254
254
 
255
255
  Aucune configuration : les événements d'authentification sont émis par défaut. Il suffit de filtrer.
256
256
  Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
257
- (`SecurityAdminApi.ts:191`) :
257
+ (`SecurityAdminApi.ts:184`) :
258
258
 
259
259
  | Paramètre | Effet | Exemple |
260
260
  | ----------- | ----------------------------------------------- | --------------------------- |
@@ -312,12 +312,12 @@ use("@nodefony/security", {
312
312
  ```
313
313
 
314
314
  Ce que ça déclenche : le service arme un `GcScheduler` de purge **toutes les heures**
315
- (`auditService.ts:152`, intervalle `GC_INTERVAL_MS` — `auditService.ts:31`), avec gigue
315
+ (`auditService.ts:159`, intervalle `GC_INTERVAL_MS` — `auditService.ts:34`), avec gigue
316
316
  anti-avalanche en cluster. Chaque
317
317
  tour appelle la purge du contrat — `gc()`, voisin de `listPage()` dans le même contrat
318
- (`IAuditStore.ts:67`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
- côté SQL (`DrizzleAuditStore.ts:231`), un défilement de file tant que l'événement de tête dépasse le
320
- `threshold` côté mémoire (`MemoryAuditStore.ts:127`).
318
+ (`IAuditStore.ts:59`) — qui supprime les événements plus vieux que la fenêtre : un `DELETE` par seuil
319
+ côté SQL (`DrizzleAuditStore.ts:256`), un défilement de file tant que l'événement de tête dépasse le
320
+ `threshold` côté mémoire (`MemoryAuditStore.ts:138`).
321
321
 
322
322
  Ce qu'on observe dans les logs : `audit gc — 1284 événement(s) purgé(s)` en niveau DEBUG.
323
323
 
@@ -331,8 +331,8 @@ qu'il arrive.
331
331
  La base est indisponible pendant trente secondes. Que se passe-t-il ?
332
332
 
333
333
  Le choix de Nodefony est explicite : **le métier passe avant la trace**. L'écriture part en
334
- fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:192`) ; côté SQL, si l'ORM n'est
335
- pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:131`). Un login n'échoue jamais
334
+ fire-and-forget — `append()` sans `await`, échec absorbé en log ERROR (`auditService.ts:199`) ; côté SQL, si l'ORM n'est
335
+ pas connecté, `append()` est un no-op assumé (`DrizzleAuditStore.ts:146`). Un login n'échoue jamais
336
336
  parce que le journal est cassé.
337
337
 
338
338
  > [!WARNING]
@@ -342,22 +342,22 @@ parce que le journal est cassé.
342
342
  > disponibilité — le contrat `IAuditStore` (`IAuditStore.ts:48`) est le point d'extension pour ça.
343
343
 
344
344
  Deux garde-fous limitent la casse au démarrage : un store **explicitement** configuré mais inconnu
345
- **avorte le boot en production** (`auditService.ts:109`), et un store `memory` en production déclenche
345
+ **avorte le boot en production** (`auditService.ts:115`), et un store `memory` en production déclenche
346
346
  un `WARNING` nommant précisément le risque — volatil, per-pod, rétention réglementaire impossible
347
- (`auditService.ts:122`).
347
+ (`auditService.ts:128`).
348
348
 
349
349
  ## 🔐 Le catalogue des événements audités
350
350
 
351
- Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:16`) : c'est
351
+ Deux axes de classement. La **catégorie** est une union **fermée** (`IAuditEvent.ts:24`) : c'est
352
352
  l'axe de filtrage principal de la console. L'**action** est une chaîne **ouverte**
353
- (`IAuditEvent.ts:66`) : la liste grandit sans jamais casser le contrat.
353
+ (`IAuditEvent.ts:74`) : la liste grandit sans jamais casser le contrat.
354
354
 
355
355
  ### Vue d'ensemble — choisir son filtre en cinq secondes
356
356
 
357
357
  <!-- prettier-ignore -->
358
358
  | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
359
359
  | --- | --- | --- |
360
- | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
360
+ | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` |
361
361
  | `authz` | autorisation (voters, `@IsGranted`) | `access.denied` |
362
362
  | `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
363
363
  | `session` | cycle de vie de session | `logout` |
@@ -369,7 +369,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
369
369
  | `cors` | politique CORS | _catégorie déclarée, aucune action émise aujourd'hui_ |
370
370
  | `config` | mutation de config runtime depuis Studio | _catégorie déclarée, aucune action émise aujourd'hui_ |
371
371
 
372
- Les trois issues possibles (`IAuditEvent.ts:35`) ne sont pas interchangeables : `failure` = **l'acteur
372
+ Les trois issues possibles (`IAuditEvent.ts:43`) ne sont pas interchangeables : `failure` = **l'acteur
373
373
  a échoué une preuve** (mauvais mot de passe, signature invalide) ; `denied` = **une politique a
374
374
  refusé** un acteur pourtant bien formé (Zero Trust, rôle manquant) ; `success` = l'action a abouti.
375
375
  Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non autorisé.
@@ -377,19 +377,21 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
377
377
  ### `auth` — la chaîne d'authentification
378
378
 
379
379
  Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
380
- (`firewall.ts:693`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
380
+ (`firewall.ts:900`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
381
381
 
382
- - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:768`) ;
383
- - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
382
+ - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
383
+ - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
384
+ - `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
385
+ (`firewall.ts:795`) ;
384
386
  - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
385
- (`firewall.ts:811`) ;
387
+ (`firewall.ts:822`) ;
386
388
  - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
387
389
 
388
390
  Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
389
391
  `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
390
392
  `login.throttled` (`authFlow.ts:139`), `login.mfa_required` quand un second facteur est réclamé
391
393
  (`authFlow.ts:175`), et `login.success` après la preuve complète (`authFlow.ts:191`,
392
- `authFlow.ts:229`).
394
+ `authFlow.ts:232`).
393
395
 
394
396
  ### `authz` — le refus d'autorisation
395
397
 
@@ -399,10 +401,10 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
399
401
  ### `token` — la vie et la mort des jetons longue durée
400
402
 
401
403
  - `token.issued` — un couple access/refresh vient d'être émis, donc une surface d'attaque vient
402
- d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:312`) ;
404
+ d'être créée ; les `scopes` et l'identifiant du jeton partent en `metadata` (`tokenService.ts:535`) ;
403
405
  - `token.reuse_detected` — **le signal d'attaque le plus fort du journal** : un refresh déjà révoqué
404
406
  a été re-présenté, donc quelqu'un détient un jeton volé. Toute la famille est coupée
405
- (`tokenService.ts:353`, RFC 9700 §4.14) ;
407
+ (`tokenService.ts:585`, RFC 9700 §4.14) ;
406
408
  - `apikey.created` / `apikey.revoked` — cycle de vie des clés d'API (`apiKeys.ts:153`,
407
409
  `apiKeys.ts:212`).
408
410
 
@@ -427,18 +429,18 @@ depuis l'utilisateur de la requête — avec repli `"admin"`, jamais une décisi
427
429
 
428
430
  ## 🧰 Le contrat d'une entrée
429
431
 
430
- Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:53`). L'émetteur ne fournit qu'un
431
- brouillon `IAuditEventDraft` (`IAuditEvent.ts:105`) : `id` et `ts` sont posés par le service, ce qui
432
+ Un événement est un objet **sérialisable JSON** (`IAuditEvent.ts:61`). L'émetteur ne fournit qu'un
433
+ brouillon `IAuditEventDraft` (`IAuditEvent.ts:113`) : `id` et `ts` sont posés par le service, ce qui
432
434
  garantit un seul appel d'horloge, centralisé hors des points d'émission.
433
435
 
434
436
  | Champ | Type | Posé par | Rôle |
435
437
  | ----------- | -------------------------- | -------- | ------------------------------------------------------------ |
436
438
  | `id` | `string` | service | unique dans le process ; sert aussi de composante de curseur |
437
- | `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:188`) |
439
+ | `ts` | `number` (epoch ms) | service | horodatage (`auditService.ts:195`) |
438
440
  | `category` | `AuditCategory` | émetteur | sous-système — union fermée |
439
441
  | `action` | `string` | émetteur | le fait, `<sujet>.<verbe>` — chaîne ouverte |
440
442
  | `outcome` | `success\|failure\|denied` | émetteur | le verdict |
441
- | `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:74`) |
443
+ | `actor` | `string \| null` | émetteur | libellé d'identité, `null` si anonyme (`IAuditEvent.ts:82`) |
442
444
  | `resource` | `string \| null` | émetteur | zone, route, canal, attribut — descripteur **léger** |
443
445
  | `reason` | `string \| null` | émetteur | motif **machine** filtrable, pas un message traduit |
444
446
  | `ip` | `string \| null` | contexte | provenance réseau |
@@ -458,19 +460,19 @@ Quatre mécanismes, tous prouvés par les tests.
458
460
 
459
461
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
460
462
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
461
- (`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
462
- Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
463
+ (`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:352`).
464
+ Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
463
465
 
464
466
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
465
- avant tout appel d'horloge si le service est inactif (`auditService.ts:182`). Aucun objet créé,
467
+ avant tout appel d'horloge si le service est inactif (`auditService.ts:187`). Aucun objet créé,
466
468
  aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens` n'est pas journalisé »
467
469
  (`auditEmissionHotPath.test.ts:499`).
468
470
 
469
471
  **3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
470
- (`auditService.ts:192`). La latence du store n'entre jamais dans la latence de la requête.
472
+ (`auditService.ts:199`). La latence du store n'entre jamais dans la latence de la requête.
471
473
 
472
474
  **4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
473
- personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:221`). Le pont
475
+ personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts:228`). Le pont
474
476
  WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
475
477
  alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est armé à la demande et
476
478
  `unref` (`auditBridge.ts:93`).
@@ -478,13 +480,13 @@ alloué qu'au premier événement reçu (`auditBridge.ts:62`) ; son minuteur est
478
480
  Le pont applique en plus un **coalescing borné** : au plus une frame WS toutes les 250 ms
479
481
  (`auditBridge.ts:59`), tampon plafonné à 200 événements (`auditBridge.ts:60`). Sous une rafale
480
482
  d'échecs de login, le tampon écrase les plus anciens et compte les omis dans `dropped`
481
- (`auditBridge.ts:84`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
483
+ (`auditBridge.ts:79`) — la console affiche un récapitulatif au lieu de se figer. Superviser ne doit
482
484
  jamais faire tomber ce qu'on supervise.
483
485
 
484
486
  ## ⚙️ Configuration
485
487
 
486
- Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
487
- (`config.ts:1152`).
488
+ Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
489
+ (`config.ts:1158`).
488
490
 
489
491
  | Option | Type | Défaut | Effet |
490
492
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
@@ -498,18 +500,18 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
498
500
  > `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
499
501
  > Les mettre à `false` ne change rien. L'immuabilité vient du **contrat** `IAuditStore`, qui n'expose
500
502
  > ni `update` ni `delete` ciblé (`IAuditStore.ts:48`) ; la diffusion live est gouvernée par la
501
- > **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:194`).
503
+ > **présence d'abonnés** — la liste `#listeners` du service (`auditService.ts:55`).
502
504
 
503
505
  ### Comment `store: "auto"` décide
504
506
 
505
507
  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:289`).
508
+ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:289`).
507
509
 
508
510
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
509
511
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
510
512
  3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
511
513
 
512
- La résolution finale est publiée au kernel (`auditService.ts:139`), ce qui alimente l'écran des stores
514
+ La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
513
515
  de Studio : configuré, résolu, disponible, motif, emplacement physique.
514
516
 
515
517
  ## 🏗️ Architecture interne
@@ -536,8 +538,8 @@ Quatre pièces, quatre responsabilités :
536
538
  - **`recordAudit()`** (`recordAudit.ts:15`) — le point d'émission côté appelant. Une résolution par le
537
539
  container, sur le cold-path uniquement. C'est ce qui rend l'audit **découplé** : module absent →
538
540
  aucun effet, jamais d'exception qui remonterait dans le flux métier.
539
- - **`AuditService`** (`auditService.ts:48`) — le propriétaire. Il construit le store au boot, le pose
540
- au container sous le nom `auditStore` (`auditService.ts:138`), arme la purge, et implémente
541
+ - **`AuditService`** (`auditService.ts:51`) — le propriétaire. Il construit le store au boot, le pose
542
+ au container sous le nom `auditStore` (`auditService.ts:144`), arme la purge, et implémente
541
543
  `IAuditSink` (`IAuditStore.ts:77`).
542
544
  - **`IAuditStore`** (`IAuditStore.ts:48`) — le contrat de persistance : `append`, `listPage`, `gc`.
543
545
  **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
@@ -569,27 +571,24 @@ ligne de garde `limit + 1` (`DrizzleAuditStore.ts:198`).
569
571
 
570
572
  ## Entité de persistance et dialectes pris en charge
571
573
 
572
- **Un seul backend durable** est fourni : `@nodefony/drizzle`. Le décompte honnête :
574
+ **Les deux backends durables portent l'audit** : `@nodefony/drizzle` et `@nodefony/mongoose`. Le
575
+ décompte honnête :
573
576
 
574
- | Backend | Store d'audit | Dialectes prouvés |
577
+ | Backend | Store d'audit | Moteurs prouvés |
575
578
  | -------------------- | ------------- | ----------------------------------- |
576
579
  | builtin `memory` | ✅ fourni | — (mémoire du process, per-pod) |
577
580
  | `@nodefony/drizzle` | ✅ fourni | SQLite · PostgreSQL · MySQL/MariaDB |
578
- | `@nodefony/mongoose` | ❌ absent | — |
581
+ | `@nodefony/mongoose` | ✅ fourni | MongoDB |
579
582
  | `@nodefony/redis` | ❌ absent | — |
580
583
 
581
- Mongoose porte session, user, jetons, passkeys et webhooks, mais **pas** l'audit ; Redis non plus.
582
- Ces deux absences n'ont pas le même statut. Redis n'en aura pas, et la raison n'est pas
583
- « c'est un cache » — il porte déjà des données durables comme les passkeys, en opt-in assumé. C'est
584
- que le journal d'audit **croît sans borne**, se conserve des mois pour la conformité, et se
585
- **consulte** (recherche, filtres, pagination) : garder tout ça en mémoire vive coûte cher pour un
586
- motif d'accès qui n'est pas le sien. Mongo, lui, est un chemin **durable** :
587
- l'audit y **manque**, et c'est un manque à combler (objectif « full NoSQL », `MIGRATION_STATUS.md`
588
- P7.11) — un utilisateur choisit sa base de données, pas de perdre sa traçabilité.
584
+ Une application peut donc tourner **entièrement sur MongoDB** sans perdre sa traçabilité : c'était
585
+ la condition pour que Mongo soit un chemin complet, et pas un chemin qui oblige à rapatrier une
586
+ base SQL pour une seule brique.
589
587
 
590
- En attendant, une application MongoDB qui veut un journal durable a trois voies : brancher un store
591
- maison (voir l'extension ci-dessous), charger `@nodefony/drizzle` à côté de Mongo — même en SQLite
592
- local, les deux modules cohabitent — ou héberger le journal sur une base SQL.
588
+ **Redis n'en aura pas, et la raison n'est pas « c'est un cache »** — il porte déjà des données
589
+ durables comme les passkeys, en opt-in assumé. C'est que le journal d'audit **croît sans borne**,
590
+ se conserve des mois pour la conformité, et se **consulte** (recherche, filtres, pagination) :
591
+ garder tout ça en mémoire vive coûte cher pour un motif d'accès qui n'est pas le sien.
593
592
 
594
593
  ### La table `audit_event`
595
594
 
@@ -631,9 +630,9 @@ La règle d'or est écrite en tête du contrat (`IAuditEvent.ts:8`) : **un secre
631
630
  Ce que le code garantit, concrètement :
632
631
 
633
632
  - **Le typage rend le secret difficile à faire entrer.** `actor` est documenté comme un _libellé
634
- d'identité_ (`IAuditEvent.ts:74`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
635
- les en-têtes de la requête » (`IAuditEvent.ts:79`).
636
- - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:41`) ne porte que deux
633
+ d'identité_ (`IAuditEvent.ts:82`) et `resource` comme un _descripteur léger_ — « jamais le corps ni
634
+ les en-têtes de la requête » (`IAuditEvent.ts:87`).
635
+ - **La présence remplace la valeur.** `IAuditEventFlags` (`IAuditEvent.ts:49`) ne porte que deux
637
636
  booléens : un en-tête `Authorization` était-il là, un cookie était-il là. `readAuditContext()`
638
637
  les calcule par un simple `Boolean(headers[…])` (`readAuditContext.ts:40`) — la valeur n'est jamais
639
638
  copiée.
@@ -642,7 +641,7 @@ Ce que le code garantit, concrètement :
642
641
  complète de l'événement **ne contient pas** le secret créé
643
642
  (`auditEmissionHotPath.test.ts:545`).
644
643
  - **Le motif reste machine.** `reason` est une valeur stable et filtrable, pas un message d'erreur
645
- libre (`IAuditEvent.ts:84`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
644
+ libre (`IAuditEvent.ts:92`) : c'est ce qui empêche une cause fine de fuir dans la trace… et qui
646
645
  permet de la garder **côté audit** alors que le client, lui, reçoit un message d'échec uniforme
647
646
  (anti-énumération).
648
647
 
@@ -661,11 +660,11 @@ Deux autres propriétés de sécurité valent d'être connues :
661
660
  | ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
662
661
  | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
663
662
  | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
664
- | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:41`) |
665
- | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:53`) |
663
+ | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
664
+ | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
666
665
  | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
667
- | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:906`) |
668
- | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:353`) |
666
+ | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:912`) |
667
+ | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
669
668
  | Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
670
669
 
671
670
  ## 📡 Observabilité — Studio
@@ -675,7 +674,7 @@ L'écran **Sécurité → Journal d'audit** (`/nodefony/audit`) (`Audit.tsx:62`)
675
674
 
676
675
  - un tableau filtrable par heure, catégorie, action, issue, acteur, raison et IP (`Audit.tsx:227`) ;
677
676
  - un indicateur du **store réellement résolu** pour la brique `audit`, alimenté par la publication de
678
- résolution du service (`auditService.ts:139`) ;
677
+ résolution du service (`auditService.ts:145`) ;
679
678
  - un interrupteur **Temps réel** qui s'abonne au canal `nodefony:audit` (`AuditLive.tsx:20`) — pensé
680
679
  pour les pics d'activité : un journal d'audit se consulte, il ne se regarde pas défiler ;
681
680
  - un renvoi vers la **trace de requête** quand l'événement porte un `requestId`, ce qui recolle
@@ -693,19 +692,19 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
693
692
 
694
693
  ## ⚠️ Pièges
695
694
 
696
- | Symptôme | Cause (dans le code) | Correction |
697
- | --------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------- |
698
- | 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`) |
699
- | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
700
- | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:109`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
701
- | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
702
- | 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 |
703
- | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
704
- | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
705
- | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:194`) | Retirer le rôle admin, ou ne pas exposer le canal |
706
- | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:192`) | Store à haute disponibilité si la conformité l'exige |
707
- | 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 |
708
- | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
695
+ | Symptôme | Cause (dans le code) | Correction |
696
+ | --------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------- |
697
+ | 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`) |
698
+ | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
699
+ | 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 |
700
+ | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
701
+ | 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 |
702
+ | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
703
+ | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
704
+ | `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 |
705
+ | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
706
+ | 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 |
707
+ | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts:320`) | Réactiver l'audit en configuration |
709
708
 
710
709
  ## 🧪 Tests & couverture
711
710
 
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/security/docs/authenticators.md"
31
31
  > Un **authenticator** répond à une seule question : _« qui es-tu, et peux-tu le prouver ? »_. Il ne
32
32
  > décide **pas** des droits (ça, c'est l'autorisation / les voters) — il établit une **identité**.
33
33
  > Le firewall enchaîne les authenticators déclarés par une zone jusqu'à obtenir une preuve valide,
34
- > sinon il ferme en 401 (Zero Trust). Nodefony en fournit **six** intégrés, tous ancrés ici sur le
34
+ > sinon il ferme en 401 (Zero Trust). Nodefony en fournit **sept** intégrés, tous ancrés ici sur le
35
35
  > code (`src/packages/@nodefony/security/nodefony/src/authenticator/`).
36
36
 
37
37
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Authenticators**
@@ -90,7 +90,7 @@ classe d'attaque précise — détaillées brique par brique dans le catalogue :
90
90
 
91
91
  ### Le contrat `IAuthenticator`
92
92
 
93
- Tout authenticator implémente le même cycle (`IAuthenticator.ts:18`), ce qui rend le firewall
93
+ Tout authenticator implémente le même cycle (`IAuthenticator.ts:23`), ce qui rend le firewall
94
94
  totalement agnostique de la stratégie :
95
95
 
96
96
  | Méthode | Rôle |
@@ -100,7 +100,7 @@ totalement agnostique de la stratégie :
100
100
  | `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
101
101
  | `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
102
102
  | `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
103
- | `challenge()` | **Optionnel** (`IAuthenticator.ts:42`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
103
+ | `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
104
104
 
105
105
  ### Le registre pluggable
106
106
 
@@ -108,11 +108,11 @@ Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticat
108
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
- - Les **cinq builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `apikey`)
111
+ - Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
112
112
  s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
113
- (`authenticatorRegistry.ts:72-125`) — donc toujours avant le boot.
114
- - Le sixième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
115
- lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:268`).
113
+ (`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
114
+ - Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
115
+ lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:279`).
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
@@ -205,16 +205,17 @@ Requête par requête, qui répond :
205
205
  | `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
206
206
  | rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1200`) |
207
207
 
208
- ## 🔐 Les six authenticators intégrés
208
+ ## 🔐 Les authenticators intégrés
209
209
 
210
- | Nom | Credential | Vérité | Révocable | Pour… |
211
- | ------------------- | ---------------------------------------- | ---------- | :-------: | ---------------------------------- |
212
- | `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
213
- | `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
214
- | `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
215
- | `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
216
- | `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
217
- | `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
210
+ | Nom | Credential | Vérité | Révocable | Pour… |
211
+ | ------------------- | --------------------------------------------------- | ---------- | :------------: | ------------------------------------------ |
212
+ | `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
213
+ | `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
214
+ | `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
215
+ | `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
216
+ | `external-jwt` | `Authorization: Bearer <a.b.c>` d'un émetteur TIERS | émetteur | via l'émetteur | jetons d'un serveur d'autorisation externe |
217
+ | `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
218
+ | `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
218
219
 
219
220
  ### `anonymous` — accepter explicitement l'anonymat
220
221
 
@@ -235,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
235
236
 
236
237
  Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
237
238
  case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
238
- le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
239
+ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
239
240
 
240
241
  - **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
241
242
  leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
@@ -245,7 +246,7 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
245
246
  (`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
246
247
  throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
247
248
  (`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
248
- (`firewall.ts:764`).
249
+ (`firewall.ts:780`).
249
250
  - **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
250
251
  attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
251
252
  - Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
@@ -258,7 +259,7 @@ Après le login, chaque requête web prouve son identité par la **session serve
258
259
  Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
259
260
 
260
261
  - **N'ouvre jamais la session lui-même** : `supports()` exige une session **déjà reprise** porteuse
261
- d'un utilisateur (`SessionAuthenticator.ts:43-46`) — le pipeline http démarre la session _avant_
262
+ d'un utilisateur (`SessionAuthenticator.ts:75-78`) — le pipeline http démarre la session _avant_
262
263
  le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
263
264
  - **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
264
265
  (`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
@@ -272,21 +273,32 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
272
273
 
273
274
  Réservé aux **API service↔service / agents** (le web reste sur la session). Vérifie un access token
274
275
  **EdDSA** signé par le keystore du serveur ; `supports()` ne réclame que la structure compacte
275
- `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:20`). Les défenses **dures** du JWT BCP, toutes
276
+ `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`). Les défenses **dures** du JWT BCP, toutes
276
277
  prouvées en test :
277
278
 
278
279
  | Défense | Comment | Attaque fermée |
279
280
  | -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
280
281
  | **Allowlist d'algorithmes** | `algorithms: ["EdDSA"]` — jamais l'algo de l'en-tête du token (`JwtAuthenticator.ts:120`) | `alg=none`, algorithm confusion (§3.1) |
281
- | **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:158`) | injection de clé / SSRF (§3.5) |
282
- | **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:105-107`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
283
- | **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:123-131`) | jeton auto-porté volé, logout global |
284
- | **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:174-186`) | compte banni encore « valide » via son token (§3.10) |
282
+ | **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:174`) | injection de clé / SSRF (§3.5) |
283
+ | **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:119-124`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
284
+ | **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:138-148`) | jeton auto-porté volé, logout global |
285
+ | **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:190-203`) | compte banni encore « valide » via son token (§3.10) |
285
286
 
286
- Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:24`) : la cause fine
287
+ Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:25`) : la cause fine
287
288
  (expiré, `aud`, signature, sujet banni) part en **audit**, jamais au client — anti-oracle. Le token
288
- promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:162-171`). `jose` est
289
- importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:96`).
289
+ promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:178-188`). `jose` est
290
+ importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:112`).
291
+
292
+ Les six premiers se déclarent dans une zone (registre `authenticatorRegistry.ts:73`) ;
293
+ `firewall-realtime` ne se déclare pas — le firewall le câble lui-même au handshake WebSocket.
294
+
295
+ ### `external-jwt` — un jeton émis par un serveur d'autorisation TIERS
296
+
297
+ Là où `jwt` ne vérifie que les jetons signés par Nodefony, `external-jwt` accepte un jeton d'un
298
+ émetteur de confiance déclaré (`resourceServer.issuers`), vérifié contre ses clés publiques, et
299
+ rattache son sujet à un compte local. `jwt` et `external-jwt` s'aiguillent par l'émetteur (`iss`) :
300
+ leur ordre dans la zone ne décide de rien. Tout le détail — audience, espace de noms du sujet, panne
301
+ de l'émetteur en 503 — est dans **[Jetons d'un émetteur tiers](external-jwt.md)**.
290
302
 
291
303
  ### `apikey` — PAT opaque révocable
292
304
 
@@ -294,17 +306,17 @@ Clé API personnelle en `Authorization: Bearer nf_…` (préfixe `apiKeys.prefix
294
306
  Contrairement au JWT (auto-porté), un PAT est un **bearer opaque** dont la vérité vit **côté
295
307
  serveur** (`ITokenStore`) → **révocable immédiatement**. Défenses :
296
308
 
297
- - **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:99-101`)
309
+ - **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:103-106`)
298
310
  → une valeur malformée n'atteint jamais la base (**anti-DoS**).
299
- - **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:105`) — le secret n'existe
311
+ - **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:109`) — le secret n'existe
300
312
  **nulle part au repos**.
301
- - **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:109-111`) +
302
- **ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:117-118`).
303
- - **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:123`).
313
+ - **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:113-116`) +
314
+ **ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:121-122`).
315
+ - **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:127`).
304
316
  - **`lastUsedAt` throttlé** : aucune écriture sur le hot path tant que la fenêtre
305
- `apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:127-133`).
317
+ `apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:136-143`).
306
318
 
307
- Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:138-140`).
319
+ Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:146-149`).
308
320
 
309
321
  ### `firewall-realtime` — la promotion, en WebSocket, de l'identité déjà posée
310
322
 
@@ -323,7 +335,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
323
335
  - **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
324
336
  (`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
325
337
  un coût évitable sur le différenciateur temps réel.
326
- - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:268`) pour toute zone
338
+ - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:279`) pour toute zone
327
339
  protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:289`).
328
340
  - **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
329
341
  on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
@@ -333,7 +345,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
333
345
  `buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
334
346
  lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:227`). En mode jeton porteur, la
335
347
  preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
336
- (`FirewallRealtimeAuthenticator.ts:127`).
348
+ (`FirewallRealtimeAuthenticator.ts:179`).
337
349
 
338
350
  > [!NOTE]
339
351
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
@@ -344,7 +356,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
344
356
  ## ⚙️ Composer une zone — ordre, mode, cohabitation
345
357
 
346
358
  La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
347
- (`firewall.ts:1112`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
359
+ (`firewall.ts:1128`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
348
360
 
349
361
  ### Situation 1 — humains ET machines sur la même API (`first`)
350
362
 
@@ -377,14 +389,14 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
377
389
  ### Situation 3 — empiler les preuves (`all`)
378
390
 
379
391
  En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
380
- (`firewall.ts:937`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:973`) —
392
+ (`firewall.ts:1144-1149`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1198-1199`) —
381
393
  utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
382
394
  re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
383
395
 
384
396
  ### Cohabitation JWT + clé API dans une même zone
385
397
 
386
398
  Les deux sont des `Bearer`, mais Nodefony les **discrimine sur la forme** — un JWT a la structure
387
- compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:20`), un PAT porte le préfixe `nf_` sans
399
+ compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`), un PAT porte le préfixe `nf_` sans
388
400
  point (`looksLikeApiKey`, `ApiKeyAuthenticator.ts:72`). Chaque `supports()` ne réclame que _son_
389
401
  format → aucun conflit, aucune double vérification.
390
402
 
@@ -428,8 +440,8 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
428
440
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
429
441
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
430
442
  | Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
431
- | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:764`) |
432
- | Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:24`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
443
+ | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:780`) |
444
+ | Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
433
445
 
434
446
  ## ⚡ Performance & mémoire
435
447
 
@@ -437,7 +449,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
437
449
  (le chemin chaud/froid vit dans le [firewall](./firewall.md)).
438
450
  - Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
439
451
  (`UserPasswordAuthenticator.ts:105`) ; keystore, `tokenStore` et `users` sont résolus du
440
- container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:96`).
452
+ container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
441
453
  - `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
442
454
  (`AnonymousToken.ts:9`).
443
455
  - `apikey` : écriture `lastUsedAt` **coalescée** — pas une écriture par requête
@@ -456,7 +468,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
456
468
  | JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
457
469
  | Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
458
470
  | Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
459
- | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:617`) | Configurer `rateLimit` pour poser le throttler |
471
+ | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:613`) | Configurer `rateLimit` pour poser le throttler |
460
472
 
461
473
  ## 🧪 Tests & couverture
462
474