@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5

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 (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/api-keys.md CHANGED
@@ -101,7 +101,7 @@ Une clé d'API découpe le problème en trois :
101
101
 
102
102
  > [!IMPORTANT]
103
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 »,
104
+ > de 256 bits (`SECRET_BYTES` de 32 octets, `apiKeyFormat.ts#SECRET_BYTES`) — inutile de la « complexifier »,
105
105
  > impossible de la deviner. Le vrai risque n'est pas le devinage, c'est la **fuite** : d'où le
106
106
  > hachage au repos, l'expiration par défaut et la révocation immédiate.
107
107
 
@@ -115,21 +115,21 @@ qu'il arrive côté serveur — révoquer exige d'ajouter… un état serveur (d
115
115
  lecture qu'on voulait éviter. Une clé d'API vit **des mois** : un jeton auto-porté de six mois qui
116
116
  fuite est une porte ouverte de six mois. Le PAT inverse le compromis : sa vérité est dans le store,
117
117
  donc `revokedAt` posé = accès coupé à la requête suivante, sans attendre aucune expiration
118
- (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:97-104`).
118
+ (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`).
119
119
 
120
120
  **Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
121
121
 
122
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
123
+ I/O — `parseApiKey()` (`apiKeyFormat.ts#parseApiKey`). Une avalanche de chaînes au hasard ne devient
124
124
  jamais une avalanche de requêtes SQL.
125
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.
126
+ (`apiKeyFormat.ts#hashApiKey`). Une fuite de la base ne donne aucune clé utilisable.
127
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, à
128
+ discriminées par `kind: "pat"` (`ITokenStore.ts#IAccessTokenRecord.kind`) — une brique de moins à configurer, à
129
129
  purger et à superviser. Le propriétaire du store reste le `TokenService` ([tokens](./tokens.md)).
130
130
 
131
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
132
+ exige argon2. La raison est écrite dans le code (`apiKeyFormat.ts#PUBID_BYTES`) — un secret de 256 bits
133
133
  tiré au hasard n'est ni brute-forçable ni exposé aux tables arc-en-ciel ; un hachage lent ne
134
134
  protégerait que des secrets faibles, et coûterait sur le chemin chaud.
135
135
 
@@ -141,25 +141,28 @@ Dans une app générée par `nodefony create app`, tout se déclare dans la conf
141
141
  dont les authenticators contiennent `apikey` exige un `Authorization: Bearer nf_…` valide :
142
142
 
143
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",
144
+ // nodefony/config/security.ts — la zone machine + la politique d'émission
145
+ import type { ISecurityConfigInput } from "@nodefony/security";
146
+
147
+ export const securityConfig = () =>
148
+ ({
149
+ areas: {
150
+ // Zone protégée : sans clé valide → 401 AVANT ton controller (Zero Trust).
151
+ // `jwt` cohabite sans conflit — les deux se discriminent par la FORME du
152
+ // bearer (JWT = a.b.c, clé d'API = nf_…).
153
+ api: {
154
+ pattern: "^/api/v1",
155
+ authenticators: ["apikey", "jwt"],
156
+ mode: "first",
157
+ },
154
158
  },
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
- });
159
+ apiKeys: {
160
+ prefix: "acme", // marque de TES clés : acme_… (secret-scanning + support)
161
+ defaultExpiryDays: 90, // une clé émise sans durée meurt au bout de 90 jours
162
+ maxPerSubject: 20, // plafond de clés ACTIVES par porteur (au-delà : 409)
163
+ allowedScopes: ["orders:read", "orders:write"], // catalogue fermé
164
+ },
165
+ }) satisfies ISecurityConfigInput;
163
166
  ```
164
167
 
165
168
  ### 2. Écrire le controller, borné par un scope
@@ -193,9 +196,9 @@ export default OrdersController;
193
196
  ### 3. Émettre la clé — par l'API, avec une session
194
197
 
195
198
  L'émission est un **endpoint fourni**, monté seulement si le service `apiKeys` existe
196
- (`mountApiKeyRoutes()`, `ApiKeyController.ts:192`). Il n'y a **pas** de commande CLI pour créer une
199
+ (`mountApiKeyRoutes()`, `ApiKeyController.ts#mountApiKeyRoutes`). Il n'y a **pas** de commande CLI pour créer une
197
200
  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
201
+ `ApiKeyController.ts#ApiKeyController`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
199
202
  paramètre.
200
203
 
201
204
  ```bash
@@ -228,23 +231,23 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
228
231
 
229
232
  > [!WARNING]
230
233
  > 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:44`). Le listing ultérieur ne
