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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
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:131`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
35
+ > (`webAuthn.ts#WebAuthnService`) 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:122`). Chaque `verify*` reçoit
55
+ (`WebAuthnController.registerOptions()`, `WebAuthnController.ts#WebAuthnController.registerOptions`). 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:294`) : un défi ne sert qu'une fois.
57
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts#WebAuthnController.#takeChallenge`) : un défi ne sert qu'une fois.
58
58
 
59
59
  ## 📖 Lexique
60
60
 
@@ -88,42 +88,42 @@ Quatre failles fermées **par construction** :
88
88
  1. **Hameçonnage** — la signature est liée à l'origine et au `rpId`. Un faux domaine ne peut pas
89
89
  obtenir de signature valide : le navigateur refuse de la produire.
90
90
  2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
91
- `IWebAuthnCredential.ts:16`). Une base volée ne donne aucun accès.
91
+ `IWebAuthnCredential.ts#IWebAuthnCredential.publicKey`). 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:294`).
93
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts#WebAuthnController.#takeChallenge`).
94
94
  4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
95
- (`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts:22`).
95
+ (`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts#IWebAuthnCredential.signCount`).
96
96
 
97
97
  C'est le facteur d'authentification le plus fort disponible aujourd'hui (NIST AAL2, AAL3 avec une clé
98
98
  matérielle attestée).
99
99
 
100
100
  ## La vision Nodefony — le serveur ne détient aucun secret
101
101
 
102
- `WebAuthnService` (`webAuthn.ts:131`) **orchestre**, il ne fait pas de cryptographie : le parsing
102
+ `WebAuthnService` (`webAuthn.ts#WebAuthnService`) **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:578`) — l'enrôlement et le login sont des
105
+ premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts#WebAuthnService.#ensureLib`) — 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:355`). Conséquence pratique : le service se teste sans transport, et un défi n'est
112
+ `webAuthn.ts#WebAuthnService.verifyRegistration`). 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
- - **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts:75`) :
114
+ - **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) :
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:323`) : c'est là que vivent les gardes délicates (session du défi, usage
117
+ `WebAuthnController.ts#mountWebAuthnRoutes`) : 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:1460`). Ce que tu déclares vraiment, c'est **ton
124
+ `passkeys.enabled` vaut `true` par défaut (`config.ts#securityConfigSchema`). 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
- adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:141`).
126
+ adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts#WebAuthnService.#rpID`).
127
127
 
128
128
  ```typescript
129
129
  // nodefony.config.ts (extrait) — activer les passkeys pour TON domaine
@@ -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:323`) :
158
+ (`mountWebAuthnRoutes()`, `WebAuthnController.ts#mountWebAuthnRoutes`) :
159
159
 
160
160
  | Route (`POST` sauf mention) | Rôle | Firewall |
161
161
  | -------------------------------------------------- | --------------------------------------------- | ------------------------- |
@@ -167,11 +167,11 @@ 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:384`) : elles
170
+ > Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts#mountWebAuthnRoutes`) : 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:128`). Les deux routes self-service, elles, restent dans
174
- > la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts:314`).
173
+ > (`flow.me()` → 401, `WebAuthnController.ts#IWebAuthnBffFlow.me`). Les deux routes self-service, elles, restent dans
174
+ > la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts#WebAuthnController.#renderAuthError`).
175
175
 
176
176
  ### 3. Ce que TU écris : l'appel navigateur
177
177
 
@@ -235,8 +235,8 @@ export async function loginWithPasskey(
235
235
  ```
236
236
 
237
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`).
238
+ `NodefonyLogin.loginWithPasskey()` (`NodefonyLogin.ts#NodefonyLogin.loginWithPasskey`, `nodefony/client`), que Studio
239
+ emploie tel quel ; l'enrôlement est `AuthService.registerPasskey()` (`AuthService.ts#AuthService.registerPasskey`).
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: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`),
286
+ `WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`) construit le défi et les
287
+ contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`) : le même
288
+ authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`),
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:355`) enchaîne dans cet ordre :
292
+ `WebAuthnService.verifyRegistration()` (`webAuthn.ts#WebAuthnService.verifyRegistration`) 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:368`). Tout échec devient un message uniforme
296
- `WebAuthn registration failed` (`webAuthn.ts:345`).
295
+ attestation (`webAuthn.ts#WebAuthnService.verifyRegistration`). Tout échec devient un message uniforme
296
+ `WebAuthn registration failed` (`webAuthn.ts#WebAuthnService.verifyRegistration`).
297
297
  2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
