@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4

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 (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
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,20 +87,20 @@ 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#TokenService.#build`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
+ `tokenStore` au container (`tokenService.ts#TokenService.#build`) puis crée le keystore et pose `jwtKeystore`
92
+ (`tokenService.ts#TokenService.#build`) — 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#TokenService.#build`).
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
98
- (`TokenAuthController.ts:11-22`).
98
+ (`TokenAuthController.ts#ITokenIssuer`).
99
99
 
100
100
  Constat clé de cohérence : `iss`/`aud`/`ttl` sont dérivés **une seule fois** par
101
- `resolveJwtRuntime()` (`jwtRuntime.ts:30-41`) et **partagés** entre l'émetteur et le vérificateur —
101
+ `resolveJwtRuntime()` (`jwtRuntime.ts#resolveJwtRuntime`) et **partagés** entre l'émetteur et le vérificateur —
102
102
  une divergence ferait tout rejeter. Fonction pure : les deux côtés obtiennent la même valeur sans la
103
- partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts:31`), à surcharger en prod.
103
+ partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts#resolveJwtRuntime`), à surcharger en prod.
104
104
 
105
105
  ### Être DÉCOUVRABLE — publier ses clés (RFC 8414)
106
106
 
@@ -136,34 +136,41 @@ seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncera
136
136
  ### Les endpoints d'émission sont FOURNIS
137
137
 
138
138
  Dans une app `nodefony create app`, dès que le module security est chargé avec `jwt.enabled` (défaut),
139
- le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:161-176`) :
139
+ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts#mountTokenAuthRoutes`) :
140
140
 
141
141
  - `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
143
143
 
144
144
  > [!IMPORTANT]
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:145`) :
146
+ > (`framework/index.ts#Framework.onKernelReady`). Module chargé mais JWT désactivé : elles existent et répondent
147
+ > `503 Token issuance unavailable` (`TokenAuthController.ts#TokenAuthController.token`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts#mountTokenAuthRoutes`) :
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
 
151
151
  ### La config : une zone protégée par `jwt` + une clé qui survit au redémarrage
152
152
 
153
- ```typescript
154
- // nodefony.config.ts (extrait) — la zone API machine + la source de clé
155
- use("@nodefony/security", {
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" },
161
- },
162
- areas: {
163
- // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
164
- api: { pattern: "^/api/v1", authenticators: ["jwt"] },
165
- },
166
- });
153
+ ```typescript ignore
154
+ // nodefony/config/security.ts — la zone API machine + la source de clé
155
+ import type { ConfigContext } from "nodefony";
156
+ import type { ISecurityConfigInput } from "@nodefony/security";
157
+ import type { env } from "../../env";
158
+
159
+ export const securityConfig = (ctx: ConfigContext<typeof env>) =>
160
+ ({
161
+ jwt: {
162
+ // dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
163
+ // prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
164
+ keystore: {
165
+ keySetJson: ctx.env.NF_JWT_KEYSET,
166
+ dir: "var/keys",
167
+ },
168
+ },
169
+ areas: {
170
+ // Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
171
+ api: { pattern: "^/api/v1", authenticators: ["jwt"] },
172
+ },
173
+ }) satisfies ISecurityConfigInput;
167
174
  ```
168
175
 
169
176
  ### Ce que TU écris : le controller scopé
@@ -220,45 +227,46 @@ curl -s -H 'Content-Type: application/json' \
220
227
  # 4) Rejouer l'ANCIEN refresh → 401 {"error":"invalid_grant"} + famille coupée
221
228
  ```
222
229
 
223
- Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts:108-120`) :
230
+ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) :
224
231
  401 message uniforme `invalid_grant` (anti-énumération), 429 avec `Retry-After` du throttler NIST.
225
232
  Émission indisponible (JWT désactivé, store absent) → 503 `isEnabled()`
226
- (`TokenAuthController.ts:67-69`).
233
+ (`TokenAuthController.ts#ITokenIssuer.isEnabled`).
227
234
 
228
235
  ## 🏗️ Architecture interne — la vie d'un couple access/refresh
229
236
 
230
237
  ### Émission (grant M2M/CLI)
231
238
 
232
- `issueForCredentials()` (`tokenService.ts:324`) vérifie l'identifiant/mot de passe via le
239
+ `issueForCredentials()` (`tokenService.ts#TokenService.issueForCredentials`) vérifie l'identifiant/mot de passe via le
233
240
  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`)
241
+ (`tokenService.ts#TokenService.issueForCredentials`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
242
+ par `#auditGrant()` (`tokenService.ts#TokenService.#auditGrant`). Puis `issueTokens()` (`tokenService.ts#TokenService.issueTokens`)
236
243
  produit :
237
244
 
238
245
  - 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`) ;
246
+ `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) ;
240
247
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
241
248
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
242
- (`tokenService.ts:681-716`).
249
+ (`tokenService.ts#TokenService.#buildRefresh`).
243
250
 
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`).
251
+ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts#ITokenResponse`). Tout succès est audité
252
+ `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts#TokenService.runGc`).
246
253
 
