@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
@@ -55,7 +55,7 @@ aucune écoute réseau (`security-token.ts:36`). C'est ce qui la rend utilisable
55
55
  ne tourne pas, en intégration continue, ou dans un conteneur d'amorçage.
56
56
 
57
57
  **Le jeton vise une porte, et une seule.** L'audience est celle de la porte visée — la porte MCP par
58
- défaut, `/nodefony/mcp` (`src/nodefony/src/mcp/protocol.ts:95`). Un jeton d'une autre audience est
58
+ défaut, `/nodefony/mcp` (`src/nodefony/src/mcp/protocol.ts:97`). Un jeton d'une autre audience est
59
59
  refusé, et c'est toute la raison d'être de la liaison d'audience
60
60
  ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707.html)).
61
61
 
@@ -125,19 +125,26 @@ Puis `npm run build` — le runtime lit le `dist`, pas la source.
125
125
 
126
126
  ### Les options, et ce que chacune décide
127
127
 
128
- | Option | Défaut | Ce qu'elle change |
129
- | ---------------------- | ----------------------------- | -------------------------------------------------------- |
130
- | `[identifier]` | `admin` | le compte porteur du jeton |
131
- | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
- | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** audience |
133
- | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
- | `-t, --ttl <minutes>` | celle de la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
- | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents |
136
- | `-j, --json` | — | sortie machine, sans invite |
128
+ | Option | Défaut | Ce qu'elle change |
129
+ | ---------------------- | -------------------------------------------- | -------------------------------------------------------- |
130
+ | `[identifier]` | `admin` | le compte porteur du jeton |
131
+ | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
+ | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** audience |
133
+ | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
+ | `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
+ | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents |
136
+ | `-j, --json` | — | sortie machine, sans invite |
137
137
 
138
138
  Le plafond de `--ttl` n'est pas décoratif : un jeton posé dans un fichier **est une clé**, et une
139
139
  clé se remplace (`security-token.ts:67-84`).
140
140
 
141
+ **Posé chez un agent, le jeton vit 8 h par défaut** (`security-token.ts:106-110`). L'en-tête d'un
142
+ agent est figé : rien ne le rafraîchit, et le défaut de la configuration (15 min, taillé pour un
143
+ client d'API qui renouvelle son jeton) produisait un 401 au bout d'un quart d'heure. Huit heures
144
+ couvrent une journée de travail et bornent une fuite à quelques heures ; `--ttl` explicite
145
+ l'emporte toujours, et le défaut global `jwt.accessTtlS` ne bouge pas. La commande affiche l'heure
146
+ d'**expiration** (`expires_at` en `--json`) : c'est elle qui explique le 401 du lendemain.
147
+
141
148
  ### Où `--write` pose la valeur
142
149
 
143
150
  La variable est `NF_MCP_TOKEN` (`src/nodefony/src/cli/aiMcpReport.ts:32`). La commande ne sert que
@@ -191,7 +198,7 @@ est décrit dans [tokens](tokens.md), pas ici.
191
198
  | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
192
199
  | `impossible d'émettre un jeton pour cette porte ici` | **en développement** : l'audience n'est pas déclarée à l'émetteur — c'est une liste blanche, pas un défaut ouvert | `use("@nodefony/security", { jwt: { audiences: ["<la porte>"] } })`, puis `npm run build` |
193
200
  | Le même message, mais l'environnement affiché n'est pas `development` | la porte MCP est servie par un module `policy: "dev"` : **elle n'existe pas** dans cet environnement | `NODE_ENV=development npx nodefony security:token`, ou viser une autre porte avec `--resource` |
194
- | Le jeton est émis, mais avec **moins** de scopes que demandé | l'émetteur retire ceux que ce porteur ne peut pas obtenir, et il le dit ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §3.3) | lire le champ `scopes` de la sortie — c'est celui qui fait foi (`security-token.ts:494`) |
201
+ | Le jeton est émis, mais avec **moins** de scopes que demandé | l'émetteur retire ceux que ce porteur ne peut pas obtenir, et il le dit ([RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749) §3.3) | lire le champ `scopes` de la sortie — c'est celui qui fait foi (`security-token.ts:460`) |
195
202
  | L'agent reçoit `401` alors que le jeton vient d'être posé | la valeur a été écrite dans un dossier que **cet** agent ne lit pas | `--agent <nom>` pour viser explicitement, ou l'`export` dans le shell qui lance l'agent |
196
203
  | `--write` ne fait rien et n'écrit aucun fichier | aucun agent n'est **constaté** dans ce projet | la commande liste alors les emplacements connus et donne la ligne d'`export` |
