@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
@@ -0,0 +1,47 @@
1
+ /** Vue minimale du contexte : seul le scheme EFFECTIF compte. */
2
+ export interface ICredentialTransportContext {
3
+ /**
4
+ * Scheme vu par le client — `https` derrière un proxy de confiance qui
5
+ * termine TLS (`trustProxy` + `X-Forwarded-Proto`), jamais le transport brut.
6
+ */
7
+ readonly scheme?: string;
8
+ }
9
+ /** Réglages de la politique, résolus UNE fois au boot du firewall. */
10
+ export interface ICredentialTransportPolicyOptions {
11
+ /** `true` = un secret reçu hors TLS est refusé (production, sans échappement). */
12
+ readonly enforce: boolean;
13
+ /** Journal du premier refus (WARNING) — absent = silencieux. */
14
+ readonly log?: (message: string) => void;
15
+ }
16
+ /**
17
+ * Dit si un secret de connexion se refuse : en production, sans échappement
18
+ * explicite. Seule source de la décision — le firewall la compose au boot.
19
+ *
20
+ * @param environment - environnement normalisé du kernel.
21
+ * @param allowInsecureCredentials - échappement `security.allowInsecureCredentials`.
22
+ */
23
+ export declare function shouldEnforceCredentialTransport(environment: string | undefined, allowInsecureCredentials: boolean): boolean;
24
+ /**
25
+ * Refuse, avant toute vérification, un mot de passe ou un code de second
26
+ * facteur reçu en clair. Une instance partagée au conteneur (`credentialTransport`)
27
+ * sert TOUTES les portes qui reçoivent un secret : formulaire de session, second
28
+ * facteur, Basic, émission de jeton — une porte oubliée serait le canal clair.
29
+ *
30
+ * La décision se prend sur le scheme EFFECTIF (`context.scheme`) : derrière un
31
+ * proxy qui termine TLS et déclaré de confiance, il vaut `https` alors que le
32
+ * pod écoute en HTTP. C'est le cas de la plupart des déploiements ; c'est donc
33
+ * `trustProxy` que nomme le journal, pas « passez en HTTPS ».
34
+ */
35
+ export declare class CredentialTransportPolicy {
36
+ #private;
37
+ readonly enforce: boolean;
38
+ constructor(options: ICredentialTransportPolicyOptions);
39
+ /**
40
+ * Laisse passer un secret reçu sur TLS, ou n'importe lequel hors production.
41
+ *
42
+ * @param context - contexte de la requête qui porte le secret.
43
+ * @param door - porte qui le reçoit, nommée dans le journal.
44
+ * @throws InsecureTransportError (403) — secret reçu en clair en production.
45
+ */
46
+ assert(context: ICredentialTransportContext, door: string): void;
47
+ }
package/docs/api-keys.md CHANGED
@@ -137,7 +137,7 @@ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
137
137
 
138
138
  ### 1. Déclarer la zone machine et les règles d'émission
139
139
 
140
- Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
140
+ Dans une app générée par `nodefony create app`, tout se déclare dans la config du module — `nodefony/config/security.ts`, importé par `nodefony.config.ts`. Une zone
141
141
  dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
142
142
 
143
143
  ```typescript
@@ -239,7 +239,7 @@ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient
239
239
 
240
240
  | Morceau | Taille | Secret ? | À quoi ça sert |
241
241
  | -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
242
- | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:736`) |
242
+ | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:823`) |
243
243
  | `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
244
244
  | `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
245
245
  | `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
@@ -330,8 +330,8 @@ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:205`).
330
330
 
331
331
  L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
332
332
  lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
333
- (`authenticatorRegistry.ts:137`), qui lit `prefix` et `lastUsedThrottleS`
334
- (`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
333
+ (`authenticatorRegistry.ts:52`), qui lit `prefix` et `lastUsedThrottleS`
334
+ (`authenticatorRegistry.ts:166`). Conséquence pratique : changer `apiKeys.prefix` change **à la
335
335
  fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
336
336
 
337
337
  ## Quatre parcours vécus