298
- (`webAuthn.ts:355`). Volontairement **après** la cryptographie et **avant** le `save` : un client
298
+ (`webAuthn.ts#WebAuthnService.verifyRegistration`). 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:397`) — `backupEligible` dérive
302
- de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:397`).
301
+ 3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts#WebAuthnService.verifyRegistration`) — `backupEligible` dérive
302
+ de `credentialDeviceType === "multiDevice"` (`webAuthn.ts#WebAuthnService.verifyRegistration`).
303
303
 
304
304
  ### Se connecter sans mot de passe
305
305
 
@@ -323,11 +323,11 @@ 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:452`) résout le credential par son id
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:492`). Le
326
+ `WebAuthnService.verifyAuthentication()` (`webAuthn.ts#WebAuthnService.verifyAuthentication`) résout le credential par son id
327
+ (`webAuthn.ts#WebAuthnService.generateAuthenticationOptions`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
328
+ `signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts#WebAuthnService.verifyAuthentication`). Le
329
329
  controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
330
- (`WebAuthnController.ts:64`).
330
+ (`WebAuthnController.ts#IWebAuthnBffFlow.establishSessionFor`).
331
331
 
332
332
  > [!IMPORTANT]
333
333
  > **La détection de clone est ici, pas dans le store.** La monotonie du `signCount` est vérifiée
@@ -340,39 +340,39 @@ 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:535`). La suppression n'aboutit que si le credential **appartient** au demandeur
344
- (`webAuthn.ts:513`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
343
+ (`webAuthn.ts#WebAuthnService.removeUserCredential`). La suppression n'aboutit que si le credential **appartient** au demandeur
344
+ (`webAuthn.ts#WebAuthnService.listCredentialsPage`) ; sinon **404 indiscernable** (`WebAuthnController.ts#WebAuthnController.loginVerify`) — 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:649`) — audité, et **404 identique** si la passkey n'appartient pas à
347
+ (`SecurityAdminApi.ts#createSecurityAdminApi`) — audité, et **404 identique** si la passkey n'appartient pas à
348
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`).
349
+ `identifier` : c'est sous lui que les passkeys sont rangées (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`).
350
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.
351
+ passkeys (`userRevocationCascade.ts#cascadeUserRevocation`) — un compte recréé sous le même identifiant n'en hérite pas.
352
352
 
353
353
  Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
354
- `backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts:47`), qui liste
354
+ `backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts#IWebAuthnListQuery`), qui liste
355
355
  exactement les porteurs à risque de verrouillage.
356
356
 
357
357
  ## ⚙️ Configuration
358
358
 
359
- Table dérivée du schéma Zod `passkeysSchema` (`config.ts:528`), monté sous la clé `passkeys`
360
- (`config.ts:1460`).
361
-
362
- | Option | Type | Défaut | Effet |
363
- | ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
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`) |
359
+ Table dérivée du schéma Zod `passkeysSchema` (`config.ts#passkeysSchema`), monté sous la clé `passkeys`
360
+ (`config.ts#securityConfigSchema`).
361
+
362
+ | Option | Type | Défaut | Effet |
363
+ | ------------------------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------- |
364
+ | `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts#passkeysSchema`) |
365
+ | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts#passkeysSchema`) |
366
+ | `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts#passkeysSchema`) |
367
+ | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts#passkeysSchema`) |
368
+ | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts#passkeysSchema`) |
369
+ | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts#passkeysSchema`) |
370
+ | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts#passkeysSchema`) |
371
+ | `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts#passkeysSchema`) |
372
+ | `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts#passkeysSchema`) |
373
+ | `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts#passkeysSchema`) |
374
+ | `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts#passkeysSchema`) |
375
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts#passkeysSchema`) |
376
376
 