197
204
 
package/docs/tokens.md CHANGED
@@ -25,7 +25,7 @@ tags:
25
25
  ]
26
26
  version: "doc"
27
27
  status: stable
28
- updated: 2026-07-19
28
+ updated: 2026-10-05
29
29
  source: "src/packages/@nodefony/security/docs/tokens.md"
30
30
  ---
31
31
 
@@ -87,11 +87,11 @@ défaut » en prod.
87
87
  ## La vision Nodefony — un service propriétaire, des endpoints minces
88
88
 
89
89
  `TokenService` est **propriétaire** du store et du keystore : à `TokenService.#build()`
90
- (`tokenService.ts:101`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
- `tokenStore` au container (`tokenService.ts:175`) puis crée le keystore et pose `jwtKeystore`
92
- (`tokenService.ts:181-186`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
90
+ (`tokenService.ts:158`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
+ `tokenStore` au container (`tokenService.ts:219`) puis crée le keystore et pose `jwtKeystore`
92
+ (`tokenService.ts:225-231`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
93
93
  **gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
94
- `tokenService.ts:188-194`).
94
+ `tokenService.ts:233-239`).
95
95
 
96
96
  Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
97
97
  nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
@@ -142,8 +142,9 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
143
143
 
144
144
  > [!IMPORTANT]
145
- > Ces routes n'existent **que si** le service `tokenService` est présent — sinon 404, zéro surface
146
- > (`TokenAuthController.ts:46-48`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
145
+ > Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
146
+ > (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:188`) :
147
148
  > elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
148
149
  > authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
149
150
 
@@ -153,10 +154,12 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
153
154
  // nodefony.config.ts (extrait) — la zone API machine + la source de clé
154
155
  use("@nodefony/security", {
155
156
  jwt: {
156
- // dev/VPS : persiste la clé Ed25519 (sinon clé ÉPHÉMÈRE + warning au boot).
157
- // prod cloud : préférer keystore.keySetJson injecté depuis l'env (même clé
158
- // sur tous les pods) — voir la section keystore.
159
- keystore: { dir: "./config/jwt" },
157
+ // dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
158
+ // prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
159
+ keystore: {
160
+ keySetJson: process.env.NF_JWT_KEYSET,
161
+ dir: "var/keys",
162
+ },
160
163
  },
161
164
  areas: {
162
165
  // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
@@ -228,36 +231,36 @@ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController
228
231
 
229
232
  ### Émission (grant M2M/CLI)
230
233
 
231
- `issueForCredentials()` (`tokenService.ts:324`) vérifie l'identifiant/mot de passe via le
234
+ `issueForCredentials()` (`tokenService.ts:387`) vérifie l'identifiant/mot de passe via le
232
235
  service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
233
- (`tokenService.ts:353`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
234
- par `#auditGrant()` (`tokenService.ts:369-381`). Puis `issueTokens()` (`tokenService.ts:497`)
236
+ (`tokenService.ts:398`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
237
+ par `#auditGrant()` (`tokenService.ts:414-426`). Puis `issueTokens()` (`tokenService.ts:582`)
235
238
  produit :
236
239
 
237
240
  - un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
238
- `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:649-662`) ;
241
+ `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:694-707`) ;
239
242
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
240
243
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
241
- (`tokenService.ts:681-716`).
244
+ (`tokenService.ts:726-761`).
242
245
 
243
- La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`). Tout succès est audité
244
- `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:312-318`).
246
+ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:50-58`). Tout succès est audité
247
+ `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:357-363`).
245
248
 
246
249
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
247
250
 
248
- `refresh()` (`tokenService.ts:562`) est le cœur défensif, dans l'ordre :
251
+ `refresh()` (`tokenService.ts:647`) est le cœur défensif, dans l'ordre :
249
252
 
250
- 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
253
+ 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:653`).
251
254
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
252
- famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
253
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:711`).
255
+ famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:622-636`).
256
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts:793`).
254
257
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:770`).
258
+ `#resolveUserForRefresh()` (`tokenService.ts:852`).
256
259
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
257
- (`tokenService.ts:600`).
260
+ (`tokenService.ts:684`).
258
261
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
259
- `"rotated"` (`tokenService.ts:634-636`). Si `rotateRefresh` est désactivé, l'access est réémis
260
- et le refresh courant reste valide (`tokenService.ts:620`).
262
+ `"rotated"` (`tokenService.ts:716-718`). Si `rotateRefresh` est désactivé, l'access est réémis
263
+ et le refresh courant reste valide (`tokenService.ts:702`).
261
264
 
