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

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 (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/totp.md CHANGED
@@ -66,7 +66,7 @@ flowchart TD
66
66
 
67
67
  Tant que le second facteur n'est pas validé, **l'identité n'est pas établie** : `session.user` reste
68
68
  vide et le Zero Trust du firewall répond 401 sur tout le reste (`AuthFlow.login()`,
69
- `authFlow.ts:170`).
69
+ `authFlow.ts#AuthFlow.login`).
70
70
 
71
71
  ## 📖 Lexique
72
72
 
@@ -110,29 +110,29 @@ reste le second facteur **universel** — celui qui marche sans matériel dédi
110
110
 
111
111
  Trois partis pris, tous vérifiables dans le code.
112
112
 
113
- **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts:74`)
113
+ **1. La logique est PURE, le service n'est qu'une prise de courant.** `TotpService` (`totp.ts#TotpService`)
114
114
  résout au boot deux choses seulement : le **store** de secrets et la **clé de chiffrement**
115
- (`TotpService.#build()`, `totp.ts:92`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
115
+ (`TotpService.#build()`, `totp.ts#TotpService.#build`). Tout le reste — enrôler, confirmer, vérifier — vit dans des
116
116
  fonctions sans I/O ni container (`totpOperations.ts`), qui reçoivent leurs dépendances en argument
117
- (`ITotpDeps`, `totpOperations.ts:22`). Conséquence directe : la logique critique se teste **sans
117
+ (`ITotpDeps`, `totpOperations.ts#ITotpDeps`). Conséquence directe : la logique critique se teste **sans
118
118
  serveur, sans base, avec une horloge injectée** — et c'est pour ça qu'elle est couverte à ~100 %.
119
119
 
120
120
  **2. Le secret est CHIFFRÉ, jamais haché.** Un mot de passe se hache (à sens unique) parce que le
121
121
  serveur n'a qu'à le **comparer**. Un secret TOTP, lui, doit être **relu en clair** à chaque
122
122
  vérification pour recalculer le code → il est **chiffré** en AES-256-GCM
123
- (`ITotpSecret.secretEnc`, `ITotpSecret.ts:18`). C'est la différence de nature qui commande la
123
+ (`ITotpSecret.secretEnc`, `ITotpSecret.ts#ITotpSecret.secretEnc`). C'est la différence de nature qui commande la
124
124
  différence de traitement, pas une négligence.
125
125
 
126
126
  **3. Le TOTP n'est pas un authenticator du firewall — c'est un step-up de login.** Il n'apparaît
127
127
  jamais dans `area.authenticators` : il s'insère **dans le flux de session BFF**, entre le mot de
128
- passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts:257`). Même dessin que
128
+ passe et l'ouverture de session (`AuthFlow.completeMfaLogin()`, `authFlow.ts#AuthFlow.completeMfaLogin`). Même dessin que
129
129
  WebAuthn et OAuth : le firewall n'a qu'un seul mécanisme à connaître, la **session**.
130
130
 
131
131
  > [!IMPORTANT]
132
132
  > Le couplage est fait **par nom de service**, jamais par import : `AuthFlow` ne connaît du 2FA
133
- > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts:44`). 2FA désactivé
133
+ > qu'une interface locale de trois méthodes (`ITotpLoginVerifier`, `authFlow.ts#ITotpLoginVerifier`). 2FA désactivé
134
134
  > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
- > `authFlow.ts:458`).
135
+ > `authFlow.ts#AuthFlow.#resolveTotp`).
136
136
 
137
137
  ## 🚀 Démarrage rapide
138
138
 
@@ -143,18 +143,18 @@ activer un second facteur depuis sa page « ma sécurité », et que le login l'
143
143
 
144
144
  Le secret TOTP est chiffré au repos → il faut une clé **stable** (sinon les secrets deviennent
145
145
  illisibles au redémarrage). La commande la génère et te dit exactement où la coller
146
- (`nodefony security:secrets`, `security-secrets.ts:79`) :
146
+ (`nodefony security:secrets`, `security-secrets.ts#SecuritySecrets`) :
147
147
 
148
148
  ```bash
149
149
  npx nodefony security:secrets --write
150
150
  # 🔐 Secrets security — 3 étapes, 3 FICHIERS
151
- # 1. Fichier .env.local — les valeurs
152
- # ✓ écrit dans .env.local (NF_TOTP_KEY, NF_WEBHOOK_KEY, NF_CSRF_SECRET)
151
+ # 1. Fichier .env — les valeurs
152
+ # ✓ écrit dans .env (NF_TOTP_KEY, NF_WEBHOOK_KEY, NF_CSRF_SECRET)
153
153
  # 2. Fichier env.ts — la déclaration typée
154
154
  # 3. Fichier nodefony.config.ts — le câblage vers le module security
155
155
  ```
156
156
 
157
- Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts:136`) et
157
+ Elle produit 32 octets aléatoires en base64 (`randomBytes(32)`, `security-secrets.ts#SecuritySecrets.generate`) et
158
158
  **n'écrase jamais** une valeur existante — une rotation reste un geste manuel et conscient.
159
159
 
160
160
  ### 2. Déclarer puis câbler la clé