247
254
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
248
255
 
249
- `refresh()` (`tokenService.ts:562`) est le cœur défensif, dans l'ordre :
256
+ `refresh()` (`tokenService.ts#TokenService.refresh`) est le cœur défensif, dans l'ordre :
250
257
 
251
- 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
258
+ 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts#TokenService.refresh`).
252
259
  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`).
260
+ famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts#TokenService.refresh`).
261
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts#TokenService.refresh`).
255
262
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
256
- `#resolveUserForRefresh()` (`tokenService.ts:770`).
257
- 5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
258
- (`tokenService.ts:600`).
263
+ `#resolveUserForRefresh()` (`tokenService.ts#TokenService.#resolveUserForRefresh`).
264
+ 5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus — et ils
265
+ sont REJUGÉS sur les rôles du compte relu (`grantableScopes`, règle d'administration) : un rôle
266
+ retiré retire son scope au prochain renouvellement (`tokenService.ts#TokenService.#grantableScopes`).
259
267
  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`).
268
+ `"rotated"` (`tokenService.ts#TokenService.refresh`). Si `rotateRefresh` est désactivé, l'access est réémis
269
+ et le refresh courant reste valide (`tokenService.ts#TokenService.refresh`).
262
270
 
263
271
  ### Mise en situation — ton refresh token a été volé
264
272
 
@@ -278,48 +286,95 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
278
286
 
279
287
  ## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
280
288
 
281
- `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:151-187`), pensée
289
+ `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts#JwtKeystore.#load`), pensée
282
290
  pour ne jamais auto-générer une clé en clair silencieusement en prod :
283
291
 
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`.
292
+ 1. **env** — `keySetJson` (`NF_JWT_KEYSET` dans une application générée) : la MÊME clé pour tous
293
+ les process qui servent l'application (`JwtKeystore.ts#JwtKeystore.#load`).
294
+ 2. **fichier** — `dir/keyset.json`, généré si absent, en mode 600, par une création **exclusive**
295
+ (`createSecretExclusive`, `secretFile.ts#createSecretExclusive`) : quand plusieurs workers de `nodefony cluster`
296
+ démarrent ensemble, un seul crée la clé et les autres relisent la sienne (`JwtKeystore.ts#JwtKeystore.#load`).
297
+ Source de développement, ou d'un serveur unique à disque persistant.
298
+ 3. **mémoire** — aucune source → clé **éphémère**, propre au process (`JwtKeystore.ts#JwtKeystore.#load`). En
299
+ développement : un WARNING au premier jeton. En production, un process qui sert **refuse de
300
+ démarrer** (§ suivant).
301
+
302
+ Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts#JwtKeystore.getPublicJWKS`.
292
303
  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).
