@nodefony/security 10.0.0-alpha.9 → 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.
Files changed (74) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +2 -2
  5. package/dist/nodefony/command/security-token.js +4 -4
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/service/apiKeys.js +2 -2
  9. package/dist/nodefony/service/auditService.js +3 -3
  10. package/dist/nodefony/service/authFlow.js +2 -2
  11. package/dist/nodefony/service/authorization.js +3 -4
  12. package/dist/nodefony/service/firewall.js +21 -9
  13. package/dist/nodefony/service/oauth2.js +5 -3
  14. package/dist/nodefony/service/securityHeaders.js +13 -2
  15. package/dist/nodefony/service/tokenService.js +27 -18
  16. package/dist/nodefony/service/webAuthn.js +38 -31
  17. package/dist/nodefony/service/webhooks.js +50 -41
  18. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  19. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  20. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  21. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  22. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  23. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  24. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  25. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  26. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  27. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  28. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  29. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  30. package/dist/nodefony/src/csp.js +4 -5
  31. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  32. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  33. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  34. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
  35. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  36. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  37. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  38. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  39. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
  40. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  41. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  42. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  43. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  44. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  45. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  46. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  47. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  48. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  49. package/dist/types/nodefony/service/securityHeaders.d.ts +7 -6
  50. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  51. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  52. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  53. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  54. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  55. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  56. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  57. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  58. package/docs/api-keys.md +16 -16
  59. package/docs/audit.md +27 -20
  60. package/docs/authenticators.md +8 -8
  61. package/docs/authorization.md +15 -15
  62. package/docs/cors.md +7 -7
  63. package/docs/csrf.md +8 -8
  64. package/docs/external-jwt.md +19 -10
  65. package/docs/firewall.md +28 -28
  66. package/docs/headers.md +20 -20
  67. package/docs/index.md +6 -4
  68. package/docs/lexique.md +15 -15
  69. package/docs/oauth2.md +23 -20
  70. package/docs/tokens.md +11 -10
  71. package/docs/totp.md +15 -14
  72. package/docs/webauthn.md +51 -54
  73. package/docs/webhooks.md +27 -30
  74. package/package.json +13 -13
package/docs/webauthn.md CHANGED
@@ -54,7 +54,7 @@ flowchart LR
54
54
  Le défi vit **hors du service**, en session BFF, posé par le controller
55
55
  (`WebAuthnController.registerOptions()`, `WebAuthnController.ts:113`). Chaque `verify*` reçoit
56
56
  l'`expectedChallenge` qu'il a émis, et le controller **l'invalide dès sa lecture**
57
- (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`) : un défi ne sert qu'une fois.
57
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`) : un défi ne sert qu'une fois.
58
58
 
59
59
  ## 📖 Lexique
60
60
 
@@ -90,7 +90,7 @@ Quatre failles fermées **par construction** :
90
90
  2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
91
91
  `IWebAuthnCredential.ts:16`). Une base volée ne donne aucun accès.
92
92
  3. **Rejeu** — le défi est à usage unique, invalidé en session dès sa lecture
93
- (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`).
93
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`).
94
94
  4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
95
95
  (`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts:22`).
96
96
 
@@ -114,7 +114,7 @@ Trois partis pris assumés :
114
114
  - **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts:75`) :
115
115
  mémoire par défaut, ORM ou Redis en production, avec le **même banc de contrat** pour tous.
116
116
  - **Les endpoints sont fournis**, pas à réécrire (`mountWebAuthnRoutes()`,
117
- `WebAuthnController.ts:308`) : c'est là que vivent les gardes délicates (session du défi, usage
117
+ `WebAuthnController.ts:314`) : c'est là que vivent les gardes délicates (session du défi, usage
118
118
  unique, messages uniformes, anti-IDOR).
119
119
 
120
120
  ## 🚀 Démarrage rapide
@@ -155,7 +155,7 @@ export default defineConfig(() => ({
155
155
  ### 2. Les endpoints BFF sont FOURNIS
156
156
 
157
157
  Dès que `@nodefony/security` est chargé, le framework monte six routes
158
- (`mountWebAuthnRoutes()`, `WebAuthnController.ts:308`) :
158
+ (`mountWebAuthnRoutes()`, `WebAuthnController.ts:314`) :
159
159
 
160
160
  | Route (`POST` sauf mention) | Rôle | Firewall |
161
161
  | -------------------------------------------------- | --------------------------------------------- | ------------------------- |
@@ -170,7 +170,7 @@ Dès que `@nodefony/security` est chargé, le framework monte six routes
170
170
  > Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:365`) : elles