@@ -190,7 +190,7 @@ export default defineConfig<typeof env>((ctx) => ({
190
190
 
191
191
  ### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
192
192
 
193
- `mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service dès que
193
+ `mountTotpRoutes()` (`TotpController.ts#mountTotpRoutes`) monte quatre routes self-service dès que
194
194
  `@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
195
195
  service non initialisé : elles répondent `503 2FA unavailable` :
196
196
 
@@ -202,17 +202,23 @@ service non initialisé : elles répondent `503 2FA unavailable` :
202
202
  | `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
203
203
 
204
204
  Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
205
- `SessionAuthController.ts:166`) :
205
+ `SessionAuthController.ts#mountSessionAuthRoutes`) :
206
206
 
207
207
  | Route | Corps | Réponse |
208
208
  | --------------------------------------------- | ------------------------ | ------------------------------------------ |
209
209
  | `POST /nodefony/security/api/auth/login` | `{ username, password }` | `200` + identité, **ou** `202 mfaRequired` |
210
210
  | `POST /nodefony/security/api/auth/login/totp` | `{ code }` | `200` + identité, `401`, ou `429` |
211
211
 
212
+ Le `202` est un **défi**, pas un succès : `{ mfaRequired: true, methods: [...] }`. La liste
213
+ `methods` est **ouverte** — aujourd'hui `["totp"]` (`MFA_METHOD_TOTP`, exporté par `nodefony`),
214
+ demain une passkey ou un code par courriel s'y ajouteront sans rupture. Un client ne retient que
215
+ les méthodes qu'il sait conduire ; `NodefonyLogin` traite un défi sans aucune méthode connue comme
216
+ une réponse inattendue (erreur `server`), jamais comme un écran de code.
217
+
212
218
  > [!WARNING]
213
- > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
219
+ > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts#TotpController`) : elles vivent dans la
214
220
  > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
215
- > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:142`) — jamais un
221
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts#TotpController.#currentSubject`) — jamais un
216
222
  > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
217
223
 
218
224
  ### 4. Ce qu'on observe
@@ -291,18 +297,18 @@ sequenceDiagram
291
297
 
292
298
  Ce que le code garantit à chaque étape :
293
299
 
294
- - **`beginTotpEnrollment()`** (`totpOperations.ts:72`) écrit le secret **déjà chiffré** avec
300
+ - **`beginTotpEnrollment()`** (`totpOperations.ts#beginTotpEnrollment`) écrit le secret **déjà chiffré** avec
295
301
  `confirmedAt: null` — l'état « en attente ». Il est **idempotent** : rappeler l'enrôlement écrase
296
302
  simplement le précédent non confirmé (l'utilisateur qui a raté son scan recommence, sans support).
297
- - **`confirmTotpEnrollment()`** (`totpOperations.ts:112`) refuse si aucun enrôlement n'est en cours
303
+ - **`confirmTotpEnrollment()`** (`totpOperations.ts#confirmTotpEnrollment`) refuse si aucun enrôlement n'est en cours
298
304
  ou s'il est déjà confirmé, et **reste en attente** si le code est faux — aucun état intermédiaire
299
305
  bancal.
300
306
  - Le pas qui a servi à confirmer est marqué **consommé** (`lastUsedStep: res.step`,
301
- `totpOperations.ts:142`) : le code de confirmation n'est pas rejouable comme premier code de login.
307
+ `totpOperations.ts#confirmTotpEnrollment`) : le code de confirmation n'est pas rejouable comme premier code de login.
302
308
 
303
309
  > [!TIP]
304
310
  > Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
305
- > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts:78`).
311
+ > tous le **même** `400 Invalid or expired code` (`TotpController.confirm()`, `TotpController.ts#TotpController.confirm`).
306
312
  > La cause fine reste côté serveur.
307
313
 
308
314
  ### La vérification au login
@@ -334,22 +340,22 @@ sequenceDiagram
334
340
  A-->>U: 200 { user }
335
341
  ```
336
342
 
337
- Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) :
343
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) :
338
344
 
339
345
  1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
340
- (`authFlow.ts:18`), posée par le login, **consommée** avant l'ouverture de session
341
- (`authFlow.ts:301`).
346
+ (`authFlow.ts#AuthFlow.completeMfaLogin`), posée par le login, **consommée** avant l'ouverture de session
347
+ (`authFlow.ts#AuthFlow.completeMfaLogin`).
342
348
  2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
343
- (`AuthFlow.#resolveThrottler()`, `authFlow.ts:447`) : 10⁶ combinaisons se forcent brute en
349
+ (`AuthFlow.#resolveThrottler()`, `authFlow.ts#AuthFlow.#resolveThrottler`) : 10⁶ combinaisons se forcent brute en
344
350
  quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
345
351
  3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
346
352
  n'a pas à refaire son mot de passe.
347
353
 
348
354
  ### La fenêtre de dérive et l'anti-rejeu
349
355
 
350
- Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts:207`)
356
+ Les deux horloges ne sont jamais parfaitement synchrones. `verifyTotp()` (`totpCrypto.ts#verifyTotp`)
351
357
  balaie donc les tranches `T-window … T+window` et compare **en temps constant**
