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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
package/docs/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,8 +190,9 @@ 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, **et seulement si**
194
- le service `totp` existe (security chargé + 2FA activé) — sinon zéro surface, 404 :
193
+ `mountTotpRoutes()` (`TotpController.ts:161`) monte quatre routes self-service dès que
194
+ `@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
195
+ service non initialisé : elles répondent `503 2FA unavailable` :
195
196
 
196
197
  | Route | Corps | Réponse |
197
198
  | ------------------------------------------ | ---------- | ---------------------------------------------- |
@@ -201,17 +202,23 @@ le service `totp` existe (security chargé + 2FA activé) — sinon zéro surfac
201
202
  | `GET /nodefony/security/api/totp/status` | — | `{ enabled, pending, recoveryCodesRemaining }` |
202
203
 
203
204
  Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
204
- `SessionAuthController.ts:166`) :
205
+ `SessionAuthController.ts:214`) :
205
206
 
206
207
  | Route | Corps | Réponse |
207
208
  | --------------------------------------------- | ------------------------ | ------------------------------------------ |
208
209
  | `POST /nodefony/security/api/auth/login` | `{ username, password }` | `200` + identité, **ou** `202 mfaRequired` |
209
210
  | `POST /nodefony/security/api/auth/login/totp` | `{ code }` | `200` + identité, `401`, ou `429` |
210
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
+
211
218
  > [!WARNING]
212
219
  > Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
213
220
  > zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
214
- > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:138`) — jamais un
221
+ > depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:145`) — jamais un
215
222
  > paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
216
223
 
217
224
  ### 4. Ce qu'on observe
@@ -301,7 +308,7 @@ Ce que le code garantit à chaque étape :
301
308
 
302
309
  > [!TIP]
303
310
  > Le HTTP ne laisse jamais fuir le détail : code faux, enrôlement absent, ou déjà confirmé donnent
304
- > 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`).
305
312
  > La cause fine reste côté serveur.
306
313
 
307
314
  ### La vérification au login
@@ -333,13 +340,13 @@ sequenceDiagram
333
340
  A-->>U: 200 { user }
334
341
  ```
335
342
 
336
- Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) :
343
+ Trois propriétés à retenir de `AuthFlow.completeMfaLogin()` (`authFlow.ts:297`) :
337
344
 
338
345
  1. **Le défi vit en session, pas dans l'URL ni dans un jeton client** — clé `mfa:pending`
339
- (`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
340
347
  (`authFlow.ts:301`).
341
348
  2. **Le code à 6 chiffres est throttlé** comme un mot de passe — même backoff partagé
342
- (`AuthFlow.#resolveThrottler()`, `authFlow.ts:447`) : 10⁶ combinaisons se forcent brute en
349
+ (`AuthFlow.#resolveThrottler()`, `authFlow.ts:630`) : 10⁶ combinaisons se forcent brute en
343
350
  quelques minutes sans lui. Trop de tentatives → `429` + `Retry-After`.
344
351
  3. **Un échec ne détruit pas le défi** — l'utilisateur qui s'est trompé de chiffre ressaisit ; il
345
352
  n'a pas à refaire son mot de passe.
@@ -452,20 +459,20 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
452
459
 
453
460
  ## ⚙️ Configuration et mises en situation
454
461
 
455
- 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
456
463
  échoue **au démarrage**, pas au premier login :
457
464
 
458
- | Option | Type | Défaut | Effet |
459
- | --------------- | ---------------------------- | ------ | ------------------------------------------------------------------ |
460
- | `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes non montées (`totp.ts:101`). |
461
- | `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
462
- | `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
463
- | `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
464
- | `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
465
- | `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
466
- | `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
467
- | `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
468
- | `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:101`). |
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:618`). |
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: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`). |
469
476
 
470
477
  ### Situation 1 — un utilisateur active la 2FA sur son compte
471
478
 
@@ -491,8 +498,24 @@ Le second facteur validé au login ne dit rien de **qui est devant l'écran dix
491
498
  (poste laissé ouvert, session volée). Pour une suppression de compte ou une rotation de clés, on