377
377
  ### Mise en situation — trois politiques, trois publics
378
378
 
@@ -396,7 +396,7 @@ passkeys: {
396
396
 
397
397
  > [!WARNING]
398
398
  > `attestation: "direct"` **récupère** le certificat, il ne le **valide pas** : Nodefony ne vérifie ni
399
- > l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:568`). Tant que cette vérification n'est pas
399
+ > l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts#passkeysSchema`). Tant que cette vérification n'est pas
400
400
  > faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
401
401
  > privée (l'attestation identifie le modèle d'authenticator).
402
402
 
@@ -410,7 +410,7 @@ passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
410
410
  Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
411
411
  `allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
412
412
  jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
413
- `WebAuthnController.ts:171`).
413
+ `WebAuthnController.ts#WebAuthnController.loginOptions`).
414
414
 
415
415
  | Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
416
416
  | --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
@@ -423,17 +423,17 @@ jamais du corps de la requête, seulement de la session (`WebAuthnController.log
423
423
  Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
424
424
  domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
425
425
 
426
- Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:152`) : `passkeys.rpId` sinon le domaine
427
- de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
426
+ Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts#WebAuthnService.#build`) : `passkeys.rpId` sinon le domaine
427
+ de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts#WebAuthnService.#store`), seul
428
428
  host non-domaine que la spécification autorise. En développement, accède donc au serveur par
429
429
  `https://localhost:5152`, jamais par `127.0.0.1`.
430
430
 
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
431
+ L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts#WebAuthnService.#expectedOrigin`) en trois
432
+ temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts#WebAuthnService.#expectedOrigin`, la voie de
433
433
  production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
434
- `rpId`** (`webAuthn.ts:568` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
434
+ `rpId`** (`webAuthn.ts#WebAuthnService.#rpID` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
435
435
  est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
436
- (`webAuthn.ts:542`).
436
+ (`webAuthn.ts#WebAuthnService.removeUserCredential`).
437
437
 
438
438
  > [!WARNING]
439
439
  > **Un seul `rpId` par instance.** Il est résolu une fois au boot et stocké dans le service ; il n'y a
@@ -446,22 +446,22 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
446
446
  `login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
447
447
  pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
448
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 à
449
+ (`WebAuthnController.loginOptions()`, `WebAuthnController.ts#WebAuthnController.loginOptions`). Deux surfaces restent à
450
450
  regarder en face.
451
451
 
452
452
  ### Amplification : bornée par le plafond, pas par une pagination
453
453
 
454
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é** —
455
+ `allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts#WebAuthnService.generateAuthenticationOptions`). Cet appel `findByUser` est **volontairement non paginé** —
456
456
  `allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
457
- répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
457
+ répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.findByUser`).
458
458
 
459
- Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:582`) :
459
+ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts#passkeysSchema`) :
460
460
 
461
- - le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
461
+ - le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts#WebAuthnError`), rendu **tel quel** au
462
462
  client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
463
- appareil (`WebAuthnController.ts:294-298`) ;
464
- - le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts:97`) : `COUNT` / `SCARD`,
463
+ appareil (`WebAuthnController.ts#WebAuthnController.#takeChallenge`) ;
464
+ - le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.countByUser`) : `COUNT` / `SCARD`,
465
465
  jamais un `findByUser().length` — `webAuthnEnrollmentLimit.test.ts` le prouve avec un store espion
466
466
  (3 comptages, 0 chargement) ;
467
467
  - retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
@@ -480,17 +480,17 @@ verrouille.
480
480
 
481
481
  Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
482
482
 
483
- | Vecteur | Garde |
484
- | ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
485
- | Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:277`) — E1/E1bis |
486
- | Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:74-75`) — E2 |
487
- | Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:309`) — E4 |
488
- | Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:513`) |
489
- | Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
483
+ | Vecteur | Garde |
484
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
485
+ | Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts#WebAuthnController.#body`) — E1/E1bis |
486
+ | Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts#REG_CHALLENGE`) — E2 |
487
+ | Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts#WebAuthnController.#renderAuthError`) — E4 |
488
+ | Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts#WebAuthnService.listCredentialsPage`) |
489
+ | Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts#WebAuthnAuthUpdate`) — A1 |
490
490
 
