@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,691 @@
1
+ ---
2
+ title: "Clés d'API — jetons opaques révocables pour les machines"
3
+ navTitle: Clés d'API
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: api-keys
7
+ coverageModule: security
8
+ coverageFiles: "apiKey"
9
+ section: "Sécurité"
10
+ audience: [developer]
11
+ tags:
12
+ [
13
+ api-keys,
14
+ pat,
15
+ bearer,
16
+ revocation,
17
+ crc,
18
+ sha256,
19
+ scopes,
20
+ rfc6750,
21
+ owasp,
22
+ securite,
23
+ ]
24
+ version: "doc"
25
+ status: stable
26
+ updated: 2026-07-19
27
+ source: "src/packages/@nodefony/security/docs/api-keys.md"
28
+ ---
29
+
30
+ # Clés d'API — jetons opaques révocables pour les machines
31
+
32
+ > Une clé d'API Nodefony (PAT, _Personal Access Token_) est un **secret opaque**
33
+ > `nf_…` que tu remets à un script, un job CI ou un partenaire. Contrairement à un JWT, elle ne
34
+ > porte aucune information : sa vérité vit dans le store côté serveur — donc elle est **révocable
35
+ > à la seconde**. Elle est montrée **une seule fois** à l'émission, stockée **hachée**, et sa
36
+ > **forme est vérifiée hors-ligne** (checksum) avant que la base ne soit touchée. Ancré sur
37
+ > `src/packages/@nodefony/security/nodefony/service/apiKeys.ts`,
38
+ > `nodefony/src/apikey/apiKeyFormat.ts` et `nodefony/src/authenticator/ApiKeyAuthenticator.ts`.
39
+
40
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Clés d'API**
41
+
42
+ ## 🧠 Le modèle mental — montrée une fois, filtrée hors-ligne, révoquée tout de suite
43
+
44
+ Trois moments dans la vie d'une clé, et ils ne coûtent pas le même prix. L'**émission** est rare et
45
+ chère (elle écrit). La **vérification** arrive à chaque requête : elle commence par un test
46
+ arithmétique local qui élimine les valeurs bidon **sans lire la base**. La **révocation** est un
47
+ simple champ posé — et elle prend effet à la requête suivante, partout.
48
+
49
+ ```mermaid
50
+ flowchart TD
51
+ subgraph EM["Émission (rare, session BFF requise)"]
52
+ POST["POST /nodefony/security/api/keys<br/>{name, scopes?, expiresInDays?}"] --> GEN["generateApiKey()<br/>32 octets aléatoires"]
53
+ GEN --> ONCE["réponse 201 : token CLAIR<br/>montré 1× puis oublié"]
54
+ GEN --> HASH["sha256(token) → secretHash"]
55
+ HASH --> ST[("ITokenStore — kind:'pat'<br/>memory · drizzle · mongoose · redis")]
56
+ end
57
+ subgraph VE["Vérification (chaque requête)"]
58
+ REQ["Authorization: Bearer nf_…"] --> FORM{"parseApiKey()<br/>longueur + charset + CRC"}
59
+ FORM -->|"invalide"| K401["401 — la base n'est PAS touchée"]
60
+ FORM -->|"valide"| LOOK["findByHash(sha256)"]
61
+ LOOK --> ST
62
+ LOOK --> CHK{"révoquée ? expirée ?<br/>porteur banni ? compte actif ?"}
63
+ CHK -->|"non"| OK["token promu : scopes + apiKeyId"]
64
+ CHK -->|"oui"| K401
65
+ end
66
+ REV["DELETE /…/keys/{id}"] -->|"revokedAt"| ST
67
+ ```
68
+
69
+ ## 📖 Lexique
70
+
71
+ | Terme | Sens |
72
+ | ---------------- | ----------------------------------------------------------------------------------------------------- |
73
+ | PAT | _Personal Access Token_ — clé d'API rattachée à un **porteur** (utilisateur ou compte de service). |
74
+ | Bearer | Schéma `Authorization: Bearer <valeur>` (RFC 6750) : « celui qui porte le jeton est cru ». |
75
+ | Opaque | Le jeton ne contient **aucune donnée lisible** : c'est un numéro de vestiaire, pas un passeport. |
76
+ | Auto-porté | À l'inverse : un JWT transporte ses propres affirmations signées, vérifiables **sans** état serveur. |
77
+ | CRC32 | Somme de contrôle (checksum) publique — détecte une clé tronquée ou inventée, **ne protège rien**. |
78
+ | `pubid` | Identifiant **public** de 8 caractères affiché dans la console (`nf_a1b2c3d4`) — jamais un secret. |
79
+ | `secretHash` | `sha256` du jeton entier : la **seule** trace stockée. Le clair n'est ni gardé ni re-dérivable. |
80
+ | Scope | Capacité accordée à la clé (`orders:read`) — axe distinct des rôles de l'humain. |
81
+ | `invalidBefore` | Seuil par porteur : tout jeton créé avant cet instant est rejeté (déconnexion globale, bannissement). |
82
+ | Anti-énumération | Répondre pareil quel que soit l'échec, pour ne pas révéler ce qui existe (404 plutôt que 403). |
83
+ | Shown once | Le secret n'est affiché qu'à la création — l'oublier impose d'en émettre un nouveau. |
84
+ | Store de jetons | Le `ITokenStore` partagé : il porte à la fois les PAT et les refresh tokens ([tokens](./tokens.md)). |
85
+
86
+ ## Qu'est-ce qu'une clé d'API — et la faille qu'elle ferme
87
+
88
+ Ton back-office est protégé par un login. Mais un **script de déploiement** ne peut pas taper un mot
89
+ de passe, et un **partenaire** ne doit surtout pas recevoir le tien. Il faut un identifiant de
90
+ machine : long, aléatoire, limité, et surtout **jetable**.
91
+
92
+ La faille concrète que ça ferme : **le mot de passe partagé**. Sans clés d'API, l'équipe finit par
93
+ coller le compte `admin` dans un fichier de CI. Le jour où ce fichier fuite, l'attaquant a
94
+ l'intégralité du compte — et pour couper l'accès, il faut changer le mot de passe de tout le monde.
95
+
96
+ Une clé d'API découpe le problème en trois :
97
+
98
+ 1. **Portée** — elle ne peut faire que ce que ses `scopes` autorisent, pas tout ce que son porteur peut faire.
99
+ 2. **Traçabilité** — chaque clé a un nom (« CI deploy », « export nocturne ») et un dernier usage.
100
+ 3. **Révocabilité** — on éteint **une** clé sans déranger personne d'autre.
101
+
102
+ > [!IMPORTANT]
103
+ > Une clé d'API n'est **pas** un mot de passe et ne s'utilise pas comme tel : elle a une entropie
104
+ > de 256 bits (`SECRET_BYTES` de 32 octets, `apiKeyFormat.ts:30`) — inutile de la « complexifier »,
105
+ > impossible de la deviner. Le vrai risque n'est pas le devinage, c'est la **fuite** : d'où le
106
+ > hachage au repos, l'expiration par défaut et la révocation immédiate.
107
+
108
+ ## La vision Nodefony — opaque par choix, vérifiable sans toucher la base
109
+
110
+ Nodefony aurait pu émettre un JWT longue durée : zéro lecture de base à la vérification. Le
111
+ compromis a été tranché dans l'autre sens, et c'est **le** choix structurant de cette brique.
112
+
113
+ **Pourquoi opaque plutôt qu'auto-porté** : un JWT signé reste valide jusqu'à son expiration, quoi
114
+ qu'il arrive côté serveur — révoquer exige d'ajouter… un état serveur (denylist), donc de payer la
115
+ lecture qu'on voulait éviter. Une clé d'API vit **des mois** : un jeton auto-porté de six mois qui
116
+ fuite est une porte ouverte de six mois. Le PAT inverse le compromis : sa vérité est dans le store,
117
+ donc `revokedAt` posé = accès coupé à la requête suivante, sans attendre aucune expiration
118
+ (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:107-114`).
119
+
120
+ **Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
121
+
122
+ - **Un filtre hors-ligne avant la base.** La forme et le checksum sont validés en O(1) sans aucun
123
+ I/O — `parseApiKey()` (`apiKeyFormat.ts:131`). Une avalanche de chaînes au hasard ne devient
124
+ jamais une avalanche de requêtes SQL.
125
+ - **Le secret n'existe nulle part au repos.** Seul `sha256(token)` est persisté — `hashApiKey()`
126
+ (`apiKeyFormat.ts:70`). Une fuite de la base ne donne aucune clé utilisable.
127
+ - **Aucun store en propre.** Les clés vivent dans le **même** `ITokenStore` que les refresh tokens,
128
+ discriminées par `kind: "pat"` (`ITokenStore.ts:74`) — une brique de moins à configurer, à
129
+ purger et à superviser. Le propriétaire du store reste le `TokenService` ([tokens](./tokens.md)).
130
+
131
+ Et une décision assumée sur la crypto : `sha256` suffit **ici**, alors qu'un mot de passe humain
132
+ exige argon2. La raison est écrite dans le code (`apiKeyFormat.ts:22-25`) — un secret de 256 bits
133
+ tiré au hasard n'est ni brute-forçable ni exposé aux tables arc-en-ciel ; un hachage lent ne
134
+ protégerait que des secrets faibles, et coûterait sur le chemin chaud.
135
+
136
+ ## 🚀 Démarrage rapide
137
+
138
+ ### 1. Déclarer la zone machine et les règles d'émission
139
+
140
+ Dans une app générée par `nodefony create app`, tout se déclare dans `nodefony.config.ts`. Une zone
141
+ dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
142
+
143
+ ```typescript
144
+ // nodefony.config.ts (extrait) — la zone machine + la politique d'émission
145
+ use("@nodefony/security", {
146
+ areas: {
147
+ // Zone protégée : sans clé valide → 401 AVANT ton controller (Zero Trust).
148
+ // `jwt` cohabite sans conflit — les deux se discriminent par la FORME du
149
+ // bearer (JWT = a.b.c, clé d'API = nf_…).
150
+ api: {
151
+ pattern: "^/api/v1",
152
+ authenticators: ["apikey", "jwt"],
153
+ mode: "first",
154
+ },
155
+ },
156
+ apiKeys: {
157
+ prefix: "acme", // marque de TES clés : acme_… (secret-scanning + support)
158
+ defaultExpiryDays: 90, // une clé émise sans durée meurt au bout de 90 jours
159
+ maxPerSubject: 20, // plafond de clés ACTIVES par porteur (au-delà : 409)
160
+ allowedScopes: ["orders:read", "orders:write"], // catalogue fermé
161
+ },
162
+ });
163
+ ```
164
+
165
+ ### 2. Écrire le controller, borné par un scope
166
+
167
+ ```typescript
168
+ // nodefony/controllers/OrdersController.ts — complet, compile tel quel
169
+ import {
170
+ controller,
171
+ Controller,
172
+ Get,
173
+ RequireScope,
174
+ CurrentUser,
175
+ } from "@nodefony/framework";
176
+ import type { IUser } from "@nodefony/user";
177
+
178
+ @controller("/api/v1/orders")
179
+ class OrdersController extends Controller {
180
+ // Le firewall a déjà validé la clé (forme, CRC, store, révocation, expiration,
181
+ // compte actif) : `user` est le PORTEUR de la clé. @RequireScope borne ce que
182
+ // CETTE clé peut faire — une clé émise sans `orders:read` reçoit 403.
183
+ @RequireScope("orders:read")
184
+ @Get("/list")
185
+ async list(@CurrentUser() user: IUser) {
186
+ return this.renderJson({ owner: user.identifier, orders: [] });
187
+ }
188
+ }
189
+
190
+ export default OrdersController;
191
+ ```
192
+
193
+ ### 3. Émettre la clé — par l'API, avec une session
194
+
195
+ L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
196
+ (`mountApiKeyRoutes()`, `ApiKeyController.ts:191`). Il n'y a **pas** de commande CLI pour créer une
197
+ clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
198
+ `ApiKeyController.ts:50-54`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
199
+ paramètre.
200
+
201
+ ```bash
202
+ # 0) Un compte porteur (mot de passe demandé masqué, jamais en dur)
203
+ npx nodefony security:user:add ci-bot
204
+
205
+ # 1) Session BFF : c'est elle qui autorise la création
206
+ curl -si -c /tmp/jar -H 'Content-Type: application/json' \
207
+ -d "{\"username\":\"ci-bot\",\"password\":\"$NF_PASS\"}" \
208
+ https://localhost:5152/nodefony/security/api/auth/login | head -1
209
+ # HTTP/1.1 200 OK
210
+
211
+ # 2) Émission → 201, le token CLAIR n'apparaît QU'ICI
212
+ curl -s -b /tmp/jar -H 'Content-Type: application/json' \
213
+ -d '{"name":"CI deploy","scopes":["orders:read"],"expiresInDays":30}' \
214
+ https://localhost:5152/nodefony/security/api/keys
215
+ # {"id":"3f2a…","prefix":"acme_a1b2c3d4","name":"CI deploy",
216
+ # "scopes":["orders:read"],"expiresAt":1234567890000,
217
+ # "token":"acme_a1b2c3d4XXXX…z9z9z9"} ← à copier MAINTENANT
218
+
219
+ # 3) La clé authentifie le script — plus aucune session, plus aucun cookie
220
+ curl -s -H "Authorization: Bearer acme_a1b2c3d4XXXX…z9z9z9" \
221
+ https://localhost:5152/api/v1/orders/list
222
+ # {"owner":"ci-bot","orders":[]}
223
+
224
+ # 4) Sans clé (ou avec une clé bidon) → 401, message uniforme
225
+ curl -si https://localhost:5152/api/v1/orders/list | head -1
226
+ # HTTP/1.1 401 Unauthorized
227
+ ```
228
+
229
+ > [!WARNING]
230
+ > Le champ `token` de la réponse 201 est la **seule** occasion de lire le secret : il n'est pas
231
+ > stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:36`). Le listing ultérieur ne
232
+ > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:393`). Perdue = ré-émise.
233
+
234
+ ## 🔐 Anatomie d'une clé — ce que chaque morceau paie
235
+
236
+ Format émis : `<prefix>_<pubid><secret><crc>` — **un seul** `_`, le reste est **positionnel**. La
237
+ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient lui-même `-` et
238
+ `_`, donc un `split("_")` serait fragile ; on découpe par longueurs fixes.
239
+
240
+ | Morceau | Taille | Secret ? | À quoi ça sert |
241
+ | -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
242
+ | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:730`) |
243
+ | `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
244
+ | `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
245
+ | `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
246
+
247
+ Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts:34`), longueur vérifiée
248
+ strictement au parsing.
249
+
250
+ ### À quoi sert vraiment le CRC (et à quoi il ne sert pas)
251
+
252
+ Le checksum n'est **pas** une protection : il est public, recalculable par n'importe qui. Il achète
253
+ deux choses très concrètes :
254
+
255
+ 1. **Rejeter une clé malformée en O(1), sans toucher le store.** `parseApiKey()` vérifie préfixe,
256
+ longueur, charset base64url puis CRC — et renvoie `null` avant tout I/O
257
+ (`apiKeyFormat.ts:136-142`). C'est une défense **anti-DoS** : un attaquant qui bombarde des
258
+ `nf_` aléatoires consomme du CPU, jamais des lectures de base.
259
+ 2. **Le secret-scanning.** GitHub, GitGuardian & co. reconnaissent un motif `nf_…` **dont le
260
+ checksum tombe juste** avec un taux de faux positifs quasi nul. Une clé poussée par erreur dans
261
+ un dépôt est détectée par l'outillage de l'écosystème, pas seulement par toi.
262
+
263
+ La table CRC32 (IEEE 802.3) est précalculée **une fois** au chargement du module — `CRC_TABLE`
264
+ (`apiKeyFormat.ts:40`) — et l'implémentation est locale et déterministe, pour ne dépendre ni d'une
265
+ dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts:53`).
266
+
267
+ ### Un test encore moins cher, pour l'aiguillage
268
+
269
+ Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
270
+ `looksLikeApiKey()` (`apiKeyFormat.ts:117`) : un simple `startsWith("<prefix>_")`, appelé par
271
+ `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:68`). C'est ce qui rend la cohabitation
272
+ `["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
273
+ jamais par le préfixe.
274
+
275
+ ## 🏗️ Architecture interne — la vie d'une clé
276
+
277
+ ### Émission — `ApiKeyService.createForSubject()`
278
+
279
+ `ApiKeyService.createForSubject()` (`apiKeys.ts:128`) est le seul chemin d'émission. Dans l'ordre :
280
+
281
+ 1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
282
+ `apiKeys.ts:337`).
283
+ 2. **Validation des scopes** — tableau de chaînes non vides, dédupliquées, et **⊆ catalogue** si
284
+ `allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:292`).
285
+ 3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
286
+ sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:370`).
287
+ 4. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
288
+ `#isActive()` (`apiKeys.ts:386`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:113`).
289
+ 5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
290
+ (`generateApiKey()`, `apiKeyFormat.ts:92`).
291
+ 6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:147`).
292
+ 7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
293
+ secret** (`apiKeys.ts:153`).
294
+
295
+ Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
296
+ au premier usage (`#resolveStore()`, `apiKeys.ts:268`) — indépendant de l'ordre de boot. Store
297
+ absent = **503 explicite**, jamais une 500 opaque.
298
+
299
+ ### Vérification — `ApiKeyAuthenticator.authenticate()`
300
+
301
+ Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts:93`) — chaque étape est un
302
+ filtre qui coûte plus cher que la précédente :
303
+
304
+ | # | Contrôle | Coût | Ancrage |
305
+ | --- | --------------------------------------- | --------------- | ----------------------------------------------------- |
306
+ | 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts:68`) |
307
+ | 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts:99`) |
308
+ | 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:105`) |
309
+ | 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:107-114` |
310
+ | 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:117`) |
311
+ | 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts:209`) |
312
+ | 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:133`) |
313
+
314
+ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
315
+
316
+ - **Le sujet est revérifié à CHAQUE requête** (étape 6). Une clé reste techniquement valide, mais si
317
+ le compte porteur est désactivé ou verrouillé, elle ne passe plus — les rôles sont **frais**, il
318
+ n'y a pas de cache d'identité. C'est ce qui fait qu'un départ de collaborateur coupe ses clés
319
+ sans avoir à les énumérer.
320
+ - **L'échec est toujours le même.** Malformée, inconnue, révoquée, expirée, porteur banni, compte
321
+ supprimé : un unique `"Invalid token"` (`INVALID_TOKEN`, `ApiKeyAuthenticator.ts:17`). Un
322
+ attaquant ne peut pas distinguer « cette clé n'existe pas » de « cette clé est révoquée » — c'est
323
+ l'**anti-énumération**, la cause fine part dans l'audit, jamais au client.
324
+
325
+ En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
326
+ `apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts:138-140`). Le challenge renvoyé sur un 401 de la
327
+ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:155`).
328
+
329
+ ### Câblage — d'où viennent le préfixe et le throttle
330
+
331
+ L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
332
+ lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
333
+ (`authenticatorRegistry.ts:117`), qui lit `prefix` et `lastUsedThrottleS`
334
+ (`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
335
+ fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
336
+
337
+ ## Quatre parcours vécus
338
+
339
+ ### Donner un accès à un script CI (sans lui donner un compte)
340
+
341
+ **Le besoin** : ton pipeline doit lire les commandes une fois par nuit. Il ne doit jamais pouvoir
342
+ écrire, ni se connecter à la console.
343
+
344
+ **La config** : un porteur dédié + un catalogue de scopes fermé.
345
+
346
+ ```typescript ignore
347
+ apiKeys: {
348
+ allowedScopes: ["orders:read"], // le catalogue REFUSE tout le reste à l'émission
349
+ defaultExpiryDays: 90,
350
+ }
351
+ ```
352
+
353
+ **Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
354
+ en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
355
+ `{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
356
+ l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:292`).
357
+
358
+ > [!TIP]
359
+ > Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
360
+ > propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
361
+ > `collectDeclaredApiScopes()` (`scopeCatalog.ts:29`), agrégés dans l'endpoint `capabilities`
362
+ > (`ApiKeyController.ts:96`). Un formulaire de création qui ne ment pas.
363
+
364
+ ### Faire tourner une clé sans coupure de service
365
+
366
+ **Le besoin** : la clé du CI arrive à expiration (ou tu appliques une rotation trimestrielle). Il ne
367
+ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
368
+
369
+ **Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
370
+ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure. Le motif est le
371
+ **recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:113`) :
372
+
373
+ 1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
374
+ 2. Déployer le nouveau secret dans le CI.
375
+ 3. Vérifier le basculement : la colonne « dernier usage » de la v2 bouge dans Studio (`lastUsedAt`).
376
+ 4. **Puis** révoquer la v1.
377
+
378
+ Ce qui rend l'étape 3 fiable : `lastUsedAt` est écrit de façon **throttlée**, pas à chaque requête —
379
+ la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:746`). Attends donc une minute
380
+ avant de conclure qu'une clé « ne sert plus ».
381
+
382
+ ### Révoquer une clé qui a fuité
383
+
384
+ **Le besoin** : le secret est apparu dans un log public. Il faut couper **maintenant**, sans toucher
385
+ aux autres clés ni au compte.
386
+
387
+ Deux chemins, selon qui agit :
388
+
389
+ | Qui | Endpoint | Portée | Ancrage |
390
+ | ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
391
+ | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:247`) |
392
+ | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:201`) |
393
+
394
+ **Ce qu'on observe** : la révocation est **idempotente** et prend effet à la requête suivante —
395
+ l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:107-114`).
396
+ Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
397
+ (`apikey-flow.test.ts:149-157`).
398
+
399
+ Une propriété de sécurité facile à manquer : si la clé n'existe pas **ou** appartient à quelqu'un
400
+ d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts:139-142`).
401
+ Un 403 dirait « cette clé existe, mais pas à toi » — assez pour énumérer les identifiants des
402
+ autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:176`).
403
+
404
+ > [!WARNING]
405
+ > Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
406
+ > révoque pas une par une : pose le seuil `invalidBefore` du porteur
407
+ > (`revokeAllForSubject`, `ITokenStore.ts:261`). L'authenticator rejette alors toute clé créée avant
408
+ > ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
409
+ > (`ApiKeyAuthenticator.ts:117-120`) — y compris pour les clés que tu aurais oubliées.
410
+
411
+ ### Auditer qui a utilisé quoi
412
+
413
+ **Le besoin** : après un incident, savoir quelles clés existent, qui les porte, quand elles ont
414
+ servi et qui les a révoquées.
415
+
416
+ Trois sources, et il faut connaître les limites de chacune :
417
+
418
+ 1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
419
+ /nodefony/security/api/apikeys` (`SecurityAdminApi.ts:394`), servi par `listPagePat()`
420
+ (`apiKeys.ts:208`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
421
+ `order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
422
+ (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:109`).
423
+
424
+ Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
425
+ (`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
426
+ `subjectId`, `id` sur mémoire/SQL/Mongo (`TOKEN_SORTABLE_FIELDS`, `tokenSort.ts:27`). Tout autre
427
+ champ est refusé en **400** — jamais accepté puis ignoré. Un backend Redis ne déclare rien (son
428
+ `SCAN` n'a pas d'ordre global) : tout `order` y est donc refusé, ce qui est la vérité de ce
429
+ store. Les champs _nullables_ (`lastUsedAt`, `expiresAt`, `revokedAt`) sont volontairement hors
430
+ du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
431
+ l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
432
+
433
+ 2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:153`) et `apikey.revoked`
434
+ (`apiKeys.ts:212` côté admin, `apiKeys.ts:255` côté porteur), catégorie `token`. La révocation
435
+ admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
436
+ 3. **Le dernier usage** — `lastUsedAt` sur chaque clé.
437
+
438
+ Ce que tu **n'auras pas** : un journal par requête. `markUsed` est appelé avec le seul horodatage
439
+ (`ApiKeyAuthenticator.ts:133`) ; les champs `lastUsedIp` et `lastUsedUserAgent` du record
440
+ (`ITokenStore.ts:135`) restent donc à `null` — ce sont des **emplacements réservés**, pas des
441
+ données remplies. Pour de la traçabilité par appel, c'est le journal d'audit applicatif qu'il faut
442
+ alimenter, pas le store de jetons.
443
+
444
+ ## ⚙️ Configuration
445
+
446
+ Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:727`), branché à la racine de la config du
447
+ module (`config.ts:727`). Toutes les valeurs ci-dessous sont les **défauts réels**.
448
+
449
+ | Option | Type | Défaut | Effet |
450
+ | ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
451
+ | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:533`) |
452
+ | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:730`) |
453
+ | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:739`) |
454
+ | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:746`) |
455
+ | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:755`) |
456
+ | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:764`) |
457
+
458
+ Deux réglages méritent une décision consciente :
459
+
460
+ - **`prefix`** doit être **propre à ton application** (`acme`, `shop`…). C'est ce qui permet à un
461
+ outil de secret-scanning de reconnaître **tes** clés, et à ton support d'identifier un jeton d'un
462
+ coup d'œil. Le changer invalide la reconnaissance des clés déjà émises.
463
+ - **`defaultExpiryDays: null`** (clé éternelle) est un choix de confort qui se paie : plus rien
464
+ n'oblige à faire tourner le secret. Préfère une durée + le motif de recouvrement décrit plus haut.
465
+
466
+ ## 🧰 API publique
467
+
468
+ ### Les endpoints — deux portées, jamais mélangées
469
+
470
+ **Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
471
+ (`ApiKeyController.ts:191`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
472
+ sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
473
+
474
+ | Méthode | Chemin | Rôle | Ancrage |
475
+ | -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
476
+ | `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:65`) |
477
+ | `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:113`) |
478
+ | `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:96`) |
479
+ | `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:126`) |
480
+
481
+ **Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
482
+ `ROLE_NODEFONY_ADMIN` :
483
+
484
+ | Méthode | Chemin | Rôle | Ancrage |
485
+ | ------- | -------------------------------------------- | ---------------------------------------- | ------------------------- |
486
+ | `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts:380` |
487
+ | `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts:416` |
488
+ | `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts:440` |
489
+
490
+ Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
491
+ console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
492
+
493
+ Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:164`) :
494
+ **400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
495
+ absent ou `enabled:false`).
496
+
497
+ ### Ce qu'on importe côté application
498
+
499
+ ```typescript ignore
500
+ import {
501
+ ApiKeyService, // service (résolu du container : `this.get("apiKeys")`)
502
+ ApiKeyAuthenticator, // enregistré sous le nom "apikey"
503
+ generateApiKey, // helpers de FORMAT — purs, sans I/O
504
+ parseApiKey,
505
+ hashApiKey,
506
+ looksLikeApiKey,
507
+ } from "@nodefony/security";
508
+ import type {
509
+ IApiKeyView, // vue publique — sans secret ni hash
510
+ IApiKeyCreated, // vue publique + token clair (création seule)
511
+ IApiKeyCapabilities, // contraintes d'émission (formulaire honnête)
512
+ ICreateApiKeyOptions,
513
+ } from "@nodefony/security";
514
+ ```
515
+
516
+ Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts:6`), `IApiKeyCreated`
517
+ (`IApiKey.ts:36`), `IApiKeyCapabilities` (`IApiKey.ts:47`), `ICreateApiKeyOptions`
518
+ (`IApiKey.ts:61`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
519
+ cette page explique l'usage, elle ne recopie pas les prototypes.
520
+
521
+ ## 🧑‍⚖️ Scopes — ce que la clé a le droit de faire
522
+
523
+ Deux axes se combinent, et les confondre est l'erreur la plus fréquente :
524
+
525
+ - **Les rôles** disent **qui tu es** — ils appartiennent au porteur (`ROLE_ADMIN`…).
526
+ - **Les scopes** disent **ce que cette clé-là peut faire** — ils appartiennent au jeton.
527
+
528
+ Une clé ne peut donc **jamais** dépasser son porteur : elle en est une restriction, pas une
529
+ extension. Concrètement, `@RequireScope("orders:read")` sur une action est tranché par le
530
+ `ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts:44`) :
531
+
532
+ - un jeton **humain** (session, mot de passe, anonyme) n'est jamais bridé par un scope — la liste
533
+ `NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts:17`) le fait passer ; ce sont ses **rôles** qui décident ;
534
+ - un jeton **machine délégué** (`apikey`, `jwt`, `oauth2`) doit porter le scope **exact**, sinon
535
+ refus par défaut du jury.
536
+
537
+ Le détail du jury (voters, veto, hiérarchie de rôles) est sur la page
538
+ [autorisation](./authorization.md) — la clé d'API n'y est qu'un porteur de scopes parmi d'autres.
539
+
540
+ ## Persistance — un store partagé avec les jetons
541
+
542
+ Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts:69`)
543
+ de `kind:"pat"` (`ITokenStore.ts:74`), dans la même table que les refresh tokens — les champs sans
544
+ objet pour un PAT (`family`, `replacedBy`, `audience`) valent `null`.
545
+
546
+ Les champs qui portent le sens **pour une clé d'API** :
547
+
548
+ | Champ | Rôle pour un PAT | Ancrage |
549
+ | ------------ | ------------------------------------------------------------- | -------------------- |
550
+ | `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts:74` |
551
+ | `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts:76` |
552
+ | `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts:78` |
553
+ | `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts:95` |
554
+ | `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts:103` |
555
+ | `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts:111` |
556
+ | `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts:113` |
557
+ | `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts:131` |
558
+ | `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts:133` |
559
+ | `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts:139` |
560
+
561
+ > [!NOTE]
562
+ > Les **colonnes et types par dialecte** ne sont pas dupliqués ici : le propriétaire du schéma est
563
+ > le `TokenService`, et la table est décrite une seule fois côté [tokens](./tokens.md) puis dans la
564
+ > doc de chaque adapter. Règle anti-triple-vérité — un seul endroit à corriger quand le schéma bouge.
565
+
566
+ ### Bases prises en charge
567
+
568
+ **Quatre** backends portent les clés, exactement ceux du store de jetons — parce que c'est le
569
+ **même** store, résolu par le `TokenService` selon la doctrine `store:"auto"` :
570
+
571
+ | Backend | Durable | Listing admin | Pour… |
572
+ | ---------- | :-----: | --------------------------- | -------------------------------------------------------- |
573
+ | `memory` | non | offset + total | dev / tests mono-process |
574
+ | `drizzle` | oui | offset + total | SQL (PostgreSQL, MySQL/MariaDB, SQLite) — défaut durable |
575
+ | `mongoose` | oui | offset + total | MongoDB |
576
+ | `redis` | oui | **curseur**, `total` absent | flotte de pods, TTL natif |
577
+
578
+ Ce qui **n'existe pas** : aucun autre backend n'est enregistré, et il n'y a pas de store propre aux
579
+ clés d'API. En `memory` **en production**, la conséquence est directe et annoncée au boot : les clés
580
+ sont per-pod et volatiles — une clé émise sur un pod n'est pas reconnue par les autres, et une
581
+ révocation ne traverse pas. Le détail de la résolution, des avertissements et de la purge est sur
582
+ [tokens](./tokens.md).
583
+
584
+ ## 📜 Normes appliquées
585
+
586
+ | Domaine | Norme | Ancrage |
587
+ | --------------------------------- | -------------------------------------- | ------------------------------------------------------ |
588
+ | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
589
+ | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
590
+ | Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
591
+ | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:37`) |
592
+ | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
593
+ | Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
594
+ | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:107-114`) |
595
+ | Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
596
+ | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:113`) |
597
+
598
+ ## ⚡ Performance & mémoire
599
+
600
+ Le coût par requête authentifiée par clé est **maîtrisé par construction**, dans cet ordre :
601
+
602
+ - **Le filtre le moins cher d'abord.** `supports()` ne fait qu'un `startsWith`
603
+ (`ApiKeyAuthenticator.ts:68`) ; le parsing complet (CRC inclus) est purement local
604
+ (`apiKeyFormat.ts:131`). Une valeur invalide ne coûte **aucun** I/O.
605
+ - **Table CRC précalculée une fois** au chargement du module, jamais par appel
606
+ (`CRC_TABLE`, `apiKeyFormat.ts:40`).
607
+ - **`lastUsedAt` throttlé** — sans cette coalescence, chaque requête d'API deviendrait une
608
+ **écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts:127-134`) ; `0` rétablit
609
+ l'écriture systématique, à ne choisir qu'en connaissance de cause.
610
+ - **Dépendances résolues paresseusement** : store et fournisseur d'utilisateurs sont récupérés du
611
+ container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
612
+ ne paie rien si aucune clé n'est jamais présentée.
613
+ - **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
614
+ (`listPagePat()`, `apiKeys.ts:216`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
615
+
616
+ Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
617
+ (`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
618
+ (100 par défaut) et c'est un chemin froid (émission), pas le chemin chaud.
619
+
620
+ ## 📡 Observabilité — Studio
621
+
622
+ L'écran **API Keys** (`/nodefony/api-keys`, `studio/frontend/src/routes/ApiKeys.tsx`) expose les
623
+ deux portées dans une seule page :
624
+
625
+ - **Mes clés** — création, listing et révocation via le data plane personnel
626
+ (`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:83`). Le secret est affiché
627
+ dans la modale de création, une fois.
628
+ - **Administration** — toutes les clés du système, pagination serveur et révocation ciblée
629
+ (`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:93`), réservé à
630
+ `ROLE_NODEFONY_ADMIN`.
631
+ - **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
632
+ console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
633
+ `studio/frontend/src/routes/apikeys/apiKeysModel.ts:99` ; handler `IApiKeysStatus`,
634
+ `SecurityAdminApi.ts:54`).
635
+
636
+ Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
637
+ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
638
+ `apikey.revoked`.
639
+
640
+ ## ⚠️ Pièges (symptôme → cause → correction)
641
+
642
+ | Symptôme | Cause (dans le code) | Correction |
643
+ | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
644
+ | 404 sur `/nodefony/security/api/keys` | Routes montées seulement si le service `apiKeys` existe (`framework/index.ts:385`) | Charger `@nodefony/security` + `apiKeys.enabled: true` |
645
+ | 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
646
+ | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
647
+ | Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts:70`) | Émettre une nouvelle clé, révoquer l'ancienne |
648
+ | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:113`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
649
+ | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:292`) | Ajouter le scope au catalogue, ou corriger la demande |
650
+ | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:142`) | Garder le `prefix` STABLE après la première émission |
651
+ | Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts:50`) | Émettre une clé portant le scope exigé par `@RequireScope` |
652
+ | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:117-120`) | Réactiver le compte, ou réémettre après le bannissement |
653
+ | 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts:139-142`) | Attendu — passer par l'endpoint d'administration |
654
+ | `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts:127-134`) | Attendre la fenêtre, ou `lastUsedThrottleS: 0` (coût : 1 écriture/req) |
655
+ | `lastUsedIp` / `lastUsedUserAgent` toujours vides | `markUsed` n'envoie que l'horodatage (`ApiKeyAuthenticator.ts:133`) | Emplacements réservés — tracer par le journal d'audit applicatif |
656
+ | Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
657
+ | Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
658
+
659
+ ## 🧪 Tests & couverture
660
+
661
+ Trois familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
662
+ (régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
663
+
664
+ - **unit** : `apiKeyFormat` (génération, parsing, CRC invalide, charset, longueurs),
665
+ `apiKeyAuthenticator` (les 7 filtres : forme, hash, révocation, expiration, `invalidBefore`,
666
+ compte inactif, throttle `lastUsedAt`), `apiKeyService` (validation nom/scopes/durée, plafond,
667
+ anti-énumération de la révocation, vue publique sans secret) ;
668
+ - **intégration** : `apikey-flow` sur serveur HTTPS réel — le parcours complet login → émission →
669
+ usage → révocation, **plus une matrice d'attaques sur le fil** : absence de Bearer, clé forgée à
670
+ CRC invalide, clé révoquée, création anonyme, IDOR sur la clé d'autrui, secret jamais ré-exposé au
671
+ listing, cohabitation JWT + PAT dans la même zone ;
672
+ - **banc de contrat** : `tokenPaginationContract` — les invariants de `listPage`/`countTokens` que
673
+ **tous** les backends doivent tenir, donc ceux dont dépend le listing d'administration des clés.
674
+
675
+ **Ce qui manque, assumé** : pas de fichier `*.attack.test.ts` dédié aux clés d'API (les attaques
676
+ sont dans le banc d'intégration, sur le fil — c'est plus fort, mais elles ne tournent pas sans
677
+ serveur) ; pas de test de charge ni de mesure mémoire propre à la vérification de clé.
678
+
679
+ Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — et un skip compte comme
680
+ vert : lire le bloc gates (`vitest.gates.ts`, affiché en fin de run) avant de conclure. Skills
681
+ utiles : `nodefony-security-review` (matrice d'attaque), `nodefony-load-test` (charge).
682
+ Couverture : `npm run coverage` dans `@nodefony/security`.
683
+
684
+ ## 🔗 Pour aller plus loin
685
+
686
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
687
+ - Le store partagé, son cycle de vie et ses backends → [tokens](./tokens.md)
688
+ - Ce que la clé a le droit de faire (scopes, voters, rôles) → [autorisation](./authorization.md)
689
+ - La zone qui exige la clé, et la cohabitation avec `jwt` → [firewall](./firewall.md)
690
+ - Le contrat commun à tous les authenticators → [authenticators](./authenticators.md)
691
+ - La trace des émissions et des révocations → [audit](./audit.md)