352
- (`timingSafeEqual`, `totpCrypto.ts:227`) — une comparaison naïve fuirait le préfixe correct par le
358
+ (`timingSafeEqual`, `totpCrypto.ts#verifyTotp`) — une comparaison naïve fuirait le préfixe correct par le
353
359
  temps de réponse.
354
360
 
355
361
  | `window` | Tolérance réelle | Codes acceptés simultanément | Verdict |
@@ -359,8 +365,8 @@ temps de réponse.
359
365
  | `2` | ±60 s | 5 | Surface d'attaque ×1,7 pour peu de gain. |
360
366
 
361
367
  Le contrepoids obligatoire, c'est l'**anti-rejeu** : la tranche qui a validé est mémorisée
362
- (`ITotpSecret.lastUsedStep`, `ITotpSecret.ts:36`), et tout code d'une tranche **≤** à la dernière
363
- consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un code intercepté —
368
+ (`ITotpSecret.lastUsedStep`, `ITotpSecret.ts#ITotpSecret.lastUsedStep`), et tout code d'une tranche **≤** à la dernière
369
+ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`). Un code intercepté —
364
370
  épaule, proxy, phishing en temps réel — est donc **mort dès qu'il a servi une fois**.
365
371
 
366
372
  > [!WARNING]
@@ -373,52 +379,52 @@ consommée est refusé par la garde `lastUsedStep` (`totpOperations.ts:173`). Un
373
379
 
374
380
  La valeur de `totp.encryptionKey` est une chaîne d'application : passphrase, hex, base64, longueur
375
381
  quelconque. AES-256 exige exactement **32 octets de haute entropie**. `deriveKey()`
376
- (`secretCipher.ts:54`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
382
+ (`secretCipher.ts#deriveKey`) passe donc le matériel dans **HKDF-SHA256** (RFC 5869) :
377
383
 
378
384
  - **Déterministe** — tous les pods d'un cluster dérivent la **même** clé du même secret : un secret
379
385
  écrit par un pod se relit par les autres, sans réplication de clé.
380
386
  - **Séparation de domaine** — chaque brique dérive avec un `salt`/`info` distinct. Le contexte TOTP
381
- est figé (`TOTP_DERIVATION`, `totpCipher.ts:23`) : un blob de webhook ne se déchiffre **jamais**
387
+ est figé (`TOTP_DERIVATION`, `totpCipher.ts#TOTP_DERIVATION`) : un blob de webhook ne se déchiffre **jamais**
382
388
  avec la clé TOTP, par construction, même si la clé maître de config est la même.
383
389
  - **Longueur libre en entrée** — une passphrase courte ne devient jamais une clé AES faible.
384
390
 
385
391
  ### Le format du blob
386
392
 
387
- `encryptSecret()` (`secretCipher.ts:77`) produit une chaîne opaque, préfixée par sa version :
393
+ `encryptSecret()` (`secretCipher.ts#encryptSecret`) produit une chaîne opaque, préfixée par sa version :
388
394
 
389
395
  ```
390
396
  gcm1.<base64url( iv‖tag‖ciphertext )>
391
397
  └ 12 o ┘└16 o┘
392
398
  ```
393
399
 
394
- - **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts:30`) — deux enrôlements du même
400
+ - **IV de 12 octets tiré à chaque chiffrement** (`secretCipher.ts#IV_BYTES`) — deux enrôlements du même
395
401
  secret donnent deux blobs différents.
396
402
  - **GCM = chiffrement authentifié** : le tag de 16 octets fait échouer `decryptSecret()`
397
- (`secretCipher.ts:90`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
403
+ (`secretCipher.ts#decryptSecret`) si le blob a été altéré **ou** si la clé est la mauvaise. GCM ne distingue
398
404
  pas les deux cas, par construction — toute manipulation du secret stocké est donc détectée.
399
405
  - **Préfixe versionné `gcm1`** : une rotation d'algorithme future pourra cohabiter avec les secrets
400
406
  existants.
401
407
 
402
408
  Le store, lui, ne voit que des octets : il ne déchiffre jamais rien (`ITotpSecret.secretEnc`,
403
- `ITotpSecret.ts:18`).
409
+ `ITotpSecret.ts#ITotpSecret.secretEnc`).
404
410
 
405
411
  ### La politique de clé — bruyante en dev, fail-closed en production
406
412
 
407
- `TotpService.#resolveKey()` (`totp.ts:211`) tranche au boot :
413
+ `TotpService.#resolveKey()` (`totp.ts#TotpService.#resolveKey`) tranche au boot :
408
414
 
409
- | Situation | Environnement | Comportement |
410
- | ---------------------------- | ------------- | ------------------------------------------------------------ |
411
- | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts:214`). |
412
- | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts:228`). |
413
- | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts:220`). |
414
- | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
415
+ | Situation | Environnement | Comportement |
416
+ | ---------------------------- | ------------- | ------------------------------------------------------------------- |
417
+ | `totp.encryptionKey` fournie | tous | Clé dérivée HKDF — cas nominal (`totp.ts#TotpService.#resolveKey`). |
418
+ | Clé absente | dev / test | Clé **éphémère** + `WARNING` (`totp.ts#TotpService.#resolveKey`). |
419
+ | Clé absente | production | `CRITIC` + **2FA désactivé** (`totp.ts#TotpService.#resolveKey`). |
420
+ | `store: "memory"` en prod | production | `WARNING` — secrets volatils, comptes verrouillés au reboot. |
415
421
 