262
265
  ### Mise en situation — ton refresh token a été volé
263
266
 
@@ -277,26 +280,73 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
277
280
 
278
281
  ## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
279
282
 
280
- `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:151-187`), pensée
283
+ `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:219`), pensée
281
284
  pour ne jamais auto-générer une clé en clair silencieusement en prod :
282
285
 
283
- 1. **env** — `keySetJson` (JWK Set injecté depuis le catalogue d'env) : prod cloud, secret géré
284
- hors-app, même clé sur tous les pods (`JwtKeystore.ts:154-160`).
285
- 2. **fichier** — `dir/keyset.json`, généré si absent, écriture atomique tmp+rename en mode 600 —
286
- `#writeAtomic()` (`JwtKeystore.ts:272-280`) : opt-in dev/VPS mono-machine.
287
- 3. **mémoire** — aucune source → clé **éphémère + WARNING** explicite : perdue au redémarrage =
288
- refresh invalidés, incohérente en cluster (`JwtKeystore.ts:179-186`).
289
-
290
- Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts:141-145`.
286
+ 1. **env** — `keySetJson` (`NF_JWT_KEYSET` dans une application générée) : la MÊME clé pour tous
287
+ les process qui servent l'application (`JwtKeystore.ts:222`).
288
+ 2. **fichier** — `dir/keyset.json`, généré si absent, en mode 600, par une création **exclusive**
289
+ (`createSecretExclusive`, `secretFile.ts:188`) : quand plusieurs workers de `nodefony cluster`
290
+ démarrent ensemble, un seul crée la clé et les autres relisent la sienne (`JwtKeystore.ts:242`).
291
+ Source de développement, ou d'un serveur unique à disque persistant.
292
+ 3. **mémoire** — aucune source → clé **éphémère**, propre au process (`JwtKeystore.ts:263`). En
293
+ développement : un WARNING au premier jeton. En production, un process qui sert **refuse de
294
+ démarrer** (§ suivant).
295
+
296
+ Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts:209`.
291
297
  La composante privée `d` en est retirée à l'import, par liste BLANCHE de paramètres
292
- (`#importKeyset()`, `JwtKeystore.ts:206-228`, RFC 8037/7517).
298
+ (`#importKeyset()`, `JwtKeystore.ts:275`, RFC 8037/7517).
293
299
  C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`),
294
- jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:147-149`).
300
+ jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:215`).
301
+
302
+ ### Plusieurs pods ou workers — UNE clé pour tous
303
+
304
+ Chaque jeton porte le `kid` de la clé qui l'a signé. Deux process qui ont chacun la leur refusent
305
+ les jetons l'un de l'autre : derrière un répartiteur, l'utilisateur est déconnecté au hasard (401),
306
+ et tout redémarrage invalide les jetons en vol.
307
+
308
+ **En production, un process qui SERT sans source partagée refuse de démarrer**
309
+ (`#requireSharedSigningKey`, `tokenService.ts:123`), avec le motif et les trois issues dans le
310
+ journal. Une commande de console (`orm:migrate`, `security:user:add`) n'est pas concernée : elle
311
+ ne signe aucun jeton. Sous `nodefony cluster`, un worker refusé ne se relance pas — le master
312
+ arrête le cluster et sort en **78** (`ClusterManager.ts:212`), le code d'une faute de configuration.
313
+
314
+ Le déploiement, de bout en bout :
315
+
316
+ ```bash
317
+ # 1. Générer la valeur UNE fois par environnement (staging ≠ production)
318
+ npx nodefony security:secrets --jwt-keyset > keyset.json
319
+
320
+ # 2. La ranger dans le gestionnaire de secrets — ici un Secret Kubernetes
321
+ kubectl create secret generic app-jwt --from-file=NF_JWT_KEYSET=keyset.json
322
+ rm keyset.json # elle porte la clé PRIVÉE : aucun exemplaire ne traîne
323
+
324
+ # 3. L'injecter dans CHAQUE pod (extrait du Deployment)
325
+ # env:
326
+ # - name: NF_JWT_KEYSET
327
+ # valueFrom: { secretKeyRef: { name: app-jwt, key: NF_JWT_KEYSET } }
328
+ ```
329
+
330
+ Le câblage, que le gabarit d'application écrit déjà :
331
+
332
+ ```typescript
333
+ jwt: {
334
+ keystore: {
335
+ keySetJson: ctx.env.NF_JWT_KEYSET, // présente : l'emporte partout
336
+ dir: ctx.isProd ? undefined : "var/keys", // développement
337
+ },
338
+ },
339
+ ```
295
340
 