491
491
  ## Entité de persistance — ce qui est écrit, et en quels types
492
492
 
493
- Un credential (`IWebAuthnCredential`, `IWebAuthnCredential.ts:10`) ne contient **aucun secret** : la
493
+ Un credential (`IWebAuthnCredential`, `IWebAuthnCredential.ts#IWebAuthnCredential`) ne contient **aucun secret** : la
494
494
  clé privée n'existe que dans l'authenticator.
495
495
 
496
496
  | Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
@@ -507,25 +507,25 @@ clé privée n'existe que dans l'authenticator.
507
507
  | `createdAt` | Enrôlement (epoch ms) | `epochMs` notNull | `Number` requis | champ |
508
508
  | `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
509
509
 
510
- Spécification SQL : `webAuthnCredentialEntity.ts:28`, index sur `userId`
511
- (`drizzle/nodefony/entity/webAuthnCredentialEntity.ts:34`). Schéma documentaire :
512
- `mongoose/nodefony/entity/webAuthnCredentialEntity.ts:25` — `_id` **est** le credentialId (String, pas
510
+ Spécification SQL : `webAuthnCredentialEntity.ts#webAuthnCredentialSchema`, index sur `userId`
511
+ (`drizzle/nodefony/entity/webAuthnCredentialEntity.ts#WebAuthnCredentialRow.userId`). Schéma documentaire :
512
+ `mongoose/nodefony/entity/webAuthnCredentialEntity.ts#webAuthnCredentialSchema` — `_id` **est** le credentialId (String, pas
513
513
  un ObjectId), les horodatages sont des `Number` epoch ms.
514
514
 
515
515
  Trois constats de conception : **aucun TTL ni `gc`** — une passkey est permanente jusqu'à révocation
516
- explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredentialStore.ts:75`) ;
516
+ explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) ;
517
517
  **`nickname` est
518
518
  stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
519
519
  existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
520
- applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:28`) — renommer un compte
520
+ applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts#IWebAuthnService.verifyRegistration`) — renommer un compte
521
521
  orphelinise donc ses passkeys.
522
522
 
523
523
  ## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
524
524
 
525
- Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est résolu au boot
526
- (`webAuthn.ts:141`) : un adapter déjà posé au container gagne, sinon la fabrique nommée par
525
+ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) est résolu au boot
526
+ (`webAuthn.ts#WebAuthnService.constructor`) : un adapter déjà posé au container gagne, sinon la fabrique nommée par
527
527
  `passkeys.store` est appelée via le registre (`registerWebAuthnStore()`,
528
- `webAuthnCredentialStoreRegistry.ts:30`).
528
+ `webAuthnCredentialStoreRegistry.ts#registerWebAuthnStore`).
529
529
 
530
530
  | Ta situation | Store | Ce que tu gagnes / perds |
531
531
  | ------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
@@ -535,48 +535,48 @@ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est ré
535
535
  | Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
536
536
 
537
537
  **`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
538
- (`webAuthn.ts:134`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
539
- Studio (`webAuthn.ts:195`). Deux garde-fous de production :
538
+ (`webAuthn.ts#WebAuthnService.#store`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
539
+ Studio (`webAuthn.ts#WebAuthnService.#build`). Deux garde-fous de production :
540
540
 
541
541
  - un store **explicitement** configuré mais inconnu **avorte le boot en production**
542
- (`webAuthn.ts:176`) — jamais de repli silencieux vers un store volatil ;
542
+ (`webAuthn.ts#WebAuthnService.#build`) — jamais de repli silencieux vers un store volatil ;
543
543
  - `memory` **en production** déclenche un `WARNING` qui nomme l'impact : passkeys perdues au
544
- redémarrage, utilisateurs verrouillés hors de leur compte (`webAuthn.ts:183`).
544
+ redémarrage, utilisateurs verrouillés hors de leur compte (`webAuthn.ts#WebAuthnService.#build`).
545
545
 
546
546
  ### Les backends, en détail