294
- 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`).
304
+ (`#importKeyset()`, `JwtKeystore.ts#JwtKeystore.#importKeyset`, RFC 8037/7517).
305
+ C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts#JwtAuthenticator.#ensureGetKey`),
306
+ jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts#JwtKeystore.#ensureLoaded`).
307
+
308
+ ### Plusieurs pods ou workers — UNE clé pour tous
309
+
310
+ Chaque jeton porte le `kid` de la clé qui l'a signé. Deux process qui ont chacun la leur refusent
311
+ les jetons l'un de l'autre : derrière un répartiteur, l'utilisateur est déconnecté au hasard (401),
312
+ et tout redémarrage invalide les jetons en vol.
313
+
314
+ **En production, un process qui SERT sans source partagée refuse de démarrer**
315
+ (`#requireSharedSigningKey`, `tokenService.ts#TokenService.#requireSharedSigningKey`), avec le motif et les trois issues dans le
316
+ journal. Une commande de console (`orm:migrate`, `security:user:add`) n'est pas concernée : elle
317
+ ne signe aucun jeton. Sous `nodefony cluster`, un worker refusé ne se relance pas — le master
318
+ arrête le cluster et sort en **78** (`ClusterManager.ts#ClusterManager.#onExit`), le code d'une faute de configuration.
319
+
320
+ Le déploiement, de bout en bout :
321
+
322
+ ```bash
323
+ # 1. Générer la valeur UNE fois par environnement (staging ≠ production)
324
+ npx nodefony security:secrets --jwt-keyset > keyset.json
325
+
326
+ # 2. La ranger dans le gestionnaire de secrets — ici un Secret Kubernetes
327
+ kubectl create secret generic app-jwt --from-file=NF_JWT_KEYSET=keyset.json
328
+ rm keyset.json # elle porte la clé PRIVÉE : aucun exemplaire ne traîne
329
+
330
+ # 3. L'injecter dans CHAQUE pod (extrait du Deployment)
331
+ # env:
332
+ # - name: NF_JWT_KEYSET
333
+ # valueFrom: { secretKeyRef: { name: app-jwt, key: NF_JWT_KEYSET } }
334
+ ```
335
+
336
+ Le câblage, que le gabarit d'application écrit déjà :
337
+
338
+ ```typescript
339
+ jwt: {
340
+ keystore: {
341
+ keySetJson: ctx.env.NF_JWT_KEYSET, // présente : l'emporte partout
342
+ dir: ctx.isProd ? undefined : "var/keys", // développement
343
+ },
344
+ },
345
+ ```
296
346
 
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.
347
+ - **Jamais dans un fichier du poste, jamais dans git** : en développement la clé vit dans `var/keys/` ;
348
+ une clé privée de production sur un poste n'apporte que le risque de fuir.
349
+ - **Dans un fichier `.env`** (Docker Compose `env_file`), l'entourer de quotes simples :
350
+ `NF_JWT_KEYSET='{"active":"…","keys":[…]}'`.
351
+ - **Valeur illisible** : la configuration security est refusée au démarrage, le chemin
352
+ `jwt.keystore.keySetJson` nommé, la valeur **jamais** recopiée dans le message.
353
+ - **Rotation** : la valeur est STABLE — la remplacer refuse les jetons en vol. Ajouter la nouvelle
354
+ clé au tableau `keys`, la désigner dans `active`, garder l'ancienne le temps que ses jetons
355
+ expirent (`accessTtlS`, `refreshTtlS`), puis la retirer.
301
356
 
302
357
  ## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
303
358
 
304
359
  ### Le contrat et l'enregistrement
305
360
 
306
361
  Le `tokenStore` héberge **trois structures** : les records (refresh + PAT), la denylist `jti` et le
307
- seuil `invalidBefore` par porteur (`ITokenStore.ts:11-15`). Les backends s'enregistrent par
308
- fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts:41-46`) : les adapters lourds importent
362
+ seuil `invalidBefore` par porteur (`ITokenStore.ts`). Les backends s'enregistrent par
363
+ fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts#registerTokenStore`) : les adapters lourds importent
309
364
  `import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
310
365
 
311
- Sa résolution au boot (`tokenService.ts:112-163`) suit la doctrine `store:"auto"` du framework :
366
+ Sa résolution au boot (`tokenService.ts#TokenService.#build`) suit la doctrine `store:"auto"` du framework :
312
367
 
313
368
  - `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`) ;
369
+ réellement enregistrés**, repli memory **annoncé** (`tokenService.ts#TokenService.#build`) ;
315
370
  - 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
371
+ annoncée avec la liste `listTokenStores()` (`tokenService.ts#TokenService.#build`) — jamais de fallback
317
372
  memory silencieux pour du durable ;
318
373
  - 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`).
374
+ volatils, révocation non partagée (`tokenService.ts#TokenService.#build`).
320
375
 
321
376
  La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
322
- (`tokenService.ts:151-160`) — visible dans Studio.
377
+ (`tokenService.ts#TokenService.#build`) — visible dans Studio.
323
378
 
324
379
  ### Mise en situation — quel store pour quelle app ?
325
380
 
@@ -335,161 +390,163 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
335
390
 
336
391
  ### `memory` — la référence 0 dépendance
337
392
 
338
- - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
393
+ - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts#registerTokenStore`).
339
394
  - `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
340
- (`MemoryTokenStore.ts:165-185`).
395
+ (`MemoryTokenStore.ts#MemoryTokenStore.listPage`).
341
396
  - Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