@@ -376,7 +376,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
376
376
  4. **Puis** révoquer la v1.
377
377
 
378
378
  Ce qui rend l'étape 3 fiable : `lastUsedAt` est écrit de façon **throttlée**, pas à chaque requête —
379
- la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:752`). Attends donc une minute
379
+ la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:839`). Attends donc une minute
380
380
  avant de conclure qu'une clé « ne sert plus ».
381
381
 
382
382
  ### Révoquer une clé qui a fuité
@@ -419,7 +419,7 @@ Trois sources, et il faut connaître les limites de chacune :
419
419
  /nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
420
420
  (`apiKeys.ts:209`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
421
421
  `order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
422
- (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
422
+ (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:122`).
423
423
 
424
424
  Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
425
425
  (`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
@@ -446,17 +446,17 @@ applicatif qu'il faut alimenter, pas le store de jetons.
446
446
 
447
447
  ## ⚙️ Configuration
448
448
 
449
- Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:733`), branché à la racine de la config du
450
- module (`config.ts:733`). Toutes les valeurs ci-dessous sont les **défauts réels**.
449
+ Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:820`), branché à la racine de la config du
450
+ module (`config.ts:739`). Toutes les valeurs ci-dessous sont les **défauts réels**.
451
451
 
452
452
  | Option | Type | Défaut | Effet |
453
453
  | ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
454
- | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:735`) |
455
- | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:736`) |
456
- | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:745`) |
457
- | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:752`) |
458
- | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:761`) |
459
- | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:770`) |
454
+ | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:811`) |
455
+ | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:823`) |
456
+ | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:832`) |
457
+ | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:839`) |
458
+ | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:848`) |
459
+ | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:857`) |
460
460
 
461
461
  Deux réglages méritent une décision consciente :
462
462
 
@@ -589,7 +589,7 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
589
589
  | Domaine | Norme | Ancrage |
590
590
  | --------------------------------- | -------------------------------------- | ------------------------------------------------------ |
591
591
  | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
592
- | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
592
+ | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 9110 §11 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
593
593
  | Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
594
594
  | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
595
595
  | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
@@ -634,7 +634,7 @@ deux portées dans une seule page :
634
634
  - **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
635
635
  console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
636
636
  `studio/frontend/src/routes/apikeys/apiKeysModel.ts:99` ; handler `IApiKeysStatus`,
637
- `SecurityAdminApi.ts:54`).
637
+ `SecurityAdminApi.ts:61`).
638
638
 
639
639
  Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
640
640
  pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
@@ -650,7 +650,7 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
650
650
  | Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
651
651
  | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
652
652
  | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
653
- | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
653
+ | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:165`) | Garder le `prefix` STABLE après la première émission |
654
654
  | Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
655
655
  | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
656
656
  | 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts:139-142`) | Attendu — passer par l'endpoint d'administration |
@@ -679,7 +679,7 @@ sont dans le banc d'intégration, sur le fil — c'est plus fort, mais elles ne
679
679
  serveur) ; pas de test de charge ni de mesure mémoire propre à la vérification de clé.
680
680
 
681
681
  Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — et un skip compte comme
682
- vert : lire le bloc gates (`vitest.gates.ts`, affiché en fin de run) avant de conclure. Skills
682
+ vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure. Skills
683
683
  utiles : `nodefony-security-review` (matrice d'attaque), `nodefony-load-test` (charge).
684
684
  Couverture : `npm run coverage` dans `@nodefony/security`.
685
685
 
package/docs/audit.md CHANGED
@@ -135,7 +135,8 @@ lourds s'enregistrent depuis **leur** module. Un
135
135
  ## 🚀 Démarrage rapide
136
136
 
137
137
  Dans une app générée par `nodefony create app`, l'audit est **déjà actif** (`enabled: true` par
138
- défaut, `config.ts:729`) sur un store mémoire. Voici le parcours complet : configurer, émettre,
138
+ défaut, `config.ts:757`) sur le store que résout `auto` : la base SQLite locale de l'application
139
+ générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
139
140
  relire.
140
141
 
141
142
  ### 1. Choisir où le journal est écrit
@@ -265,7 +266,7 @@ Les critères se **combinent en ET** et sont traduits par `parseAuditQuery()`
265
266
  | `requestId` | tous les événements d'**une seule requête** | `requestId=req-7c1e` |
266
267
  | `since` | borne basse d'horodatage, epoch ms **inclus** | `since=1763503200000` |
267
268
  | `until` | borne haute d'horodatage, epoch ms **inclus** | `until=1763524800000` |
268
- | `limit` | taille de page (défaut 100, **plafonné à 500**) | `limit=200` |
269
+ | `limit` | taille de page (défaut 100, **plafonné à 200**) | `limit=200` |
269
270
  | `cursor` | le `nextCursor` de la page précédente | `cursor=1763548800123:9f-1` |
270
271
 
271
272
  ```bash
