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

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 (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
package/docs/webauthn.md CHANGED
@@ -32,7 +32,7 @@ source: "src/packages/@nodefony/security/docs/webauthn.md"
32
32
  > l'authenticator (Touch ID, Windows Hello, clé FIDO). Le serveur ne détient que des **clés
33
33
  > publiques** et ne fait que **vérifier des signatures** : rien à hameçonner, rien à rejouer, rien à
34
34
  > voler dans la base. Nodefony orchestre les deux cérémonies FIDO2 dans `WebAuthnService`
35
- > (`webAuthn.ts:99`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
35
+ > (`webAuthn.ts:131`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
36
36
  > navigateur.
37
37
 
38
38
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **WebAuthn / Passkeys**
@@ -52,9 +52,9 @@ flowchart LR
52
52
  ```
53
53
 
54
54
  Le défi vit **hors du service**, en session BFF, posé par le controller
55
- (`WebAuthnController.registerOptions()`, `WebAuthnController.ts:113`). Chaque `verify*` reçoit
55
+ (`WebAuthnController.registerOptions()`, `WebAuthnController.ts:122`). 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:281`) : un défi ne sert qu'une fois.
57
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:294`) : 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:281`).
93
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:294`).
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
 
@@ -99,29 +99,29 @@ matérielle attestée).
99
99
 
100
100
  ## La vision Nodefony — le serveur ne détient aucun secret
101
101
 
102
- `WebAuthnService` (`webAuthn.ts:99`) **orchestre**, il ne fait pas de cryptographie : le parsing
102
+ `WebAuthnService` (`webAuthn.ts:131`) **orchestre**, il ne fait pas de cryptographie : le parsing
103
103
  CBOR/COSE et la vérification des signatures (ES256/RS256/EdDSA) sont délégués à
104
104
  `@simplewebauthn/server`, une bibliothèque auditée de l'écosystème, **importée paresseusement** au
105
- premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — l'enrôlement et le login sont des
105
+ premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts:578`) — l'enrôlement et le login sont des
106
106
  chemins froids, ils ne doivent rien coûter aux requêtes ordinaires.
107
107
 
108
108
  Trois partis pris assumés :
109
109
 
110
110
  - **Le service est sans état de session.** Le défi est porté par le controller BFF ; le service reçoit
111
111
  toujours l'`expectedChallenge` en paramètre (`WebAuthnService.verifyRegistration()`,
112
- `webAuthn.ts:323`). Conséquence pratique : le service se teste sans transport, et un défi n'est
112
+ `webAuthn.ts:355`). Conséquence pratique : le service se teste sans transport, et un défi n'est
113
113
  jamais « oublié » quelque part côté serveur.
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:314`) : c'est là que vivent les gardes délicates (session du défi, usage
117
+ `WebAuthnController.ts:323`) : 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
121
121
 
122
122
  ### 1. Les passkeys sont déjà actives — la config utile
123
123
 
124
- `passkeys.enabled` vaut `true` par défaut (`config.ts:463`). Ce que tu déclares vraiment, c'est **ton
124
+ `passkeys.enabled` vaut `true` par défaut (`config.ts:1460`). Ce que tu déclares vraiment, c'est **ton
125
125
  domaine** : sans `rpId`, le service prend le domaine de l'app, et bascule sur `localhost` si c'est une
126
126
  adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:141`).
127
127
 