171
171
  > **sont** le mécanisme d'authentification — les protéger exigerait d'être déjà connecté pour se
172
172
  > connecter. Le contrôle d'accès de `register/*` est fait **dans le controller**
173
- > (`flow.me()` → 401, `WebAuthnController.ts:106`). Les deux routes self-service, elles, restent dans
173
+ > (`flow.me()` → 401, `WebAuthnController.ts:119`). Les deux routes self-service, elles, restent dans
174
174
  > la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts:314`).
175
175
 
176
176
  ### 3. Ce que TU écris : l'appel navigateur
@@ -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 }> {
@@ -340,7 +340,7 @@ controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
340
340
  Deux chemins, deux portées :
341
341
 
342
342
  - **Self-service** : `DELETE …/webauthn/credentials/{id}` → `WebAuthnService.removeUserCredential()`
343
- (`webAuthn.ts:507`). La suppression n'aboutit que si le credential **appartient** au demandeur
343
+ (`webAuthn.ts:502`). La suppression n'aboutit que si le credential **appartient** au demandeur
344
344
  (`webAuthn.ts:513`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
345
345
  jamais l'existence de la passkey d'autrui.
346
346
  - **Reset administrateur** : `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}`
@@ -361,7 +361,7 @@ Table dérivée du schéma Zod `passkeysSchema` (`config.ts:461`), monté sous l
361
361
  | `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:463`) |
362
362
  | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:469`) |
363
363
  | `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:473`) |
364
- | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:479`) |
364
+ | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:469`) |
365
365
  | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:483`) |
366
366
  | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:489`) |
367
367
  | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:495`) |
@@ -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
 
@@ -551,7 +549,7 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
551
549
  - `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
552
550
  (`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
553
551
  - `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:181`) ; le service
554
- déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:257`).
552
+ déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:47`).
555
553
 
556
554
  ### `drizzle` — SQL, le durable par défaut
557
555
 
@@ -611,8 +609,8 @@ symbolique (`.ai/symbols.json`).
611
609
  | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
612
610
  | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
613
611
  | `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:490`) |
614
- | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:507`) |
615
- | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:496`) |
612
+ | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
613
+ | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:492`) |
616
614
 
617
615
  Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
618
616
  `IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
@@ -644,7 +642,7 @@ doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532
644
642
  backend sans comptage (Redis).
645
643
 
646
644
  Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
647
- passkey (`AuthStore.ts:209`).
645
+ passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:212`).
648
646
 
649
647
  ## 📜 Normes appliquées
650
648
 
@@ -658,7 +656,7 @@ passkey (`AuthStore.ts:209`).
658
656
  | Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
659
657
  | FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
660
658
  | Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:483`) |
661
- | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:507`) |
659
+ | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:502`) |
662
660
 
663
661
  La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
664
662
  ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
@@ -673,26 +671,25 @@ requête. Le code en tire trois conséquences.
673
671
  réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — une app qui n'enrôle personne ne paie
674
672
  jamais son coût de parse.
675
673
  - **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
676
- (`webAuthn.ts:129-131`) — pas de store, pas de `Map`.
674
+ (`webAuthn.ts:119-130`) — pas de store, pas de `Map`.
677
675
  - **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
678
676
  store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
679
677
  `maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
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
@@ -66,11 +66,11 @@ Le détail de chaque étape est plus bas, dans **Architecture interne**.
66
66
 
67
67
  Trois usages courants, tous branchés sur des événements que Nodefony émet déjà :
68
68
 