547
547
 
548
548
  ### `memory` — la référence, 0 dépendance
549
549
 
550
- - Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts:53`). Deux index :
551
- `#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts:47`.
550
+ - Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts`). Deux index :
551
+ `#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts#cloneCredential`.
552
552
  - `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
553
- (`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
554
- - `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:181`) ; le service
555
- déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:47`).
553
+ (`MemoryWebAuthnCredentialStore.ts#toWebAuthnSummary`). C'est lui qui pilote le banc de contrat partagé.
554
+ - `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts#MemoryWebAuthnCredentialStore.restore`) ; le service
555
+ déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts#IFlushableStore.flushNow`).
556
556
 
557
557
  ### `drizzle` — SQL, le durable par défaut
558
558
 
559
- - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:262`) ;
560
- `DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts:37`) est **100 % portable** —
559
+ - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores`) ;
560
+ `DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts#DrizzleWebAuthnCredentialStore`) est **100 % portable** —
561
561
  aucune requête SQL native, tout passe par `IRepository` d'`orm-core`.
562
562
  - Trois dialectes sur le même banc : **sqlite** (toujours, `:memory:`), **postgres** et **mysql**
563
- (gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts:199`).
563
+ (gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts#DrizzleWebAuthnCredentialStore.listPage`).
564
564
 
565
565
  ### `mongoose` — MongoDB
566
566
 
567
- - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:139`) ;
568
- `MongooseWebAuthnCredentialStore` (`MongooseWebAuthnCredentialStore.ts:36`) partage le helper
569
- `paginate()` — offset + `total`, départage sur `_id` (`MongooseWebAuthnCredentialStore.ts:158`).
567
+ - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores`) ;
568
+ `MongooseWebAuthnCredentialStore` (`MongooseWebAuthnCredentialStore.ts#MongooseWebAuthnCredentialStore`) partage le helper
569
+ `paginate()` — offset + `total`, départage sur `_id` (`MongooseWebAuthnCredentialStore.ts#MongooseWebAuthnCredentialStore.#listCriteria`).
570
570
 
571
571
  ### `redis` — cluster, lecture O(1)
572
572
 
573
- - Enregistré par le module redis (`redis/nodefony/registerStores.ts:62`) ;
574
- `RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts:93`) stocke un **HASH** par
573
+ - Enregistré par le module redis (`redis/nodefony/registerStores.ts#resolveRedisService`) ;
574
+ `RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.#resolvePrefix`) stocke un **HASH** par
575
575
  credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
576
- l'enregistrement (`RedisWebAuthnCredentialStore.ts:240`).
577
- - Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:299`) :
576
+ l'enregistrement (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.update`).
577
+ - Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.listPage`) :
578
578
  **ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
579
- défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:339`).
579
+ défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.countCredentials`).
580
580
 
581
581
  ### Brancher son propre store
582
582
 
@@ -602,18 +602,18 @@ la vue admin **ne porte jamais la clé publique**.
602
602
  Le point d'entrée est le service `webauthn` du container ; les signatures vivent dans le graphe
603
603
  symbolique (`.ai/symbols.json`).
604
604
 
605
- | Méthode | Quand tu l'appelles |
606
- | --------------------------------- | ------------------------------------------------------------------------ |
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`) |
605
+ | Méthode | Quand tu l'appelles |
606
+ | --------------------------------- | -------------------------------------------------------------------------------------------------------- |
607
+ | `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts#WebAuthnService.isEnabled`) |
608
+ | `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`) |
609
+ | `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts#WebAuthnService.verifyRegistration`) |
610
+ | `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts#WebAuthnService.generateAuthenticationOptions`) |
611
+ | `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts#WebAuthnService.verifyAuthentication`) |
612
+ | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts#WebAuthnService.listUserCredentials`) |
613
+ | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts#WebAuthnService.listCredentialsPage`) |
614
+ | `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts#WebAuthnService.countCredentials`) |
615
+ | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts#WebAuthnService.removeUserCredential`) |
616
+ | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts#WebAuthnService.removeCredential`) |
617
617
 
618
618
  Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