232
- > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:386`). Perdue = ré-émise.
234
+ > stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts#IApiKeyCreated`). Le listing ultérieur ne
235
+ > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts#ApiKeyService.#toView`). Perdue = ré-émise.
233
236
 
234
237
  ## 🔐 Anatomie d'une clé — ce que chaque morceau paie
235
238
 
236
239
  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
240
+ raison est dans le code (`apiKeyFormat.ts#PUBID_BYTES`) : le charset base64url contient lui-même `-` et
238
241
  `_`, donc un `split("_")` serait fragile ; on découpe par longueurs fixes.
239
242
 
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:823`) |
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`) |
243
+ | Morceau | Taille | Secret ? | À quoi ça sert |
244
+ | -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------------------- |
245
+ | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts#apiKeysSchema`) |
246
+ | `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts#generateApiKey`) |
247
+ | `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts#SECRET_BYTES`) |
248
+ | `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts#crcChunk`) |
246
249
 
247
- Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts:34`), longueur vérifiée
250
+ Le corps total fait donc 57 caractères — `BODY_LEN` (`apiKeyFormat.ts#BODY_LEN`), longueur vérifiée
248
251
  strictement au parsing.
249
252
 
250
253
  ### À quoi sert vraiment le CRC (et à quoi il ne sert pas)
@@ -254,21 +257,21 @@ deux choses très concrètes :
254
257
 
255
258
  1. **Rejeter une clé malformée en O(1), sans toucher le store.** `parseApiKey()` vérifie préfixe,
256
259
  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
260
+ (`apiKeyFormat.ts#parseApiKey`). C'est une défense **anti-DoS** : un attaquant qui bombarde des
258
261
  `nf_` aléatoires consomme du CPU, jamais des lectures de base.
259
262
  2. **Le secret-scanning.** GitHub, GitGuardian & co. reconnaissent un motif `nf_…` **dont le
260
263
  checksum tombe juste** avec un taux de faux positifs quasi nul. Une clé poussée par erreur dans
261
264
  un dépôt est détectée par l'outillage de l'écosystème, pas seulement par toi.
262
265
 
263
266
  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`).
267
+ (`apiKeyFormat.ts#CRC_TABLE`) — et l'implémentation est locale et déterministe, pour ne dépendre ni d'une
268
+ dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts#crc32`).
266
269
 
267
270
  ### Un test encore moins cher, pour l'aiguillage
268
271
 
269
272
  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:75`). C'est ce qui rend la cohabitation
273
+ `looksLikeApiKey()` (`apiKeyFormat.ts#looksLikeApiKey`) : un simple `startsWith("<prefix>_")`, appelé par
274
+ `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.supports`). C'est ce qui rend la cohabitation
272
275
  `["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
273
276
  jamais par le préfixe.
274
277
 
@@ -276,40 +279,44 @@ jamais par le préfixe.
276
279
 
277
280
  ### Émission — `ApiKeyService.createForSubject()`
278
281
 
279
- `ApiKeyService.createForSubject()` (`apiKeys.ts:121`) est le seul chemin d'émission. Dans l'ordre :
282
+ `ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Dans l'ordre :
280
283
 
281
284
  1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