@@ -357,7 +358,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
357
358
  <!-- prettier-ignore -->
358
359
  | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
359
360
  | --- | --- | --- |
360
- | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
361
+ | `auth` | authentification, chaîne du firewall | `auth.failure` · `auth.unverifiable` · `auth.throttled` · `auth.denied` · `login.success` · `login.failure` · `login.throttled` · `login.mfa_required` · `user.totp_enabled` · `user.totp_disabled` · `user.password_change_self` (par `@nodefony/user`) |
361
362
  | `authz` | autorisation (voters, `@IsGranted`), administration des comptes | `access.denied` · `user.created` · `user.updated` · `user.password_changed` · `user.deleted` (ces quatre-là par `@nodefony/user`) |
362
363
  | `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
363
364
  | `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
@@ -382,15 +383,15 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
382
383
  ### `auth` — la chaîne d'authentification
383
384
 
384
385
  Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
385
- (`firewall.ts:907`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
386
+ (`firewall.ts:989`), qui enrichit l'événement de la provenance et pose la **zone** en `resource` :
386
387
 
387
- - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
388
- - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:807`) ;
388
+ - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:819`) ;
389
+ - `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:808`) ;
389
390
  - `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
390
- (`firewall.ts:795`) ;
391
+ (`firewall.ts:813`) ;
391
392
  - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
392
- (`firewall.ts:822`) ;
393
- - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
393
+ (`firewall.ts:823`) ;
394
+ - `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:851`).
394
395
 
395
396
  Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
396
397
  `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
@@ -415,7 +416,7 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
415
416
 
416
417
  ### `ws` — le verrou de frame WebSocket
417
418
 
418
- `frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts:302`) quand une
419
+ `frame.denied` est émis par le rapporteur branché sur le verrou de frame (`firewall.ts:308`) quand une
419
420
  socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
420
421
  porte ni IP ni `requestId` — acteur et cible suffisent.
421
422
 
@@ -465,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
465
466
 
466
467
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
467
468
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
468
- (`firewall.ts:907`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:358`).
469
+ (`firewall.ts:989`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:379`).
469
470
  Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
470
471
 
471
472
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
@@ -490,13 +491,13 @@ jamais faire tomber ce qu'on supervise.
490
491
 
491
492
  ## ⚙️ Configuration
492
493
 
493
- Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
494
- (`config.ts:1158`).
494
+ Table dérivée du schéma Zod `auditSchema` (`config.ts:970`), rattaché à la racine sous la clé `audit`
495
+ (`config.ts:1482`).
495
496
 
496
497
  | Option | Type | Défaut | Effet |
497
498
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
498
499
  | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
499
- | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle` |
500
+ | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
500
501
  | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
501
502
  | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
502
503
  | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
@@ -514,7 +515,8 @@ enregistrés (`auditService.ts:96`, logique `resolveAutoStore()` dans `infra.ts:
514
515
 
515
516
  1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
516
517
  2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