416
422
  Le refus en production est délibéré : une clé éphémère chiffrerait des secrets **illisibles au
417
423
  redémarrage suivant** et sur les autres pods — les utilisateurs seraient enfermés dehors, sans
418
424
  message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement en pleine nuit.
419
425
 
420
426
  > [!CAUTION]
421
- > Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts:23`). Changer son sel ou son `info` rend
427
+ > Ne **jamais** modifier `TOTP_DERIVATION` (`totpCipher.ts#TOTP_DERIVATION`). Changer son sel ou son `info` rend
422
428
  > illisibles **tous** les secrets déjà stockés — chaque utilisateur devra ré-enrôler.
423
429
 
424
430
  ## Les codes de récupération — perdre son téléphone
@@ -426,24 +432,24 @@ message. Mieux vaut un 2FA absent et bruyant qu'un 2FA qui casse silencieusement
426
432
  Un second facteur crée un risque neuf : **s'enfermer dehors**. Les codes de récupération sont la
427
433
  sortie de secours — le NIST les classe comme _look-up secrets_ (SP 800-63B §5.1.2).
428
434
 
429
- **Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts:330`) :
435
+ **Comment ils sont fabriqués** (`generateRecoveryCodes()`, `totpCrypto.ts#generateRecoveryCodes`) :
430
436
 
431
437
  - 10 codes par défaut (`totp.recoveryCodes`), au format lisible `XXXXX-XXXXX` ;
432
- - alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts:269`) : on les
438
+ - alphabet **sans caractères ambigus** — ni `I`, ni `L`, ni `O`, ni `U` (`totpCrypto.ts#unbiasedIndices`) : on les
433
439
  recopie à la main, souvent sous stress ;
434
440
  - ~50 bits d'aléa chacun — non devinable, mais ce n'est **pas** un mot de passe humain.
435
441
 
436
- **Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts:347`),
442
+ **Comment ils sont stockés** : en condensat `sha256` (`hashRecoveryCode()`, `totpCrypto.ts#hashRecoveryCode`),
437
443
  jamais en clair. Un `sha256` simple suffit ici, précisément parce que l'entrée est **aléatoire** (une
438
444
  attaque par dictionnaire n'a rien à mordre) — contrairement à un mot de passe, qui exige Argon2id.
439
445
 
440
446
  **Comment ils sont consommés** : au login, si le code présenté n'est pas un TOTP valide,
441
447
  `verifyTotpLogin()` cherche une correspondance parmi les condensats — **en temps constant sur chaque
442
448
  entrée**, et sans court-circuit à la première trouvaille (`matchRecoveryCode()`,
443
- `totpCrypto.ts:356`). Le code trouvé est **retiré de la liste** (`totpOperations.ts:186`) : usage
449
+ `totpCrypto.ts#matchRecoveryCode`). Le code trouvé est **retiré de la liste** (`totpOperations.ts#verifyTotpLogin`) : usage
444
450
  unique, strictement.
445
451
 
446
- La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts:272`) :
452
+ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totpCrypto.ts#unbiasedIndices`) :
447
453
  `k7m2p9xq4r` vaut `K7M2P-9XQ4R`.
448
454
 
449
455
  > [!TIP]
@@ -453,20 +459,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
453
459
 
454
460
  ## ⚙️ Configuration et mises en situation
455
461
 
456
- La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
462
+ La section `totp` du schéma Zod (`config.ts#securityConfigSchema`) — validée au boot, donc une valeur hors bornes
457
463
  échoue **au démarrage**, pas au premier login :
458
464
 
459
- | Option | Type | Défaut | Effet |
460
- | --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
461
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
462
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
463
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
464
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
465
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
466
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
467
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
468
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
469
- | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
465
+ | Option | Type | Défaut | Effet |
466
+ | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------------------ |
467
+ | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts#TotpService.#build`). |
468
+ | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
469
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts#totpSchema`). |
470
+ | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
471
+ | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
472
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts#totpSchema`). |
473
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts#passkeysSchema`). |
474
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts#totpSchema`). |
475
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts#passkeysSchema`). |
470
476
 
471
477
  ### Situation 1 — un utilisateur active la 2FA sur son compte
472
478
 