282
- `apiKeys.ts:330`).
285
+ `apiKeys.ts#ApiKeyService.#normalizeName`).
283
286
  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:341`).
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:363`).
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:379`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:48`).
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:177`).
292
- 7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
293
- secret** (`apiKeys.ts:183-189`).
287
+ `allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts#ApiKeyService.#normalizeScopes`).
288
+ 3. **Droit d'obtenir chaque scope** — un scope réservé par `grantableScopes` à des rôles que le
289
+ porteur n'a pas, ou `admin:*` sans le rôle d'administration, refuse la création entière :
290
+ `ApiKeyError` **403** qui nomme le scope (`refusedScopes()`, `apiKeys.ts#ApiKeyService.createForSubject`). Même juge que
291
+ l'émission des jetons — voir [autorisation](./authorization.md).
292
+ 4. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
293
+ sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts#ApiKeyService.#resolveExpiry`).
294
+ 5. **Plafond anti-abus** — on ne compte que les clés **actives** (ni révoquées ni expirées) via
295
+ `#isActive()` (`apiKeys.ts#ApiKeyService.#isActive`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts#ApiKeyService.#maxPerSubject`).
296
+ 6. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
297
+ (`generateApiKey()`, `apiKeyFormat.ts#generateApiKey`).
298
+ 7. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts#ApiKeyService.createForSubject`).
299
+ 8. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
300
+ secret** (`apiKeys.ts#ApiKeyService.createForSubject`).
294
301
 
295
302
  Le service ne connaît pas le store à la construction : il le résout **paresseusement** du container
296
- au premier usage (`#resolveStore()`, `apiKeys.ts:317`) — indépendant de l'ordre de boot. Store
303
+ au premier usage (`#resolveStore()`, `apiKeys.ts#ApiKeyService.#resolveStore`) — indépendant de l'ordre de boot. Store
297
304
  absent = **503 explicite**, jamais une 500 opaque.
298
305
 
299
306
  ### Vérification — `ApiKeyAuthenticator.authenticate()`
300
307
 
301
- Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts:93`) — chaque étape est un
308
+ Le chemin chaud, dans l'ordre exact du code (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) — chaque étape est un
302
309
  filtre qui coûte plus cher que la précédente :
303
310
 
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:103`) |
308
- | 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts:109`) |
309
- | 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts:111-118` |
310
- | 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts:121`) |
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:187`) |
311
+ | # | Contrôle | Coût | Ancrage |
312
+ | --- | --------------------------------------- | --------------- | ------------------------------------------------------------------------------------------ |
313
+ | 1 | Bearer présent + préfixe | regex | `readBearerHeader()` (`runtime/bearer.ts#readBearerHeader`) |
314
+ | 2 | Longueur, charset, **CRC** | CPU local | `parseApiKey` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
315
+ | 3 | Lookup par `secretHash` | 1 lecture store | `findByHash` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
316
+ | 4 | `kind:"pat"`, non révoquée, non expirée | en mémoire | `ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate` |
317
+ | 5 | Porteur banni ? (`invalidBefore`) | 1 lecture store | `getInvalidBefore` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
318
+ | 6 | Compte actif et non verrouillé | 1 lecture user | `#resolveUserOrReject` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.#resolveUserOrReject`) |
319
+ | 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`) |
313
320
 
314
321
  Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
315
322
 
@@ -318,20 +325,20 @@ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sé
318
325
  n'y a pas de cache d'identité. C'est ce qui fait qu'un départ de collaborateur coupe ses clés
319
326
  sans avoir à les énumérer.
320
327
  - **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
328
+ supprimé : un unique `"Invalid token"` (`INVALID_TOKEN`, `ApiKeyAuthenticator.ts#INVALID_TOKEN`). Un
322
329
  attaquant ne peut pas distinguer « cette clé n'existe pas » de « cette clé est révoquée » — c'est
323
330
  l'**anti-énumération**, la cause fine part dans l'audit, jamais au client.
324
331
 
325
332
  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:205`).
333
+ `apiKeyId` et `tenantId` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`). Le challenge renvoyé sur un 401 de la
334
+ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts#ApiKeyAuthenticator.challenge`).
328
335
 
329
336
  ### Câblage — d'où viennent le préfixe et le throttle
330
337
 
331
338
  L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
332
339
  lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
333
- (`authenticatorRegistry.ts:52`), qui lit `prefix` et `lastUsedThrottleS`
334
- (`authenticatorRegistry.ts:166`). Conséquence pratique : changer `apiKeys.prefix` change **à la
340
+ (`authenticatorRegistry.ts#registerAuthenticatorFactory`), qui lit `prefix` et `lastUsedThrottleS`
341
+ (`authenticatorRegistry.ts`). Conséquence pratique : changer `apiKeys.prefix` change **à la
335
342
  fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
336
343
 
337
344
  ## Quatre parcours vécus
@@ -353,13 +360,13 @@ apiKeys: {
353
360
  **Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
354
361
  en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
355
362
  `{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
356
- l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:341`).
363
+ l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts#ApiKeyService.#normalizeScopes`).
357
364
 
358
365
  > [!TIP]
359
366
  > Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
360
367
  > 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:97`). Un formulaire de création qui ne ment pas.
368
+ > `collectDeclaredApiScopes()` (`scopeCatalog.ts#collectDeclaredApiScopes`), agrégés dans l'endpoint `capabilities`
369
+ > (`ApiKeyController.ts#ApiKeyController.capabilities`). Un formulaire de création qui ne ment pas.
363
370
 
364
371
  ### Faire tourner une clé sans coupure de service
365
372
 
@@ -368,7 +375,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
368
375
 
369
376
  **Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
370
377
  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:48`) :
378
+ **recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts#ApiKeyService.#maxPerSubject`) :
372
379
 
373
380
  1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
374
381
  2. Déployer le nouveau secret dans le CI.
@@ -376,7 +383,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
376
383
  4. **Puis** révoquer la v1.
377
384
 
378
385
  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:839`). Attends donc une minute
386
+ la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts#apiKeysSchema`). Attends donc une minute
380
387
  avant de conclure qu'une clé « ne sert plus ».
381
388
 
382
389
  ### Révoquer une clé qui a fuité