@@ -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:314`) :
158
+ (`mountWebAuthnRoutes()`, `WebAuthnController.ts:323`) :
159
159
 
160
160
  | Route (`POST` sauf mention) | Rôle | Firewall |
161
161
  | -------------------------------------------------- | --------------------------------------------- | ------------------------- |
@@ -167,10 +167,10 @@ Dès que `@nodefony/security` est chargé, le framework monte six routes
167
167
  | `DELETE …/webauthn/credentials/{id}` | Révoquer **sa** passkey | zone protégée |
168
168
 
169
169
  > [!IMPORTANT]
170
- > Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:365`) : elles
170
+ > Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:384`) : 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:119`). Les deux routes self-service, elles, restent dans
173
+ > (`flow.me()` → 401, `WebAuthnController.ts:128`). 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 }> {
@@ -234,9 +234,9 @@ export async function loginWithPasskey(
234
234
  }
235
235
  ```
236
236
 
237
- Référence vivante dans le dépôt : Studio fait exactement ces deux appels —
238
- `AuthService.loginWithPasskey()` (`AuthService.ts:111`) et `AuthService.registerPasskey()`
239
- (`AuthService.ts:133`).
237
+ Référence vivante dans le dépôt : la connexion passe par le déroulé partagé
238
+ `NodefonyLogin.loginWithPasskey()` (`NodefonyLogin.ts:409`, `nodefony/client`), que Studio
239
+ emploie tel quel ; l'enrôlement est `AuthService.registerPasskey()` (`AuthService.ts:36`).
240
240
 
241
241
  ### 4. Ce qu'on observe
242
242
 
@@ -283,23 +283,23 @@ sequenceDiagram
283
283
 
284
284
  ### Enrôler une passkey sur un compte existant
285
285
 
286
- `WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:282`) construit le défi et les
287
- contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:297`) : le même
288
- authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:301`),
286
+ `WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:314`) construit le défi et les
287
+ contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:329`) : le même
288
+ authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:333`),
289
289
  `authenticatorAttachment` n'est transmis **que** s'il vaut autre chose que `"any"` — `"any"` rend la
290
290
  main au navigateur, téléphone par QR compris.
291
291
 
292
- `WebAuthnService.verifyRegistration()` (`webAuthn.ts:323`) enchaîne dans cet ordre :
292
+ `WebAuthnService.verifyRegistration()` (`webAuthn.ts:355`) enchaîne dans cet ordre :
293
293
 
294
294
  1. **Vérification déléguée** à `verifyRegistrationResponse` — défi, origine, rpIdHash, flags,
295
- attestation (`webAuthn.ts:330`). Tout échec devient un message uniforme
295
+ attestation (`webAuthn.ts:368`). Tout échec devient un message uniforme
296
296
  `WebAuthn registration failed` (`webAuthn.ts:345`).
297
297
  2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
298
298
  (`webAuthn.ts:355`). Volontairement **après** la cryptographie et **avant** le `save` : un client
299
299
  peut poster `register/verify` sans jamais appeler `register/options` — c'est l'écriture qu'il faut
300
300
  garder, pas la génération du défi.
301
- 3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts:365`) — `backupEligible` dérive
302
- de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:365`).
301
+ 3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts:397`) — `backupEligible` dérive
302
+ de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:397`).
303
303
 
304
304
  ### Se connecter sans mot de passe
305
305
 
@@ -323,9 +323,9 @@ est ignoré.
323
323
  > produit des credentials non découvrables — leurs porteurs ne pourront plus se connecter, et le
324
324
  > service l'avertit au boot.
325
325
 
326
- `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:420`) résout le credential par son id
326
+ `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:452`) résout le credential par son id
327
327
  (`webAuthn.ts:420`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
328
- `signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:460`). Le
328
+ `signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:492`). Le
329
329
  controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
330
330
  (`WebAuthnController.ts:64`).
331
331
 
@@ -340,12 +340,15 @@ 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:502`). La suppression n'aboutit que si le credential **appartient** au demandeur
343
+ (`webAuthn.ts:535`). 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}`
347
- (`SecurityAdminApi.ts:590`) — audité, et **404 identique** si la passkey n'appartient pas à
348
- l'utilisateur visé, même pour un admin.
347
+ (`SecurityAdminApi.ts:649`) — audité, et **404 identique** si la passkey n'appartient pas à
348
+ l'utilisateur visé, même pour un admin. L'`id` de la route est d'abord traduit en
349
+ `identifier` : c'est sous lui que les passkeys sont rangées (`IWebAuthnCredentialStore.ts:81`).
350
+ - **Suppression du compte** : la cascade `onUserRevoked` (`reason: "deleted"`) retire toutes ses
351
+ passkeys (`userRevocationCascade.ts:51`) — un compte recréé sous le même identifiant n'en hérite pas.
349
352
 
350
353
  Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
351
354
  `backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts:47`), qui liste
@@ -353,23 +356,23 @@ exactement les porteurs à risque de verrouillage.
353
356
 
354
357
  ## ⚙️ Configuration
355
358
 
356
- Table dérivée du schéma Zod `passkeysSchema` (`config.ts:461`), monté sous la clé `passkeys`
357
- (`config.ts:1143`).
359
+ Table dérivée du schéma Zod `passkeysSchema` (`config.ts:528`), monté sous la clé `passkeys`
360
+ (`config.ts:1460`).
358
361
 
359
362
  | Option | Type | Défaut | Effet |
360
363
  | ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
361
- | `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:463`) |
362
- | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:469`) |
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:469`) |
365
- | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:483`) |
366
- | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:489`) |
367
- | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:495`) |
368
- | `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:501`) |
369
- | `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:507`) |
370
- | `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:515`) |
371
- | `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:523`) |
372
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:528`) |
364
+ | `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:530`) |
365
+ | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:536`) |
366
+ | `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:540`) |
367
+ | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:536`) |
368
+ | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:550`) |
369
+ | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:556`) |
370
+ | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:562`) |
371
+ | `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:568`) |
372
+ | `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:574`) |
373
+ | `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:582`) |
374
+ | `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:590`) |
375
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:552`) |
373
376
 
374
377
  ### Mise en situation — trois politiques, trois publics
375
378
 
@@ -393,7 +396,7 @@ passkeys: {
393
396
 
394
397
  > [!WARNING]
395
398
  > `attestation: "direct"` **récupère** le certificat, il ne le **valide pas** : Nodefony ne vérifie ni
396
- > l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:501`). Tant que cette vérification n'est pas
399
+ > l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:568`). Tant que cette vérification n'est pas
397
400
  > faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
398
401
  > privée (l'attestation identifie le modèle d'authenticator).
399
402
 
@@ -404,30 +407,31 @@ sans champ à remplir.
404
407
  passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
405
408
  ```
