@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/api-keys.md CHANGED
@@ -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,7 @@ 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 le store que résout `auto` : la base SQLite locale de l'application
138
+ défaut, `config.ts:757`) sur le store que résout `auto` : la base SQLite locale de l'application
139
139
  générée, la mémoire seulement si aucun ORM n'est chargé. Voici le parcours complet : configurer, émettre,
140
140
  relire.
141
141
 
@@ -358,7 +358,7 @@ l'axe de filtrage principal de la console. L'**action** est une chaîne **ouvert
358
358
  <!-- prettier-ignore -->
359
359
  | Catégorie | Ce qu'elle trace | Actions réellement émises par le framework |
360
360
  | --- | --- | --- |
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_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`) |
362
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`) |
363
363
  | `token` | jetons longue durée et clés d'API | `token.issued` · `token.reuse_detected` · `apikey.created` · `apikey.revoked` |
364
364
  | `session` | cycle de vie de session | `logout` · `session.revoked` (révocation depuis l'administration, par `@nodefony/http`) |
@@ -383,15 +383,15 @@ Pour un auditeur, la colonne `denied` est celle des tentatives d'accès non auto
383
383
  ### `auth` — la chaîne d'authentification
384
384
 
385
385
  Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recordAuth()`
386
- (`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` :
387
387
 
388
- - `auth.throttled` — backoff NIST déclenché, réponse 429 (`firewall.ts:781`) ;
389
- - `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`) ;
390
390
  - `auth.unverifiable` — le vérificateur de jetons est indisponible : réponse 503, sans défi
391
- (`firewall.ts:795`) ;
391
+ (`firewall.ts:813`) ;
392
392
  - `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
393
- (`firewall.ts:822`) ;
394
- - `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`).
395
395
 
396
396
  Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
397
397
  `login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
@@ -416,7 +416,7 @@ Un seul événement, mais c'est le plus parlant : `access.denied`, émis par le
416
416
 
417
417
  ### `ws` — le verrou de frame WebSocket
418
418
 
419
- `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
420
420
  socket tente un `api.request` vers une zone protégée ou s'abonne à un canal interdit. La frame ne
421
421
  porte ni IP ni `requestId` — acteur et cible suffisent.
422
422
 
@@ -466,7 +466,7 @@ Quatre mécanismes, tous prouvés par les tests.
466
466
 
467
467
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
468
468
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
469
- (`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`).
470
470
  Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:333`).
471
471
 
472
472
  **2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
@@ -491,8 +491,8 @@ jamais faire tomber ce qu'on supervise.
491
491
 
492
492
  ## ⚙️ Configuration
493
493
 
494
- Table dérivée du schéma Zod `auditSchema` (`config.ts:883`), rattaché à la racine sous la clé `audit`
495
- (`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`).
496
496
 
497
497
  | Option | Type | Défaut | Effet |
498
498
  | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
@@ -552,7 +552,7 @@ Quatre pièces, quatre responsabilités :
552
552
  **`append` est la seule écriture** : ni `update`, ni `delete` ciblé. L'immuabilité EST la garantie
553
553
  d'audit.
554
554
  - **`createAuditBridge()`** (`auditBridge.ts:53`) — le pont vers le canal WS `nodefony:audit`
555
- (`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
556
556
  gardé par le plancher `security:` → `ROLE_NODEFONY_ADMIN` (`frameAuthorizer.ts:106`).
557
557
 
558
558
  ### La lecture paginée — pourquoi un curseur et pas un décalage
@@ -665,14 +665,14 @@ Deux autres propriétés de sécurité valent d'être connues :
665
665
 
666
666
  | Exigence | Norme | Comment le code s'y conforme |
667
667
  | ----------------------------------------- | --------------------------------- | --------------------------------------------------------------------------------- |
668
- | 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`) |
669
669
  | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts:130`) |
670
670
  | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts:49`) |
671
671
  | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts:61`) |