517
- 3. sinon, repli **annoncé** sur `memory` — la décision est loggée en INFO, jamais silencieuse.
518
+ 3. sinon, un backend local persistant réellement chargé (`drizzle` = SQLite, puis `mongoose`) ;
519
+ 4. sinon, repli **annoncé** sur `memory` (volatil) — la décision est loggée en INFO, jamais silencieuse.
518
520
 
519
521
  La résolution finale est publiée au kernel (`auditService.ts:145`), ce qui alimente l'écran des stores
520
522
  de Studio : configuré, résolu, disponible, motif, emplacement physique.
@@ -550,7 +552,7 @@ Quatre pièces, quatre responsabilités :
550
552
  **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
551
553
  d'audit.
552
554
  - **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
553
- (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:387`) et donc
555
+ (`auditBridge.ts:8`), enregistré comme canal **système** par le firewall (`firewall.ts:388`) et donc
554
556
  gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
555
557
 
556
558
  ### La lecture paginée — pourquoi un curseur et pas un décalage
@@ -663,14 +665,14 @@ Deux autres propriétés de sécurité valent d'être connues :
663
665
 
664
666
  | Exigence | Norme | Comment le code s'y conforme |
665
667
  | ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
666
- | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:693`) |
668
+ | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts:711`) |
667
669
  | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
668
670
  | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
669
671
  | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
670
672
  | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
671
- | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:912`) |
673
+ | Rétention bornée / minimisation | RGPD art. 5.1.e | purge par âge pilotée par `retentionDays` (`config.ts:999`) |
672
674
  | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
673
- | Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:773`) |
675
+ | Backoff de login journalisé | NIST SP 800-63B | `auth.throttled` avec `reason: "throttled"` (`firewall.ts:947`) |
674
676
 
675
677
  ## 📡 Observabilité — Studio
676
678
 
@@ -702,10 +704,10 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
702
704
  | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts:37`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
703
705
  | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
704
706
  | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts:115`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
705
- | `limit=5000` ne rend que 500 événements | Plafond du store (`MemoryAuditStore.ts:12`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
707
+ | `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts:77`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
706
708
  | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
707
- | Filtre `?category=authen` sans effet | Catégorie inconnue **ignorée** (`SecurityAdminApi.ts:191`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
708
- | Le paramètre `q` ne filtre rien | Non appliqué sur ce journal (`IAuditStore.ts:20`) | Filtrer par `category`/`actor`/`action`/`requestId` |
709
+ | `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
710
+ | Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
709
711
  | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts:55`) | Retirer le rôle admin, ou ne pas exposer le canal |
710
712
  | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts:199`) | Store à haute disponibilité si la conformité l'exige |
711
713
  | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
@@ -49,7 +49,7 @@ flowchart TD
49
49
  S --> CTRL["→ autorisation → contrôleur"]
50
50
  ```
51
51
 
52
- C'est `Firewall.#authenticate()` (`firewall.ts:1128`) qui déroule ce cycle pour chaque maillon de la
52
+ C'est `Firewall.#authenticate()` (`firewall.ts:1236`) qui déroule ce cycle pour chaque maillon de la
53
53
  zone, dans l'ordre déclaré. Le succès pose l'identité dans l'ALS ; l'échec remonte au firewall qui
54
54
  pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la réponse.
55
55
 
@@ -67,7 +67,7 @@ pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la r
67
67
  | EdDSA | Algorithme de signature asymétrique (Ed25519) — le seul accepté par le vérificateur JWT. |
68
68
  | CRC | Somme de contrôle embarquée dans une clé API — filtre les valeurs malformées avant la base. |
69
69
  | ALS | _AsyncLocalStorage_ : le contexte ambiant de la requête où le firewall pose `user` + `token`. |
70
- | Challenge | En-tête `WWW-Authenticate` renvoyé avec un 401 (RFC 7235) indiquant comment s'authentifier. |
70
+ | Challenge | En-tête `WWW-Authenticate` renvoyé avec un 401 (RFC 9110 §11) indiquant comment s'authentifier. |
71
71
  | Zero Trust | Sur une zone protégée, **aucune preuve valide ⇒ 401** ; l'anonymat n'est accepté que s'il est déclaré. |