406
409
 
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.
410
+ Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
411
+ `allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
412
+ jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
413
+ `WebAuthnController.ts:171`).
410
414
 
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 |
415
+ | Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
416
+ | --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
417
+ | anonyme, `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
418
+ | anonyme, `{"username":"alice"}` | identique : le `username` est ignoré | aucun ciblage, rien à apprendre sur `alice` |
419
+ | session authentifiée (ré-auth, step-up) | défi + `allowCredentials` des clés **de ce porteur** | ciblage de ses propres passkeys |
416
420
 
417
421
  ## 🛡️ `rpId` et origines — l'ancre anti-phishing
418
422
 
419
423
  Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
420
424
  domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
421
425
 
422
- Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:120`) : `passkeys.rpId` sinon le domaine
426
+ Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:152`) : `passkeys.rpId` sinon le domaine
423
427
  de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
424
428
  host non-domaine que la spécification autorise. En développement, accède donc au serveur par
425
429
  `https://localhost:5152`, jamais par `127.0.0.1`.
426
430
 
427
- L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:528`) en trois
428
- temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:529`, la voie de
431
+ L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:559`) en trois
432
+ temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:560`, la voie de
429
433
  production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
430
- `rpId`** (`webAuthn.ts:535` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
434
+ `rpId`** (`webAuthn.ts:568` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
431
435
  est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
432
436
  (`webAuthn.ts:542`).
433
437
 
@@ -439,18 +443,20 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
439
443
 
440
444
  ## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
441
445
 
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.
446
+ `login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
447
+ pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
448
+ reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
449
+ (`WebAuthnController.loginOptions()`, `WebAuthnController.ts:171`). Deux surfaces restent à
450
+ regarder en face.
445
451
 
446
452
  ### Amplification : bornée par le plafond, pas par une pagination
447
453
 
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é** —
454
+ Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
455
+ `allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:422`). Cet appel `findByUser` est **volontairement non paginé** —
450
456
  `allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
451
457
  répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
452
458
 
453
- Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:515`) :
459
+ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:582`) :
454
460
 
455
461
  - le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
456
462
  client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
@@ -460,22 +466,17 @@ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `c
460
466
  (3 comptages, 0 chargement) ;
461
467
  - retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
462
468
 
463
- ### Énumération : le statut est uniforme, la liste ne l'est pas
469
+ ### Énumération : rien ne distingue deux comptes
464
470
 
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.
471
+ Un anonyme reçoit **toujours le même défi découvrable**, sans `allowCredentials`, qu'il poste ou non
472
+ un `username` : aucun statut, aucune liste, aucune latence ne dit si un compte existe ou porte une
473
+ passkey, et l'appel ne touche même pas le store. C'est le remède que la spécification W3C WebAuthn
474
+ propose contre la fuite des identifiants de credentials ; `webauthnLoginOptionsPrivacy.test.ts` le
475
+ verrouille.
468
476
 
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`.
477
+ > [!NOTE]
478
+ > `login/options` reste une route en `bypassFirewall` : le firewall ne la protège pas. Un rate-limit
479
+ > en amont reste de bonne hygiène, pour borner le coût des défis émis.
479
480
 
480
481
  Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
481
482
 
@@ -483,7 +484,7 @@ Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
483
484
  | ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
484
485
  | Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:277`) — E1/E1bis |
485
486
  | Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:74-75`) — E2 |