@@ -475,7 +481,7 @@ self-service sont déjà là, la console Studio les consomme déjà (`/nodefony/
475
481
 
476
482
  ```typescript
477
483
  use("@nodefony/security", {
478
- totp: { encryptionKey: process.env.NF_TOTP_KEY },
484
+ totp: { encryptionKey: ctx.env.NF_TOTP_KEY },
479
485
  });
480
486
  ```
481
487
 
@@ -492,8 +498,24 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
492
498
  (poste laissé ouvert, session volée). Pour une suppression de compte ou une rotation de clés, on
493
499
  redemande le code : c'est le _sudo mode_.
494
500
 
495
- Nodefony fournit le step-up **de login** ; la re-vérification en cours de session, elle, se compose
496
- dans ton controller à partir du service public `TotpService.verifyLogin()` (`totp.ts:269`) :
501
+ **La voie déclarative** — une étiquette sur la route, qui lit ce que la session a retenu à son
502
+ ouverture (quand, et avec quels moyens) :
503
+
504
+ ```typescript
505
+ import { Post, RequireAuthentication } from "@nodefony/framework";
506
+
507
+ @Post("/delete")
508
+ @RequireAuthentication({ maxAge: 300, amr: ["otp"] }) // connexion < 5 min, avec le code
509
+ async remove() { … }
510
+ ```
511
+
512
+ Une connexion trop ancienne ou sans le code reçoit un **401** dont le `WWW-Authenticate` porte
513
+ `error="insufficient_user_authentication"` (RFC 9470) : le client redemande une connexion — mot de
514
+ passe puis code — et rejoue l'action. Évaluée APRÈS `@IsGranted` : un rôle absent reste un 403.
515
+ Une session qui ne dit pas comment elle a été ouverte est refusée.
516
+
517
+ **La voie composée** — quand le code doit être saisi DANS l'action, sans repasser par la page de
518
+ connexion, à partir du service public `TotpService.verifyLogin()` (`totp.ts#TotpService.verifyLogin`) :
497
519
 
498
520
  ```typescript
499
521
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -504,7 +526,7 @@ class DangerController extends Controller {
504
526
  @Post("/delete")
505
527
  async remove() {
506
528
  const totp = this.get<TotpService>("totp");
507
- const code = (this.queryPost as { code?: unknown }).code;
529
+ const code = (this.body as { code?: unknown }).code;
508
530
  // Zone protégée : le firewall a déjà authentifié. On exige la PREUVE FRAÎCHE.
509
531
  if (!totp?.isEnabled() || typeof code !== "string") {
510
532
  return this.renderJson({ error: "2FA required" }, 403);
@@ -535,7 +557,7 @@ totp: { algorithm: "SHA512", digits: 8 }, // ❌ Google Authenticator ignore c
535
557
  ```
536
558
 
537
559
  L'URI `otpauth://` transporte bien `algorithm` et `digits` (`buildOtpauthUri()`,
538
- `totpCrypto.ts:253`), mais plusieurs applications grand public les **ignorent** et calculent en
560
+ `totpCrypto.ts#buildOtpauthUri`), mais plusieurs applications grand public les **ignorent** et calculent en
539
561
  `SHA1`/6 chiffres. Résultat : le QR est scanné, l'app affiche un code… systématiquement refusé, sans
540
562
  que rien ne semble anormal. Le gain de sécurité réel est par ailleurs nul — l'anti-rejeu et le
541
563
  throttle bornent déjà les tentatives bien avant l'espace des codes.
@@ -543,10 +565,23 @@ throttle bornent déjà les tentatives bien avant l'espace des codes.
543
565
  ## L'entité de persistance — un secret par utilisateur
544
566
 
545
567
  Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
546
- (`ITotpSecretStore`, `ITotpSecretStore.ts:69`). Pas d'identifiant de ligne, pas d'index secondaire —
568
+ (`ITotpSecretStore`, `ITotpSecretStore.ts#ITotpSecretStore`). Pas d'identifiant de ligne, pas d'index secondaire —
547
569
  tout accès passe par la clé primaire.
548
570
 
549
- Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
571
+ **`userId` vaut `IUser.identifier`, jamais `IUser.id`** — la même clé que la session et les jetons,
572
+ et elle est immuable. Les deux sont des chaînes : un appelant qui passe l'`id` ne se fait pas
573
+ refuser, il vise une clé vide et reçoit un succès qui n'a rien touché. Le plan d'administration,
574
+ qui adresse les comptes par leur `id`, traduit donc d'abord l'`id` en `identifier`
575
+ (`resolveSubject`, `SecurityAdminApi.ts#resolveSubject`).
576
+
577
+ **Le secret disparaît avec le compte.** Supprimer un compte (plan d'administration ou
578
+ `security:user:delete`) passe par `UserService.delete`, qui émet `onUserRevoked`
579
+ (`reason: "deleted"`) ; la cascade retire le secret
580
+ TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts#cascadeUserRevocation`). Sans elle, un compte
581
+ recréé sous le même identifiant hériterait du second facteur de l'ancien titulaire. Un compte
582
+ seulement désactivé ou verrouillé garde ses facteurs.
583
+
584
+ Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts#TOTP_SECRET_TABLE_SPEC`), déclinée par
550
585
  dialecte via le colKit :
551
586
 
552
587
  | Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
@@ -565,18 +600,18 @@ dialecte via le colKit :
565
600
  Deux pièges de lecture, signalés dans l'entité elle-même :
566
601
 
