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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. package/package.json +17 -15
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:142`).
70
70
 
71
71
  ## 📖 Lexique
72
72
 
@@ -125,14 +125,14 @@ 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:297`). 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:79`). 2FA désactivé
134
134
  > ⇒ service absent ⇒ le login nominal **ne paie strictement rien** (`AuthFlow.#resolveTotp()`,
135
- > `authFlow.ts:458`).
135
+ > `authFlow.ts:642`).
136
136
 
137
137
  ## 🚀 Démarrage rapide
138
138
 
@@ -148,13 +148,13 @@ illisibles au redémarrage). La commande la génère et te dit exactement où la
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:156`) 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:161`) 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:214`) :
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
219
  > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : 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:145`) — 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
@@ -302,7 +308,7 @@ Ce que le code garantit à chaque étape :
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:82`).
306
312
  > La cause fine reste côté serveur.
307
313
 
308
314
  ### La vérification au login
@@ -334,13 +340,13 @@ 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:297`) :
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
346
+ (`authFlow.ts:283`), posée par le login, **consommée** avant l'ouverture de session
341
347
  (`authFlow.ts:301`).
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:630`) : 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.
@@ -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:1461`) — validée au boot, donc une valeur hors bornes
457
463
  échoue **au démarrage**, pas au premier login :
458
464
 
459
465
  | Option | Type | Défaut | Effet |
460
466
  | --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
461
467
  | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
462
468
  | `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`). |
469
+ | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:618`). |
464
470
  | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
465
471
  | `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`). |
472
+ | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:639`). |
473
+ | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:586`). |
474
+ | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:655`). |
475
+ | `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:595`). |
470
476
 
471
477
  ### Situation 1 — un utilisateur active la 2FA sur son compte
472
478
 
@@ -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:300`) :
497
519
 
498
520
  ```typescript
499
521
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -543,9 +565,22 @@ 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:75`). Pas d'identifiant de ligne, pas d'index secondaire —
547
569
  tout accès passe par la clé primaire.
548
570
 
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:270`).
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:51`). 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
+
549
584
  Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
550
585
  dialecte via le colKit :
551
586
 
@@ -632,7 +667,7 @@ Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'a
632
667
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
633
668
  (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). 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:96`) donne le KPI de couverture sans énumérer une seule
636
671
  ligne.
637
672
 
638
673
  ## 🧰 API publique
@@ -646,12 +681,12 @@ Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.a
646
681
  | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
647
682
  | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
648
683
  | `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. |
684
+ | `verifyLogin()` (`totp.ts:300`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
685
+ | `disable()` (`totp.ts:316`) | Retire secret et codes. |
686
+ | `status()` (`totp.ts:331`) | `{ enabled, pending, recoveryCodesRemaining }`. |
687
+ | `isEnabledFor()` (`totp.ts:358`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
688
+ | `listPage()` (`totp.ts:342`) | Page d'enrôlements (data plane admin). |
689
+ | `countEnrollments()` (`totp.ts:353`) | 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()`,
@@ -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:174`) |
730
+ | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
731
+ | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
732
+ | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
733
+ | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
734
+ | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
735
+ | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
736
+ | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
737
+ | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
738
+ | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) |
739
+ | Rate limit (429) | RFC 6585 | `429` + `Retry-After` (`SessionAuthController.ts:164-172`) |
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,7 +745,7 @@ 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:642`)
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
@@ -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:601`).
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:748`).
742
778
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
743
- (`SecurityAdminApi.ts:711`).
779
+ (`SecurityAdminApi.ts:767`).
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`),
787
+ Côté journal d'audit, cinq actions tracent le cycle : `login.mfa_required` (`authFlow.ts:177`),
749
788
  `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`).
789
+ `reason: "mfa_invalid"` (`authFlow.ts:291`), `user.totp_enabled` quand le titulaire active son
790
+ facteur (`totp.ts:290`), et `user.totp_disabled` — par le titulaire (`totp.ts:321`,
791
+ `viaAdmin: false`) ou par un administrateur (`SecurityAdminApi.ts:789`, `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:220`) | `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:170`) | Terminer le step-up |
805
+ | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) | Respecter `Retry-After` — attendu sous attaque |
806
+ | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | 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:173`) | — |
809
+ | 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 |
769
810
 
770
811
  ## 🧪 Tests & couverture
771
812