492
499
  redemande le code : c'est le _sudo mode_.
493
500
 
494
- Nodefony fournit le step-up **de login** ; la re-vérification en cours de session, elle, se compose
495
- 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`) :
496
519
 
497
520
  ```typescript
498
521
  import { controller, Controller, Post } from "@nodefony/framework";
@@ -542,9 +565,22 @@ throttle bornent déjà les tentatives bien avant l'espace des codes.
542
565
  ## L'entité de persistance — un secret par utilisateur
543
566
 
544
567
  Le modèle est volontairement minimal : **clé naturelle = `userId`**, `save` est un upsert
545
- (`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 —
546
569
  tout accès passe par la clé primaire.
547
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
+
548
584
  Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts:36`), déclinée par
549
585
  dialecte via le colKit :
550
586
 
@@ -631,7 +667,7 @@ Ce dernier point est une garantie **de contrat**, pas une redaction faite à l'a
631
667
  soit le backend, ces champs ne peuvent pas remonter par ce chemin, même si un appelant les demandait
632
668
  (`toTotpEnrollment()`, `MemoryTotpSecretStore.ts:19`). C'est ce qu'exerce le banc de contrat partagé.
633
669
 
634
- `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
635
671
  ligne.
636
672
 
637
673
  ## 🧰 API publique
@@ -645,12 +681,12 @@ Tout est exporté depuis `@nodefony/security` — signatures complètes dans `.a
645
681
  | `isEnabled()` (`totp.ts:254`) | 2FA opérationnel (activé en config **et** boot réussi). |
646
682
  | `beginEnrollment()` (`totp.ts:259`) | Démarre l'enrôlement → secret + URI `otpauth://`, 1×. |
647
683
  | `confirmEnrollment()` (`totp.ts:264`) | Confirme par un 1ᵉʳ code → active + codes de récupération. |
648
- | `verifyLogin()` (`totp.ts:269`) | Vérifie un code TOTP **ou** de récupération. Ne lève jamais. |
649
- | `disable()` (`totp.ts:274`) | Retire secret et codes. |
650
- | `status()` (`totp.ts:279`) | `{ enabled, pending, recoveryCodesRemaining }`. |
651
- | `isEnabledFor()` (`totp.ts:306`) | Raccourci du flux de login (`false` si le 2FA est inerte). |
652
- | `listPage()` (`totp.ts:290`) | Page d'enrôlements (data plane admin). |
653
- | `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. |
654
690
 
655
691
  **Les opérations pures**, si tu veux le 2FA **sans** le service (test, script, autre transport) —
656
692
  elles prennent leurs dépendances en argument : `beginTotpEnrollment()`, `confirmTotpEnrollment()`,
@@ -688,19 +724,19 @@ vérifie que ta projection n'expose ni secret ni condensat.
688
724
 
689
725
  ## 📜 Normes appliquées
690
726
 
691
- | Domaine | Norme | Ancrage dans le code |
692
- | --------------------------- | ------------------------ | ------------------------------------------------------ |
693
- | TOTP (algorithme) | RFC 6238 §4 | `totpCode()` (`totpCrypto.ts:174`) |
694
- | HOTP + troncature dynamique | RFC 4226 §5.3 | `hotp()` (`totpCrypto.ts:129`), masque `0x7f` (`:141`) |
695
- | Taille du secret (≥ 128 b) | RFC 4226 R6 | `TOTP_DEFAULTS.secretBytes` = 20 (`totpCrypto.ts:33`) |
696
- | Fenêtre de dérive | RFC 6238 §5.2 | `verifyTotp()` (`totpCrypto.ts:207`) |
697
- | Anti-rejeu du code | RFC 6238 §5.2 | garde `lastUsedStep` (`totpOperations.ts:173`) |
698
- | Encodage du secret | RFC 4648 (base32) | `base32Encode()` (`totpCrypto.ts:58`) |
699
- | Dérivation de clé | RFC 5869 (HKDF) | `deriveKey()` (`secretCipher.ts:54`) |
700
- | Nonce GCM 96 bits | NIST SP 800-38D §5.2.1.1 | `IV_BYTES` (`secretCipher.ts:30`) |
701
- | Codes de secours | NIST SP 800-63B §5.1.2 | `generateRecoveryCodes()` (`totpCrypto.ts:330`) |
702
- | Backoff des tentatives | NIST SP 800-63B | `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) |
703
- | 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`) |
704
740
 
705
741
  Les **vecteurs de test de la RFC 6238 (Appendix B)** sont rejoués en test sur les trois fonctions de
706
742
  hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
@@ -709,7 +745,7 @@ hachage — c'est la preuve d'interopérabilité, pas une auto-évaluation.
709
745
 
710
746
  Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas.
711
747
 
712
- - **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`)
713
749
  résout le service **une seule fois** puis met le résultat en cache. Service absent ⇒ `null` ⇒
