@nodefony/security 10.0.0-alpha.1

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 (258) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +182 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/index.js +151 -0
  6. package/dist/nodefony/command/security-secrets.js +158 -0
  7. package/dist/nodefony/command/security-token.js +335 -0
  8. package/dist/nodefony/command/security-user-add.js +131 -0
  9. package/dist/nodefony/command/security-user-delete.js +102 -0
  10. package/dist/nodefony/command/security-user-list.js +77 -0
  11. package/dist/nodefony/config/config.js +366 -0
  12. package/dist/nodefony/config/defineModuleConfig.js +35 -0
  13. package/dist/nodefony/contracts/IAccessVoter.js +13 -0
  14. package/dist/nodefony/contracts/IApiKey.js +1 -0
  15. package/dist/nodefony/contracts/IAuditEvent.js +1 -0
  16. package/dist/nodefony/contracts/IAuditStore.js +1 -0
  17. package/dist/nodefony/contracts/IAuthenticator.js +1 -0
  18. package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
  19. package/dist/nodefony/contracts/IFirewall.js +1 -0
  20. package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
  21. package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
  22. package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
  23. package/dist/nodefony/contracts/ISecuredArea.js +1 -0
  24. package/dist/nodefony/contracts/IToken.js +1 -0
  25. package/dist/nodefony/contracts/ITokenStore.js +1 -0
  26. package/dist/nodefony/contracts/ITotpSecret.js +1 -0
  27. package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
  28. package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
  29. package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
  30. package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
  31. package/dist/nodefony/contracts/IWebhookStore.js +1 -0
  32. package/dist/nodefony/contracts/index.js +2 -0
  33. package/dist/nodefony/errors/AccessDeniedError.js +14 -0
  34. package/dist/nodefony/errors/ApiKeyError.js +21 -0
  35. package/dist/nodefony/errors/AuthenticationError.js +14 -0
  36. package/dist/nodefony/errors/CsrfError.js +23 -0
  37. package/dist/nodefony/errors/InvalidTargetError.js +39 -0
  38. package/dist/nodefony/errors/SsrfError.js +17 -0
  39. package/dist/nodefony/errors/ThrottledError.js +21 -0
  40. package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
  41. package/dist/nodefony/errors/WebAuthnError.js +21 -0
  42. package/dist/nodefony/errors/index.js +9 -0
  43. package/dist/nodefony/service/accessTokenVerifier.js +77 -0
  44. package/dist/nodefony/service/apiKeys.js +310 -0
  45. package/dist/nodefony/service/auditService.js +145 -0
  46. package/dist/nodefony/service/authFlow.js +332 -0
  47. package/dist/nodefony/service/authorization.js +95 -0
  48. package/dist/nodefony/service/cors.js +81 -0
  49. package/dist/nodefony/service/csrf.js +97 -0
  50. package/dist/nodefony/service/firewall.js +699 -0
  51. package/dist/nodefony/service/oauth2.js +153 -0
  52. package/dist/nodefony/service/securityHeaders.js +80 -0
  53. package/dist/nodefony/service/tokenService.js +486 -0
  54. package/dist/nodefony/service/totp.js +209 -0
  55. package/dist/nodefony/service/webAuthn.js +343 -0
  56. package/dist/nodefony/service/webhooks.js +539 -0
  57. package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
  58. package/dist/nodefony/src/SecuredArea.js +51 -0
  59. package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
  60. package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
  61. package/dist/nodefony/src/admin/adminAudit.js +37 -0
  62. package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
  63. package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
  64. package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
  65. package/dist/nodefony/src/audit/auditBridge.js +82 -0
  66. package/dist/nodefony/src/audit/auditFilters.js +60 -0
  67. package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
  68. package/dist/nodefony/src/audit/readAuditContext.js +24 -0
  69. package/dist/nodefony/src/audit/recordAudit.js +16 -0
  70. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
  71. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
  72. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
  73. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
  74. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
  75. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
  76. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
  77. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
  78. package/dist/nodefony/src/authenticator/bearer.js +2 -0
  79. package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
  80. package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
  81. package/dist/nodefony/src/crypto/secretCipher.js +79 -0
  82. package/dist/nodefony/src/csp.js +54 -0
  83. package/dist/nodefony/src/csrfToken.js +65 -0
  84. package/dist/nodefony/src/net/ssrfGuard.js +130 -0
  85. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +65 -0
  87. package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
  88. package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
  89. package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
  90. package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
  91. package/dist/nodefony/src/sessionIdentity.js +35 -0
  92. package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
  93. package/dist/nodefony/src/token/AnonymousToken.js +40 -0
  94. package/dist/nodefony/src/token/JwtKeystore.js +160 -0
  95. package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
  96. package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
  97. package/dist/nodefony/src/token/UserToken.js +67 -0
  98. package/dist/nodefony/src/token/jwtRuntime.js +19 -0
  99. package/dist/nodefony/src/token/secretFile.js +134 -0
  100. package/dist/nodefony/src/token/tokenCriteria.js +35 -0
  101. package/dist/nodefony/src/token/tokenFilters.js +72 -0
  102. package/dist/nodefony/src/token/tokenSort.js +40 -0
  103. package/dist/nodefony/src/token/tokenStatus.js +35 -0
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
  106. package/dist/nodefony/src/totp/totpCipher.js +30 -0
  107. package/dist/nodefony/src/totp/totpCrypto.js +226 -0
  108. package/dist/nodefony/src/totp/totpOperations.js +129 -0
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
  110. package/dist/nodefony/src/voter/RoleVoter.js +32 -0
  111. package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
  112. package/dist/nodefony/src/voter/voterRegistry.js +20 -0
  113. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
  114. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
  115. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
  116. package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
  117. package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
  118. package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
  119. package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
  120. package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
  121. package/dist/nodefony/src/webhook/webhookSort.js +48 -0
  122. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
  123. package/dist/types/index.d.ts +157 -0
  124. package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
  125. package/dist/types/nodefony/command/security-token.d.ts +44 -0
  126. package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
  127. package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
  128. package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
  129. package/dist/types/nodefony/config/config.d.ts +295 -0
  130. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  131. package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
  132. package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
  133. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
  134. package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
  135. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
  136. package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
  137. package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
  138. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
  139. package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
  140. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
  141. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
  142. package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
  143. package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
  144. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
  145. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
  146. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
  147. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
  148. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
  149. package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
  150. package/dist/types/nodefony/contracts/index.d.ts +9 -0
  151. package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
  152. package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
  153. package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
  154. package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
  155. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
  156. package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
  157. package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
  158. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
  159. package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
  160. package/dist/types/nodefony/errors/index.d.ts +8 -0
  161. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
  162. package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
  163. package/dist/types/nodefony/service/auditService.d.ts +30 -0
  164. package/dist/types/nodefony/service/authFlow.d.ts +123 -0
  165. package/dist/types/nodefony/service/authorization.d.ts +33 -0
  166. package/dist/types/nodefony/service/cors.d.ts +48 -0
  167. package/dist/types/nodefony/service/csrf.d.ts +57 -0
  168. package/dist/types/nodefony/service/firewall.d.ts +148 -0
  169. package/dist/types/nodefony/service/oauth2.d.ts +66 -0
  170. package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
  171. package/dist/types/nodefony/service/tokenService.d.ts +103 -0
  172. package/dist/types/nodefony/service/totp.d.ts +58 -0
  173. package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
  174. package/dist/types/nodefony/service/webhooks.d.ts +160 -0
  175. package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
  176. package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
  177. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
  178. package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
  179. package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
  180. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
  181. package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
  182. package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
  183. package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
  184. package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
  185. package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
  186. package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
  187. package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
  188. package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
  189. package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
  190. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
  191. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
  192. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
  193. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
  194. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
  195. package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
  196. package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
  197. package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
  198. package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
  199. package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
  200. package/dist/types/nodefony/src/csp.d.ts +39 -0
  201. package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
  202. package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
  203. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
  204. package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
  205. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
  206. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
  207. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
  208. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
  209. package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
  210. package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
  211. package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
  212. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
  213. package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
  214. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
  215. package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
  216. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
  217. package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
  218. package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
  219. package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
  220. package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
  221. package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
  222. package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
  223. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
  224. package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
  225. package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
  226. package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
  227. package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
  228. package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
  229. package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
  230. package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
  231. package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
  232. package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
  233. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
  234. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
  235. package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
  236. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
  237. package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
  238. package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
  239. package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
  240. package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
  241. package/docs/api-keys.md +691 -0
  242. package/docs/audit.md +751 -0
  243. package/docs/authenticators.md +487 -0
  244. package/docs/authorization.md +497 -0
  245. package/docs/cors.md +497 -0
  246. package/docs/csrf.md +392 -0
  247. package/docs/external-jwt.md +181 -0
  248. package/docs/firewall.md +546 -0
  249. package/docs/headers.md +616 -0
  250. package/docs/index.md +207 -0
  251. package/docs/lexique.md +190 -0
  252. package/docs/oauth2.md +575 -0
  253. package/docs/obtenir-un-jeton.md +225 -0
  254. package/docs/tokens.md +520 -0
  255. package/docs/totp.md +804 -0
  256. package/docs/webauthn.md +733 -0
  257. package/docs/webhooks.md +1016 -0
  258. package/package.json +83 -0