342
- (`MemoryTokenStore.ts:393`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
397
+ (`MemoryTokenStore.ts#MemoryTokenStore.#maybeSweep`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
343
398
  - `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
344
- (`MemoryTokenStore.ts:324-354`).
399
+ (`MemoryTokenStore.ts#MemoryTokenStore.restore`).
345
400
  - Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
346
401
 
347
402
  ### `drizzle` — SQL, le défaut durable
348
403
 
349
- - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
404
+ - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores`).
350
405
  - É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`).
406
+ est chargé (`config.ts#jwtSchema`).
352
407
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
353
408
 
354
409
  ### `mongoose` — MongoDB
355
410
 
356
- - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:170`).
357
- - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:236-246`).
358
- - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:170`).
411
+ - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores`).
412
+ - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts#MongooseTokenStore.listPage`).
413
+ - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts#MongooseTokenStore.#toRecord`).
359
414
 
360
415
  ### `redis` — cluster, TTL natif
361
416
 
362
- - 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
417
+ - Enregistré par le module redis (`redis/nodefony/registerStores.ts#registerRedisFrameworkStores`).
418
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts#RedisClientLike.expire`) — l'expiration ne
364
419
  dépend pas du gc.
365
420
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
366
- (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
421
+ (`RedisTokenStore.ts#RedisTokenStore.listAll`) — sans ordre global ni total, capacité réduite **assumée**.
367
422
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
368
- (`RedisTokenStore.ts:483`).
423
+ (`RedisTokenStore.ts#RedisTokenStore.countTokens`).
369
424
 
370
425
  ### Le record — une seule table pour PAT et refresh
371
426
 
372
- `IAccessTokenRecord` (`ITokenStore.ts:69`) est **single-table** : un même schéma porte PAT et
373
- refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts:60-64`). Deux
427
+ `IAccessTokenRecord` (`ITokenStore.ts#IAccessTokenRecord`) est **single-table** : un même schéma porte PAT et
428
+ refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts#IAccessTokenRecord`). Deux
374
429
  constats de design :
375
430
 
376
431
  - `subjectId` est une **référence logique souple, PAS une FK SQL** : porteur polymorphe
377
432
  (user/service), store multi-backend, découplage des modules — intégrité assurée à l'usage
378
- (`ITokenStore.ts:80-95`) ;
433
+ (`ITokenStore.ts#IAccessTokenRecord.subjectId`) ;
379
434
  - slots prêts sans migration : `resources` (permissions fine-grained façon GitHub) et `cnf`
380
- (sender-constrained DPoP/mTLS) (`ITokenStore.ts:101-119`).
435
+ (sender-constrained DPoP/mTLS) (`ITokenStore.ts#IAccessTokenRecord.cnf`).
381
436
 
382
437
  Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-triple-vérité).
383
438
 
384
439
  ### Lister pour l'admin — pagination native
385
440
 
386
- - `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts:233`) — capacité par
441
+ - `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts#ITokenStore.listPage`) — capacité par
387
442
  backend : **offset + total** (SQL/Mongo/mémoire) ou **curseur** (`nextCursor`, Redis).
388
- - `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts:238`).
389
- - Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts:154-161`) —
390
- prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts:11-26`).
443
+ - `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts#ITokenStore.countTokens`).
444
+ - Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts#ITokenListQuery`) —
445
+ prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts#matchesTokenQuery`).
391
446
  - `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
392
- (`ITokenStore.ts:222`).
447
+ (`ITokenStore.ts#ITokenStore.listAll`).
393
448
  - Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
394
- (`apiKeys.ts:209`), jamais un listAll matérialisé.
449
+ (`apiKeys.ts#ApiKeyService.listPagePat`), jamais un listAll matérialisé.
395
450
 
396
451
  ### Révoquer — trois portées
397
452
 
398
- - **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts:251`).
453
+ - **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts#ITokenStore.isJtiDenied`).
399
454
  - **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
400
- (`ITokenStore.ts:242`).
455
+ (`ITokenStore.ts#ITokenStore.revokeFamily`).
401
456
  - **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
402
- `iat < invalidBefore` est rejeté (`ITokenStore.ts:259-263`) ; le seuil est **monotone**, deux
403
- logouts successifs ne le reculent pas (`MemoryTokenStore.ts:262-267`).
457
+ `iat < invalidBefore` est rejeté (`ITokenStore.ts#ITokenStore`) ; le seuil est **monotone**, deux
458
+ logouts successifs ne le reculent pas (`MemoryTokenStore.ts#MemoryTokenStore.revokeAllForSubject`).
404
459
 
405
460
  ### La maintenance (gc)
406
461
 
407
462
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
408
- 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`).
463
+ la rétention (`ITokenStore.ts#ITokenStore.gc`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
464
+ un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts#TokenService.getPublicJWKS`).
410
465
 