296
- > [!WARNING]
297
- > **Race au 1ᵉʳ boot d'un cluster sans clé pré-provisionnée** : deux workers peuvent générer des
298
- > clés différentes — le dernier `rename` gagne (`JwtKeystore.ts:272-280`). En prod, provisionner
299
- > `keySetJson` hors-bande élimine ce cas : c'est la source recommandée.
341
+ - **Jamais dans un fichier du poste, jamais dans git** : en développement la clé vit dans `var/keys/` ;
342
+ une clé privée de production sur un poste n'apporte que le risque de fuir.
343
+ - **Dans un fichier `.env`** (Docker Compose `env_file`), l'entourer de quotes simples :
344
+ `NF_JWT_KEYSET='{"active":"…","keys":[…]}'`.
345
+ - **Valeur illisible** : la configuration security est refusée au démarrage, le chemin
346
+ `jwt.keystore.keySetJson` nommé, la valeur **jamais** recopiée dans le message.
347
+ - **Rotation** : la valeur est STABLE — la remplacer refuse les jetons en vol. Ajouter la nouvelle
348
+ clé au tableau `keys`, la désigner dans `active`, garder l'ancienne le temps que ses jetons
349
+ expirent (`accessTtlS`, `refreshTtlS`), puis la retirer.
300
350
 
301
351
  ## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
302
352
 
@@ -307,18 +357,18 @@ seuil `invalidBefore` par porteur (`ITokenStore.ts:11-15`). Les backends s'enreg
307
357
  fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts:41-46`) : les adapters lourds importent
308
358
  `import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
309
359
 
310
- Sa résolution au boot (`tokenService.ts:112-163`) suit la doctrine `store:"auto"` du framework :
360
+ Sa résolution au boot (`tokenService.ts:156-207`) suit la doctrine `store:"auto"` du framework :
311
361
 
312
362
  - `auto` (défaut) → suit l'infra database déclarée via `resolveAutoStore` — **borné aux backends
313
- réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:116-124`) ;
363
+ réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:160-168`) ;
314
364
  - store explicite **inconnu** → en prod, **boot avorté** (fail-loud) ; en dev, brique désactivée et
315
- annoncée avec la liste `listTokenStores()` (`tokenService.ts:127-138`) — jamais de fallback
365
+ annoncée avec la liste `listTokenStores()` (`tokenService.ts:171-182`) — jamais de fallback
316
366
  memory silencieux pour du durable ;
317
367
  - store `memory` **en prod** → `WARNING` nommant l'impact : denylist/refresh/clés API per-pod et
318
- volatils, révocation non partagée (`tokenService.ts:142-149`).
368
+ volatils, révocation non partagée (`tokenService.ts:186-193`).
319
369
 
320
370
  La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
321
- (`tokenService.ts:151-160`) — visible dans Studio.
371
+ (`tokenService.ts:195-204`) — visible dans Studio.
322
372
 
323
373
  ### Mise en situation — quel store pour quelle app ?
324
374
 
@@ -347,7 +397,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
347
397
 
348
398
  - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
349
399
  - Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
350
- est chargé (`config.ts:428-433`).
400
+ est chargé (`config.ts:452-457`).
351
401
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
352
402
 
353
403
  ### `mongoose` — MongoDB
@@ -359,12 +409,12 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
359
409
  ### `redis` — cluster, TTL natif
360
410
 
361
411
  - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
362
- - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:41`) — l'expiration ne
412
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:94`) — l'expiration ne
363
413
  dépend pas du gc.
364
414
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
365
415
  (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
366
416
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
367
- (`RedisTokenStore.ts:483`).
417
+ (`RedisTokenStore.ts:529`).
368
418
 
369
419
  ### Le record — une seule table pour PAT et refresh
370
420
 
@@ -405,7 +455,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
405
455
 
406
456
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
407
457
  la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
408
- un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
458
+ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:338`).
409
459
 
410
460
  > [!TIP]
411
461
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
@@ -415,80 +465,82 @@ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
415
465
 
416
466
  ## ⚙️ Configuration
417
467
 
418
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
419
- (`config.ts:426-458`), défauts inclus.
468
+ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:373-449`) et `tokenStoreSchema`
469
+ (`config.ts:482-514`), défauts inclus.
420
470
 
421
471
  ### `jwt.*`
422
472
 
423
473
  <!-- prettier-ignore -->
424
474
  | Option | Type | Défaut | Effet |
425
475
  | --- | --- | --- | --- |