@@ -386,27 +393,27 @@ aux autres clés ni au compte.
386
393
 
387
394
  Deux chemins, selon qui agit :
388
395
 
389
- | Qui | Endpoint | Portée | Ancrage |
390
- | ---------- | ------------------------------------------------- | ---------------------- | --------------------------------------- |
391
- | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:296`) |
392
- | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:250`) |
396
+ | Qui | Endpoint | Portée | Ancrage |
397
+ | ---------- | ------------------------------------------------- | ---------------------- | ------------------------------------------------------------------ |
398
+ | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts#ApiKeyService.revokeForSubject`) |
399
+ | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts#ApiKeyService.revokeAnyPat`) |
393
400
 
394
401
  **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:111-118`).
402
+ l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`).
396
403
  Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
397
- (`apikey-flow.test.ts:182-191`).
404
+ (`apikey-flow.test.ts`).
398
405
 
399
406
  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`).
407
+ d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts#ApiKeyController.revoke`).
401
408
  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:209`).
409
+ autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts`).
403
410
 
404
411
  > [!WARNING]
405
412
  > Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
406
413
  > 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
414
+ > (`revokeAllForSubject`, `ITokenStore.ts#ITokenStore.revokeAllForSubject`). L'authenticator rejette alors toute clé créée avant
408
415
  > ce seuil : `getInvalidBefore` est comparé au `createdAt` du record
409
- > (`ApiKeyAuthenticator.ts:121-124`) — y compris pour les clés que tu aurais oubliées.
416
+ > (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) — y compris pour les clés que tu aurais oubliées.
410
417
 
411
418
  ### Auditer qui a utilisé quoi
412
419
 
@@ -416,26 +423,26 @@ servi et qui les a révoquées.
416
423
  Trois sources, et il faut connaître les limites de chacune :
417
424
 
418
425
  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:209`). 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:122`).
426
+ /nodefony/security/api/apikeys` (`SecurityAdminApi.ts#createSecurityAdminApi`), servi par `listPagePat()`
427
+ (`apiKeys.ts#ApiKeyService.listPagePat`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
428
+ `order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts#parseTokenListQuery`), plafonnée à 200 entrées
429
+ (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts#KEYS_MAX_LIMIT`).
423
430
 
424
431
  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
432
+ (`sortableFields()`, `apiKeys.ts#ApiKeyService.sortableFields` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
433
+ `subjectId`, `id` sur mémoire/SQL/Mongo (`TOKEN_SORTABLE_FIELDS`, `tokenSort.ts#TOKEN_SORTABLE_FIELDS`). Tout autre
427
434
  champ est refusé en **400** — jamais accepté puis ignoré. Un backend Redis ne déclare rien (son
428
435
  `SCAN` n'a pas d'ordre global) : tout `order` y est donc refusé, ce qui est la vérité de ce
429
436
  store. Les champs _nullables_ (`lastUsedAt`, `expiresAt`, `revokedAt`) sont volontairement hors
430
437
  du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
431
438
  l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
432
439
 
433
- 2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts:183-189`) et `apikey.revoked`
434
- (`apiKeys.ts:268-271` côté admin, `apiKeys.ts:311-313` côté porteur), catégorie `token`. La révocation
440
+ 2. **Le journal** — les événements d'audit `apikey.created` (`apiKeys.ts#ApiKeyService.createForSubject`) et `apikey.revoked`
441
+ (`apiKeys.ts#ApiKeyService.revokeAnyPat` côté admin, `apiKeys.ts#ApiKeyService.revokeForSubject` côté porteur), catégorie `token`. La révocation
435
442
  admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
436
443
  3. **Le dernier usage** — `lastUsedAt`, avec l'IP et l'agent de ce dernier appel
437
- (`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts:135`), écrits ensemble par `markUsed`
438
- (`ApiKeyAuthenticator.ts:187`). L'IP est lue par l'accesseur du contexte, qui tient compte des
444
+ (`lastUsedIp`, `lastUsedUserAgent`, `ITokenStore.ts#IAccessTokenRecord.lastUsedUserAgent`), écrits ensemble par `markUsed`
445
+ (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`). L'IP est lue par l'accesseur du contexte, qui tient compte des
439
446
  proxys de confiance ; si elle ne peut pas être lue, le champ reste vide sans faire échouer
440
447
  l'authentification.
441
448
 
@@ -446,17 +453,17 @@ applicatif qu'il faut alimenter, pas le store de jetons.
446
453
 
447
454
  ## ⚙️ Configuration
448
455
 
449
- Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:820`), branché à la racine de la config du
450
- module (`config.ts:739`). Toutes les valeurs ci-dessous sont les **défauts réels**.
456
+ Table dérivée du schéma Zod `apiKeysSchema` (`config.ts#apiKeysSchema`), branché à la racine de la config du
457
+ module (`config.ts#trustedIssuerSchema`). Toutes les valeurs ci-dessous sont les **défauts réels**.
451
458
 
452
- | Option | Type | Défaut | Effet |
453
- | ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
454
- | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:811`) |
455
- | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:823`) |
456
- | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:832`) |
457
- | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:839`) |
458
- | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:848`) |
459
- | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:857`) |
459
+ | Option | Type | Défaut | Effet |
460
+ | ------------------- | ---------------- | ------ | --------------------------------------------------------------------------------------------------- |
461
+ | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts#tokenExchangeSchema`) |
462
+ | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts#apiKeysSchema`) |
463
+ | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts#apiKeysSchema`) |
464
+ | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts#apiKeysSchema`) |
465
+ | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts#apiKeysSchema`) |
466
+ | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts#apiKeysSchema`) |
460
467
 