411
466
  > [!TIP]
412
467
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
413
468
  > `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
414
- > (`MemoryTokenStore.ts:301-310`). Un store **local** (memory) est par-process : seul SON process
469
+ > (`MemoryTokenStore.ts#MemoryTokenStore.#isPurgeable`). Un store **local** (memory) est par-process : seul SON process
415
470
  > peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
416
471
 
417
472
  ## ⚙️ Configuration
418
473
 
419
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
420
- (`config.ts:426-458`), défauts inclus.
474
+ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tokenStoreSchema`
475
+ (`config.ts#tokenStoreSchema`), défauts inclus.
421
476
 
422
477
  ### `jwt.*`
423
478
 
424
479
  <!-- prettier-ignore -->
425
480
  | Option | Type | Défaut | Effet |
426
481
  | --- | --- | --- | --- |
427
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
428
- | `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`) |
482
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts#rateLimitSchema`) |
483
+ | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts#IJwtRuntime.alg`) |
484
+ | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts#jwtSchema`) |
485
+ | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
486
+ | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
432
487
  | `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`) |
488
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts#jwtSchema`) |
434
489
  | `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`) |
490
+ | `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#jwtSchema`) |
491
+ | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
437
492
 
438
493
  ### `tokenStore.*`
439
494
 
440
- | Option | Type | Défaut | Effet |
441
- | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
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`) |
495
+ | Option | Type | Défaut | Effet |
496
+ | ---------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------- |
497
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts#jwtSchema`) |
498
+ | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts#tokenStoreSchema`) |
499
+ | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts#tokenStoreSchema`) |
500
+ | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts#tokenStoreSchema`) |
446
501
 
447
502
  ## 📜 Normes appliquées
448
503
 
449
- | Domaine | Norme | Ancrage |
450
- | -------------------------------- | --------------- | ------------------------------------------------------- |
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`) |
456
- | 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`) |
504
+ | Domaine | Norme | Ancrage |
505
+ | -------------------------------- | --------------- | ------------------------------------------------------------------------------------ |
506
+ | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts#ITokenResponse`) |
507
+ | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts#TokenService.refresh`) |
508
+ | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
509
+ | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
510
+ | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts#JwtKeystore.#importKeyset`) |
511
+ | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts#IAccessTokenRecord.audience`) |
512
+ | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) |
513
+ | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts#TokenService.issueForCredentials`) |
459
514
 
460
515
  ## ⚡ Performance & mémoire
461
516
 
462
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
517
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts#TokenService.#ensureJose`)
463
518
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
464
519
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
465
520
  vérification (hot path) vit chez le `JwtAuthenticator`.
466
521
  - **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
468
- (`MemoryTokenStore.ts:331-341`).
469
- - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
522
+ simultanés (`tokenService.ts#TokenService.#build`) ; denylist mémoire bornée par purge amortie
523
+ (`MemoryTokenStore.ts#MemoryTokenStore.restore`).
524
+ - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts#ITokenStore.listPage`).
470
525
 
471
526
  ## 📡 Observabilité — Studio
472
527
 
473
528
  - **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
474
- raison) publiée par `registerStoreResolution()` (`tokenService.ts:151-160`).
529
+ raison) publiée par `registerStoreResolution()` (`tokenService.ts#TokenService.#build`).
475
530
  - **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
476
531
  `login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
477
532
  - **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.
478
533
 
479
534
  ## ⚠️ Pièges (symptôme → cause → correction)
480
535
 
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 |
536
+ | Symptôme | Cause (dans le code) | Correction |
537
+ | ---------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
538
+ | 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
539
+ | 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
540
+ | Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `NF_JWT_KEYSET` (prod) ou `jwt.keystore.dir` (dev) |
541
+ | 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` |
542
+ | 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 |
543
+ | 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 |
544
+ | Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
545
+ | Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
546
+ | Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
547
+ | Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
548
+ | Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
549
+ | Listing admin sans `total` sur Redis | `countTokens()` = `-1` (comptage O(N) refusé), curseur SCAN | Attendu — capacité réduite annoncée, paginer par curseur |
493
550
 
494
551
  ## 🧪 Tests & couverture
495
552
 
@@ -508,7 +565,7 @@ Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte
508
565
  test de charge sur le grant.
509
566
 
510
567
  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.
568
+ vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure.
512
569
  Couverture : `npm run coverage` dans `@nodefony/security`.
513
570
 
514
571
  ## 🔗 Pour aller plus loin