72
72
 
73
73
  ## Qu'est-ce qu'un authenticator — et quelle faille il ferme
@@ -100,23 +100,23 @@ totalement agnostique de la stratégie :
100
100
  | `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
101
101
  | `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
102
102
  | `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
103
- | `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
103
+ | `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 9110 §11) des 401 de la zone. |
104
104
 
105
105
  ### Le registre pluggable
106
106
 
107
107
  Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
108
- (`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
108
+ (`firewall.ts:468`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
109
109
  un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
110
110
 
111
111
  - Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
112
112
  s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
113
113
  (`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
114
114
  - Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
115
- lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:279`).
115
+ lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:301`).
116
116
  - La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
117
117
  `tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
118
118
  - Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
119
- (`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
119
+ (`firewall.ts:634`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
120
120
  faute de frappe.
121
121
 
122
122
  ## 🚀 Démarrage rapide
@@ -124,7 +124,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
124
124
  ### Une zone, trois preuves — la même API pour le web et les machines
125
125
 
126
126
  Dans une app `nodefony create app`, on déclare quelles preuves une zone accepte — un **objet par
127
- nom**, validé Zod au boot (`areas: z.record(...)`, `config.ts:902`) :
127
+ nom**, validé Zod au boot (`areas: z.record(...)`, `config.ts:1358`) :
128
128
 
129
129
  ```typescript
130
130
  // nodefony.config.ts (extrait)
@@ -144,7 +144,7 @@ use("@nodefony/security", {
144
144
  > [!IMPORTANT]
145
145
  > **Le login est FOURNI** : `POST /nodefony/security/api/auth/login` (body `{ username, password }`
146
146
  > → `Set-Cookie` de session, ID régénéré anti-fixation), avec `logout` et `me`
147
- > (`SessionAuthController.ts:37-39`). Pas de LoginController à écrire — tes routes ne font que
147
+ > (`SessionAuthController.ts:131-148`). Pas de LoginController à écrire — tes routes ne font que
148
148
  > consommer l'identité.
149
149
 
150
150
  ### Ce que TU écris : le controller qui consomme l'identité
@@ -185,7 +185,7 @@ curl -si http://localhost:5151/api/v1/whoami | grep -E "^(HTTP|WWW)"
185
185
 
186
186
  # 2) Web — login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
187
187
  curl -si -c /tmp/jar -H 'Content-Type: application/json' \
188
- -d '{"username":"admin","password":"admin"}' \
188
+ -d '{"username":"admin","password":"nodefony-dev-42"}' \
189
189
  http://localhost:5151/nodefony/security/api/auth/login | head -1
190
190
  # HTTP/1.1 200 OK
191
191
 
@@ -203,7 +203,7 @@ Requête par requête, qui répond :
203
203
  | le cookie de session | `session` | identifié — rôles frais re-résolus en base |
204
204
  | `Authorization: Bearer eyJ…` (a.b.c) | `jwt` | identifié — signature EdDSA + claims vérifiés |
205
205
  | `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
206
- | rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1200`) |
206
+ | rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1267`) |
207
207
 
208
208
  ## 🔐 Les authenticators intégrés
209
209
 
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
228
228
  si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
229
229
  « canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
230
230
  - Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
231
- token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
231
+ token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:929`).
232
232
  - **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
233
233
  défaut.
234
234
 
@@ -236,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
236
236
 
237
237
  Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
238
238
  case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
239
- le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
239
+ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:81`).
240
240
 
241
241
  - **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
242
242
  leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
@@ -246,9 +246,13 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
246
246
  (`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
247
247
  throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
248
248
  (`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
249
- (`firewall.ts:780`).
249
+ (`firewall.ts:841`).
250
250
  - **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
251
251
  attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
252
+ - **En production, un mot de passe reçu en clair est refusé avant d'être décodé** :
253
+ `createToken()` interroge la politique `credentialTransport`
254
+ (`UserPasswordAuthenticator.ts:82`) → **403**, sans challenge — un `Basic` ferait renvoyer le
255
+ mot de passe par le même canal (`firewall.ts:876`). Voir [Mot de passe en clair](#mot-de-passe-en-clair--refusé-en-production).
252
256
  - Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
253
257
  - **Piège** : le login par formulaire (JSON) n'est **pas** ici — c'est le BFF
254
258
  (`/nodefony/security/api/auth/login`). Basic sert l'outillage (scripts, CLI).
@@ -335,28 +339,28 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
335
339
  - **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
336
340
  (`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
337
341
  un coût évitable sur le différenciateur temps réel.
338
- - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:279`) pour toute zone
339
- protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:289`).
342
+ - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:301`) pour toute zone
343
+ protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:290`).
340
344
  - **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
341
345
  on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
342
346
  - **Filet Zero Trust** : il câble un revalidateur appelé avant chaque action data plane
343
347
  (`FirewallRealtimeAuthenticator.ts:105-107`) — la socket peut survivre à l'identité qui l'a
344
348
  ouverte (logout, changement de compte, `jti` denylisté). En mode session,
345
349
  `buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
346
- lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:227`). En mode jeton porteur, la
350
+ lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:260`). En mode jeton porteur, la
347
351
  preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
348
- (`FirewallRealtimeAuthenticator.ts:179`).
352
+ (`FirewallRealtimeAuthenticator.ts:204`).
349
353
 
350
354
  > [!NOTE]
351
355
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
352
- > frames lisent un cache O(1)) → une révocation prend effet **à la reconnexion**, pas à la frame
356
+ > frames lisent un cache O(1)) → une révocation prend effet **en une fenêtre** (tick du hub, et devant chaque `api.request`), pas à la frame
353
357
  > suivante. C'est l'état de l'art (Socket.IO/Phoenix figent aussi au handshake) ; la révocation
354
358
  > immédiate forte passe par le JWT + un canal « token révoqué ».
355
359
 
356
360
  ## ⚙️ Composer une zone — ordre, mode, cohabitation
357
361
 
358
362
  La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
359
- (`firewall.ts:1128`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
363
+ (`firewall.ts:1236`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
360
364
 
361
365
  ### Situation 1 — humains ET machines sur la même API (`first`)
362
366
 
@@ -365,12 +369,12 @@ Deux preuves différentes, mêmes routes — c'est la config du Démarrage rapid
365
369
  de lecture :
366
370
 
367
371
  - un maillon dont `supports()` est faux est simplement **sauté** en mode `first`
368
- (`firewall.ts:1114`) ;
372
+ (`firewall.ts:1211`) ;
369
373
  - un credential **présenté mais invalide échoue immédiatement** — l'échec d'`authenticate()`
370
- remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1128`). Une clé
374
+ remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1236`). Une clé
371
375
  API révoquée donne un 401 direct, même si un autre maillon aurait pu réussir.
372
376
  - aucune preuve présentée sur toute la chaîne → `handleSecurity()` lève l'`AuthenticationError`
373
- Zero Trust (`firewall.ts:754`).
377
+ Zero Trust (`firewall.ts:825`).
374
378
 
375
379
  ### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
376
380
 
@@ -389,7 +393,7 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
389
393
  ### Situation 3 — empiler les preuves (`all`)
390
394
 
391
395
  En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
392
- (`firewall.ts:1144-1149`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1198-1199`) —
396
+ (`firewall.ts:1150-1155`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1204-1205`) —
393
397
  utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
394
398
  re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
395
399
 
@@ -409,7 +413,35 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
409
413
  Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
410
414
  invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
411
415
  client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
412
- en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
416
+ en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1345`).
417
+
418
+ ## Mot de passe en clair — refusé en production
419
+
420
+ Un mot de passe, ou un code de second facteur, qui arrive sur une requête HTTP en clair a déjà
421
+ traversé le réseau en lisible : le vérifier ne le protège plus. En production, toutes les
422
+ portes qui reçoivent un secret le refusent donc **avant toute vérification** — formulaire de
423
+ session, second facteur, Basic, émission de jeton (`/nodefony/security/api/token`), et les
424
+ endpoints d'administration marqués `credentials: true` (création d'un compte, changement de mot
425
+ de passe — cf la doc du plan d'administration de `@nodefony/framework`) :
426
+
427
+ - **403**, message constant `Credentials must be sent over HTTPS`
428
+ (`INSECURE_TRANSPORT_MESSAGE`, exporté par `nodefony`) ; la page de connexion le reconnaît et
429
+ n'affiche pas « mot de passe incorrect ».
430
+ - Le refus précède le throttle et le verifier : aucun hash calculé, aucun échec compté. Il est
431
+ audité (`reason: "insecure_transport"`).
432
+ - Le **premier** refus du process part en WARNING, les suivants non : un pod mal configuré
433
+ refuserait chaque connexion (`#warned`, `CredentialTransportPolicy.ts:69`).
434
+ - **La décision se prend sur le scheme EFFECTIF** (`context.scheme`), pas sur le transport du
435
+ pod. Derrière un proxy qui termine TLS — le cas de la plupart des déploiements — il suffit de
436
+ déclarer ce proxy de confiance : `trustProxy` dans la config de `@nodefony/http`, pour que
437
+ `X-Forwarded-Proto: https` soit lu.
438
+ - Une seule décision, posée au conteneur par le firewall au boot (`credentialTransport`,
439
+ `firewall.ts:438`) : production ⇒ refus, sauf échappement **écrit**
440
+ `security.allowInsecureCredentials: true` (`CredentialTransportPolicy.ts:31`). Hors
441
+ production, rien n'est refusé.
442
+
443
+ Pourquoi un 403 et pas un 401 : aucune preuve n'a été jugée. Le client ne corrigera rien dans
444
+ son secret — c'est le canal qu'il doit changer.
413
445
 