461
468
  Deux réglages méritent une décision consciente :
462
469
 
@@ -471,30 +478,30 @@ Deux réglages méritent une décision consciente :
471
478
  ### Les endpoints — deux portées, jamais mélangées
472
479
 
473
480
  **Console « mes clés »** (le porteur gère les siennes) — montées par `mountApiKeyRoutes()`
474
- (`ApiKeyController.ts:192`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
481
+ (`ApiKeyController.ts#mountApiKeyRoutes`) **seulement si** le service `apiKeys` existe (`framework/index.ts#Framework.onKernelReady`) ;
475
482
  sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
476
483
 
477
- | Méthode | Chemin | Rôle | Ancrage |
478
- | -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
479
- | `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts:66`) |
480
- | `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts:114`) |
481
- | `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts:97`) |
482
- | `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts:127`) |
484
+ | Méthode | Chemin | Rôle | Ancrage |
485
+ | -------- | ------------------------------------------ | ------------------------------------------------- | ---------------------------------------------------------------------- |
486
+ | `POST` | `/nodefony/security/api/keys` | Émission → **201** + `token` clair (1×) | `create()` (`ApiKeyController.ts#ApiKeyController.create`) |
487
+ | `GET` | `/nodefony/security/api/keys` | Mes clés, sans secret | `list()` (`ApiKeyController.ts#ApiKeyController.list`) |
488
+ | `GET` | `/nodefony/security/api/keys/capabilities` | Plafond, scopes proposés, préfixe, durée | `capabilities()` (`ApiKeyController.ts#ApiKeyController.capabilities`) |
489
+ | `DELETE` | `/nodefony/security/api/keys/{id}` | Révoque **ma** clé ; 404 sinon (anti-énumération) | `revoke()` (`ApiKeyController.ts#ApiKeyController.revoke`) |
483
490
 
484
491
  **Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
485
492
  `ROLE_NODEFONY_ADMIN` :
486
493
 
487
- | Méthode | Chemin | Rôle | Ancrage |
488
- | ------- | -------------------------------------------- | ---------------------------------------- | ------------------------- |
489
- | `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts:380` |
490
- | `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts:416` |
491
- | `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts:440` |
494
+ | Méthode | Chemin | Rôle | Ancrage |
495
+ | ------- | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- |
496
+ | `GET` | `/nodefony/security/api/apikeys` | Toutes les clés, **paginé au store** | `SecurityAdminApi.ts#createSecurityAdminApi` |
497
+ | `GET` | `/nodefony/security/api/apikeys/status` | « Où on écrit » : classe réelle + driver | `SecurityAdminApi.ts#createSecurityAdminApi` |
498
+ | `POST` | `/nodefony/security/api/apikeys/{id}/revoke` | Révoque n'importe quelle clé, audité | `SecurityAdminApi.ts#createSecurityAdminApi` |
492
499
 
493
500
  Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
494
501
  console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
495
502
 
496
- Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:165`) :
497
- **400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
503
+ Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts#ApiKeyController.#renderApiKeyError`) :
504
+ **400** validation (nom, scope, durée), **403** scope que le porteur ne peut pas obtenir, **409** plafond atteint, **503** clés indisponibles (store
498
505
  absent ou `enabled:false`).
499
506
 
500
507
  ### Ce qu'on importe côté application
@@ -516,9 +523,9 @@ import type {
516
523
  } from "@nodefony/security";