69
- | Ce que tu veux | Tu abonnes | Le tiers qui reçoit |
70
- | -------------------------------------------------------------------------- | ------------------------------------ | ---------------------------------- |
71
- | Être prévenu quand quelqu'un s'acharne sur un compte | `login.failure` | un canal Slack, un SMS d'astreinte |
72
- | Garder une trace inviolable des accès, hors de l'application | `*` | un SIEM, un bucket d'archives |
73
- | Couper l'accès d'un salarié partout ailleurs quand sa session est révoquée | `token.revoked`, `session.destroyed` | ton annuaire, ton outil de tickets |
69
+ | Ce que tu veux | Tu abonnes | Le tiers qui reçoit |
70
+ | -------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------- |
71
+ | Être prévenu quand quelqu'un s'acharne sur un compte | `login.failure` | un canal Slack, un SMS d'astreinte |
72
+ | Garder une trace inviolable des accès, hors de l'application | `*` | un SIEM, un bucket d'archives |
73
+ | Couper l'accès d'un salarié partout ailleurs quand sa session est révoquée | `session.revoked`, `token.reuse_detected` | ton annuaire, ton outil de tickets |
74
74
 
75
75
  Le premier, en entier — un serveur qui prévient une équipe quand un compte est attaqué :
76
76
 
@@ -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
 
@@ -418,8 +418,8 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donne
418
418
  | ---------- | -------------------------------------------------------------------------- |
419
419
  | `auth` | Login/logout, chaîne d'authentification (`login.success`, `login.failure`) |
420
420
  | `authz` | Accès accordé/refusé, voters, `@IsGranted` (`access.denied`) |
421
- | `token` | Jetons longue durée émis/révoqués — JWT refresh, PAT (`token.revoked`) |
422
- | `session` | Cycle de vie de session (`session.opened`) |
421
+ | `token` | Jetons longue durée et clés d'API (`token.issued`, `apikey.revoked`) |
422
+ | `session` | Cycle de vie de session (`logout`, `session.revoked`) |
423
423
  | `oauth` | Login social : authorize, callback, provisioning JIT |
424
424
  | `webauthn` | Passkeys : enregistrement, assertion |
425
425
  | `csrf` | Défense CSRF déclenchée |
@@ -672,7 +672,7 @@ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
672
672
 
673
673
  ### Auto-désactivation d'un endpoint mort
674
674
 
675
- Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:687`) : succès →
675
+ Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:682`) : succès →
676
676
  `failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
677
677
  est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
678
678
  meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
@@ -725,7 +725,7 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
725
725
 
726
726
  > [!NOTE]
727
727
  > `timestampToleranceS` est **transporté** dans la politique de livraison
728
- > (`getDeliveryPolicy()`, `webhooks.ts:667`) mais l'émetteur ne l'applique jamais : la fenêtre
728
+ > (`getDeliveryPolicy()`, `webhooks.ts:663`) mais l'émetteur ne l'applique jamais : la fenêtre
729
729
  > anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
730
730
  > tu documentes à tes destinataires — c'est celle du récepteur qui protège.
731
731
 
@@ -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
@@ -1017,6 +1017,3 @@ Couverture : `npm run coverage` dans `@nodefony/security`. Revue de sécurité d
1017
1017
  - La **source des événements** livrés → [audit](audit.md) · Le pare-feu qui les produit → [firewall](firewall.md)
1018
1018
  - Secrets et jetons du module, mêmes principes de chiffrement au repos → [tokens](tokens.md)
1019
1019
  - Vocabulaire transverse de la sécurité → [lexique](lexique.md)
1020
- </content>
1021
-
1022
- </invoke>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-alpha.9",
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",
@@ -48,28 +48,28 @@
48
48
  "directory": "src/packages/@nodefony/security"
49
49
  },
50
50
  "dependencies": {
51
- "@simplewebauthn/server": "14.0.2",
51
+ "@simplewebauthn/server": "14.0.3",
52
52
  "jose": "6.2.12",
53
53
  "tslib": "2.8.1",
54
54
  "zod": "^4.6.5"
55
55
  },
56
56
  "devDependencies": {
57
- "@nodefony/framework": "^10.0.0-alpha.9",
58
- "@nodefony/http": "^10.0.0-alpha.9",
59
- "@nodefony/user": "^10.0.0-alpha.9",
60
- "@types/node": "26.6.2",
61
- "@vitest/coverage-v8": "5.0.1",
62
- "nodefony": "^10.0.0-alpha.9",
63
- "vitest": "5.0.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
+ "@types/node": "26.6.3",
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-alpha.9",
70
- "@nodefony/http": "^10.0.0-alpha.9",
71
- "@nodefony/user": "^10.0.0-alpha.9",
72
- "nodefony": "^10.0.0-alpha.9"
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",