426
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
476
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:375`) |
427
477
  | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
428
- | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:370`) |
429
- | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
430
- | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
478
+ | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:420`) |
479
+ | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:425`) |
480
+ | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:430`) |
431
481
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
432
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:396`) |
482
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:446`) |
433
483
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
434
- | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
435
- | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
484
+ | `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts:452`) |
485
+ | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts:434`) |
436
486
 
437
487
  ### `tokenStore.*`
438
488
 
439
489
  | Option | Type | Défaut | Effet |
440
490
  | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
441
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:428-433`) |
442
- | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:434`) |
443
- | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:442`) |
444
- | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:448`) |
491
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:452-457`) |
492
+ | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:501`) |
493
+ | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:509`) |
494
+ | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:515`) |
445
495
 
446
496
  ## 📜 Normes appliquées
447
497
 
448
498
  | Domaine | Norme | Ancrage |
449
499
  | -------------------------------- | --------------- | ------------------------------------------------------- |
450
- | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:49-57`) |
451
- | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:562`) |
452
- | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:649-662`) |
453
- | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:649-657`) |
454
- | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:156-158`) |
500
+ | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:50-58`) |
501
+ | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:647`) |
502
+ | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:694-707`) |
503
+ | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:731-739`) |
504
+ | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:275`) |
455
505
  | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
456
- | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
457
- | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:353`) |
506
+ | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:111-118`) |
507
+ | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:433`) |
458
508
 
459
509
  ## ⚡ Performance & mémoire
460
510
 
461
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
511
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:826`)
462
512
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
463
513
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
464
514
  vérification (hot path) vit chez le `JwtAuthenticator`.
465
515
  - **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
466
- simultanés (`tokenService.ts:188-194`) ; denylist mémoire bornée par purge amortie
516
+ simultanés (`tokenService.ts:233-239`) ; denylist mémoire bornée par purge amortie
467
517
  (`MemoryTokenStore.ts:331-341`).
468
518
  - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
469
519
 
470
520
  ## 📡 Observabilité — Studio
471
521
 
472
522
  - **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
473
- raison) publiée par `registerStoreResolution()` (`tokenService.ts:151-160`).
523
+ raison) publiée par `registerStoreResolution()` (`tokenService.ts:195-204`).
474
524
  - **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
475
525
  `login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
476
526
  - **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.
477
527
 
478
528
  ## ⚠️ Pièges (symptôme → cause → correction)
479
529
 
480
- | Symptôme | Cause (dans le code) | Correction |
481
- | --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
482
- | 404 sur `/nodefony/security/api/token` | Routes montées seulement si `tokenService` existe | Charger `@nodefony/security` + `jwt.enabled: true` |
483
- | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
484
- | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
485
- | JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
486
- | Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
487
- | Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
488
- | Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
489
- | Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
490
- | Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
491
- | Listing admin sans `total` sur Redis | `countTokens()` = `-1` (comptage O(N) refusé), curseur SCAN | Attendu — capacité réduite annoncée, paginer par curseur |
530
+ | Symptôme | Cause (dans le code) | Correction |
531
+ | ---------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
532
+ | 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
533
+ | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
534
+ | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `NF_JWT_KEYSET` (prod) ou `jwt.keystore.dir` (dev) |
535
+ | Boot refusé « DÉMARRAGE REFUSÉ — aucune clé de signature » | Production, process qui sert, ni `keySetJson` ni `dir` | `security:secrets --jwt-keyset` → secret → `NF_JWT_KEYSET` |
536
+ | Cluster arrêté « cluster master down (exit 78) » | Un worker a refusé de démarrer (faute de configuration) | Lire le motif du worker juste au-dessus, le corriger |
537
+ | JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | La même `NF_JWT_KEYSET` injectée dans chaque pod |
538
+ | Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
539
+ | Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
540
+ | Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
541
+ | Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
542
+ | Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
543
+ | Listing admin sans `total` sur Redis | `countTokens()` = `-1` (comptage O(N) refusé), curseur SCAN | Attendu — capacité réduite annoncée, paginer par curseur |
492
544
 
493
545
  ## 🧪 Tests & couverture
494
546
 
@@ -507,7 +559,7 @@ Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte
507
559
  test de charge sur le grant.
508
560
 
509
561
  Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — un skip compte comme
510
- vert : lire le bloc gates (`vitest.gates.ts`, affiché en fin de run) avant de conclure.
562
+ vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure.
511
563
  Couverture : `npm run coverage` dans `@nodefony/security`.
512
564
 
513
565
  ## 🔗 Pour aller plus loin