@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
@@ -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-10-02
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
@@ -144,7 +144,7 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
144
144
  > [!IMPORTANT]
145
145
  > Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
146
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:145`) :
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:188`) :
148
148
  > elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
149
149
  > authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
150
150
 
@@ -154,10 +154,12 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
154
154
  // nodefony.config.ts (extrait) — la zone API machine + la source de clé
155
155
  use("@nodefony/security", {
156
156
  jwt: {
157
- // dev/VPS : persiste la clé Ed25519 (sinon clé ÉPHÉMÈRE + warning au boot).
158
- // prod cloud : préférer keystore.keySetJson injecté depuis l'env (même clé
159
- // sur tous les pods) — voir la section keystore.
160
- 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
+ },
161
163
  },
162
164
  areas: {
163
165
  // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
@@ -229,36 +231,36 @@ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController
229
231
 
230
232
  ### Émission (grant M2M/CLI)
231
233
 
232
- `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
233
235
  service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
234
- (`tokenService.ts:353`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
235
- 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`)
236
238
  produit :
237
239
 
238
240
  - un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
239
- `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`) ;
240
242
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
241
243
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
242
- (`tokenService.ts:681-716`).
244
+ (`tokenService.ts:726-761`).
243
245
 
244
- La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`). Tout succès est audité
245
- `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`).
246
248
 
247
249
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
248
250
 
249
- `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 :
250
252
 
251
- 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`).
252
254
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
253
- famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
254
- 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`).
255
257
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
256
- `#resolveUserForRefresh()` (`tokenService.ts:770`).
258
+ `#resolveUserForRefresh()` (`tokenService.ts:852`).
257
259
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
258
- (`tokenService.ts:600`).
260
+ (`tokenService.ts:684`).
259
261
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
260
- `"rotated"` (`tokenService.ts:634-636`). Si `rotateRefresh` est désactivé, l'access est réémis
261
- 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`).
262
264
 
263
265
  ### Mise en situation — ton refresh token a été volé
264
266
 
@@ -278,26 +280,73 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
278
280
 
279
281
  ## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
280
282
 
281
- `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
282
284
  pour ne jamais auto-générer une clé en clair silencieusement en prod :
283
285
 
284
- 1. **env** — `keySetJson` (JWK Set injecté depuis le catalogue d'env) : prod cloud, secret géré
285
- hors-app, même clé sur tous les pods (`JwtKeystore.ts:154-160`).
286
- 2. **fichier** — `dir/keyset.json`, généré si absent, écriture atomique tmp+rename en mode 600 —
287
- `#writeAtomic()` (`JwtKeystore.ts:272-280`) : opt-in dev/VPS mono-machine.
288
- 3. **mémoire** — aucune source → clé **éphémère + WARNING** explicite : perdue au redémarrage =
289
- refresh invalidés, incohérente en cluster (`JwtKeystore.ts:179-186`).
290
-
291
- 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`.
292
297
  La composante privée `d` en est retirée à l'import, par liste BLANCHE de paramètres
293
- (`#importKeyset()`, `JwtKeystore.ts:206-228`, RFC 8037/7517).
298
+ (`#importKeyset()`, `JwtKeystore.ts:275`, RFC 8037/7517).
294
299
  C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`),
295
- 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
+ ```
296
340
 
297
- > [!WARNING]
298
- > **Race au 1ᵉʳ boot d'un cluster sans clé pré-provisionnée** : deux workers peuvent générer des
299
- > clés différentes — le dernier `rename` gagne (`JwtKeystore.ts:272-280`). En prod, provisionner
300
- > `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.
301
350
 
302
351
  ## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
303
352
 
@@ -308,18 +357,18 @@ seuil `invalidBefore` par porteur (`ITokenStore.ts:11-15`). Les backends s'enreg
308
357
  fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts:41-46`) : les adapters lourds importent