567
602
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
568
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts:61`).
603
+ partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
569
604
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
570
605
  (SQLite, lui, a des INTEGER 64 bits).
571
606
 
572
607
  ### Les backends disponibles
573
608
 
574
- | Backend | Enregistré par | Durabilité | État |
575
- | ---------- | --------------------------------------------- | ----------------------------------- | -------------------- |
576
- | `memory` | builtin (`totpSecretStoreRegistry.ts:54`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
577
- | `drizzle` | `@nodefony/drizzle` (`registerStores.ts:320`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
578
- | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
579
- | `redis` | — | — | ⏳ manquant, à venir |
609
+ | Backend | Enregistré par | Durabilité | État |
610
+ | ---------- | ------------------------------------------------------------------------ | ----------------------------------- | -------------------- |
611
+ | `memory` | builtin (`totpSecretStoreRegistry.ts`) | **volatile** — perdu au redémarrage | ✅ dev / tests |
612
+ | `drizzle` | `@nodefony/drizzle` (`registerStores.ts#registerDrizzleFrameworkStores`) | durable, partagé entre pods | ✅ 3 dialectes SQL |
613
+ | `mongoose` | `@nodefony/mongoose` (`registerStores.ts`) | durable, partagé entre pods | ✅ MongoDB |
614
+ | `redis` | — | — | ⏳ manquant, à venir |
580
615
 
581
616
  Une application peut donc activer le 2FA **entièrement sur MongoDB**, sans charger d'ORM SQL : une
582
617
  application choisit son ORM, elle ne choisit pas de se passer du second facteur.
@@ -597,12 +632,12 @@ primaire (`_id` = `userId`) — donc l'unicité existe dès le premier document,
597
632
  en tâche de fond.
598
633
 
599
634
  Côté `drizzle`, les **trois dialectes** sont portés — `TOTP_PORTED` vaut l'ensemble des dialectes
600
- (`registerStores.ts:94`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
601
- tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts:44`)
635
+ (`registerStores.ts#TOTP_PORTED`) : SQLite, PostgreSQL, MySQL/MariaDB. Le store n'écrit **aucun SQL natif**,
636
+ tout passe par le contrat `IRepository` (`DrizzleTotpSecretStore`, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore`)
602
637
  — c'est ce qui rend la portabilité gratuite.
603
638
 
604
639
  **Comment le backend est choisi.** `store: "auto"` (le défaut) suit l'infra déclarée puis les
605
- adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
640
+ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts#TotpService.#resolveStore`) :
606
641
 
607
642
  1. `NF_STORE` (override global de banc de charge), s'il est enregistré ici ;
608
643
  2. infra base de données déclarée (`NF_DATABASE_URL`) → `drizzle` ;
@@ -610,7 +645,7 @@ adapters réellement chargés (`TotpService.#resolveStore()`, `totp.ts:137`) :
610
645
  4. sinon **repli `memory`, annoncé** — jamais silencieux.
611
646
 
612
647
  Un `store` **explicite** introuvable, en revanche, ne se replie pas : `CRITIC` en dev, boot avorté en
613
- production (`totp.ts:174`). Une faute de frappe ne dégrade jamais la sécurité en douce.
648
+ production (`totp.ts#TotpService.#resolveStore`). Une faute de frappe ne dégrade jamais la sécurité en douce.
614
649
 
615
650
  ## Le listing paginé des enrôlements
616
651
 
@@ -618,48 +653,48 @@ Question d'exploitation : « quelle est la **couverture** 2FA, et qui est resté
618
653
  confirmation ? » Un secret jamais confirmé ne protège personne, et c'est invisible depuis la fiche
619
654
  d'un seul utilisateur.
620
655
 
621
- `ITotpSecretStore.listPage()` (`ITotpSecretStore.ts:85`) répond, avec trois garanties :
656
+ `ITotpSecretStore.listPage()` (`ITotpSecretStore.ts#ITotpSecretStore.listPage`) répond, avec trois garanties :
622
657
 
623
658
  - **pagination native au store** — jamais de parcours complet en mémoire ; l'ordre est contractuel
624
659
  (`createdAt` DESC, départagé par `userId` ASC) ;
625
660
  - **filtres appliqués côté backend** — `confirmed` (activés / en attente) et `q` (préfixe d'`userId`,
626
- donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts:176`) ;
627
- - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts:16`)
661
+ donc indexable : le critère `$like` est **ancré à gauche**, `DrizzleTotpSecretStore.ts#DrizzleTotpSecretStore.#listCriteria`) ;
662
+ - **la vue ne peut pas porter de secret** — `ITotpEnrollmentSummary` (`ITotpSecretStore.ts#ITotpEnrollmentSummary`)
628
663
  n'a ni `secretEnc` ni les condensats de récupération, seulement leur **nombre**
629
664
  (`recoveryCodesLeft`).
630
665
 
631
666
  Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'affichage : quel que