486
- | Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:292`) — E4 |
487
+ | Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:309`) — E4 |
487
488
  | Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:513`) |
488
489
  | Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
489
490
 
@@ -495,7 +496,7 @@ clé privée n'existe que dans l'authenticator.
495
496
  | Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
496
497
  | ---------------- | ---------------------------------------------------- | ------------------- | ---------------- | ----------------------- |
497
498
  | `id` | Identifiant du credential, base64url — clé naturelle | `text` PK | `_id: String` | clé `nf:wac:cred:<id>` |
498
- | `userId` | Porteur (= identifiant applicatif / `userHandle`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
499
+ | `userId` | Porteur = `IUser.identifier` (jamais `IUser.id`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
499
500
  | `publicKey` | Clé publique **COSE**, base64url | `text` notNull | `String` requis | champ |
500
501
  | `signCount` | Compteur anti-clone (§6.1.1) | `int64` notNull | `Number` requis | champ |
501
502
  | `transports` | `usb`\|`nfc`\|`ble`\|`internal`\|`hybrid` | `json` notNull | `[String]` | champ JSON |
@@ -516,7 +517,7 @@ explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredenti
516
517
  **`nickname` est
517
518
  stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
518
519
  existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
519
- applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:109`) — renommer un compte
520
+ applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:28`) — renommer un compte
520
521
  orphelinise donc ses passkeys.
521
522
 
522
523
  ## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
@@ -534,7 +535,7 @@ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est ré
534
535
  | Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
535
536
 
536
537
  **`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
537
- (`webAuthn.ts:155`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
538
+ (`webAuthn.ts:134`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
538
539
  Studio (`webAuthn.ts:195`). Deux garde-fous de production :
539
540
 
540
541
  - un store **explicitement** configuré mais inconnu **avorte le boot en production**
@@ -572,10 +573,10 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
572
573
  - Enregistré par le module redis (`redis/nodefony/registerStores.ts:62`) ;
573
574
  `RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts:93`) stocke un **HASH** par
574
575
  credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
575
- l'enregistrement (`RedisWebAuthnCredentialStore.ts:219`).
576
- - Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:257`) :
576
+ l'enregistrement (`RedisWebAuthnCredentialStore.ts:240`).
577
+ - Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:299`) :
577
578
  **ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
578
- défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:330`).
579
+ défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:339`).
579
580
 
580
581
  ### Brancher son propre store
581
582
 
@@ -603,16 +604,16 @@ symbolique (`.ai/symbols.json`).
603
604
 
604
605
  | Méthode | Quand tu l'appelles |
605
606
  | --------------------------------- | ------------------------------------------------------------------------ |
