@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,236 @@
1
+ import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS } from "./tokenSort.js";
2
+ import { matchesTokenStatus } from "./tokenStatus.js";
3
+ import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
4
+ //#region nodefony/src/token/MemoryTokenStore.ts
5
+ /**
6
+ * Filtre un record contre une requête de listing — prédicat de RÉFÉRENCE du
7
+ * contrat, réutilisé par les implémentations qui évaluent en mémoire.
8
+ *
9
+ * @param now - instant de référence pour l'état du jeton (`status`). Requis :
10
+ * « expiré » n'a pas de sens sans une horloge, et la lire ici ferait dépendre
11
+ * le résultat du moment du test plutôt que de la donnée.
12
+ */
13
+ function matchesTokenQuery(record, query, now) {
14
+ if (query.subjectId !== void 0 && record.subjectId !== query.subjectId) return false;
15
+ if (query.kind !== void 0 && record.kind !== query.kind) return false;
16
+ if (!matchesTokenStatus(record, query.status, now)) return false;
17
+ return true;
18
+ }
19
+ /**
20
+ * Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
21
+ *
22
+ * 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
23
+ * partagée entre process (pas de cluster) et **volatile** (tout est perdu au
24
+ * redémarrage) → en production multi-process, utiliser un adapter ORM ou Redis.
25
+ *
26
+ * Perf/mémoire : les `Map` n'existent que si le store est instancié (JWT activé),
27
+ * jamais sur le hot path par requête. La denylist `jti` est bornée par un
28
+ * **balayage amorti** (purge des entrées expirées tous les 256 ajouts) doublé
29
+ * d'une expiration paresseuse à la lecture — pas de minuterie, pas de fuite.
30
+ *
31
+ * Horloge injectable (`now`) pour des tests déterministes (pattern `LoginThrottler`).
32
+ */
33
+ var MemoryTokenStore = class {
34
+ /**
35
+ * {@inheritDoc ITokenStore.sortableFields}
36
+ *
37
+ * Le store mémoire porte l'enregistrement complet : il sait donc trier tout le
38
+ * vocabulaire public, sans réduction de capacité.
39
+ */
40
+ sortableFields = TOKEN_SORTABLE_FIELDS;
41
+ /** id → enregistrement (source de vérité). */
42
+ #byId = /* @__PURE__ */ new Map();
43
+ /** hash de secret → id (recherche au login). */
44
+ #idByHash = /* @__PURE__ */ new Map();
45
+ /** famille de rotation → ids (révocation groupée, reuse detection). */
46
+ #idsByFamily = /* @__PURE__ */ new Map();
47
+ /** porteur → ids (console « mes jetons », révocation ciblée). */
48
+ #idsBySubject = /* @__PURE__ */ new Map();
49
+ /** jti d'access denylisté → expiration (epoch ms). */
50
+ #deniedJti = /* @__PURE__ */ new Map();
51
+ /** porteur → seuil `invalidBefore` (epoch ms) — révocation en masse. */
52
+ #invalidBefore = /* @__PURE__ */ new Map();
53
+ #now;
54
+ /** Fenêtre de conservation d'un PAT révoqué sans expiration (audit) avant purge. */
55
+ #retentionRevokedMs;
56
+ #sweepCounter = 0;
57
+ constructor(now = Date.now, retentionRevokedMs = 2592e6) {
58
+ this.#now = now;
59
+ this.#retentionRevokedMs = retentionRevokedMs;
60
+ }
61
+ put(record) {
62
+ this.#byId.set(record.id, record);
63
+ this.#idByHash.set(record.secretHash, record.id);
64
+ this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
65
+ if (record.family) this.#addToIndex(this.#idsByFamily, record.family, record.id);
66
+ return Promise.resolve();
67
+ }
68
+ findById(id) {
69
+ return Promise.resolve(this.#byId.get(id) ?? null);
70
+ }
71
+ findByHash(secretHash) {
72
+ const id = this.#idByHash.get(secretHash);
73
+ return Promise.resolve(id !== void 0 ? this.#byId.get(id) ?? null : null);
74
+ }
75
+ findBySubject(subjectId) {
76
+ const ids = this.#idsBySubject.get(subjectId);
77
+ if (!ids) return Promise.resolve([]);
78
+ const out = [];
79
+ for (const id of ids) {
80
+ const record = this.#byId.get(id);
81
+ if (record) out.push(record);
82
+ }
83
+ return Promise.resolve(out);
84
+ }
85
+ listAll() {
86
+ return Promise.resolve([...this.#byId.values()]);
87
+ }
88
+ listPage(query) {
89
+ assertPageQuery(query, "offset");
90
+ const limit = Math.max(1, Math.floor(query.limit));
91
+ const offset = Math.max(0, Math.floor(query.offset ?? 0));
92
+ const now = this.#now();
93
+ const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
94
+ const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
95
+ filtered.sort(compareByOrder(order, (r, field) => r[field]));
96
+ const items = filtered.slice(offset, offset + limit);
97
+ const total = query.withTotal === false ? void 0 : filtered.length;
98
+ return Promise.resolve({
99
+ items,
100
+ total,
101
+ limit,
102
+ offset,
103
+ hasNext: offset + items.length < filtered.length
104
+ });
105
+ }
106
+ countTokens(query) {
107
+ const now = this.#now();
108
+ let n = 0;
109
+ for (const r of this.#byId.values()) if (matchesTokenQuery(r, query, now)) n += 1;
110
+ return Promise.resolve(n);
111
+ }
112
+ markUsed(id, usage) {
113
+ const record = this.#byId.get(id);
114
+ if (record) {
115
+ record.lastUsedAt = usage.at;
116
+ record.lastUsedIp = usage.ip ?? null;
117
+ record.lastUsedUserAgent = usage.userAgent ?? null;
118
+ }
119
+ return Promise.resolve();
120
+ }
121
+ revoke(id, reason) {
122
+ this.#revokeRecord(this.#byId.get(id), reason);
123
+ return Promise.resolve();
124
+ }
125
+ revokeFamily(family, reason) {
126
+ const ids = this.#idsByFamily.get(family);
127
+ if (ids) for (const id of ids) this.#revokeRecord(this.#byId.get(id), reason);
128
+ return Promise.resolve();
129
+ }
130
+ denyJti(jti, expiresAt) {
131
+ this.#deniedJti.set(jti, expiresAt);
132
+ this.#maybeSweep();
133
+ return Promise.resolve();
134
+ }
135
+ isJtiDenied(jti) {
136
+ const expiresAt = this.#deniedJti.get(jti);
137
+ if (expiresAt === void 0) return Promise.resolve(false);
138
+ if (expiresAt <= this.#now()) {
139
+ this.#deniedJti.delete(jti);
140
+ return Promise.resolve(false);
141
+ }
142
+ return Promise.resolve(true);
143
+ }
144
+ revokeAllForSubject(subjectId, invalidBefore) {
145
+ const current = this.#invalidBefore.get(subjectId);
146
+ if (current === void 0 || invalidBefore > current) this.#invalidBefore.set(subjectId, invalidBefore);
147
+ return Promise.resolve();
148
+ }
149
+ getInvalidBefore(subjectId) {
150
+ return Promise.resolve(this.#invalidBefore.get(subjectId) ?? null);
151
+ }
152
+ gc(now = this.#now()) {
153
+ let purged = 0;
154
+ for (const [jti, expiresAt] of this.#deniedJti) if (expiresAt <= now) {
155
+ this.#deniedJti.delete(jti);
156
+ purged++;
157
+ }
158
+ for (const [id, record] of this.#byId) if (this.#isPurgeable(record, now)) {
159
+ this.#removeRecord(id, record);
160
+ purged++;
161
+ }
162
+ return Promise.resolve(purged);
163
+ }
164
+ /**
165
+ * Un record est purgeable s'il est **expiré** (`expiresAt` passé — couvre les
166
+ * refresh, y compris révoqués par rotation : conservés jusqu'à leur `exp` =
167
+ * fenêtre de détection de rejeu, puis tombent ici), OU s'il est un PAT
168
+ * **révoqué sans expiration** au-delà de la fenêtre de rétention (audit) —
169
+ * sinon il resterait éternellement.
170
+ */
171
+ #isPurgeable(record, now) {
172
+ if (record.expiresAt !== null && record.expiresAt <= now) return true;
173
+ return record.revokedAt !== null && record.expiresAt === null && record.revokedAt + this.#retentionRevokedMs <= now;
174
+ }
175
+ /** Instantané sérialisable de l'état courant (records + denylist + seuils). */
176
+ snapshot() {
177
+ return {
178
+ records: [...this.#byId.values()],
179
+ deniedJti: [...this.#deniedJti.entries()],
180
+ invalidBefore: [...this.#invalidBefore.entries()]
181
+ };
182
+ }
183
+ /** Remplace l'état par celui d'un instantané (reconstruit les index dérivés). */
184
+ restore(snapshot) {
185
+ this.#byId.clear();
186
+ this.#idByHash.clear();
187
+ this.#idsByFamily.clear();
188
+ this.#idsBySubject.clear();
189
+ this.#deniedJti.clear();
190
+ this.#invalidBefore.clear();
191
+ for (const record of snapshot.records) {
192
+ this.#byId.set(record.id, record);
193
+ this.#idByHash.set(record.secretHash, record.id);
194
+ this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
195
+ if (record.family) this.#addToIndex(this.#idsByFamily, record.family, record.id);
196
+ }
197
+ for (const [jti, expiresAt] of snapshot.deniedJti) this.#deniedJti.set(jti, expiresAt);
198
+ for (const [subjectId, ts] of snapshot.invalidBefore) this.#invalidBefore.set(subjectId, ts);
199
+ }
200
+ #addToIndex(index, key, id) {
201
+ let set = index.get(key);
202
+ if (!set) {
203
+ set = /* @__PURE__ */ new Set();
204
+ index.set(key, set);
205
+ }
206
+ set.add(id);
207
+ }
208
+ #revokeRecord(record, reason) {
209
+ if (record && record.revokedAt === null) {
210
+ record.revokedAt = this.#now();
211
+ record.revokedReason = reason;
212
+ }
213
+ }
214
+ /** Retire un record et nettoie TOUS ses index (évite les références fantômes). */
215
+ #removeRecord(id, record) {
216
+ this.#byId.delete(id);
217
+ this.#idByHash.delete(record.secretHash);
218
+ this.#dropFromIndex(this.#idsBySubject, record.subjectId, id);
219
+ if (record.family) this.#dropFromIndex(this.#idsByFamily, record.family, id);
220
+ }
221
+ #dropFromIndex(index, key, id) {
222
+ const set = index.get(key);
223
+ if (set) {
224
+ set.delete(id);
225
+ if (set.size === 0) index.delete(key);
226
+ }
227
+ }
228
+ /** Purge amortie des `jti` expirés — borne la denylist sans coût par appel. */
229
+ #maybeSweep() {
230
+ if ((++this.#sweepCounter & 255) !== 0) return;
231
+ const now = this.#now();
232
+ for (const [jti, expiresAt] of this.#deniedJti) if (expiresAt <= now) this.#deniedJti.delete(jti);
233
+ }
234
+ };
235
+ //#endregion
236
+ export { MemoryTokenStore, matchesTokenQuery };
@@ -0,0 +1,231 @@
1
+ import { canonicalIssuer, extractScopes, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
2
+ //#region nodefony/src/token/RemoteJwtVerifier.ts
3
+ /**
4
+ * Codes d'erreur `jose` qui désignent un **jeton fautif** — la liste est
5
+ * BLANCHE, et c'est le point important.
6
+ *
7
+ * La distinction refus / panne n'est pas cosmétique : répondre « jeton refusé »
8
+ * quand l'émetteur est injoignable envoie le client en chercher un autre — qui
9
+ * échouera pareil — et noie la panne dans une statistique d'authentification.
10
+ *
11
+ * Le sens de la liste a été choisi en lisant la source de `jose`, pas de
12
+ * mémoire : un JWKS qui répond `500`, ou dont le corps n'est pas du JSON, y
13
+ * lève une erreur GÉNÉRIQUE (`ERR_JOSE_GENERIC`), et une panne réseau brute
14
+ * remonte sans aucun code. Une liste noire des pannes aurait donc classé ces
15
+ * trois cas — tous des pannes — en « jeton invalide », silencieusement. Ici,
16
+ * ce qui n'est pas explicitement imputable au jeton devient une panne visible.
17
+ */
18
+ const TOKEN_FAULT_CODES = /* @__PURE__ */ new Set([
19
+ "ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
20
+ "ERR_JWT_EXPIRED",
21
+ "ERR_JWT_CLAIM_VALIDATION_FAILED",
22
+ "ERR_JWT_INVALID",
23
+ "ERR_JWS_INVALID",
24
+ "ERR_JOSE_ALG_NOT_ALLOWED",
25
+ "ERR_JWKS_NO_MATCHING_KEY"
26
+ ]);
27
+ /**
28
+ * Vérificateur de jetons d'accès émis par un **serveur d'autorisation tiers**.
29
+ *
30
+ * C'est la pièce qui manquait pour que le rôle *serveur de ressource* du cœur
31
+ * (`nodefony/src/oauth/`) soit autre chose qu'un refus poli : il sait publier ce
32
+ * qu'il protège et dire où prendre un jeton, mais rien, jusqu'ici, ne savait
33
+ * LIRE ce jeton. `JwtAuthenticator` ne vérifie que les jetons émis par
34
+ * Nodefony lui-même (jeu de clés local) ; ici, les clés appartiennent à
35
+ * quelqu'un d'autre, arrivent par le réseau et tournent sans prévenir.
36
+ *
37
+ * ## Ce qui vaut garantie
38
+ *
39
+ * - **L'audience est obligatoire et vient de l'APPELANT** — jamais du jeton. Un
40
+ * jeton parfaitement valide, émis par un émetteur de confiance, pour un AUTRE
41
+ * service, est refusé (RFC 8707 §2). C'est la seule chose qui empêche le
42
+ * rejeu d'un jeton légitime d'une ressource vers une autre.
43
+ * - **L'algorithme est imposé par la configuration** (RFC 8725 §3.1), jamais lu
44
+ * dans l'en-tête ; `alg: none` n'existe pas pour cette API.
45
+ * - **Les clés viennent du `jwks_uri` de l'émetteur**, jamais d'un `jku` ou
46
+ * d'un `jwk` porté par le jeton (§3.5) — sans quoi un attaquant fournirait
47
+ * la clé qui valide sa propre signature.
48
+ * - **La liste des émetteurs est fermée** : un `iss` inconnu est refusé avant
49
+ * toute requête sortante.
50
+ *
51
+ * ## Ce que cette classe ne fait pas
52
+ *
53
+ * Elle n'établit pas d'utilisateur applicatif : elle rend un sujet et des
54
+ * scopes. Rattacher ce sujet à un compte local (approvisionnement à la volée,
55
+ * comptes de service) est une décision d'application, pas de protocole — et
56
+ * l'entremêler ici rendrait impossible d'accepter un appelant purement machine,
57
+ * qui est précisément le cas d'usage.
58
+ *
59
+ * @see references/rfc/ietf/rfc8707.txt — l'audience, qui LIE un jeton à CE service
60
+ */
61
+ var RemoteJwtVerifier = class {
62
+ #issuers;
63
+ #resolved = /* @__PURE__ */ new Map();
64
+ #options;
65
+ #jose = null;
66
+ /**
67
+ * @param options - émetteurs de confiance et réglages réseau
68
+ * @throws Error si un émetteur est invalide, dupliqué, ou déclare un
69
+ * algorithme à secret partagé
70
+ */
71
+ constructor(options) {
72
+ this.#options = options;
73
+ this.#issuers = /* @__PURE__ */ new Map();
74
+ for (const trusted of options.issuers) {
75
+ const issuer = canonicalIssuer(trusted.issuer);
76
+ if (this.#issuers.has(issuer)) throw new Error(`émetteur « ${issuer} » déclaré deux fois — deux politiques pour un même émetteur ne peuvent pas coexister : la seconde serait ignorée en silence.`);
77
+ assertAsymmetric(issuer, trusted.algorithms);
78
+ this.#issuers.set(issuer, {
79
+ ...trusted,
80
+ issuer
81
+ });
82
+ }
83
+ }
84
+ /** Nombre d'émetteurs de confiance — pour l'introspection et les journaux. */
85
+ get size() {
86
+ return this.#issuers.size;
87
+ }
88
+ /**
89
+ * Vérifie un jeton porté, pour UNE ressource donnée.
90
+ *
91
+ * Conforme au contrat `IAccessTokenVerifier` du cœur : un refus est un `null`,
92
+ * jamais une exception. Les exceptions sont réservées aux pannes — un
93
+ * émetteur injoignable n'est pas un jeton invalide.
94
+ *
95
+ * @param token - le jeton brut, tel que présenté
96
+ * @param audience - URI canonique de la ressource visée ; le jeton DOIT la
97
+ * porter dans `aud`
98
+ * @returns le principal établi, ou `null` si le jeton est refusé
99
+ * @throws Error si l'émetteur ne peut pas être joint ou publie un jeu de clés
100
+ * inutilisable — la porte doit alors refuser de servir, pas répondre
101
+ * « jeton invalide »
102
+ */
103
+ async verify(token, audience) {
104
+ if (typeof token !== "string" || token.length === 0) return null;
105
+ const jose = this.#jose ??= await import("jose");
106
+ let claimedIssuer;
107
+ try {
108
+ const claims = jose.decodeJwt(token);
109
+ if (typeof claims.iss !== "string") return null;
110
+ claimedIssuer = canonicalIssuer(claims.iss);
111
+ } catch {
112
+ return null;
113
+ }
114
+ const trusted = this.#issuers.get(claimedIssuer);
115
+ if (!trusted) {
116
+ this.#audit(`jeton refusé : émetteur « ${claimedIssuer} » non déclaré.`);
117
+ return null;
118
+ }
119
+ const { getKey } = await this.#resolve(trusted, jose);
120
+ try {
121
+ const { payload } = await jose.jwtVerify(token, getKey, {
122
+ algorithms: [...trusted.algorithms],
123
+ issuer: trusted.issuer,
124
+ audience,
125
+ clockTolerance: this.#options.clockToleranceS ?? 5,
126
+ ...trusted.typ ? { typ: trusted.typ } : {},
127
+ ...trusted.requiredClaims?.length ? { requiredClaims: [...trusted.requiredClaims] } : {}
128
+ });
129
+ const subject = typeof payload.sub === "string" ? payload.sub : void 0;
130
+ return {
131
+ issuer: claimedIssuer,
132
+ subject,
133
+ scopes: extractScopes(payload),
134
+ expiresAt: typeof payload.exp === "number" ? payload.exp : void 0,
135
+ issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
136
+ tokenId: typeof payload.jti === "string" ? payload.jti : void 0
137
+ };
138
+ } catch (error) {
139
+ const code = error.code;
140
+ if (!code || !TOKEN_FAULT_CODES.has(code)) throw new Error(`vérification impossible : l'émetteur « ${trusted.issuer} » n'a pas fourni de jeu de clés utilisable (${code ?? error.message}).`, { cause: error });
141
+ this.#audit(`jeton refusé (${trusted.issuer}) : ${code}`);
142
+ return null;
143
+ }
144
+ }
145
+ /**
146
+ * Résout — une seule fois par émetteur — la fonction qui fournit les clés.
147
+ *
148
+ * La promesse elle-même est mémorisée, pas son résultat : deux jetons qui
149
+ * arrivent ensemble à froid partagent la même découverte au lieu d'en lancer
150
+ * deux. En cas d'échec, l'entrée est retirée pour qu'un appel ultérieur
151
+ * réessaie — une panne passagère ne doit pas condamner l'émetteur pour la
152
+ * durée de vie du processus.
153
+ */
154
+ #resolve(trusted, jose) {
155
+ if (trusted.localJwks) return this.#build(trusted, jose);
156
+ const pending = this.#resolved.get(trusted.issuer);
157
+ if (pending) return pending;
158
+ const promise = this.#build(trusted, jose).catch((error) => {
159
+ this.#resolved.delete(trusted.issuer);
160
+ throw error;
161
+ });
162
+ this.#resolved.set(trusted.issuer, promise);
163
+ return promise;
164
+ }
165
+ async #build(trusted, jose) {
166
+ if (trusted.localJwks) return {
167
+ trusted,
168
+ getKey: jose.createLocalJWKSet(await trusted.localJwks())
169
+ };
170
+ const jwksUri = trusted.jwksUri ?? await this.#discover(trusted.issuer);
171
+ const options = {
172
+ timeoutDuration: this.#options.timeoutMs ?? 5e3,
173
+ cooldownDuration: this.#options.cooldownMs ?? 3e4,
174
+ cacheMaxAge: this.#options.cacheMaxAgeMs ?? 6e5
175
+ };
176
+ const fetchImpl = this.#options.fetch;
177
+ if (fetchImpl) options[jose.customFetch] = fetchImpl;
178
+ return {
179
+ trusted,
180
+ getKey: jose.createRemoteJWKSet(new URL(jwksUri), options)
181
+ };
182
+ }
183
+ /**
184
+ * Trouve le `jwks_uri` d'un émetteur en essayant les points bien connus,
185
+ * dans l'ordre normatif, et en s'arrêtant au premier document VALIDE.
186
+ *
187
+ * Un document qui répond mais ne se réclame pas du bon émetteur n'est pas une
188
+ * « meilleure réponse que rien » : il est écarté comme s'il n'avait pas
189
+ * répondu, et la recherche continue.
190
+ */
191
+ async #discover(issuer) {
192
+ const doFetch = this.#options.fetch ?? globalThis.fetch;
193
+ const timeout = this.#options.timeoutMs ?? 5e3;
194
+ const failures = [];
195
+ for (const url of issuerMetadataUrls(issuer)) try {
196
+ const response = await doFetch(url, {
197
+ signal: AbortSignal.timeout(timeout),
198
+ redirect: "manual",
199
+ headers: { accept: "application/json" }
200
+ });
201
+ if (!response.ok) {
202
+ failures.push(`${url} → HTTP ${response.status}`);
203
+ continue;
204
+ }
205
+ const metadata = validateIssuerMetadata(await response.json(), issuer);
206
+ this.#audit(`émetteur « ${issuer} » découvert : clés sur ${metadata.jwksUri}.`);
207
+ return metadata.jwksUri;
208
+ } catch (error) {
209
+ failures.push(`${url} → ${error.message}`);
210
+ }
211
+ throw new Error(`découverte impossible pour l'émetteur « ${issuer} » : aucun document de métadonnées valide. Essayé — ${failures.join(" ; ")}. Déclarer \`jwksUri\` évite toute découverte.`);
212
+ }
213
+ #audit(message) {
214
+ this.#options.log?.(message);
215
+ }
216
+ };
217
+ /**
218
+ * Refuse un algorithme à secret partagé sur un jeu de clés PUBLIC.
219
+ *
220
+ * C'est la confusion d'algorithme la plus classique (RFC 8725 §2.1) : la clé
221
+ * publique de l'émetteur est lisible par tout le monde ; acceptée comme secret
222
+ * HMAC, elle laisse n'importe qui signer un jeton valide. La règle vaut au
223
+ * démarrage, où elle empêche la configuration d'exister, plutôt qu'à la
224
+ * vérification, où elle dépendrait d'un jeton pour se manifester.
225
+ */
226
+ function assertAsymmetric(issuer, algorithms) {
227
+ if (algorithms.length === 0) throw new Error(`émetteur « ${issuer} » : aucun algorithme accepté — la liste est la garde principale (RFC 8725 §3.1), elle ne peut pas être vide.`);
228
+ for (const alg of algorithms) if (alg.startsWith("HS") || alg.toLowerCase() === "none") throw new Error(`émetteur « ${issuer} » : algorithme « ${alg} » refusé. Les clés proviennent d'un jeu PUBLIC : un algorithme à secret partagé y transformerait la clé publique en secret de signature.`);
229
+ }
230
+ //#endregion
231
+ export { RemoteJwtVerifier, RemoteJwtVerifier as default };
@@ -0,0 +1,67 @@
1
+ import { anonymousUser } from "@nodefony/user";
2
+ //#region nodefony/src/token/UserToken.ts
3
+ /**
4
+ * Jeton porteur d'un utilisateur réel — produit par les authenticators à
5
+ * credential (`userpassword`, puis `session`/`jwt`...).
6
+ *
7
+ * Cycle en deux états, UN SEUL objet alloué par tentative (cold path login) :
8
+ * 1. `createToken()` → non authentifié : porte le credential brut extrait de la
9
+ * requête, `getUser()` rend l'anonyme (jamais `null`, Zero Trust).
10
+ * 2. `authenticate()` réussit → {@link promote} : l'utilisateur vérifié est posé
11
+ * et le credential est **effacé** (anti-fuite : un mot de passe ne doit
12
+ * survivre ni en mémoire ni dans un heap dump/log).
13
+ *
14
+ * Les attributs (claims, providerId...) sont lazy — `null` tant que rien n'est posé.
15
+ */
16
+ var UserToken = class {
17
+ type;
18
+ #user = null;
19
+ #credentials;
20
+ #attributes = null;
21
+ /**
22
+ * @param type - type du token (`"userpassword"`, `"session"`, `"jwt"`...).
23
+ * @param credentials - credential brut extrait de la requête (vidé au succès).
24
+ */
25
+ constructor(type, credentials = null) {
26
+ this.type = type;
27
+ this.#credentials = credentials;
28
+ }
29
+ /**
30
+ * Marque le jeton authentifié : pose l'utilisateur vérifié et EFFACE le
31
+ * credential. Appelé uniquement par l'authenticator au succès.
32
+ *
33
+ * @param user - utilisateur vérifié par la source d'identité.
34
+ * @returns le jeton lui-même (chaînage).
35
+ */
36
+ promote(user) {
37
+ this.#user = user;
38
+ this.#credentials = null;
39
+ return this;
40
+ }
41
+ getUser() {
42
+ return this.#user ?? anonymousUser;
43
+ }
44
+ getUserIdentifier() {
45
+ return (this.#user ?? anonymousUser).identifier;
46
+ }
47
+ isAuthenticated() {
48
+ return this.#user !== null;
49
+ }
50
+ getRoles() {
51
+ return this.#user ? [...this.#user.roles] : [...anonymousUser.roles];
52
+ }
53
+ getCredentials() {
54
+ return this.#credentials;
55
+ }
56
+ getScopes() {
57
+ return this.#attributes?.get("scopes") ?? [];
58
+ }
59
+ getAttribute(key) {
60
+ return this.#attributes?.get(key);
61
+ }
62
+ setAttribute(key, value) {
63
+ (this.#attributes ??= /* @__PURE__ */ new Map()).set(key, value);
64
+ }
65
+ };
66
+ //#endregion
67
+ export { UserToken, UserToken as default };
@@ -0,0 +1,19 @@
1
+ //#region nodefony/src/token/jwtRuntime.ts
2
+ /**
3
+ * Dérive les paramètres effectifs depuis la config sécurité. `issuer` omis →
4
+ * `"nodefony"` (DEVRAIT être surchargé en prod) ; `audiences` vide → l'app est sa
5
+ * propre audience (`[issuer]`).
6
+ */
7
+ function resolveJwtRuntime(jwt) {
8
+ const issuer = jwt.issuer && jwt.issuer.length > 0 ? jwt.issuer : "nodefony";
9
+ return {
10
+ issuer,
11
+ audiences: jwt.audiences.length > 0 ? [...jwt.audiences] : [issuer],
12
+ accessTtlS: jwt.accessTtlS,
13
+ refreshTtlS: jwt.refreshTtlS,
14
+ rotateRefresh: jwt.rotateRefresh,
15
+ alg: "EdDSA"
16
+ };
17
+ }
18
+ //#endregion
19
+ export { resolveJwtRuntime };
@@ -0,0 +1,134 @@
1
+ import path from "node:path";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
+ import process from "node:process";
5
+ //#region nodefony/src/token/secretFile.ts
6
+ /**
7
+ * Écrire un SECRET sur disque — la seule implémentation du dépôt.
8
+ *
9
+ * ## Les trois règles, et ce que chacune évite
10
+ *
11
+ * 1. **Ne jamais tester la présence avant de lire.** `existsSync(f) ? read(f)
12
+ * : ""` ouvre une fenêtre entre le test et l'usage : le fichier peut
13
+ * disparaître, ou devenir un lien vers ailleurs. La forme juste est de lire
14
+ * et de traiter `ENOENT` — le système de fichiers répond en une opération ce
15
+ * que deux appels ne peuvent pas garantir.
16
+ * 2. **Écrire en 0600, atomiquement.** Un secret créé au masque par défaut est
17
+ * lisible par tous les comptes de la machine, et rien ne le signale. Le
18
+ * couple fichier temporaire + `rename` évite en plus qu'un lecteur tombe sur
19
+ * un fichier à demi écrit.
20
+ * 3. **CONSTATER le mode obtenu.** Le mode demandé est une intention, pas une
21
+ * garantie : NTFS l'ignore, comme un montage FAT/exFAT ou NFS sans mapping
22
+ * d'identité. Une capacité se constate, elle ne se déduit pas de
23
+ * `process.platform` — et si la restriction n'a pas pris, il faut le DIRE
24
+ * plutôt que laisser croire à une protection.
25
+ *
26
+ * ## Pourquoi les deux formes, synchrone et asynchrone
27
+ *
28
+ * Le runtime persiste ses clés dans du code asynchrone ; une commande de CLI
29
+ * écrit un jeton dans un flot synchrone, où introduire une promesse
30
+ * changerait l'ordre des messages affichés. Les deux formes appliquent le même
31
+ * raisonnement, écrit ici une seule fois — deux copies divergeraient, et l'on
32
+ * sait exactement comment : l'une porterait le mode 0600, l'autre non.
33
+ *
34
+ * @module
35
+ */
36
+ /** Mode attendu d'un fichier qui porte un secret : lisible par son seul propriétaire. */
37
+ const MODE_SECRET = 384;
38
+ /**
39
+ * Le contenu du fichier, ou `null` s'il n'existe pas.
40
+ *
41
+ * @throws Toute erreur autre qu'`ENOENT` — un fichier illisible pour cause de
42
+ * droits n'est PAS un fichier absent, et le confondre ferait écraser un
43
+ * secret existant par un fichier neuf.
44
+ */
45
+ async function readIfPresent(file) {
46
+ try {
47
+ return await readFile(file, "utf8");
48
+ } catch (e) {
49
+ if (e.code === "ENOENT") return null;
50
+ throw e;
51
+ }
52
+ }
53
+ /** Forme synchrone de {@link readIfPresent}. */
54
+ function readIfPresentSync(file) {
55
+ try {
56
+ return readFileSync(file, "utf8");
57
+ } catch (e) {
58
+ if (e.code === "ENOENT") return null;
59
+ throw e;
60
+ }
61
+ }
62
+ /**
63
+ * Le mode effectif du fichier n'est-il PAS restreint au propriétaire ?
64
+ *
65
+ * @returns `null` si le fichier a disparu ou n'est pas interrogeable (le chemin
66
+ * d'erreur normal parlera), sinon le mode effectif quand il diffère de
67
+ * 0600 — et `undefined` quand tout va bien.
68
+ */
69
+ function modeNonRestreint(file) {
70
+ let mode;
71
+ try {
72
+ mode = statSync(file).mode & 511;
73
+ } catch {
74
+ return null;
75
+ }
76
+ return mode === 384 ? void 0 : mode;
77
+ }
78
+ /** Forme asynchrone de {@link modeNonRestreint}. */
79
+ async function modeNonRestreintAsync(file) {
80
+ let mode;
81
+ try {
82
+ mode = (await stat(file)).mode & 511;
83
+ } catch {
84
+ return null;
85
+ }
86
+ return mode === 384 ? void 0 : mode;
87
+ }
88
+ /**
89
+ * La phrase à journaliser quand la restriction n'a PAS pris.
90
+ *
91
+ * Elle nomme la cause probable et ce qui reste à faire : un avertissement qui
92
+ * dit seulement « mode inattendu » se lit comme du bruit et finit ignoré.
93
+ */
94
+ function messageNonRestreint(file, mode) {
95
+ return `${file} porte un SECRET mais n'est PAS restreint au seul propriétaire (mode ${mode.toString(8).padStart(4, "0")}, attendu 0600). Le système de fichiers n'applique pas les permissions POSIX (NTFS, FAT/exFAT, NFS sans mapping d'identité), ou le fichier a été déposé par un tiers. La confidentialité dépend alors des seuls droits du dossier — restreignez-les : ` + (process.platform === "win32" ? `icacls "${file}" /inheritance:r /grant:r "%USERNAME%:R"` : `chmod 600 "${file}"`) + `.`;
96
+ }
97
+ /**
98
+ * Écrit un secret : dossier créé, mode 0600, remplacement ATOMIQUE.
99
+ *
100
+ * Le mode est posé à la création du temporaire — et non après `rename` — pour
101
+ * qu'il n'existe à aucun instant un fichier au contenu secret et au masque par
102
+ * défaut. `chmod` est ensuite réappliqué sur la cible : un `rename` par-dessus
103
+ * un fichier EXISTANT conserve, sur certains systèmes, le mode de la cible.
104
+ */
105
+ async function writeSecret(file, content) {
106
+ await mkdir(path.dirname(file), { recursive: true });
107
+ const tmp = `${file}.${process.pid}.tmp`;
108
+ await writeFile(tmp, content, { mode: 384 });
109
+ try {
110
+ await rename(tmp, file);
111
+ } catch (e) {
112
+ await rm(tmp, { force: true }).catch(() => {});
113
+ throw e;
114
+ }
115
+ }
116
+ /** Forme synchrone de {@link writeSecret}. */
117
+ function writeSecretSync(file, content) {
118
+ mkdirSync(path.dirname(file), { recursive: true });
119
+ const tmp = `${file}.${process.pid}.tmp`;
120
+ writeFileSync(tmp, content, { mode: 384 });
121
+ try {
122
+ renameSync(tmp, file);
123
+ } catch (e) {
124
+ try {
125
+ rmSync(tmp, { force: true });
126
+ } catch {}
127
+ throw e;
128
+ }
129
+ try {
130
+ chmodSync(file, 384);
131
+ } catch {}
132
+ }
133
+ //#endregion
134
+ export { MODE_SECRET, messageNonRestreint, modeNonRestreint, modeNonRestreintAsync, readIfPresent, readIfPresentSync, writeSecret, writeSecretSync };