632
667
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
633
- (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
668
+ (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts#toTotpEnrollment`). C'est ce qu'exerce le banc de contrat partagé.
634
669
 
635
- `countEnrollments()` (`ITotpSecretStore.ts:90`) donne le KPI de couverture sans énumérer une seule
670
+ `countEnrollments()` (`ITotpSecretStore.ts#ITotpSecretStore.countEnrollments`) donne le KPI de couverture sans énumérer une seule
636
671
  ligne.
637
672
 
638
673
  ## 🧰 API publique
639
674
 
640
675
  Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.ai/symbols.json`.
641
676
 
642
- **Le service** (`TotpService`, `totp.ts:74`), résolu par nom dans le container (`"totp"`) :
677
+ **Le service** (`TotpService`, `totp.ts#TotpService`), résolu par nom dans le container (`"totp"`) :
643
678
 
644
- | Méthode | Rôle |
645
- | ------------------------------------- | ------------------------------------------------------------ |
646
- | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
647
- | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
648
- | `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
649
- | `verifyLogin()` (`totp.ts:269`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
650
- | `disable()` (`totp.ts:274`) | Retire secret et codes. |
651
- | `status()` (`totp.ts:279`) | `{ enabled, pending, recoveryCodesRemaining }`. |
652
- | `isEnabledFor()` (`totp.ts:306`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
653
- | `listPage()` (`totp.ts:290`) | Page d'enrôlements (data plane admin). |
654
- | `countEnrollments()` (`totp.ts:301`) | Compte filtré, sans énumération. |
679
+ | Méthode | Rôle |
680
+ | --------------------------------------------------------------- | ------------------------------------------------------------ |
681
+ | `isEnabled()` (`totp.ts#TotpService.isEnabled`) | 2FA opérationnel (activé en config **et** boot réussi). |
682
+ | `beginEnrollment()` (`totp.ts#TotpService.beginEnrollment`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
683
+ | `confirmEnrollment()` (`totp.ts#TotpService.confirmEnrollment`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
684
+ | `verifyLogin()` (`totp.ts#TotpService.verifyLogin`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
685
+ | `disable()` (`totp.ts#TotpService.disable`) | Retire secret et codes. |
686
+ | `status()` (`totp.ts#TotpService.status`) | `{ enabled, pending, recoveryCodesRemaining }`. |
687
+ | `isEnabledFor()` (`totp.ts#TotpService.isEnabledFor`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
688
+ | `listPage()` (`totp.ts#TotpService.listPage`) | Page d'enrôlements (data plane admin). |
689
+ | `countEnrollments()` (`totp.ts#TotpService.countEnrollments`) | Compte filtré, sans énumération. |
655
690
 
656
691
  **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
657
692
  elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
658
- `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts:200`).
693
+ `verifyTotpLogin()`, `disableTotp()`, `totpStatus()` (`totpOperations.ts#totpStatus`).
659
694
 
660
695
  **Les primitives crypto**, pour écrire un client ou un banc de test : `totpCode()`
661
- (`totpCrypto.ts:174`), `base32Decode()` (`totpCrypto.ts:81`), `deriveTotpKey()`
662
- (`totpCipher.ts:33`).
696
+ (`totpCrypto.ts#totpCode`), `base32Decode()` (`totpCrypto.ts#base32Decode`), `deriveTotpKey()`
697
+ (`totpCipher.ts#deriveTotpKey`).
663
698
 
664
699
  ```typescript
665
700
  // Calculer le code attendu côté « application d'authentification » — exactement
@@ -671,7 +706,7 @@ const code = totpCode(base32Decode(secretBase32), { epochMs: Date.now() });
671
706
  ## 🧩 Extension — brancher son propre store
672
707
 
673
708
  Le registre découple le cœur du backend : implémente `ITotpSecretStore`
674
- (`ITotpSecretStore.ts:69`), enregistre la fabrique, sélectionne-la en config.
709
+ (`ITotpSecretStore.ts#ITotpSecretStore`), enregistre la fabrique, sélectionne-la en config.
675
710
 
676
711
  ```typescript
677
712
  import { registerTotpStore, type ITotpSecretStore } from "@nodefony/security";
@@ -689,19 +724,19 @@ vérifie que ta projection n'expose ni secret ni condensat.
689
724
 
690
725
  ## 📜 Normes appliquées
691
726
 
692
- | Domaine | Norme | Ancrage dans le code |
693
- | --------------------------- | ------------------------ | ------------------------------------------------------ |
694
- | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`) |
695
- | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
696
- | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
697
- | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
698
- | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
699
- | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
700
- | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
701
- | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
702
- | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
703
- | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) |
704
- | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:145`) |
727
+ | Domaine | Norme | Ancrage dans le code |
728
+ | --------------------------- | ------------------------ | --------------------------------------------------------------------------- |
729
+ | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts#totpCode`) |
730
+ | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts#hotp`), masque `0x7f` (`totpCrypto.ts#hotp`) |
731
+ | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts#TOTP_DEFAULTS`) |
732
+ | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts#verifyTotp`) |
733
+ | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`) |
734
+ | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts#base32Encode`) |
735
+ | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts#deriveKey`) |
736
+ | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts#IV_BYTES`) |
737
+ | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts#generateRecoveryCodes`) |
738
+ | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) |
739
+ | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts#SessionAuthController.me`) |
705
740
 
706
741
  Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
707
742
  hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
@@ -710,18 +745,18 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
710
745
 
711
746
  Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
712
747
 
713
- - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts:458`)
748
+ - **Sur le login nominal** (2FA absent ou désactivé) : `AuthFlow.#resolveTotp()` (`authFlow.ts#AuthFlow.#resolveTotp`)
714
749
  résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
715
750
  **zéro accès au store**, zéro allocation par login.
716
751
  - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
717
752
  jamais dans le pipeline HTTP/WS.
718
753
  - **Allocation paresseuse du store** : la `Map` de `MemoryTotpSecretStore`