606
- | `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:268`) |
607
- | `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:282`) |
608
- | `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:323`) |
609
- | `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:390`) |
610
- | `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:420`) |
611
- | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
612
- | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
613
- | `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:502`) |
615
- | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:492`) |
607
+ | `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:300`) |
608
+ | `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:314`) |
609
+ | `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:355`) |
610
+ | `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:422`) |
611
+ | `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:452`) |
612
+ | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:498`) |
613
+ | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:510`) |
614
+ | `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:520`) |
615
+ | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:535`) |
616
+ | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:525`) |
616
617
 
617
618
  Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
618
619
  `IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
@@ -637,28 +638,28 @@ Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:308`), t
637
638
  | `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:590`) |
638
639
 
639
640
  Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
640
- publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:267`),
641
+ publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:308`),
641
642
  et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
642
643
  défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
643
644
  doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532`). `total: -1` signale un
644
645
  backend sans comptage (Redis).
645
646
 
646
647
  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.loginWithPasskey()`, `AuthStore.ts:212`).
648
+ passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:316`, qui délègue au déroulé partagé).
648
649
 
649
650
  ## 📜 Normes appliquées
650
651
 
651
652
  | Domaine | Norme | Ancrage |
652
653
  | ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
653
- | Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:323`) |
654
- | Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:420`) |
654
+ | Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:355`) |
655
+ | Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:452`) |
655
656
  | Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
656
657
  | Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts:29`) |
657
- | Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:528`) |
658
+ | Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:559`) |
658
659
  | Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
659
- | FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
660
- | 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:502`) |
660
+ | FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:582`) |
661
+ | Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:550`) |
662
+ | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:535`) |
662
663
 
663
664
  La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
664
665
  ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
@@ -670,29 +671,28 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
670
671
  requête. Le code en tire trois conséquences.
671
672
 
672
673
  - **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
673
- réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — une app qui n'enrôle personne ne paie
674
+ réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:578`) — une app qui n'enrôle personne ne paie
674
675
  jamais son coût de parse.
675
676
  - **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
676
- (`webAuthn.ts:119-130`) — pas de store, pas de `Map`.
677
+ (`webAuthn.ts:152-164`) — pas de store, pas de `Map`.
677
678
  - **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
678
679
  store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
679
680
  `maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
680
681
 
681
682
  ## ⚠️ Pièges (symptôme → cause → correction)
682
683
 
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 |
684
+ | Symptôme | Cause (dans le code) | Correction |
685
+ | ---------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------- |
686
+ | `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
687
+ | `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`) |
688
+ | `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
689
+ | 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>` |
690
+ | 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`) |
691
+ | Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
692
+ | 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 |
693
+ | `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
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:28`) | Ne pas renommer, ou ré-enrôler après renommage |
696
696
 
697
697
  ## 🧪 Tests & couverture
698
698
 
package/docs/webhooks.md CHANGED
@@ -160,7 +160,7 @@ Trois partis pris, tous vérifiables dans le code :
160
160
 
161
161
  ### 1. Activer les webhooks et poser la clé de chiffrement
162
162
 
163
- Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:785`).
163
+ Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:809`).
164
164
  La seule chose que tu dois vraiment fournir, c'est la **clé de chiffrement des secrets de signature** :
165
165
  sans elle, une clé éphémère est générée en dev (avec un WARNING), et en production les webhooks sont
166
166
  **désactivés** — un secret chiffré par une clé perdue au redémarrage serait illisible
@@ -168,7 +168,7 @@ sans elle, une clé éphémère est générée en dev (avec un WARNING), et en p
168
168
 
169
169
  ```bash
170
170
  # Génère les clés du module security et guide le câblage en 3 fichiers.
171
- npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env.local
171
+ npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env
172
172
  ```
173
173
 
174
174
  ```typescript
@@ -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
 
@@ -705,7 +705,7 @@ sont `unref()` (`webhooks.ts:222`), donc ils n'empêchent jamais Node de sortir.
705
705
 
706
706
  ## ⚙️ Configuration
707
707
 
708
- Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:783`), lue via
708
+ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:870`), lue via
709
709
  `use("@nodefony/security", { webhooks: … })`.
710
710
 
711
711
  | Option | Type | Défaut | Effet |
@@ -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