714
750
  **zéro accès au store**, zéro allocation par login.
715
751
  - **Aucun coût par requête** : le TOTP n'est pas un authenticator du firewall, il ne s'exécute donc
@@ -734,37 +770,43 @@ Le 2FA est un chemin **froid** : il ne coûte rien tant qu'on ne se connecte pas
734
770
 
735
771
  Le data plane admin correspondant, gardé par `ROLE_NODEFONY_ADMIN` :
736
772
 
737
- - `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`).
738
775
  Réponse **honnête** si le 2FA est désactivé : `{ enabled: false, items: [] }`, jamais une erreur —
739
776
  la console doit pouvoir afficher « 2FA désactivé ».
740
- - `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`).
741
778
  - `POST /nodefony/security/api/users/{id}/totp/disable` — reset admin, **audité**
742
- (`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.
743
783
 
744
784
  L'admin peut **désactiver**, jamais **activer** pour autrui : le secret se scanne sur l'appareil de
745
785
  l'utilisateur, lui seul peut l'armer.
746
786
 
747
- 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`),
748
788
  `login.success` avec `reason: "totp"` ou `"recovery"` (`authFlow.ts:232`), `login.failure` avec
749
- `reason: "mfa_invalid"` (`authFlow.ts:291`), et `user.totp_disabled` côté admin
750
- (`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.
751
793
 
752
794
  ## ⚠️ Pièges (symptôme → cause → correction)
753
795
 
754
- | Symptôme | Cause (dans le code) | Correction |
755
- | ------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------------------ |
756
- | 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` |
757
- | 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 |
758
- | Secrets perdus à chaque redémarrage | Store résolu en `memory` (aucun adapter durable chargé) | Charger `@nodefony/drizzle` ou déclarer `NF_DATABASE_URL` |
759
- | 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 |
760
- | Le QR est scanné mais aucun code ne passe | `digits`/`algorithm` non standard, ignorés par l'app | Rester en `SHA1` / 6 chiffres |
761
- | `202` au login au lieu de `200` | Comportement **attendu** : second facteur requis | Enchaîner sur `POST …/auth/login/totp` |
762
- | `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 |
763
- | `429` pendant la saisie du code | Throttle NIST dans `AuthFlow.completeMfaLogin()` (`authFlow.ts:257`) | Respecter `Retry-After` — attendu sous attaque |
764
- | `503 2FA unavailable` sur `…/totp/*` | Service absent ou `isEnabled()` faux (`TotpController.ts:21`) | Vérifier `totp.enabled` + la clé + les logs de boot |
765
- | Utilisateur bloqué, plus aucun code | Codes de récupération épuisés | Reset admin via `…/users/{id}/totp/disable`, puis ré-enrôlement |
766
- | Même code accepté deux fois | Impossible — anti-rejeu `lastUsedStep` (`totpOperations.ts:173`) | — |
767
- | 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 |
768
810
 
769
811
  ## 🧪 Tests & couverture
770
812