719
- (`MemoryTotpSecretStore.ts:74`) n'existe que si le 2FA est activé — le service ne construit rien
720
- quand `totp.enabled` est `false` (`totp.ts:101`).
754
+ (`MemoryTotpSecretStore.ts#MemoryTotpSecretStore.#byUser`) n'existe que si le 2FA est activé — le service ne construit rien
755
+ quand `totp.enabled` est `false` (`totp.ts#TotpService.#build`).
721
756
  - **Le coût réel d'une vérification** : ≤ `2·window + 1` HMAC (3 par défaut) + un déchiffrement
722
757
  AES-GCM. De l'ordre de la microseconde — négligeable devant le hachage Argon2id du mot de passe
723
758
  qui l'a précédé.
724
- - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts:241`)
759
+ - **Arrêt propre** : si le store sait se vider sur disque, `TotpService.#shutdown()` (`totp.ts#TotpService.#shutdown`)
725
760
  le déclenche à `onTerminate` — aucune écriture en attente perdue.
726
761
 
727
762
  ## 📡 Observabilité — Studio
@@ -735,37 +770,43 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
735
770
 
736
771
  Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
737
772
 
738
- - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée (`SecurityAdminApi.ts:636`).
773
+ - `GET /nodefony/security/api/totp/list` — couverture 2FA paginée, `?q=` = préfixe d'`userId`
774
+ (`SecurityAdminApi.ts#createSecurityAdminApi`).
739
775
  Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
740
776
  la console doit pouvoir afficher « 2FA désactivé ».
741
- - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts:686`).
777
+ - `GET /nodefony/security/api/users/{id}/totp` — statut d'un utilisateur (`SecurityAdminApi.ts#createSecurityAdminApi`).
742
778
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
743
- (`SecurityAdminApi.ts:711`).
779
+ (`SecurityAdminApi.ts#createSecurityAdminApi`).
780
+
781
+ Ces deux routes répondent `503` si le service `users` manque : sans lui, l'`id` ne se traduit pas en
782
+ clé de sujet, et agir quand même rendrait un faux succès.
744
783
 
745
784
  L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
746
785
  l'utilisateur, lui seul peut l'armer.
747
786
 
748
- Côté journal d'audit, quatre actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
749
- `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
750
- `reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
751
- (`SecurityAdminApi.ts:735`).
787
+ Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts#AuthFlow.login`),
788
+ `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts#AuthFlow.login`), `login.failure` avec
789
+ `reason: "mfa_invalid"` (`authFlow.ts#AuthFlow.completeMfaLogin`), `user.totp_enabled` quand le titulaire active son
790
+ facteur (`totp.ts#TotpService.confirmEnrollment`), et `user.totp_disabled` — par le titulaire (`totp.ts#TotpService.disable`,
791
+ `viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts#createSecurityAdminApi`, `viaAdmin: true`). Les
792
+ deux gestes du titulaire portent l'IP, le navigateur et l'identifiant de la requête.
752
793
 
753
794
  ## ⚠️ Pièges (symptôme → cause → correction)
754
795
 
755
- | Symptôme | Cause (dans le code) | Correction |
756
- | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
757
- | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts:220`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
758
- | Tous les secrets illisibles après déploiement | Clé éphémère (dev) ou `TOTP_DERIVATION` modifié | Clé **stable** partagée ; ne jamais toucher au contexte HKDF |
759
- | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
760
- | Code « juste » systématiquement refusé | Horloge décalée de plus d'un pas (fenêtre = ±30 s) | Synchroniser NTP serveur **et** téléphone |
761
- | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
762
- | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
763
- | `401` sur `…/auth/me` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts:170`) | Terminer le step-up |
764
- | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) | Respecter `Retry-After` — attendu sous attaque |
765
- | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
766
- | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
767
- | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
768
- | Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts:186`) | Régénérer un lot en ré-enrôlant si le stock est bas |
796
+ | Symptôme | Cause (dans le code) | Correction |
797
+ | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
798
+ | 2FA inactif en production, `CRITIC` au boot | `totp.encryptionKey` absente — fail-closed (`totp.ts#TotpService.#resolveKey`) | `npx nodefony security:secrets`, puis câbler `ctx.env.NF_TOTP_KEY` |
799
+ | Tous les secrets illisibles après déploiement | Clé éphémère (dev) ou `TOTP_DERIVATION` modifié | Clé **stable** partagée ; ne jamais toucher au contexte HKDF |
800
+ | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
801
+ | Code « juste » systématiquement refusé | Horloge décalée de plus d'un pas (fenêtre = ±30 s) | Synchroniser NTP serveur **et** téléphone |
802
+ | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
803
+ | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
804
+ | `…/auth/me` rend `{ user: null }` juste après le mot de passe | L'identité n'est posée qu'après le 2ᵉ facteur (`authFlow.ts#AuthFlow.login`) | Terminer le step-up |
805
+ | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts#AuthFlow.completeMfaLogin`) | Respecter `Retry-After` — attendu sous attaque |
806
+ | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts#ITotpManager.isEnabled`) | Vérifier `totp.enabled` + la clé + les logs de boot |
807
+ | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
808
+ | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts#verifyTotpLogin`) | — |
809
+ | Code de récupération réutilisable | Impossible — retiré du stock à l'usage (`totpOperations.ts#verifyTotpLogin`) | Régénérer un lot en ré-enrôlant si le stock est bas |
769
810
 
770
811
  ## 🧪 Tests & couverture
771
812