672
672
  | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts:48`) |
673
- | 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`) |
674
674
  | Détection de rejeu de jeton | RFC 9700 §4.14 | `token.reuse_detected` + coupure de famille (`tokenService.ts:585`) |
675
- | 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`) |
676
676
 
677
677
  ## 📡 Observabilité — Studio
678
678
 
@@ -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é
@@ -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,17 +339,17 @@ 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
@@ -356,7 +360,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
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
 
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
331
331
 
332
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
333
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
334
- (`Resolver.ts:878-885`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:896-903`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
335
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
336
336
  `@IsGranted("doc.edit", { subject: "id" })`.
337
337
 
@@ -356,29 +356,29 @@ se voit au premier test.
356
356
  ## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
357
357
 
358
358
  Les décorateurs n'écrivent **que des métadonnées** (0 import `@nodefony/security`, 0 cycle) ; le
359
- moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`) :
359
+ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:678-679`) :
360
360
 
361
361
  | Déclaration | Sémantique |
362
362
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
363
363
  | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
364
364
  | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
365
- | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
366
- | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
367
- | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:923`) |
365
+ | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1676`) |
366
+ | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1648`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:941`) |
368
368
  | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
369
- | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
370
- | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
369
+ | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:969`) |
370
+ | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1308`) |
371
371
 
372
372
  La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
373
- 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:489-491`). Le
373
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:507-509`). Le
374
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
375
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
376
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:853`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:871`).
377
377
 
378
378
  > [!IMPORTANT]
379
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
380
380
  > chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
381
- > (`Resolver.ts:678-688`). Une route gardée doit être couverte par une zone — voir
381
+ > (`Resolver.ts:683-693`). Une route gardée doit être couverte par une zone — voir
382
382
  > [firewall](./firewall.md).
383
383
 
384
384
  ## 🧑‍⚖️ Les voters intégrés — deux axes, un même jury
@@ -399,7 +399,7 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
399
399
  default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
400
400
  (`@IsGranted(["A","B"])`) possible.
401
401
  - La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
402
- (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:209`).
402
+ (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:210`).
403
403
  - Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
404
404
 
405
405
  #### Deux échelles de rôles — plateforme et organisation
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
511
511
 
512
512
  ## 🔌 HTTP et WebSocket — une garde, N transports
513
513
 
514
- - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:446`) est le point unique
514
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:450`) est le point unique
515
515
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
516
516
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
517
517
  `ws-isgranted-jwt`).
@@ -519,7 +519,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
519
519
  et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
520
520
  - **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
521
521
  hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
522
- (`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
522
+ (`firewall.ts:521`) — les rôles exigés par un canal héritent comme partout ailleurs.
523
523
 
524
524
  ## 📜 Normes appliquées
525
525
 
@@ -529,7 +529,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
529
529
  | Modèle de rôles | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()` |
530
530
  | Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
531
531
  | Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
532
- | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:678-688`) |
532
+ | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:683-693`) |
533
533
  | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
534
534
 
535
535
  > Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
@@ -541,7 +541,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
541
541
  ## ⚡ Performance & mémoire
542
542
 
543
543
  - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
544
- alloc (`Resolver.ts:486`) ; l'exigence est **figée une fois** par route et partagée entre
544
+ alloc (`Resolver.ts:490`) ; l'exigence est **figée une fois** par route et partagée entre
545
545
  requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
546
546
  - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
547
547
  instanciés **une seule fois** au boot (`authorization.ts:70`).
@@ -560,7 +560,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
560
560
  | Symptôme | Cause (dans le code) | Correction |
561
561
  | -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
562
562
  | Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
563
- | 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:678-688`) | Couvrir la route par une zone firewall |
563
+ | 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:683-693`) | Couvrir la route par une zone firewall |
564
564
  | Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas | Renvoyer `ABSTAIN` hors de son domaine |
565
565
  | Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé | Porter l'interdit en clause AND (empiler les `@IsGranted`) |
566
566
  | Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist) | Vérifier que le type n'est pas dans `NON_SCOPABLE_TOKEN_TYPES` |