517
524
  ```
518
525
 
519
- Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts:6`), `IApiKeyCreated`
520
- (`IApiKey.ts:44`), `IApiKeyCapabilities` (`IApiKey.ts:55`), `ICreateApiKeyOptions`
521
- (`IApiKey.ts:69`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
526
+ Les contrats vivent dans `IApiKey.ts` : `IApiKeyView` (`IApiKey.ts#IApiKeyView`), `IApiKeyCreated`
527
+ (`IApiKey.ts#IApiKeyCreated`), `IApiKeyCapabilities` (`IApiKey.ts#IApiKeyCapabilities`), `ICreateApiKeyOptions`
528
+ (`IApiKey.ts#ICreateApiKeyOptions`). Les signatures détaillées vivent dans le graphe TSDoc (`.ai/symbols.json`) —
522
529
  cette page explique l'usage, elle ne recopie pas les prototypes.
523
530
 
524
531
  ## 🧑‍⚖️ Scopes — ce que la clé a le droit de faire
@@ -530,10 +537,10 @@ Deux axes se combinent, et les confondre est l'erreur la plus fréquente :
530
537
 
531
538
  Une clé ne peut donc **jamais** dépasser son porteur : elle en est une restriction, pas une
532
539
  extension. Concrètement, `@RequireScope("orders:read")` sur une action est tranché par le
533
- `ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts:44`) :
540
+ `ScopeVoter`, dont la règle est asymétrique (`ScopeVoter.ts#ScopeVoter`) :
534
541
 
535
542
  - un jeton **humain** (session, mot de passe, anonyme) n'est jamais bridé par un scope — la liste
536
- `NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts:17`) le fait passer ; ce sont ses **rôles** qui décident ;
543
+ `NON_SCOPABLE_TOKEN_TYPES` (`ScopeVoter.ts#NON_SCOPABLE_TOKEN_TYPES`) le fait passer ; ce sont ses **rôles** qui décident ;
537
544
  - un jeton **machine délégué** (`apikey`, `jwt`, `oauth2`) doit porter le scope **exact**, sinon
538
545
  refus par défaut du jury.
539
546
 
@@ -542,24 +549,24 @@ Le détail du jury (voters, veto, hiérarchie de rôles) est sur la page
542
549
 
543
550
  ## Persistance — un store partagé avec les jetons
544
551
 
545
- Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts:69`)
546
- de `kind:"pat"` (`ITokenStore.ts:74`), dans la même table que les refresh tokens — les champs sans
552
+ Une clé d'API **n'a pas de table à elle**. Elle est un `IAccessTokenRecord` (`ITokenStore.ts#IAccessTokenRecord`)
553
+ de `kind:"pat"` (`ITokenStore.ts#IAccessTokenRecord.kind`), dans la même table que les refresh tokens — les champs sans
547
554
  objet pour un PAT (`family`, `replacedBy`, `audience`) valent `null`.
548
555
 
549
556
  Les champs qui portent le sens **pour une clé d'API** :
550
557
 
551
- | Champ | Rôle pour un PAT | Ancrage |
552
- | ------------ | ------------------------------------------------------------- | -------------------- |
553
- | `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts:74` |
554
- | `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts:76` |
555
- | `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts:78` |
556
- | `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts:95` |
557
- | `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts:103` |
558
- | `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts:111` |
559
- | `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts:113` |
560
- | `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts:131` |
561
- | `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts:133` |
562
- | `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts:139` |
558
+ | Champ | Rôle pour un PAT | Ancrage |
559
+ | ------------ | ------------------------------------------------------------- | ---------------------------------------------- |
560
+ | `kind` | `"pat"` — discrimine du refresh dans la même table | `ITokenStore.ts#IAccessTokenRecord.kind` |
561
+ | `name` | Libellé humain (« CI deploy ») — ce qu'on lit dans la console | `ITokenStore.ts#IAccessTokenRecord.name` |
562
+ | `prefix` | Préfixe public `nf_a1b2c3d4` (jamais le secret) | `ITokenStore.ts#IAccessTokenRecord.prefix` |
563
+ | `subjectId` | Porteur — **référence logique**, pas une clé étrangère SQL | `ITokenStore.ts#IAccessTokenRecord.subjectId` |
564
+ | `scopes` | Capacités de la clé (lues par le `ScopeVoter`) | `ITokenStore.ts#IAccessTokenRecord.scopes` |
565
+ | `secretHash` | `sha256` du token entier — clé de `findByHash` | `ITokenStore.ts#ITokenStore.findByHash` |
566
+ | `hashAlg` | `"sha256"` — agilité crypto pour une migration future | `ITokenStore.ts#IAccessTokenRecord.hashAlg` |
567
+ | `expiresAt` | Expiration ou `null` (clé longue durée) | `ITokenStore.ts#IAccessTokenRecord.expiresAt` |
568
+ | `lastUsedAt` | Dernier usage, écrit **throttlé** | `ITokenStore.ts#IAccessTokenRecord.lastUsedAt` |
569
+ | `revokedAt` | Révocation — le contrôle n°1 de l'authenticator | `ITokenStore.ts#IAccessTokenRecord.revokedAt` |
563
570
 