309
358
  `import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
310
359
 
311
- 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 :
312
361
 
313
362
  - `auto` (défaut) → suit l'infra database déclarée via `resolveAutoStore` — **borné aux backends
314
- réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:116-124`) ;
363
+ réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:160-168`) ;
315
364
  - store explicite **inconnu** → en prod, **boot avorté** (fail-loud) ; en dev, brique désactivée et
316
- 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
317
366
  memory silencieux pour du durable ;
318
367
  - store `memory` **en prod** → `WARNING` nommant l'impact : denylist/refresh/clés API per-pod et
319
- volatils, révocation non partagée (`tokenService.ts:142-149`).
368
+ volatils, révocation non partagée (`tokenService.ts:186-193`).
320
369
 
321
370
  La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
322
- (`tokenService.ts:151-160`) — visible dans Studio.
371
+ (`tokenService.ts:195-204`) — visible dans Studio.
323
372
 
324
373
  ### Mise en situation — quel store pour quelle app ?
325
374
 
@@ -348,7 +397,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
348
397
 
349
398
  - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
350
399
  - Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
351
- est chargé (`config.ts:428-433`).
400
+ est chargé (`config.ts:452-457`).
352
401
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
353
402
 
354
403
  ### `mongoose` — MongoDB
@@ -360,12 +409,12 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
360
409
  ### `redis` — cluster, TTL natif
361
410
 
362
411
  - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
363
- - 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
364
413
  dépend pas du gc.
365
414
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
366
415
  (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
367
416
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
368
- (`RedisTokenStore.ts:483`).
417
+ (`RedisTokenStore.ts:529`).
369
418
 
370
419
  ### Le record — une seule table pour PAT et refresh
371
420
 
@@ -406,7 +455,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
406
455
 
407
456
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
408
457
  la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
409
- un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
458
+ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:338`).
410
459
 
411
460
  > [!TIP]
412
461
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
@@ -416,80 +465,82 @@ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
416
465
 
417
466
  ## ⚙️ Configuration
418
467
 
419
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
420
- (`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.
421
470
 
422
471
  ### `jwt.*`
423
472
 
424
473
  <!-- prettier-ignore -->
425
474
  | Option | Type | Défaut | Effet |
426
475
  | --- | --- | --- | --- |
427
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
476
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:375`) |
428
477
  | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
429
- | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:370`) |
430
- | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
431
- | `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`) |
432
481
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
433
- | `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`) |
434
483
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
435
- | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
436
- | `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`) |
437
486
 
438
487
  ### `tokenStore.*`
439
488
 
440
489
  | Option | Type | Défaut | Effet |
441
490
  | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
442
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:428-433`) |
443
- | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:434`) |
444
- | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:442`) |
445
- | `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`) |
446
495
 
447
496
  ## 📜 Normes appliquées
448
497
 
449
498
  | Domaine | Norme | Ancrage |
450
499
  | -------------------------------- | --------------- | ------------------------------------------------------- |
451
- | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:49-57`) |
452
- | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:562`) |
453
- | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:649-662`) |
454
- | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:649-657`) |
455
- | 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`) |
456
505
  | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
457
- | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
458
- | 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`) |
459
508
 
460
509
  ## ⚡ Performance & mémoire
461
510
 
462
- - **`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`)
463
512
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
464
513
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
465
514
  vérification (hot path) vit chez le `JwtAuthenticator`.
466
515
  - **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
467
- 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
468
517
  (`MemoryTokenStore.ts:331-341`).
469
518
  - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
470
519
 
471
520
  ## 📡 Observabilité — Studio
472
521
 
473
522
  - **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
474
- raison) publiée par `registerStoreResolution()` (`tokenService.ts:151-160`).
523
+ raison) publiée par `registerStoreResolution()` (`tokenService.ts:195-204`).
475
524
  - **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
476
525
  `login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
477
526
  - **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.
478
527
 
479
528
  ## ⚠️ Pièges (symptôme → cause → correction)
480
529
 
481
- | Symptôme | Cause (dans le code) | Correction |
482
- | --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
483
- | 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
484
- | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
485
- | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
486
- | 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) |
487
- | Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
488
- | Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
489
- | Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
490
- | Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
491
- | Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
492
- | 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 |
493
544
 
494
545
  ## 🧪 Tests & couverture
495
546
 
@@ -508,7 +559,7 @@ Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte
508
559
  test de charge sur le grant.
509
560
 
510
561
  Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — un skip compte comme
511
- 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.
512
563
  Couverture : `npm run coverage` dans `@nodefony/security`.
513
564
 
514
565
  ## 🔗 Pour aller plus loin