@@ -0,0 +1,486 @@
1
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
2
+ import { ThrottledError } from "../errors/ThrottledError.js";
3
+ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
4
+ import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
5
+ import { recordAudit } from "../src/audit/recordAudit.js";
6
+ import { InvalidTargetError } from "../errors/InvalidTargetError.js";
7
+ import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
8
+ import { JwtKeystore } from "../src/token/JwtKeystore.js";
9
+ import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, readStoreLocation, refusedAdminScopes, resolveAutoStore } from "nodefony";
10
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
11
+ //#region nodefony/service/tokenService.ts
12
+ const serviceName = "tokenService";
13
+ /**
14
+ * Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
15
+ * **maintenance du store** (le seam `ITokenStore.gc()` n'a pas d'autre appelant).
16
+ *
17
+ * Au boot (si `jwt.enabled`) : résout le store pluggable (`tokenStore.store`),
18
+ * crée le keystore Ed25519, les pose au container (`tokenStore`/`jwtKeystore`,
19
+ * consommés par le `JwtAuthenticator` et les endpoints framework), puis arme un
20
+ * **timer de gc** `unref` (n'empêche pas l'arrêt) avec **jitter** de phase
21
+ * (étale les balayages entre process d'un cluster sur un store partagé). À
22
+ * l'arrêt (`onTerminate`) : `clearInterval`/`clearTimeout`.
23
+ *
24
+ * Émission = « password grant » M2M/CLI : credential vérifié par le service
25
+ * `users` → access (JWT signé, 15 min) + refresh (secret opaque haute entropie,
26
+ * stocké **haché**). Refresh = rotation + détection de rejeu (RFC 9700 §4.14).
27
+ */
28
+ var TokenService = class extends Service {
29
+ module;
30
+ #runtime = null;
31
+ /** Émetteur publiable, résolu une fois au boot (cf `#resolvePublication`). */
32
+ #published = null;
33
+ #store = null;
34
+ #keystore = null;
35
+ #jose = null;
36
+ #users = null;
37
+ #throttler = null;
38
+ #throttlerResolved = false;
39
+ #gc = null;
40
+ constructor(module) {
41
+ super(serviceName, module.container, module.notificationsCenter, module.options);
42
+ this.module = module;
43
+ this.kernel?.once("onBoot", () => this.#build());
44
+ this.kernel?.once("onTerminate", () => this.#shutdown());
45
+ }
46
+ #build() {
47
+ let config;
48
+ try {
49
+ config = defineSecurityConfig(this.options);
50
+ } catch {
51
+ return;
52
+ }
53
+ const jwtEnabled = config.jwt.enabled;
54
+ const apiKeysEnabled = config.apiKeys.enabled;
55
+ if (!jwtEnabled && !apiKeysEnabled) {
56
+ this.log("token service idle — JWT et clés API désactivés", "DEBUG");
57
+ return;
58
+ }
59
+ let storeName = config.tokenStore.store;
60
+ let reason = `store explicitement configuré ("${storeName}")`;
61
+ if (storeName === AUTO_STORE) {
62
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores());
63
+ storeName = auto.store;
64
+ reason = auto.reason;
65
+ this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
66
+ }
67
+ const factory = getTokenStoreFactory(storeName);
68
+ if (!factory) {
69
+ const msg = `token store "${storeName}" inconnu (enregistrés : ${listTokenStores().join(", ") || "aucun"})`;
70
+ if (this.kernel?.environment === "production") throw new Error(`${msg} — JWT/clés API indisponibles : boot avorté.`);
71
+ this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
72
+ return;
73
+ }
74
+ if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. Déclarer une infra durable (NF_DATABASE_URL) ou un store persistant.", "WARNING");
75
+ this.#store = factory({
76
+ container: this.container,
77
+ config
78
+ });
79
+ this.kernel?.registerStoreResolution({
80
+ brick: "tokens",
81
+ nature: "durable",
82
+ configured: config.tokenStore.store,
83
+ resolved: storeName,
84
+ available: listTokenStores(),
85
+ reason,
86
+ configPath: "security.tokenStore.store",
87
+ location: readStoreLocation(this.#store)
88
+ });
89
+ this.container?.set("tokenStore", this.#store);
90
+ if (jwtEnabled) {
91
+ this.#runtime = resolveJwtRuntime(config.jwt);
92
+ this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
93
+ this.container?.set("jwtKeystore", this.#keystore);
94
+ this.#resolvePublication(config.jwt.jwks, this.#runtime.issuer);
95
+ }
96
+ this.#gc = new GcScheduler({
97
+ intervalS: config.tokenStore.gcIntervalS,
98
+ jitter: config.tokenStore.gcJitter,
99
+ run: () => this.runGc(),
100
+ onError: (e) => this.log(e, "ERROR")
101
+ });
102
+ this.#gc.start();
103
+ this.log(`token service ready — store "${config.tokenStore.store}", jwt=${jwtEnabled}, apiKeys=${apiKeysEnabled}, gc ${config.tokenStore.gcIntervalS}s`, "DEBUG");
104
+ }
105
+ #shutdown() {
106
+ this.#gc?.stop();
107
+ this.#gc = null;
108
+ }
109
+ /** `true` si l'émission JWT (signature + refresh) est opérationnelle. */
110
+ isEnabled() {
111
+ return this.#keystore !== null && this.#runtime !== null;
112
+ }
113
+ /**
114
+ * Décide UNE fois, au boot, si cette application peut se déclarer émetteur.
115
+ *
116
+ * Trois conditions, et la troisième est celle qui surprend : l'émetteur doit
117
+ * être une **URL https** (RFC 8414 §2). Le défaut `"nodefony"` de
118
+ * {@link resolveJwtRuntime} n'en est pas une — parfaitement inoffensif tant
119
+ * que Nodefony émet ET vérifie ses propres jetons (`iss` n'est alors qu'une
120
+ * chaîne comparée à elle-même), mais inutilisable comme identifiant public.
121
+ *
122
+ * 🔴 **On ne DEVINE pas cette URL.** Derrière un relais (HAProxy, ingress,
123
+ * CDN), le processus n'a aucun moyen fiable de connaître son adresse
124
+ * publique : `Host` et `X-Forwarded-*` viennent de la requête, donc du client
125
+ * en dernière analyse. La dériver ferait servir, par le VRAI serveur, un
126
+ * document `issuer: https://attaquant.example` — crédible, et empoisonnant
127
+ * tout cache mutualisé. Un argument non sécuritaire suffirait d'ailleurs :
128
+ * l'émetteur est gravé dans le `iss` de chaque jeton DÉJÀ émis ; variable
129
+ * selon l'hôte d'entrée, il ferait rejeter un jeton valide.
130
+ *
131
+ * Le refus est donc ANNONCÉ (avertissement au boot) plutôt que masqué
132
+ * derrière un document qui ne mènerait nulle part.
133
+ */
134
+ #resolvePublication(wanted, issuer) {
135
+ if (!wanted) return;
136
+ try {
137
+ this.#published = canonicalIssuer(issuer);
138
+ } catch {
139
+ this.#published = null;
140
+ this.log(`JWKS non publié : \`security.jwt.issuer\` vaut « ${issuer} », qui n'est pas une URL https. Renseigner l'URL publique de cette application (RFC 8414 §2) — derrière un relais, elle ne peut pas être devinée. Les jetons restent émis et vérifiés normalement.`, "WARNING");
141
+ }
142
+ }
143
+ /**
144
+ * Émetteur sous lequel cette application accepte d'être DÉCOUVERTE, ou `null`.
145
+ *
146
+ * C'est la question que pose `@nodefony/framework` au moment de monter (ou
147
+ * non) `/.well-known/oauth-authorization-server` et `/.well-known/jwks.json` :
148
+ * il ne lit pas la configuration de sécurité, il obtient une réponse. `null`
149
+ * = aucune route, donc `404` — pas de document creux, pas de demi-mesure.
150
+ *
151
+ * @returns l'émetteur canonique publiable, ou `null` si rien ne doit l'être
152
+ */
153
+ publishedIssuer() {
154
+ return this.#published;
155
+ }
156
+ /**
157
+ * Jeu de clés **publiques** de signature, tel qu'il doit être servi.
158
+ *
159
+ * Ne contient que des paramètres publics (RFC 8037/7517) — le keystore ne
160
+ * sérialise jamais `d`. Rien n'est calculé ici : la route est une porte, la
161
+ * matière vient du keystore.
162
+ *
163
+ * @returns le JWKS public
164
+ * @throws Error si la capacité JWT n'est pas active (garde de programmation :
165
+ * les routes ne sont montées que si {@link publishedIssuer} répond)
166
+ */
167
+ async getPublicJWKS() {
168
+ if (!this.#keystore) throw new Error("JWKS demandé alors que la capacité JWT est inactive (`security.jwt.enabled: false`).");
169
+ return this.#keystore.getPublicJWKS();
170
+ }
171
+ /**
172
+ * Une passe de purge du store (`ITokenStore.gc()`) — point d'entrée public d'un
173
+ * ordonnanceur : le {@link GcScheduler} l'appelle, mais le futur worker cron
174
+ * (`security:token-gc` / k8s CronJob) peut l'appeler à sa place (poser alors
175
+ * `tokenStore.gcIntervalS: 0`). L'anti-empilement et la capture d'erreur vivent
176
+ * dans le GcScheduler (via `onError`) — ici, la passe métier nue.
177
+ *
178
+ * ⚠️ Un store **local** (`memory`/`file`) est par-process (mémoires disjointes) :
179
+ * seul SON process peut le purger → le timer in-process reste indispensable. Un
180
+ * store **partagé** (ORM) peut être délégué au worker cron (un seul balayage).
181
+ *
182
+ * @returns nombre d'entrées purgées.
183
+ */
184
+ async runGc() {
185
+ if (!this.#store) return 0;
186
+ const t0 = performance.now();
187
+ const purged = await this.#store.gc();
188
+ if (purged > 0) this.log(`token gc: ${purged} jeton(s) purgé(s) en ${(performance.now() - t0).toFixed(1)}ms`, "DEBUG");
189
+ return purged;
190
+ }
191
+ /**
192
+ * Émet un couple access/refresh après vérification d'un credential
193
+ * identifiant/mot de passe (grant M2M/CLI). Throttling NIST partagé si activé.
194
+ *
195
+ * @throws ThrottledError (429) — backoff actif.
196
+ * @throws AuthenticationError (401, message uniforme) — credential invalide.
197
+ */
198
+ async issueForCredentials(identifier, password, requestedScopes, resource) {
199
+ if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
200
+ this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "invalid_credentials");
201
+ throw new AuthenticationError("Invalid credentials");
202
+ }
203
+ const throttler = this.#resolveThrottler();
204
+ if (throttler !== null) {
205
+ const retryAfterS = throttler.check(identifier);
206
+ if (retryAfterS > 0) {
207
+ this.#auditGrant("login.throttled", identifier, "throttled");
208
+ throw new ThrottledError(retryAfterS);
209
+ }
210
+ }
211
+ const user = await this.#resolveUsers().authenticate(identifier, password);
212
+ if (user === null) {
213
+ throttler?.recordFailure(identifier);
214
+ this.#auditGrant("login.failure", identifier, "invalid_credentials");
215
+ throw new AuthenticationError("Invalid credentials");
216
+ }
217
+ throttler?.recordSuccess(identifier);
218
+ return this.issueTokens(user, requestedScopes, resource);
219
+ }
220
+ #auditGrant(action, actor, reason) {
221
+ recordAudit(this.container, {
222
+ category: "auth",
223
+ action,
224
+ outcome: "failure",
225
+ actor,
226
+ reason
227
+ });
228
+ }
229
+ /**
230
+ * Résout l'audience (`aud`) d'un jeton à émettre — RFC 8707 §2.
231
+ *
232
+ * Le client dit POUR QUI il demande le jeton ; le serveur décide s'il accepte.
233
+ * La liste `security.jwt.audiences` est donc une **liste blanche de ressources
234
+ * demandables**, et non une simple valeur par défaut : sans elle, n'importe
235
+ * quel porteur d'un identifiant valide se ferait délivrer un jeton portant
236
+ * l'audience de son choix — y compris celle d'une ressource à laquelle il n'a
237
+ * rien à faire, dont la porte accepterait alors ce jeton sans sourciller.
238
+ *
239
+ * **Une seule ressource.** La RFC autorise plusieurs `resource` mais recommande
240
+ * l'inverse (§3) : « If a bearer token has multiple intended recipients […] the
241
+ * token is valid at more than one protected resource and can be used by any one
242
+ * of those resources to access any of the others », d'où « a high degree of
243
+ * trust between the involved parties is needed » — et elle prévoit qu'un
244
+ * serveur soit « unwilling or unable » de le faire. Nous le sommes : la portée
245
+ * minimale est le seul réglage qui ne se retourne pas contre l'application.
246
+ *
247
+ * @param requested - valeur `resource` telle que reçue, ou rien
248
+ * @returns l'audience à inscrire dans le jeton
249
+ * @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
250
+ * non déclarée
251
+ */
252
+ #resolveAudience(requested) {
253
+ const fallback = this.#runtime.audiences[0];
254
+ if (requested === void 0 || requested === null) return fallback;
255
+ if (Array.isArray(requested)) throw new InvalidTargetError("A single `resource` is accepted — a token valid at several resources lets each of them act at the others (RFC 8707 §3).");
256
+ if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
257
+ let url;
258
+ try {
259
+ url = new URL(requested);
260
+ } catch {
261
+ throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
262
+ }
263
+ if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
264
+ if (!this.#runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
265
+ return requested;
266
+ }
267
+ /**
268
+ * Émet un couple access/refresh pour un utilisateur déjà authentifié.
269
+ *
270
+ * @param user - porteur, déjà authentifié
271
+ * @param requestedScopes - scopes demandés (RFC 6749 §3.3)
272
+ * @param resource - ressource visée (RFC 8707) ; omise = audience par défaut
273
+ * @throws InvalidTargetError (400) si `resource` ne peut pas être servie
274
+ */
275
+ /**
276
+ * Borne les scopes demandés à ce que le porteur peut légitimement obtenir.
277
+ *
278
+ * 🔴 Un scope se DEMANDE ; il ne s'accorde pas pour autant. Les scopes
279
+ * d'administration du framework (`admin:read`, `admin:write`) ouvrent le plan
280
+ * d'administration à un porteur de jeton : les signer sur simple demande
281
+ * ferait de n'importe quel compte un administrateur — il lui suffirait de le
282
+ * demander. La règle de ce qui est réservé vit au cœur
283
+ * ({@link refusedAdminScopes}), à côté de la traduction inverse qui les
284
+ * consomme ; ici on l'applique et on le DIT.
285
+ *
286
+ * Retirer plutôt que refuser tout le grant : RFC 6749 §3.3 prévoit
287
+ * explicitement qu'un serveur accorde MOINS que demandé, à condition
288
+ * d'informer le client — c'est ce que fait le champ `scope` de la réponse,
289
+ * déjà rempli avec les scopes réellement accordés.
290
+ *
291
+ * Les scopes d'une APPLICATION ne sont pas jugés ici : c'est à elle de dire
292
+ * qui peut obtenir `shop:read`.
293
+ *
294
+ * @param user - le porteur, déjà authentifié.
295
+ * @param requested - ce qu'il demande.
296
+ * @returns ce qui lui est accordé.
297
+ */
298
+ #grantableScopes(user, requested) {
299
+ const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
300
+ const refuses = refusedAdminScopes(requested, roles);
301
+ if (refuses.length === 0) return requested;
302
+ this.log(`grant : scope(s) « ${refuses.join(" ")} » NON accordé(s) à « ${user.identifier} » — réservé(s) au rôle d'administration. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
303
+ return requested.filter((scope) => !refuses.includes(scope));
304
+ }
305
+ async issueTokens(user, requestedScopes, resource, accessTtlS) {
306
+ this.#ensureReady();
307
+ const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
308
+ const audience = this.#resolveAudience(resource);
309
+ const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
310
+ const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
311
+ await this.#store.put(record);
312
+ recordAudit(this.container, {
313
+ category: "token",
314
+ action: "token.issued",
315
+ outcome: "success",
316
+ actor: user.identifier,
317
+ metadata: {
318
+ tokenId: record.id,
319
+ scopes
320
+ }
321
+ });
322
+ return {
323
+ access_token: access,
324
+ refresh_token: raw,
325
+ token_type: "Bearer",
326
+ expires_in: accessTtlS ?? this.#runtime.accessTtlS,
327
+ scope: scopes.join(" ")
328
+ };
329
+ }
330
+ /**
331
+ * Rotation d'un refresh token (RFC 9700 §4.14) : valide le refresh présenté,
332
+ * émet un nouveau couple, révoque l'ancien. Un refresh **déjà révoqué**
333
+ * re-présenté = rejeu → toute la famille est coupée.
334
+ *
335
+ * @param rawRefresh - le refresh token présenté, en clair
336
+ * @param resource - ressource visée (RFC 8707 §2.2). Sur un `refresh_token`,
337
+ * la politique « may limit the acceptable resources to those that
338
+ * were originally granted […] or a subset thereof » : un jeton ne
339
+ * portant qu'une seule audience, le seul sous-ensemble possible est
340
+ * elle-même. Demander autre chose est donc refusé, jamais ignoré —
341
+ * sinon la rotation devient le chemin par lequel on obtient une
342
+ * audience qu'on n'a pas su demander à l'émission.
343
+ * @throws AuthenticationError (401) — refresh inconnu/expiré/révoqué, sujet banni.
344
+ * @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
345
+ */
346
+ async refresh(rawRefresh, resource) {
347
+ this.#ensureReady();
348
+ if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
349
+ const store = this.#store;
350
+ const record = await store.findByHash(this.#hash(rawRefresh));
351
+ if (!record || record.kind !== "refresh") throw new AuthenticationError("Invalid token");
352
+ if (record.revokedAt !== null) {
353
+ if (record.family) await store.revokeFamily(record.family, "reuse_detected");
354
+ recordAudit(this.container, {
355
+ category: "token",
356
+ action: "token.reuse_detected",
357
+ outcome: "denied",
358
+ actor: record.subjectId,
359
+ reason: "reuse_detected",
360
+ metadata: {
361
+ tokenId: record.id,
362
+ family: record.family
363
+ }
364
+ });
365
+ throw new AuthenticationError("Invalid token");
366
+ }
367
+ const now = Date.now();
368
+ if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
369
+ const user = await this.#resolveUserForRefresh(record.subjectId);
370
+ const scopes = [...record.scopes];
371
+ const audience = record.audience?.[0] ?? this.#runtime.audiences[0];
372
+ if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
373
+ const access = await this.#signAccess(user.identifier, scopes, audience);
374
+ if (!this.#runtime.rotateRefresh) return {
375
+ access_token: access,
376
+ refresh_token: rawRefresh,
377
+ token_type: "Bearer",
378
+ expires_in: this.#runtime.accessTtlS,
379
+ scope: scopes.join(" ")
380
+ };
381
+ const family = record.family ?? this.#randomId();
382
+ const next = this.#buildRefresh(user.identifier, scopes, family, audience);
383
+ await store.put(next.record);
384
+ record.replacedBy = next.record.id;
385
+ record.revokedAt = now;
386
+ record.revokedReason = "rotated";
387
+ await store.put(record);
388
+ return {
389
+ access_token: access,
390
+ refresh_token: next.raw,
391
+ token_type: "Bearer",
392
+ expires_in: this.#runtime.accessTtlS,
393
+ scope: scopes.join(" ")
394
+ };
395
+ }
396
+ async #signAccess(subject, scopes, audience, ttlS) {
397
+ const jose = await this.#ensureJose();
398
+ const { key, kid } = await this.#keystore.getSigningKey();
399
+ const rt = this.#runtime;
400
+ return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
401
+ alg: "EdDSA",
402
+ kid,
403
+ typ: "at+jwt"
404
+ }).setIssuedAt().setIssuer(rt.issuer).setSubject(subject).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
405
+ }
406
+ /**
407
+ * Construit (sans persister) un record refresh + son secret opaque en clair.
408
+ *
409
+ * @param audience - ressource ACCORDÉE, mémorisée pour que la rotation rende
410
+ * un jeton de même portée. Sans elle, le renouvellement élargirait
411
+ * silencieusement l'accès à l'audience par défaut — un downscoping
412
+ * qui s'annule au bout de quelques minutes n'en est pas un.
413
+ */
414
+ #buildRefresh(subject, scopes, family, audience) {
415
+ const raw = `nfr_${randomBytes(32).toString("base64url")}`;
416
+ const now = Date.now();
417
+ return {
418
+ record: {
419
+ id: randomUUID(),
420
+ kind: "refresh",
421
+ name: "refresh token",
422
+ prefix: null,
423
+ subjectId: subject,
424
+ subjectType: "user",
425
+ tenantId: null,
426
+ scopes: [...scopes],
427
+ audience: [audience],
428
+ resources: null,
429
+ secretHash: this.#hash(raw),
430
+ hashAlg: "sha256",
431
+ clientId: null,
432
+ cnf: null,
433
+ family,
434
+ replacedBy: null,
435
+ createdAt: now,
436
+ expiresAt: now + this.#runtime.refreshTtlS * 1e3,
437
+ lastUsedAt: null,
438
+ lastUsedIp: null,
439
+ lastUsedUserAgent: null,
440
+ revokedAt: null,
441
+ revokedReason: null,
442
+ metadata: {}
443
+ },
444
+ raw
445
+ };
446
+ }
447
+ #ensureReady() {
448
+ if (!this.#store || !this.#keystore || !this.#runtime) throw new Error("TokenService: non initialisé (JWT désactivé ou store indisponible)");
449
+ }
450
+ async #ensureJose() {
451
+ return this.#jose ??= await import("jose");
452
+ }
453
+ #hash(secret) {
454
+ return createHash("sha256").update(secret).digest("hex");
455
+ }
456
+ #randomId() {
457
+ return randomBytes(16).toString("base64url");
458
+ }
459
+ #resolveUsers() {
460
+ if (this.#users === null) {
461
+ const users = this.get("users");
462
+ if (!users) throw new Error("TokenService: aucun service 'users' (IPasswordVerifier & IUserProvider) dans le container — enregistrer un UserService au boot.");
463
+ this.#users = users;
464
+ }
465
+ return this.#users;
466
+ }
467
+ async #resolveUserForRefresh(subjectId) {
468
+ let user;
469
+ try {
470
+ user = await this.#resolveUsers().loadUserByIdentifier(subjectId);
471
+ } catch {
472
+ throw new AuthenticationError("Invalid token");
473
+ }
474
+ if (!user.isActive() || user.isLocked()) throw new AuthenticationError("Invalid token");
475
+ return user;
476
+ }
477
+ #resolveThrottler() {
478
+ if (!this.#throttlerResolved) {
479
+ this.#throttler = this.get("loginThrottler") ?? null;
480
+ this.#throttlerResolved = true;
481
+ }
482
+ return this.#throttler;
483
+ }
484
+ };
485
+ //#endregion
486
+ export { TokenService, TokenService as default };
@@ -0,0 +1,209 @@
1
+ import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
+ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
3
+ import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
4
+ import { deriveTotpKey } from "../src/totp/totpCipher.js";
5
+ import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
6
+ import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, readStoreLocation, resolveAutoStore } from "nodefony";
7
+ //#region nodefony/service/totp.ts
8
+ const serviceName = "totp";
9
+ function isFlushable(s) {
10
+ return s !== null && typeof s.flushNow === "function";
11
+ }
12
+ /**
13
+ * **2FA TOTP** (P6.17, RFC 6238) — service d'orchestration du second facteur.
14
+ *
15
+ * Coquille fine : au boot (si `totp.enabled`) il résout le **store** de secrets
16
+ * pluggable + la **clé de chiffrement** AES-256-GCM, puis délègue toute la logique
17
+ * aux opérations pures `totpOperations` (testées sans serveur). Le TOTP est un
18
+ * facteur de **login step-up** (le code n'est présenté qu'à la connexion, calque
19
+ * WebAuthn/OAuth), pas un authenticator du firewall — `session.user` n'est posé
20
+ * qu'une fois le second facteur validé (Zero Trust 401 protège tout le reste).
21
+ *
22
+ * **Clé de chiffrement** : le secret TOTP est réversible (le serveur le relit pour
23
+ * calculer le code) → chiffré, jamais haché. La clé vient de `totp.encryptionKey`
24
+ * (dérivée HKDF). Absente : en dev une clé **éphémère** est générée + WARNING (les
25
+ * secrets ne survivent pas au redémarrage) ; en **production** c'est fatal — 2FA
26
+ * désactivé (une clé éphémère rendrait les secrets illisibles après redémarrage /
27
+ * sur les autres pods). Politique calquée sur RedisIdempotencyStore.
28
+ */
29
+ var TotpService = class extends Service {
30
+ module;
31
+ #deps = null;
32
+ #store = null;
33
+ #ready = false;
34
+ constructor(module) {
35
+ super(serviceName, module.container, module.notificationsCenter, module.options);
36
+ this.module = module;
37
+ this.kernel?.once("onBoot", () => this.#build());
38
+ this.kernel?.once("onTerminate", () => void this.#shutdown());
39
+ }
40
+ #build() {
41
+ let config;
42
+ try {
43
+ config = defineSecurityConfig(this.options);
44
+ } catch {
45
+ return;
46
+ }
47
+ if (!config.totp.enabled) {
48
+ this.log("totp idle — 2FA désactivé en config", "DEBUG");
49
+ return;
50
+ }
51
+ const store = this.#resolveStore(config);
52
+ if (!store) return;
53
+ const key = this.#resolveKey(config);
54
+ if (!key) return;
55
+ this.#store = store;
56
+ this.#deps = {
57
+ store,
58
+ key,
59
+ now: () => Date.now(),
60
+ issuer: config.totp.issuer ?? this.#defaultIssuer(),
61
+ algorithm: config.totp.algorithm,
62
+ digits: config.totp.digits,
63
+ period: config.totp.period,
64
+ window: config.totp.window,
65
+ recoveryCodesCount: config.totp.recoveryCodes
66
+ };
67
+ this.#ready = true;
68
+ this.log(`totp ready — store "${config.totp.store}", ${config.totp.digits} chiffres / ${config.totp.period}s`, "DEBUG");
69
+ }
70
+ /**
71
+ * Résout le store de secrets : adapter posé au container (ORM/Redis) en priorité,
72
+ * sinon le driver configuré. `auto` (défaut) suit l'infra database déclarée puis
73
+ * un backend local persistant (sqlite/drizzle chargé), repli `memory` ANNONCÉ.
74
+ */
75
+ #resolveStore(config) {
76
+ const existing = this.get("totpSecretStore");
77
+ if (existing) {
78
+ this.kernel?.registerStoreResolution({
79
+ brick: "totp",
80
+ nature: "durable",
81
+ configured: config.totp.store,
82
+ resolved: deriveStoreBackend(existing),
83
+ available: listTotpStores(),
84
+ reason: "adapter posé au container (infra database déclarée)",
85
+ configPath: "security.totp.store",
86
+ location: readStoreLocation(existing)
87
+ });
88
+ return existing;
89
+ }
90
+ let driver = config.totp.store;
91
+ let reason = `store explicitement configuré ("${driver}")`;
92
+ if (driver === AUTO_STORE) {
93
+ const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores());
94
+ driver = auto.store;
95
+ reason = auto.reason;
96
+ this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
97
+ }
98
+ const factory = getTotpStoreFactory(driver);
99
+ if (!factory) {
100
+ const msg = `totp store "${driver}" inconnu (enregistrés : ${listTotpStores().join(", ") || "aucun"})`;
101
+ if (this.kernel?.environment === "production") throw new Error(`${msg} — 2FA indisponible : boot avorté.`);
102
+ this.log(`${msg} — 2FA indisponible`, "CRITIC");
103
+ return null;
104
+ }
105
+ if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). Déclarer une infra durable (NF_DATABASE_URL) ou charger @nodefony/drizzle.", "WARNING");
106
+ const store = factory({
107
+ container: this.container,
108
+ config
109
+ });
110
+ this.container?.set("totpSecretStore", store);
111
+ this.kernel?.registerStoreResolution({
112
+ brick: "totp",
113
+ nature: "durable",
114
+ configured: config.totp.store,
115
+ resolved: driver,
116
+ available: listTotpStores(),
117
+ reason,
118
+ configPath: "security.totp.store",
119
+ location: readStoreLocation(store)
120
+ });
121
+ return store;
122
+ }
123
+ /**
124
+ * Résout la clé de chiffrement AES-256 du secret au repos. Clé de config →
125
+ * dérivée HKDF. Absente : dev = clé éphémère + WARNING ; prod = fatal (null →
126
+ * 2FA désactivé), pour ne jamais chiffrer un secret avec une clé non
127
+ * reproductible (illisible après redémarrage ou sur un autre pod).
128
+ */
129
+ #resolveKey(config) {
130
+ const material = config.totp.encryptionKey;
131
+ if (material && material.length > 0) return deriveTotpKey(material);
132
+ if (this.kernel?.environment === "production") {
133
+ this.log("totp: AUCUNE clé de chiffrement (`security.totp.encryptionKey`) en PRODUCTION — 2FA désactivé (un secret chiffré par une clé éphémère serait illisible après redémarrage / sur les autres pods). Fournir une clé ≥ 32 octets depuis l'environnement.", "CRITIC");
134
+ return null;
135
+ }
136
+ this.log("totp: aucune clé de chiffrement configurée — clé ÉPHÉMÈRE générée (dev). Les secrets 2FA ne survivront pas au redémarrage. Définir `totp.encryptionKey` — générer la clé et le câblage : `npx nodefony security:secrets`.", "WARNING");
137
+ return generateEphemeralKey();
138
+ }
139
+ /**
140
+ * Arrêt propre : flush immédiat du store s'il est persistant (driver `file`) →
141
+ * aucune écriture en attente n'est perdue. No-op pour un store mémoire/adapter.
142
+ */
143
+ async #shutdown() {
144
+ if (isFlushable(this.#store)) try {
145
+ await this.#store.flushNow();
146
+ } catch (e) {
147
+ this.log(e, "ERROR");
148
+ }
149
+ }
150
+ /** `true` si le 2FA est opérationnel (activé en config, boot OK). */
151
+ isEnabled() {
152
+ return this.#ready;
153
+ }
154
+ /** Démarre l'enrôlement (secret + QR affichés 1×). */
155
+ beginEnrollment(userId, account) {
156
+ return beginTotpEnrollment(this.#ensureReady(), userId, account);
157
+ }
158
+ /** Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs. */
159
+ confirmEnrollment(userId, code) {
160
+ return confirmTotpEnrollment(this.#ensureReady(), userId, code);
161
+ }
162
+ /** Vérifie un second facteur au login (code TOTP ou code de récupération). */
163
+ verifyLogin(userId, code) {
164
+ return verifyTotpLogin(this.#ensureReady(), userId, code);
165
+ }
166
+ /** Désactive le 2FA d'un utilisateur. */
167
+ disable(userId) {
168
+ return disableTotp(this.#ensureReady(), userId);
169
+ }
170
+ /** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
171
+ status(userId) {
172
+ return totpStatus(this.#ensureReady(), userId);
173
+ }
174
+ /**
175
+ * Page d'enrôlements 2FA — pagination **native au store** (jamais un parcours
176
+ * complet). Vue sans secret ni condensats, garantie par le contrat du store.
177
+ *
178
+ * @param query - fenêtre + filtres ({@link ITotpListQuery}).
179
+ * @returns la page d'enrôlements.
180
+ */
181
+ listPage(query) {
182
+ return this.#ensureReady().store.listPage(query);
183
+ }
184
+ /**
185
+ * Nombre d'enrôlements correspondant aux filtres — le KPI « couverture 2FA »
186
+ * sans énumérer.
187
+ *
188
+ * @param query - filtres ({@link ITotpListQuery}) ; `limit` ignoré.
189
+ * @returns le compte exact.
190
+ */
191
+ countEnrollments(query) {
192
+ return this.#ensureReady().store.countEnrollments(query);
193
+ }
194
+ /** 2FA activé pour cet utilisateur ? (raccourci pour le flow de login). */
195
+ async isEnabledFor(userId) {
196
+ if (!this.#ready || !this.#deps) return false;
197
+ return (await totpStatus(this.#deps, userId)).enabled;
198
+ }
199
+ #defaultIssuer() {
200
+ const name = this.kernel?.projectName;
201
+ return name && name !== "NODEFONY" ? name : "Nodefony";
202
+ }
203
+ #ensureReady() {
204
+ if (!this.#ready || !this.#deps) throw new Error("TotpService: non initialisé (2FA désactivé ou boot échoué)");
205
+ return this.#deps;
206
+ }
207
+ };
208
+ //#endregion
209
+ export { TotpService, TotpService as default };