564
571
  > [!NOTE]
565
572
  > Les **colonnes et types par dialecte** ne sont pas dupliqués ici : le propriétaire du schéma est
@@ -586,38 +593,38 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
586
593
 
587
594
  ## 📜 Normes appliquées
588
595
 
589
- | Domaine | Norme | Ancrage |
590
- | --------------------------------- | -------------------------------------- | ------------------------------------------------------ |
591
- | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
592
- | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 9110 §11 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
593
- | Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts:70`) |
594
- | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
595
- | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
596
- | Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts:17`) |
597
- | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
598
- | Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts:30`) |
599
- | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:48`) |
596
+ | Domaine | Norme | Ancrage |
597
+ | --------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------- |
598
+ | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts#readBearerHeader`) |
599
+ | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 9110 §11 | `challenge()` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.challenge`) |
600
+ | Secret **jamais** stocké en clair | OWASP ASVS (secret storage) | `hashApiKey()` (`apiKeyFormat.ts#hashApiKey`) |
601
+ | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts#IApiKeyCreated.token`) |
602
+ | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts#ApiKeyController.revoke`) |
603
+ | Message d'échec uniforme | OWASP API2:2023 (Broken Auth) | `INVALID_TOKEN` (`ApiKeyAuthenticator.ts#INVALID_TOKEN`) |
604
+ | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) |
605
+ | Entropie du secret (≥ 128 bits) | NIST SP 800-63B | 32 octets aléatoires (`apiKeyFormat.ts#SECRET_BYTES`) |
606
+ | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts#ApiKeyService.#maxPerSubject`) |
600
607
 
601
608
  ## ⚡ Performance & mémoire
602
609
 
603
610
  Le coût par requête authentifiée par clé est **maîtrisé par construction**, dans cet ordre :
604
611
 
605
612
  - **Le filtre le moins cher d'abord.** `supports()` ne fait qu'un `startsWith`