619
619
  `IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
@@ -623,43 +623,43 @@ Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
623
623
  > `listUserCredentials()` et `listCredentialsPage()` ne se remplacent pas. Le premier sert la fiche
624
624
  > d'**un** porteur (borné par `maxPerUser`) et le login ; le second est le chemin **froid** d'une
625
625
  > console d'administration, qui ne matérialise jamais plus d'une page et dont la projection exclut la
626
- > clé publique par construction (`IWebAuthnCredentialSummary`, `IWebAuthnCredentialStore.ts:16`).
626
+ > clé publique par construction (`IWebAuthnCredentialSummary`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialSummary`).
627
627
 
628
628
  ## 📡 Observabilité — Studio
629
629
 
630
- Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:308`), toutes en
630
+ Le data plane admin du module expose trois routes (`SecurityAdminApi.ts#toCredentialView`), toutes en
631
631
  `ROLE_NODEFONY_ADMIN` :
632
632
 
633
633
  <!-- prettier-ignore -->
634
634
  | Route | Ce qu'elle montre |
635
635
  | --- | --- |
636
- | `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts:499`) |
637
- | `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts:558`) |
638
- | `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:590`) |
636
+ | `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts#createSecurityAdminApi`) |
637
+ | `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts#createSecurityAdminApi`) |
638
+ | `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts#createSecurityAdminApi`) |
639
639
 
640
640
  Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
641
- publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:308`),
641
+ publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts#toCredentialView`),
642
642
  et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
643
643
  défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
644
- doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532`). `total: -1` signale un
644
+ doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts#createSecurityAdminApi`). `total: -1` signale un
645
645
  backend sans comptage (Redis).
646
646
 
647
647
  Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
648
- passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:316`, qui délègue au déroulé partagé).
648
+ passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts#AuthStore.loginWithPasskey`, qui délègue au déroulé partagé).
649
649
 
650
650
  ## 📜 Normes appliquées
651
651
 
652
- | Domaine | Norme | Ancrage |
653
- | ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
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`) |
656
- | Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
657
- | Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts:29`) |
658
- | Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:559`) |
659
- | Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
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`) |
652
+ | Domaine | Norme | Ancrage |
653
+ | ----------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- |
654
+ | Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts#WebAuthnService.verifyRegistration`) |
655
+ | Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts#WebAuthnService.verifyAuthentication`) |
656
+ | Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts#IWebAuthnCredential.signCount`) |
657
+ | Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts#IWebAuthnCredential.backupEligible`) |
658
+ | Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts#WebAuthnService.#expectedOrigin`) |
659
+ | Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts#IWebAuthnCredential.publicKey`) |
660
+ | FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts#passkeysSchema`) |
661
+ | Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts#passkeysSchema`) |
662
+ | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts#WebAuthnService.removeUserCredential`) |
663
663
 
664
664
  La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
665
665
  ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
@@ -671,28 +671,28 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
671
671
  requête. Le code en tire trois conséquences.
672
672
 
673
673
  - **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
674
- réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:578`) — une app qui n'enrôle personne ne paie
674
+ réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts#WebAuthnService.#ensureLib`) — une app qui n'enrôle personne ne paie
675
675
  jamais son coût de parse.
676
676
  - **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
677
- (`webAuthn.ts:152-164`) — pas de store, pas de `Map`.
677
+ (`webAuthn.ts#WebAuthnService.#build`) — pas de store, pas de `Map`.
678
678
  - **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
679
- store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
680
- `maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
679
+ store (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.listPage`). Le seul appel non paginé, `findByUser`, est borné par
680
+ `maxPerUser` — par conception (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.findByUser`).
681
681
 
682
682
  ## ⚠️ Pièges (symptôme → cause → correction)
683
683
 
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 |
684
+ | Symptôme | Cause (dans le code) | Correction |
685
+ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
686
+ | `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts#WebAuthnService.verifyRegistration`) | 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#WebAuthnService.#build`) | 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#IWebAuthnService.verifyRegistration`) | Ne pas renommer, ou ré-enrôler après renommage |
696
696
 
697
697
  ## 🧪 Tests & couverture
698
698