@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9

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 (41) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -3
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/SecuredArea.js +7 -0
  13. package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
  14. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  15. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  16. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  17. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  18. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  19. package/dist/types/nodefony/config/config.d.ts +2 -0
  20. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  21. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
  22. package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
  23. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
  24. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  25. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  26. package/docs/api-keys.md +62 -60
  27. package/docs/audit.md +77 -78
  28. package/docs/authenticators.md +55 -43
  29. package/docs/authorization.md +128 -23
  30. package/docs/cors.md +29 -29
  31. package/docs/csrf.md +26 -26
  32. package/docs/external-jwt.md +4 -4
  33. package/docs/firewall.md +80 -29
  34. package/docs/headers.md +26 -26
  35. package/docs/oauth2.md +69 -62
  36. package/docs/obtenir-un-jeton.md +2 -2
  37. package/docs/tokens.md +55 -55
  38. package/docs/totp.md +73 -72
  39. package/docs/webauthn.md +81 -81
  40. package/docs/webhooks.md +43 -37
  41. package/package.json +16 -13
@@ -18,6 +18,12 @@ export declare class SecuredArea implements ISecuredArea {
18
18
  readonly host?: string;
19
19
  readonly realtime: boolean;
20
20
  readonly resource?: string;
21
+ /**
22
+ * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
23
+ * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
24
+ * et aucune zone ne porte de tableau qu'elle n'utilise pas.
25
+ */
26
+ readonly roles: readonly string[] | null;
21
27
  constructor(name: string, config: ISecurityAreaConfig);
22
28
  /**
23
29
  * Cœur du match — pathname (+ host éventuel) déjà extraits, SANS `context`.
@@ -48,9 +48,22 @@ export interface ISystemChannelRule {
48
48
  export type FrameDenyReporter = (surface: "api.request" | "channel", target: string, reason: string, token: IRealtimeToken) => void;
49
49
  /**
50
50
  * Politique système par défaut des canaux d'**introspection serveur** : réservés
51
- * aux administrateurs. DURCISSEMENT Zero Trust (P6) : avant, « authentifié
52
- * suffisait » (tout `ROLE_USER` lisait `nodefony:syslog`) ; désormais `ROLE_ADMIN`.
53
- * Surchargeable finement par `realtimeChannels` (ex. `ROLE_SECURITY_AUDITOR`).
51
+ * à l'exploitant de l'INSTANCE, pas à l'administrateur d'une application.
52
+ *
53
+ * Ce que ces canaux exposent est l'état du **processus** — journaux, base,
54
+ * métriques, supervision, et jusqu'au déclenchement d'un ramasse-miettes
55
+ * bloquant. C'est l'échelle PLATEFORME, celle que le préfixe `ROLE_NODEFONY_*`
56
+ * désigne : le seul rôle qui traverse l'isolation entre organisations
57
+ * clientes. Un `ROLE_ADMIN` est, lui, destiné à être scopé à une organisation ;
58
+ * le jour où plusieurs partagent une instance, il aurait lu la supervision du
59
+ * processus qui les héberge toutes.
60
+ *
61
+ * Le canal d'audit ({@link SECURITY_CHANNEL_POLICY}) partage désormais cette
62
+ * échelle — il ne se distingue plus par le rôle, mais par ce qu'il contient.
63
+ *
64
+ * Surchargeable finement par `realtimeChannels` : une application qui veut
65
+ * ouvrir un de ces canaux à un rôle d'organisation le DÉCLARE, elle ne l'hérite
66
+ * pas.
54
67
  */
55
68
  export declare const SYSTEM_CHANNEL_POLICY: IChannelPolicy;
56
69
  /**
@@ -67,11 +80,12 @@ export declare const SYSTEM_CHANNEL_POLICY: IChannelPolicy;
67
80
  */
68
81
  export declare const DEFAULT_SYSTEM_PREFIXES: readonly ["nodefony:"];
69
82
  /**
70
- * Plancher des canaux de **sécurité** (`nodefony:audit`, P6.14 lot 4) : réservé au
71
- * super-admin Nodefony (`ROLE_NODEFONY_ADMIN`) — un cran AU-DESSUS du plancher
72
- * d'observabilité générique (`ROLE_ADMIN`). Le journal d'audit du pod ne se lit
73
- * pas avec un simple rôle admin applicatif. Cohérent avec le data plane HTTP de
74
- * l'audit (`SecurityAdminApi`, lot 3, même rôle).
83
+ * Plancher des canaux de **sécurité** (`nodefony:audit`) : réservé à l'exploitant
84
+ * de l'instance (`ROLE_NODEFONY_ADMIN`). Même échelle que
85
+ * {@link SYSTEM_CHANNEL_POLICY} — le journal d'audit du pod n'est pas plus
86
+ * exposé que sa supervision, et aucun des deux ne se lit avec un rôle
87
+ * d'organisation. Cohérent avec le plan d'administration HTTP de l'audit
88
+ * (`SecurityAdminApi`, même rôle).
75
89
  *
76
90
  * Multi-tenant (futur) : `nodefony:audit` reste un canal **plateforme** (pod),
77
91
  * jamais exposé à un user tenant ; l'événement portera le `tenantId` (via l'ALS)
@@ -16,15 +16,6 @@ export declare function matchesTotpQuery(secret: ITotpSecret, query: ITotpListQu
16
16
  export interface TotpStoreSnapshot {
17
17
  secrets: ITotpSecret[];
18
18
  }
19
- /**
20
- * Store de secrets TOTP **en mémoire** — implémentation de référence
21
- * d'{@link ITotpSecretStore}. Clé = `userId` (un secret par utilisateur).
22
- *
23
- * 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
24
- * partagée entre process et **volatile** → en production multi-process, utiliser
25
- * un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
26
- * (2FA activé), jamais sur le hot path par requête.
27
- */
28
19
  export declare class MemoryTotpSecretStore implements ITotpSecretStore {
29
20
  #private;
30
21
  findByUser(userId: string): Promise<ITotpSecret | null>;
@@ -10,14 +10,6 @@ import type { IWebhookEndpoint, WebhookEndpointUpdate } from "../../contracts/IW
10
10
  * @returns `true` si l'endpoint appartient à la collection filtrée.
11
11
  */
12
12
  export declare function matchesWebhookQuery(e: IWebhookEndpoint, query: IWebhookListQuery): boolean;
13
- /**
14
- * Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
15
- * les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
16
- * `mongoose` (config `webhooks.store`).
17
- *
18
- * Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
19
- * store détient la vérité, un consommateur ne peut pas muter un record en place.
20
- */
21
13
  export declare class MemoryWebhookStore implements IWebhookStore {
22
14
  #private;
23
15
  /**
package/docs/api-keys.md CHANGED
@@ -115,7 +115,7 @@ 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:107-114`).
118
+ (`ApiKeyAuthenticator.authenticate()`, `ApiKeyAuthenticator.ts:97-104`).
119
119
 
120
120
  **Ce que Nodefony fait pour que ça reste bon marché** — trois décisions ancrées au code :
121
121
 
@@ -193,7 +193,7 @@ export default OrdersController;
193
193
  ### 3. Émettre la clé — par l'API, avec une session
194
194
 
195
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
196
+ (`mountApiKeyRoutes()`, `ApiKeyController.ts:192`). Il n'y a **pas** de commande CLI pour créer une
197
197
  clé : la création exige une **session BFF** (les routes ne sont pas `bypassFirewall` —
198
198
  `ApiKeyController.ts:50-54`), parce que le porteur est **toujours** l'utilisateur courant, jamais un
199
199
  paramètre.
@@ -228,7 +228,7 @@ curl -si https://localhost:5152/api/v1/orders/list | head -1
228
228
 
229
229
  > [!WARNING]
230
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
231
+ > stocké, donc pas re-dérivable (`IApiKeyCreated`, `IApiKey.ts:44`). Le listing ultérieur ne
232
232
  > renvoie que le `prefix` public (`#toView()`, `apiKeys.ts:393`). Perdue = ré-émise.
233
233
 
234
234
  ## 🔐 Anatomie d'une clé — ce que chaque morceau paie
@@ -239,7 +239,7 @@ raison est dans le code (`apiKeyFormat.ts:8-10`) : le charset base64url contient
239
239
 
240
240
  | Morceau | Taille | Secret ? | À quoi ça sert |
241
241
  | -------- | -------------------- | :------: | -------------------------------------------------------------------------------------------------- |
242
- | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:730`) |
242
+ | `prefix` | ≤ 12 car. minuscules | non | Marque applicative — discrimine du JWT, aide le secret-scanning (`config.ts:736`) |
243
243
  | `pubid` | 6 octets → 8 car. | non | Identifiant affichable dans la console (`nf_a1b2c3d4`) — `generateApiKey()` (`apiKeyFormat.ts:92`) |
244
244
  | `secret` | 32 octets → 43 car. | **oui** | 256 bits d'entropie — `SECRET_BYTES` (`apiKeyFormat.ts:30`) |
245
245
  | `crc` | 4 octets → 6 car. | non | CRC32 du `prefix_pubid+secret` — `crcChunk()` (`apiKeyFormat.ts:63`) |
@@ -268,7 +268,7 @@ dépendance ni d'une variation de version Node (`crc32()`, `apiKeyFormat.ts:53`)
268
268
 
269
269
  Avant même de parser, le firewall doit savoir **quel** authenticator prend la main. C'est
270
270
  `looksLikeApiKey()` (`apiKeyFormat.ts:117`) : un simple `startsWith("<prefix>_")`, appelé par
271
- `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:68`). C'est ce qui rend la cohabitation
271
+ `ApiKeyAuthenticator.supports()` (`ApiKeyAuthenticator.ts:75`). C'est ce qui rend la cohabitation
272
272
  `["apikey", "jwt"]` possible dans une même zone — un JWT a la structure `a.b.c`, il ne commence
273
273
  jamais par le préfixe.
274
274
 
@@ -281,19 +281,19 @@ jamais par le préfixe.
281
281
  1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
282
282
  `apiKeys.ts:337`).
283
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`).
284
+ `allowedScopes` est défini ; sinon 400 (`#normalizeScopes()`, `apiKeys.ts:348`).
285
285
  3. **Résolution de l'expiration** — `expiresInDays` explicite, sinon le défaut de config, `null` =
286
286
  sans expiration ; une valeur non positive lève un 400 (`#resolveExpiry()`, `apiKeys.ts:370`).
287
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`).
288
+ `#isActive()` (`apiKeys.ts:386`) ; au-delà de `maxPerSubject` → 409 (`apiKeys.ts:55`).
289
289
  5. **Génération** — 32 octets aléatoires, `publicPrefix` et `secretHash` dérivés
290
290
  (`generateApiKey()`, `apiKeyFormat.ts:92`).
291
- 6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:147`).
291
+ 6. **Écriture** — le `record` de `kind:"pat"` posé au store par `store.put()` (`apiKeys.ts:177`).
292
292
  7. **Audit** — `apikey.created`, catégorie `token`, avec l'**id public** et les scopes, **jamais le
293
- secret** (`apiKeys.ts:153`).
293
+ secret** (`apiKeys.ts:183-189`).
294
294
 
295
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
296
+ au premier usage (`#resolveStore()`, `apiKeys.ts:324`) — indépendant de l'ordre de boot. Store
297
297
  absent = **503 explicite**, jamais une 500 opaque.
298
298
 
299
299
  ### Vérification — `ApiKeyAuthenticator.authenticate()`
@@ -304,12 +304,12 @@ filtre qui coûte plus cher que la précédente :
304
304
  | # | Contrôle | Coût | Ancrage |
305
305
  | --- | --------------------------------------- | --------------- | ----------------------------------------------------- |
306
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`) |
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
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`) |
312
+ | 7 | `lastUsedAt` (throttlé) | 0 ou 1 écriture | `markUsed` (`ApiKeyAuthenticator.ts:187`) |
313
313
 
314
314
  Deux points méritent d'être soulignés parce qu'ils décident du niveau de sécurité réel :
315
315
 
@@ -324,13 +324,13 @@ Deux points méritent d'être soulignés parce qu'ils décident du niveau de sé
324
324
 
325
325
  En cas de succès, le jeton est promu et porte trois attributs consommés en aval : `scopes`,
326
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`).
327
+ zone est un simple `Bearer` (`challenge()`, `ApiKeyAuthenticator.ts:205`).
328
328
 
329
329
  ### Câblage — d'où viennent le préfixe et le throttle
330
330
 
331
331
  L'authenticator n'est jamais instancié à la main : le firewall le construit depuis le registre, en
332
332
  lui injectant la config effective — `registerAuthenticatorFactory("apikey")`
333
- (`authenticatorRegistry.ts:117`), qui lit `prefix` et `lastUsedThrottleS`
333
+ (`authenticatorRegistry.ts:137`), qui lit `prefix` et `lastUsedThrottleS`
334
334
  (`authenticatorRegistry.ts:143`). Conséquence pratique : changer `apiKeys.prefix` change **à la
335
335
  fois** l'émission et la reconnaissance — les anciennes clés ne sont plus reconnues.
336
336
 
@@ -353,13 +353,13 @@ apiKeys: {
353
353
  **Ce qu'on observe** : `npx nodefony security:user:add ci-bot` (rôle `ROLE_USER` par défaut), login
354
354
  en tant que `ci-bot`, puis émission avec `{"scopes":["orders:read"]}`. Demander
355
355
  `{"scopes":["orders:write"]}` renvoie **400 `scope not allowed: orders:write`** — refusé à
356
- l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:292`).
356
+ l'émission, pas seulement à l'usage (`#normalizeScopes()`, `apiKeys.ts:348`).
357
357
 
358
358
  > [!TIP]
359
359
  > Le catalogue `allowedScopes` de la config est un **complément**, pas la source : la console
360
360
  > propose aussi les scopes **découverts sur tes routes** (`@RequireScope`) par
361
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.
362
+ > (`ApiKeyController.ts:97`). Un formulaire de création qui ne ment pas.
363
363
 
364
364
  ### Faire tourner une clé sans coupure de service
365
365
 
@@ -368,7 +368,7 @@ doit y avoir **aucune** fenêtre pendant laquelle le job échoue.
368
368
 
369
369
  **Il n'y a pas de bouton « rotate »** — et c'est délibéré : une rotation atomique impliquerait
370
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`) :
371
+ **recouvrement**, rendu possible par le plafond `maxPerSubject` (`apiKeys.ts:55`) :
372
372
 
373
373
  1. Émettre une **seconde** clé (même porteur, mêmes scopes, nom `CI deploy v2`).
374
374
  2. Déployer le nouveau secret dans le CI.
@@ -376,7 +376,7 @@ soit deux secrets valides sous le même id (ambigu à auditer), soit une coupure
376
376
  4. **Puis** révoquer la v1.
377
377
 
378
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
379
+ la fenêtre par défaut est de 60 s (`lastUsedThrottleS`, `config.ts:752`). Attends donc une minute
380
380
  avant de conclure qu'une clé « ne sert plus ».
381
381
 
382
382
  ### Révoquer une clé qui a fuité
@@ -388,25 +388,25 @@ Deux chemins, selon qui agit :
388
388
 
389
389
  | Qui | Endpoint | Portée | Ancrage |
390
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`) |
391
+ | Le porteur | `DELETE /nodefony/security/api/keys/{id}` | **ses** clés seulement | `revokeForSubject()` (`apiKeys.ts:303`) |
392
+ | Un admin | `POST /nodefony/security/api/apikeys/{id}/revoke` | n'importe quelle clé | `revokeAnyPat()` (`apiKeys.ts:257`) |
393
393
 
394
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`).
395
+ l'authenticator lit `revokedAt` avant toute autre décision (`ApiKeyAuthenticator.ts:111-118`).
396
396
  Le banc d'intégration le prouve bout en bout : 200 avant, 401 après
397
- (`apikey-flow.test.ts:149-157`).
397
+ (`apikey-flow.test.ts:182-191`).
398
398
 
399
399
  Une propriété de sécurité facile à manquer : si la clé n'existe pas **ou** appartient à quelqu'un
400
400
  d'autre, le porteur reçoit un **404 indiscernable**, jamais un 403 (`ApiKeyController.ts:139-142`).
401
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`).
402
+ autres. Le banc couvre explicitement cet IDOR (`apikey-flow.test.ts:209`).
403
403
 
404
404
  > [!WARNING]
405
405
  > Si tu dois couper **toutes** les clés d'un porteur d'un coup (compte compromis, départ), ne les
406
406
  > révoque pas une par une : pose le seuil `invalidBefore` du porteur
407
407
  > (`revokeAllForSubject`, `ITokenStore.ts:261`). L'authenticator rejette alors toute clé créée avant
408
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.
409
+ > (`ApiKeyAuthenticator.ts:121-124`) — y compris pour les clés que tu aurais oubliées.
410
410
 
411
411
  ### Auditer qui a utilisé quoi
412
412
 
@@ -417,9 +417,9 @@ Trois sources, et il faut connaître les limites de chacune :
417
417
 
418
418
  1. **L'état** — le listing d'administration paginé, tous porteurs confondus : `GET
419
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
420
+ (`apiKeys.ts:216`). Filtres `subjectId`, `revoked`, fenêtre `limit`/`offset`/`cursor` et tri
421
421
  `order=champ:ASC` (`parseTokenListQuery()`, `SecurityAdminApi.ts:126`), plafonnée à 200 entrées
422
- (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:109`).
422
+ (`KEYS_MAX_LIMIT`, `SecurityAdminApi.ts:114`).
423
423
 
424
424
  Le tri n'est accepté que sur les champs que le backend branché **déclare** savoir trier
425
425
  (`sortableFields()`, `apiKeys.ts:102` → `ITokenStore.sortableFields`) : `createdAt`, `name`,
@@ -430,30 +430,33 @@ Trois sources, et il faut connaître les limites de chacune :
430
430
  du vocabulaire : le placement des valeurs absentes diffère d'un moteur à l'autre, et un tri dont
431
431
  l'ordre dépend de la base configurée ne vaut pas mieux qu'un tri absent.
432
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
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
435
435
  admin trace **l'acteur ET le porteur cible** — voir [audit](./audit.md).
436
- 3. **Le dernier usage** — `lastUsedAt` sur chaque clé.
436
+ 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
439
+ proxys de confiance ; si elle ne peut pas être lue, le champ reste vide sans faire échouer
440
+ l'authentification.
437
441
 
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.
442
+ Ce que tu **n'auras pas** : un journal par requête. Le dernier usage est **écrasé** à chaque
443
+ écriture, et cette écriture est throttlée (`lastUsedThrottleS`) : entre deux écritures, les appels
444
+ ne laissent aucune trace dans le store. Pour de la traçabilité par appel, c'est le journal d'audit
445
+ applicatif qu'il faut alimenter, pas le store de jetons.
443
446
 
444
447
  ## ⚙️ Configuration
445
448
 
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**.
449
+ Table dérivée du schéma Zod `apiKeysSchema` (`config.ts:733`), branché à la racine de la config du
450
+ module (`config.ts:733`). Toutes les valeurs ci-dessous sont les **défauts réels**.
448
451
 
449
452
  | Option | Type | Défaut | Effet |
450
453
  | ------------------- | ---------------- | ------ | -------------------------------------------------------------------------------------- |
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`) |
454
+ | `enabled` | boolean | `true` | Coupe l'émission ET le listing (l'authenticator reste déclarable) (`config.ts:735`) |
455
+ | `prefix` | string ≤ 12 | `"nf"` | Marque des clés ; minuscules/chiffres — discrimine du JWT (`config.ts:736`) |
456
+ | `defaultExpiryDays` | number \| null | `90` | Expiration appliquée si l'appelant n'en donne pas ; `null` = jamais (`config.ts:745`) |
457
+ | `lastUsedThrottleS` | number (s) | `60` | Coalescence d'écriture de `lastUsedAt` ; `0` = à chaque usage (`config.ts:752`) |
458
+ | `maxPerSubject` | number > 0 | `100` | Plafond de clés **actives** par porteur ; au-delà → 409 (`config.ts:761`) |
459
+ | `allowedScopes` | string[] \| null | `null` | Catalogue fermé à la création ; `null` = tout scope non vide accepté (`config.ts:770`) |
457
460
 
458
461
  Deux réglages méritent une décision consciente :
459
462
 
@@ -468,15 +471,15 @@ Deux réglages méritent une décision consciente :
468
471
  ### Les endpoints — deux portées, jamais mélangées
469
472
 
470
473
  **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`) ;
474
+ (`ApiKeyController.ts:192`) **seulement si** le service `apiKeys` existe (`framework/index.ts:468`) ;
472
475
  sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exige la session BFF.
473
476
 
474
477
  | Méthode | Chemin | Rôle | Ancrage |
475
478
  | -------- | ------------------------------------------ | ------------------------------------------------- | ------------------------------------------- |
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`) |
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`) |
480
483
 
481
484
  **Administration** (gouvernance, réponse à incident) — data plane `SecurityAdminApi`, RBAC
482
485
  `ROLE_NODEFONY_ADMIN` :
@@ -490,7 +493,7 @@ sinon 404, zéro surface. Aucune n'est `bypassFirewall` : la zone data plane exi
490
493
  Les deux espaces de chemins sont **disjoints** (`keys` vs `apikeys`) — aucune collision, et une
491
494
  console d'admin ne peut pas atterrir par erreur sur l'endpoint personnel.
492
495
 
493
- Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:164`) :
496
+ Codes d'erreur mappés par duck-typing sur `code` (`#renderApiKeyError()`, `ApiKeyController.ts:165`) :
494
497
  **400** validation (nom, scope, durée), **409** plafond atteint, **503** clés indisponibles (store
495
498
  absent ou `enabled:false`).
496
499
 
@@ -514,8 +517,8 @@ import type {
514
517
  ```
515
518
 
516
519
  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`) —
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`) —
519
522
  cette page explique l'usage, elle ne recopie pas les prototypes.
520
523
 
521
524
  ## 🧑‍⚖️ Scopes — ce que la clé a le droit de faire
@@ -588,12 +591,12 @@ révocation ne traverse pas. Le détail de la résolution, des avertissements et
588
591
  | Schéma `Bearer` (transport) | RFC 6750 §2.1 | `readBearerHeader()` (`runtime/bearer.ts:68`) |
589
592
  | `invalid_token` → 401 + challenge | RFC 6750 §3.1 · RFC 7235 | `challenge()` (`ApiKeyAuthenticator.ts:205`) |
590
593
  | 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`) |
594
+ | Secret montré une seule fois | Pratique « shown once » | `IApiKeyCreated.token` (`IApiKey.ts:46`) |
592
595
  | Anti-énumération des ressources | OWASP API1:2023 (BOLA/IDOR) | 404 indiscernable (`ApiKeyController.ts:139-142`) |
593
596
  | 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`) |
597
+ | Révocation immédiate côté serveur | OWASP API2:2023 | `revokedAt` vérifié (`ApiKeyAuthenticator.ts:111-118`) |
595
598
  | 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`) |
599
+ | Plafond de ressources par acteur | OWASP API4:2023 (Resource Consumption) | `maxPerSubject` (`apiKeys.ts:55`) |
597
600
 
598
601
  ## ⚡ Performance & mémoire
599
602
 
@@ -645,14 +648,13 @@ pas masqué à l'affichage. Voir aussi l'écran **Audit** pour les événements
645
648
  | 503 « API keys unavailable » | Store non provisionné (`TokenService` absent/désactivé) (`apiKeys.ts:330`) | Vérifier `jwt`/`tokenStore` — le `TokenService` pose le store |
646
649
  | 401 à la création de clé | Ces routes exigent une **session** (pas de `bypassFirewall`) | Se connecter d'abord (`/nodefony/security/api/auth/login`) |
647
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 |
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 |
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:363`) | Ajouter le scope au catalogue, ou corriger la demande |
650
653
  | 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
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` |
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 |
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 |
653
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 |
654
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) |
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
658
  | Révocation sans effet entre pods | Store `memory` en production (per-pod) | Store durable partagé — voir [tokens](./tokens.md) |
657
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` |
658
660