606
- (`ApiKeyAuthenticator.ts:68`) ; le parsing complet (CRC inclus) est purement local
607
- (`apiKeyFormat.ts:131`). Une valeur invalide ne coûte **aucun** I/O.
613
+ (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.constructor`) ; le parsing complet (CRC inclus) est purement local
614
+ (`apiKeyFormat.ts#parseApiKey`). Une valeur invalide ne coûte **aucun** I/O.
608
615
  - **Table CRC précalculée une fois** au chargement du module, jamais par appel
609
- (`CRC_TABLE`, `apiKeyFormat.ts:40`).
616
+ (`CRC_TABLE`, `apiKeyFormat.ts#CRC_TABLE`).
610
617
  - **`lastUsedAt` throttlé** — sans cette coalescence, chaque requête d'API deviendrait une
611
- **écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts:127-134`) ; `0` rétablit
618
+ **écriture** en base. Fenêtre par défaut 60 s (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) ; `0` rétablit
612
619
  l'écriture systématique, à ne choisir qu'en connaissance de cause.
613
620
  - **Dépendances résolues paresseusement** : store et fournisseur d'utilisateurs sont récupérés du
614
- container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts:173`, `apiKeys.ts:268`) — le boot
621
+ container au premier usage et mémoïsés (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.onSuccess`, `apiKeys.ts#ApiKeyService.revokeAnyPat`) — le boot
615
622
  ne paie rien si aucune clé n'est jamais présentée.
616
623
  - **Jamais N enregistrements en RAM** côté administration : le listing est paginé **au store**
617
- (`listPagePat()`, `apiKeys.ts:209`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts:107`).
624
+ (`listPagePat()`, `apiKeys.ts#ApiKeyService.listPagePat`), fenêtre plafonnée à 200 (`SecurityAdminApi.ts#IApiKeyAdmin`).
618
625
 
619
626
  Le point de vigilance restant : `createForSubject()` compte les clés actives via `findBySubject()`
620
- (`ITokenStore.ts:211`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
627
+ (`ITokenStore.ts#ITokenStore.findBySubject`), qui charge **toutes** les clés du porteur. C'est borné par `maxPerSubject`
621
628
  (100 par défaut) et c'est un chemin froid (émission), pas le chemin chaud.
622
629
 
623
630
  ## 📡 Observabilité — Studio
@@ -626,15 +633,15 @@ L'écran **API Keys** (`/nodefony/api-keys`, `studio/frontend/src/routes/ApiKeys
626
633
  deux portées dans une seule page :
627
634
 
628
635
  - **Mes clés** — création, listing et révocation via le data plane personnel
629
- (`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:83`). Le secret est affiché
636
+ (`KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts#KEYS_ENDPOINT`). Le secret est affiché
630
637
  dans la modale de création, une fois.
631
638
  - **Administration** — toutes les clés du système, pagination serveur et révocation ciblée
632
- (`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts:93`), réservé à
639
+ (`ADMIN_KEYS_ENDPOINT`, `studio/frontend/src/routes/apikeys/apiKeysModel.ts#ADMIN_KEYS_ENDPOINT`), réservé à
633
640
  `ROLE_NODEFONY_ADMIN`.
634
641
  - **Badge « où on écrit »** — la classe réelle du store et son driver, lu défensivement pour que la
635
642
  console affiche toujours un état honnête (`API_KEYS_STATUS_ENDPOINT`,
636
- `studio/frontend/src/routes/apikeys/apiKeysModel.ts:99` ; handler `IApiKeysStatus`,
637
- `SecurityAdminApi.ts:61`).
643
+ `studio/frontend/src/routes/apikeys/apiKeysModel.ts#API_KEYS_STATUS_ENDPOINT` ; handler `IApiKeysStatus`,
644
+ `SecurityAdminApi.ts#IApiKeysStatus`).
638
645
 
639
646
  Les types du front sont des **miroirs** du contrat serveur — le secret est exclu par construction,
640
647
  pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements `apikey.created` /
@@ -642,21 +649,21 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
642
649
 
643
650
  ## ⚠️ Pièges (symptôme → cause → correction)
644
651
 
645
- | Symptôme | Cause (dans le code) | Correction |
646
- | ------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
647
- | 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` |
648
- | 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
649
- | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
650
- | 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 |
651
- | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts:144-147`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
652
- | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts:284`) | Ajouter le scope au catalogue, ou corriger la demande |
653
- | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts:165`) | Garder le `prefix` STABLE après la première émission |
654
- | 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` |
655
- | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts:121-124`) | Réactiver le compte, ou réémettre après le bannissement |
656
- | 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 |
657
- | `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) |
658
- | Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
659
- | Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
652
+ | Symptôme | Cause (dans le code) | Correction |
653
+ | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
654
+ | 404 sur `/nodefony/security/api/keys` | Routes montées seulement si le service `apiKeys` existe (`framework/index.ts#Framework.onKernelReady`) | Charger `@nodefony/security` + `apiKeys.enabled: true` |
655
+ | 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts#ApiKeyService.revokeForSubject`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
656
+ | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
657
+ | Le token clair est introuvable après coup | Seul `sha256` est stocké — non re-dérivable (`apiKeyFormat.ts#hashApiKey`) | Émettre une nouvelle clé, révoquer l'ancienne |
658
+ | 409 « API key limit reached » | Plafond de clés **actives** atteint (`apiKeys.ts#ApiKeyService.createForSubject`) | Révoquer les clés inutilisées ou relever `maxPerSubject` |
659
+ | 400 « scope not allowed » | Scope hors du catalogue `allowedScopes` (`apiKeys.ts#ApiKeyService.#allowedScopes`) | Ajouter le scope au catalogue, ou corriger la demande |
660
+ | Toutes les clés rejetées après un changement de config | `prefix` modifié → les anciennes ne sont plus reconnues (`authenticatorRegistry.ts`) | Garder le `prefix` STABLE après la première émission |
661
+ | Clé valide mais 403 sur la route | Autorisation, pas authentification : scope manquant — `ScopeVoter.vote()` (`ScopeVoter.ts#ScopeVoter.vote`) | Émettre une clé portant le scope exigé par `@RequireScope` |
662
+ | Clé rejetée alors qu'elle n'est ni expirée ni révoquée | Porteur désactivé/verrouillé, ou seuil `invalidBefore` (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) | Réactiver le compte, ou réémettre après le bannissement |
663
+ | 404 en révoquant la clé d'un autre porteur | Anti-énumération volontaire, jamais 403 (`ApiKeyController.ts#ApiKeyController.revoke`) | Attendu — passer par l'endpoint d'administration |
664
+ | `lastUsedAt` qui ne bouge pas tout de suite | Écriture throttlée, 60 s par défaut (`ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate`) | Attendre la fenêtre, ou `lastUsedThrottleS: 0` (coût : 1 écriture/req) |
665
+ | Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
666
+ | Listing d'admin sans `total` sur Redis | Comptage exact refusé (O(N)) — pagination par curseur | Attendu : capacité réduite annoncée, paginer par `nextCursor` |
660
667
 
661
668
  ## 🧪 Tests & couverture
662
669