414
446
  ## 🧩 Ajouter un authenticator maison
415
447
 
@@ -435,12 +467,12 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
435
467
  <!-- prettier-ignore -->
436
468
  | Domaine | Norme | Ancrage |
437
469
  | --- | --- | --- |
438
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
470
+ | Challenge d'auth (401) | RFC 9110 §11 | `Firewall.#setChallenge()` (`firewall.ts:1345`) |
439
471
  | Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
440
472
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
441
473
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
442
474
  | Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
443
- | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:780`) |
475
+ | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:841`) |
444
476
  | Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
445
477
 
446
478
  ## ⚡ Performance & mémoire
@@ -448,7 +480,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
448
480
  - `supports()` est un test **bon marché** (en-tête + regex) — et n'est payé que sur zone protégée
449
481
  (le chemin chaud/froid vit dans le [firewall](./firewall.md)).
450
482
  - Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
451
- (`UserPasswordAuthenticator.ts:105`) ; keystore, `tokenStore` et `users` sont résolus du
483
+ (`UserPasswordAuthenticator.ts:59`) ; keystore, `tokenStore` et `users` sont résolus du
452
484
  container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
453
485
  - `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
454
486
  (`AnonymousToken.ts:9`).
@@ -463,12 +495,13 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
463
495
  <!-- prettier-ignore -->
464
496
  | Symptôme | Cause (dans le code) | Correction |
465
497
  | --- | --- | --- |
466
- | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
498
+ | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:929`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
467
499
  | 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
468
500
  | JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
469
501
  | Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
470
502
  | Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
471
- | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:613`) | Configurer `rateLimit` pour poser le throttler |
503
+ | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:646`) | Configurer `rateLimit` pour poser le throttler |
504
+ | 403 `Credentials must be sent over HTTPS` sur TOUTES les connexions en production | Pod en HTTP derrière un proxy TLS non déclaré de confiance : `context.scheme` reste `http` (`CredentialTransportPolicy.ts:31`) | Déclarer le proxy dans `trustProxy` (`@nodefony/http`) ; `allowInsecureCredentials` seulement si le clair est un choix assumé |
472
505
 
473
506
  ## 🧪 Tests & couverture
474
507