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

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 (104) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +63 -61
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +30 -11
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  8. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  9. package/dist/nodefony/errors/SsrfError.js +9 -1
  10. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  11. package/dist/nodefony/errors/index.js +7 -5
  12. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  13. package/dist/nodefony/keycloak/scaffold.js +31 -14
  14. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  15. package/dist/nodefony/service/accessTokenVerifier.js +6 -6
  16. package/dist/nodefony/service/apiKeys.js +8 -4
  17. package/dist/nodefony/service/auditService.js +48 -3
  18. package/dist/nodefony/service/authFlow.js +9 -6
  19. package/dist/nodefony/service/authorization.js +1 -1
  20. package/dist/nodefony/service/firewall.js +281 -44
  21. package/dist/nodefony/service/keycloak.js +1 -1
  22. package/dist/nodefony/service/oauth2.js +8 -4
  23. package/dist/nodefony/service/securityHeaders.js +340 -47
  24. package/dist/nodefony/service/tokenService.js +16 -11
  25. package/dist/nodefony/service/totp.js +10 -5
  26. package/dist/nodefony/service/webAuthn.js +46 -7
  27. package/dist/nodefony/service/webhooks.js +11 -4
  28. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  29. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  30. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  31. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  32. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  33. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
  34. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
  35. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
  36. package/dist/nodefony/src/csp.js +265 -10
  37. package/dist/nodefony/src/cspReports.js +307 -0
  38. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  39. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  40. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  41. package/dist/nodefony/src/oauth/metadata.js +1 -1
  42. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  43. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  44. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  45. package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
  46. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  47. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  48. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  49. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  50. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  51. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  52. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  53. package/dist/nodefony/testing/index.js +26 -36
  54. package/dist/types/index.d.ts +5 -1
  55. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  56. package/dist/types/nodefony/config/config.d.ts +8 -0
  57. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  58. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  59. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  60. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  61. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  62. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  63. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  64. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  65. package/dist/types/nodefony/errors/index.d.ts +3 -0
  66. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  67. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  68. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  69. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  70. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  71. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  72. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  73. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  74. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  75. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  76. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  77. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  78. package/dist/types/nodefony/src/csp.d.ts +142 -0
  79. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  80. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  81. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  82. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  83. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  84. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
  85. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  86. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  87. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  88. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  89. package/dist/types/nodefony/testing/index.d.ts +6 -0
  90. package/docs/api-keys.md +3 -1
  91. package/docs/audit.md +112 -28
  92. package/docs/authenticators.md +6 -4
  93. package/docs/csrf.md +3 -2
  94. package/docs/external-jwt.md +5 -1
  95. package/docs/firewall.md +3 -2
  96. package/docs/headers.md +427 -71
  97. package/docs/keycloak.md +4 -4
  98. package/docs/login-page.md +4 -3
  99. package/docs/obtenir-un-jeton.md +17 -10
  100. package/docs/tokens.md +1 -1
  101. package/docs/totp.md +2 -2
  102. package/docs/webauthn.md +2 -2
  103. package/docs/webhooks.md +63 -18
  104. package/package.json +17 -17
@@ -22,7 +22,10 @@ export interface IDeliveryOptions {
22
22
  readonly allowHttp?: boolean;
23
23
  }
24
24
  /**
25
- * POST le corps signé vers l'URL, en pinnant l'IP validée et sans suivre les
26
- * redirections. Ne lève jamais : tout échec est encodé dans {@link IDeliveryResult}.
25
+ * POST le corps signé vers l'URL, par {@link fetchPublic} : IP vérifiée épinglée,
26
+ * aucune redirection suivie, délai TOTAL (un destinataire qui goutte un octet
27
+ * ne retient pas la livraison) et lecture du corps coupée à
28
+ * {@link RESPONSE_BODY_CAP} (connexion détruite, pas drainée). Ne lève jamais :
29
+ * tout échec est encodé dans {@link IDeliveryResult}.
27
30
  */
28
31
  export declare function deliverWebhook(url: string, body: string, headers: Record<string, string>, opts: IDeliveryOptions): Promise<IDeliveryResult>;
@@ -4,6 +4,12 @@ import type { FrameAuthorizer, IRealtimeToken } from "../src/realtime/realtimeCo
4
4
  export interface ITestIdentityOptions {
5
5
  /** Identifiant de l'utilisateur (défaut `"alice"`). */
6
6
  readonly identifier?: string;
7
+ /**
8
+ * Identifiant IMMUABLE du compte (défaut : `identifier`) — l'`id` que lit la
9
+ * règle `allow` d'un canal, distinct du login comme au runtime. Le poser quand
10
+ * l'appartenance se relit par la clé primaire de l'entité.
11
+ */
12
+ readonly id?: string;
7
13
  /** Rôles portés (défaut `["ROLE_USER"]`) — ceux qu'aurait résolus le pare-feu. */
8
14
  readonly roles?: readonly string[];
9
15
  /** Scopes portés (défaut aucun) — l'axe des jetons d'API, pas de la session. */
package/docs/api-keys.md CHANGED
@@ -279,7 +279,9 @@ jamais par le préfixe.
279
279
 
280
280
  ### Émission — `ApiKeyService.createForSubject()`
281
281
 
282
- `ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Dans l'ordre :
282
+ `ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Un `tenantId` rempli est refusé d'entrée (`ApiKeyError` 400) : le
283
+ multi-tenant n'existe pas, aucun lecteur ne cloisonne sur ce champ, et une clé « rattachée à un
284
+ locataire » verrait tout. Il sera accepté le jour où il sera appliqué. Ensuite, dans l'ordre :
283
285
 
284
286
  1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
285
287
  `apiKeys.ts#ApiKeyService.#normalizeName`).
package/docs/audit.md CHANGED
@@ -21,7 +21,7 @@ tags:
21
21
  ]
22
22
  version: "doc"
23
23
  status: stable
24
- updated: 2026-07-19
24
+ updated: 2026-11-13
25
25
  source: "src/packages/@nodefony/security/docs/audit.md"
26
26
  ---
27
27
 
@@ -87,6 +87,7 @@ webhooks, console — se branche autour de lui.
87
87
  | Append-only | On n'ajoute que des lignes ; rien n'est modifié ni supprimé, sauf la purge de rétention. |
88
88
  | Rétention | Durée pendant laquelle un événement reste consultable avant purge (`retentionDays`). |
89
89
  | GC | _Garbage collection_ : la purge périodique des événements hors rétention. |
90
+ | Agrégation | Un flot de refus d'une même source s'écrit en quelques lignes, puis une ligne de **synthèse**. |
90
91
  | Curseur | Jeton opaque « donne-moi la suite après celui-ci » — la façon de paginer un flux qui grandit. |
91
92
  | Cold-path | Chemin d'exception (échec, refus). Par opposition au **hot-path** : le chemin nominal, à coût nul. |
92
93
  | Fire-and-forget | On lance l'écriture sans l'attendre : le flux métier ne dépend pas de son succès. |
@@ -246,7 +247,7 @@ sans qu'on l'ait demandée (IP, User-Agent, `requestId`), et les `flags` disent
246
247
 
247
248
  Le même journal est consultable dans **Studio → Sécurité → Journal d'audit**.
248
249
 
249
- ## ⚙️ Quatre situations — du besoin à la config
250
+ ## ⚙️ Cinq situations — du besoin à la config
250
251
 
251
252
  ### Situation 1 — « Qui s'est connecté à ce compte cette nuit ? »
252
253
 
@@ -327,7 +328,70 @@ Le store mémoire porte une **seconde** protection, indépendante du temps : un
327
328
  pas une politique de rétention, c'est un garde-fou anti-fuite : la mémoire d'un pod est bornée quoi
328
329
  qu'il arrive.
329
330
 
330
- ### Situation 4 — « Ne rien perdre quand le store tombe »
331
+ ### Situation 4 — « Un flot de refus ne doit pas noyer la base »
332
+
333
+ Un robot essaie des mots de passe à 1 000 requêtes par seconde. Une ligne par refus, c'est
334
+ 86 millions de lignes par jour, conservées toute la rétention — et l'OWASP Logging Cheat Sheet
335
+ demande justement que la journalisation n'épuise ni les ressources ni la base.
336
+
337
+ Rien à configurer : les **refus** (`failure` et `denied`) sont agrégés par défaut
338
+ (`AuditRefusalAggregator.ts#AuditRefusalAggregator`). Pour une même **source** — même catégorie,
339
+ action, issue, motif, ressource et **IP** — et par fenêtre de 60 secondes ouverte au premier refus :
340
+
341
+ 1. les **10 premiers** refus sont écrits tels quels, acteur compris : l'enquêteur voit les premiers
342
+ essais et les comptes visés ;
343
+ 2. les suivants sont **comptés**, ni écrits ni diffusés en direct
344
+ (`AuditRefusalAggregator.ts#AuditRefusalAggregator.admit`) ;
345
+ 3. la fenêtre close écrit **une** ligne de synthèse qui porte le compte.
346
+
347
+ ```json
348
+ {
349
+ "category": "auth",
350
+ "action": "auth.failure",
351
+ "outcome": "failure",
352
+ "actor": null,
353
+ "resource": "api",
354
+ "reason": "invalid_credentials",
355
+ "ip": "203.0.113.7",
356
+ "metadata": {
357
+ "aggregated": {
358
+ "count": 59990,
359
+ "firstTs": 1763548800450,
360
+ "lastTs": 1763548859998,
361
+ "windowMs": 60000
362
+ }
363
+ }
364
+ }
365
+ ```
366
+
367
+ Trois choix à connaître :
368
+
369
+ - **L'IP est celle du contexte**, résolue derrière un proxy de confiance (`trustProxy`), donc non
370
+ forgeable par le client (`readAuditContext.ts#readAuditContext`).
371
+ - **L'acteur n'entre pas dans la clé.** Un bourrage d'identifiants change de compte à chaque essai :
372
+ c'est sa source qui le trahit. La synthèse porte `actor: null` plutôt qu'un acteur choisi au hasard.
373
+ - **Les succès ne sont jamais agrégés** : un login réussi reste une ligne, toujours.
374
+
375
+ La mémoire est bornée : au-delà de 10 000 sources suivies, les refus se regroupent sous l'IP `*`.
376
+ Le balayage des fenêtres échues n'est armé qu'au premier refus et s'arrête quand la table est vide
377
+ (`auditService.ts#AuditService.#startSweep`) ; à l'arrêt du serveur, les comptes en cours sont résumés
378
+ avant que le store ne soit lâché (`auditService.ts#AuditService.#shutdown`).
379
+
380
+ ```typescript
381
+ use("@nodefony/security", {
382
+ audit: {
383
+ // Plus de lignes détaillées par source, sur une fenêtre plus courte.
384
+ aggregate: { burst: 50, windowMs: 10_000 },
385
+ // Ou une ligne par refus, sans exception :
386
+ // aggregate: { enabled: false },
387
+ },
388
+ });
389
+ ```
390
+
391
+ Ce qu'on observe : pour lister les flots, filtrer sur l'issue et repérer les événements dont
392
+ `metadata.aggregated` est présent ; leur `ip` désigne la source.
393
+
394
+ ### Situation 5 — « Ne rien perdre quand le store tombe »
331
395
 
332
396
  La base est indisponible pendant trente secondes. Que se passe-t-il ?
333
397
 
@@ -453,7 +517,7 @@ garantit un seul appel d'horloge, centralisé hors des points d'émission.
453
517
  | `userAgent` | `string \| null` | contexte | provenance client |
454
518
  | `requestId` | `string \| null` | contexte | corrélation log ↔ audit ↔ trace |
455
519
  | `flags` | `IAuditEventFlags` | contexte | **présence** de matériel sensible, jamais la valeur |
456
- | `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres, absents par défaut |
520
+ | `metadata` | `Record<string, unknown>` | émetteur | extras applicatifs libres ; `aggregated` sur une ligne de synthèse |
457
521
 
458
522
  Les quatre derniers champs de provenance sont remplis d'un coup par `readAuditContext()`
459
523
  (`readAuditContext.ts#readAuditContext`), qui lit un contexte HTTP ou WS de façon défensive — tous les champs sont
@@ -462,7 +526,7 @@ optionnels, un contexte partiel ne casse rien.
462
526
  ## ⚡ Performance & mémoire — pourquoi l'audit ne pèse pas sur la requête
463
527
 
464
528
  C'est un point d'honneur du framework : **l'audit ne se paie que quand il se passe quelque chose**.
465
- Quatre mécanismes, tous prouvés par les tests.
529
+ Six mécanismes, tous prouvés par les tests.
466
530
 
467
531
  **1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
468
532
  n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
@@ -477,7 +541,17 @@ aucun appel d'horloge. Prouvé par le banc « audit désactivé → `issueTokens
477
541
  **3. L'écriture ne bloque pas.** `append()` part sans `await`, avec un `.catch()` qui logge
478
542
  (`auditService.ts#AuditService.record`). La latence du store n'entre jamais dans la latence de la requête.
479
543
 
480
- **4. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
544
+ **4. Les écritures SQL sont groupées.** Le store Drizzle accumule les événements d'un tour de boucle
545
+ et les écrit en un seul `INSERT` multi-lignes au tour suivant (`DrizzleAuditStore.ts#DrizzleAuditStore.append`) ;
546
+ une lecture ou une purge vide d'abord le lot en attente, donc on relit toujours ce qu'on vient
547
+ d'écrire. Mesuré sur un serveur de production local : un refus coûtait plus cher qu'un appel réussi
548
+ quand chaque événement avait son `INSERT` ; groupé, il revient au même ordre de grandeur.
549
+
550
+ **5. Un flot de refus se résume.** Au-delà de quelques refus d'une même source par fenêtre, les
551
+ suivants sont comptés au lieu d'être écrits (voir la situation 4). Le coût d'un refus compté se
552
+ réduit à une recherche dans une table bornée.
553
+
554
+ **6. Tout ce qui n'est pas utilisé n'est pas alloué.** La liste d'abonnés live reste `null` tant que
481
555
  personne n'écoute et **redevient** `null` au dernier désabonnement (`auditService.ts#AuditService.subscribe`). Le pont
482
556
  WS n'existe qu'entre le premier et le dernier auditeur connecté, et son tampon circulaire n'est
483
557
  alloué qu'au premier événement reçu (`auditBridge.ts#createAuditBridge`) ; son minuteur est armé à la demande et
@@ -494,13 +568,17 @@ jamais faire tomber ce qu'on supervise.
494
568
  Table dérivée du schéma Zod `auditSchema` (`config.ts#auditSchema`), rattaché à la racine sous la clé `audit`
495
569
  (`config.ts#securityConfigSchema`).
496
570
 
497
- | Option | Type | Défaut | Effet |
498
- | --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
499
- | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
500
- | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
501
- | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
502
- | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
503
- | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
571
+ | Option | Type | Défaut | Effet |
572
+ | -------------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
573
+ | `enabled` | `boolean` | `true` | `false` → `record()` no-op à coût nul, `listPage()` page vide, endpoint admin en 503 |
574
+ | `store` | `string` | `"auto"` | nom résolu par le registre : `auto`, `memory`, `drizzle`, `mongoose` |
575
+ | `retentionDays` | `number` | `365` | fenêtre de conservation ; au-delà, la purge horaire supprime |
576
+ | `immutable` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
577
+ | `stream` | `boolean` | `true` | déclaré au schéma — voir l'avertissement ci-dessous |
578
+ | `aggregate.enabled` | `boolean` | `true` | agrège les refus d'une même source ; `false` → une ligne par refus |
579
+ | `aggregate.windowMs` | `number` | `60000` | durée d'une fenêtre, ouverte au premier refus d'une source |
580
+ | `aggregate.burst` | `number` | `10` | refus écrits tels quels par source et par fenêtre, avant le simple comptage |
581
+ | `aggregate.maxKeys` | `number` | `10000` | sources suivies au plus ; au-delà, regroupement sous l'IP `*` |
504
582
 
505
583
  > [!WARNING]
506
584
  > `immutable` et `stream` sont déclarés dans le schéma mais **ne sont lus par aucun code** aujourd'hui.
@@ -626,8 +704,9 @@ filtrage, jamais de sémantique.
626
704
  L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
627
705
  (`registerStores.ts#registerMongooseFrameworkStores`, entité via `registerAuditEntities()`, `auditEventEntity.ts#registerAuditEntities`). Côté
628
706
  implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts#DrizzleAuditStore`) résout son handle de base **à chaque
629
- appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
630
- `onTerminate` avant le drain des serveurs.
707
+ appel**, pas à la construction : l'ordre de démarrage n'est pas garanti. À l'arrêt, l'audit vide son
708
+ agrégateur à `onTerminate`, après le drain des serveurs et avant la déconnexion de l'ORM : sa
709
+ libération s'inscrit à la résolution du store, donc après celle du connecteur (ADR-0027).
631
710
 
632
711
  ## 🔐 Sécurité — ce qui n'entre JAMAIS dans le journal
633
712
 
@@ -667,6 +746,7 @@ Deux autres propriétés de sécurité valent d'être connues :
667
746
  | ----------------------------------------- | --------------------------------- | -------------------------------------------------------------------------------------------- |
668
747
  | Journaliser les échecs d'authentification | OWASP Logging Cheat Sheet | `auth.failure`/`auth.throttled`/`auth.denied` (`firewall.ts#Firewall.describeRoleHierarchy`) |
669
748
  | Journaliser les refus d'autorisation | OWASP A09:2021 | `access.denied` sur tout refus du jury (`authorization.ts#Authorization.#auditDeny`) |
749
+ | Ne pas épuiser les ressources ni la base | OWASP Logging Cheat Sheet | refus agrégés par source et par fenêtre (`AuditRefusalAggregator.ts#AuditRefusalAggregator`) |
670
750
  | Ne jamais journaliser de secret | OWASP Logging Cheat Sheet | flags de **présence** seuls (`IAuditEvent.ts#AuditOutcome`) |
671
751
  | Traçabilité « qui, quoi, quand, d'où » | ISO 27001 A.8.15 (journalisation) | acteur, action, horodatage et provenance dans `IAuditEvent` (`IAuditEvent.ts#IAuditEvent`) |
672
752
  | Journal inaltérable | ISO 27001 A.8.15 | contrat append-only, aucune mutation exposée (`IAuditStore.ts#IAuditStore`) |
@@ -699,19 +779,20 @@ webhook. Détail des souscriptions, de la signature et des relivraisons → [web
699
779
 
700
780
  ## ⚠️ Pièges
701
781
 
702
- | Symptôme | Cause (dans le code) | Correction |
703
- | --------------------------------------------------- | -------------------------------------------------------------------------------------- | ---------------------------------------------------------- |
704
- | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts#MemoryAuditStore`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
705
- | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
706
- | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts#AuditService.#build`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
707
- | `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts#toPageParams`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
708
- | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
709
- | `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
710
- | Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
711
- | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts#AuditService.#listeners`) | Retirer le rôle admin, ou ne pas exposer le canal |
712
- | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts#AuditService.record`) | Store à haute disponibilité si la conformité l'exige |
713
- | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
714
- | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts#createSecurityAdminApi`) | Réactiver l'audit en configuration |
782
+ | Symptôme | Cause (dans le code) | Correction |
783
+ | --------------------------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
784
+ | Journal vide après un redémarrage | Store `memory` : volatile et per-pod (`MemoryAuditStore.ts#MemoryAuditStore`) | `audit.store: "drizzle"` (ou déclarer `NF_DATABASE_URL`) |
785
+ | Un pod voit des événements, l'autre non | Store `memory` non partagé | Store durable partagé |
786
+ | Le boot échoue en production sur l'audit | Store **explicite** inconnu, fail-closed (`auditService.ts#AuditService.#build`) | Corriger le nom, ou charger l'adapter qui l'enregistre |
787
+ | `limit=5000` ne rend que 200 événements | Plafond de l'endpoint (`parsePageQuery`, `pageQuery.ts#toPageParams`) | Paginer avec `nextCursor`, jamais gonfler `limit` |
788
+ | La page 2 répète ou saute des événements | Pagination réimplémentée en offset | Repasser le `nextCursor` reçu — le curseur est opaque |
789
+ | `?category=authen` renvoie 400 | Valeur hors union refusée (`PageQueryError`) | Utiliser une valeur de l'union (`auth`, `authz`, `token`…) |
790
+ | Le paramètre `q` renvoie 400 | Recherche plein texte non supportée sur ce journal | Filtrer par `category`/`actor`/`action`/`requestId` |
791
+ | `stream: false` ne coupe pas le live | Drapeau non lu ; le live suit `#listeners` (`auditService.ts#AuditService.#listeners`) | Retirer le rôle admin, ou ne pas exposer le canal |
792
+ | Trous dans le journal pendant une panne de base | Écriture best-effort (`auditService.ts#AuditService.record`) | Store à haute disponibilité si la conformité l'exige |
793
+ | Aucun `login.success` alors que les logins marchent | Le succès du **firewall** est muet ; `login.success` vient du BFF | Filtrer `action=login.success`, pas `category=auth` seul |
794
+ | 10 lignes pour 1 000 échecs d'une même IP | Agrégation des refus (`AuditRefusalAggregator.ts#AuditRefusalAggregator`) | Lire la synthèse (`metadata.aggregated`), ou régler `aggregate` |
795
+ | Endpoint d'audit en 503 | `audit.enabled: false` (`SecurityAdminApi.ts#createSecurityAdminApi`) | Réactiver l'audit en configuration |
715
796
 
716
797
  ## 🧪 Tests & couverture
717
798
 
@@ -723,6 +804,9 @@ Cinq familles couvrent la brique :
723
804
  isolation d'un abonné qui lève) ; `auditStoreRegistry` (builtin enregistré à l'import, fabrique
724
805
  défensive sur config partielle, backend inconnu) ; `auditBridge` (coalescing borné, nettoyage,
725
806
  plancher `ROLE_NODEFONY_ADMIN` du canal).
807
+ - **unit — l'agrégation** : `auditRefusalAggregator` prouve qu'un flot d'une source s'écrit en
808
+ 10 lignes plus une synthèse, que deux IP restent deux sources, que la mémoire est bornée, que
809
+ l'arrêt résume les comptes en cours et que les succès ne sont jamais agrégés.
726
810
  - **unit — l'émission** : `auditEmission` prouve que `AuthFlow` et `Authorization` journalisent
727
811
  **réellement** via le container partagé, exactement comme en production.
728
812
  - **unit — le hot-path** : `auditEmissionHotPath` est le banc le plus instructif de la brique. Il
@@ -270,8 +270,9 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
270
270
  partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts#resolveSessionIdentity`).
271
271
  - `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
272
272
  principal courant (`SessionAuthenticator.ts#SessionAuthenticator.onSuccess`).
273
- - **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login,
274
- jamais une popup Basic (`SessionAuthenticator.ts#SessionAuthenticator`).
273
+ - **Pas de `challenge()`** : session absente = 401 portant le défi de repli du pare-feu,
274
+ `Session realm="nodefony"` (RFC 9110 §15.5.2) — schéma inconnu des navigateurs, jamais une popup ;
275
+ le front redirige vers son écran de login (`SessionAuthenticator.ts#SessionAuthenticator`).
275
276
 
276
277
  ### `jwt` — Bearer signé pour les API (RFC 6750 + BCP RFC 8725)
277
278
 
@@ -348,8 +349,9 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
348
349
  ouverte (logout, changement de compte, `jti` denylisté). En mode session,
349
350
  `buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
350
351
  lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts#SessionLike.storage`). En mode jeton porteur, la
351
- preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
352
- (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.#buildBearerRevalidator`).
352
+ preuve est autre : `jti` denylisté, `invalidBefore`, puis `exp` — dans cet ordre, car révoqué
353
+ l'emporte sur expiré — et la cause est rendue : un jeton expiré ferme la socket en `4006`, un jeton
354
+ révoqué en `4001` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.#buildBearerStatus`).
353
355
 
354
356
  > [!NOTE]
355
357
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
package/docs/csrf.md CHANGED
@@ -271,8 +271,9 @@ sûres → coût nul sur le GET dominant (`csrf.ts#Csrf.enforce`). Pour une muta
271
271
  Lectures durcies côté firewall :
272
272
 
273
273
  - en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
274
- `headerValue()`, `firewall.ts#headerValue`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
275
- parse du contexte (`cookieValue()`, `firewall.ts#cookieValue`) ;
274
+ `headerValue()`, `firewall.ts#headerValue`) ; cookie `csrf-token` lu sur le contexte, dont
275
+ l'en-tête `Cookie` n'a qu'un lecteur (`requestCookie()`, `firewall.ts#requestCookie`) — parsé à la
276
+ construction du contexte, donc avant toute étape du pare-feu ;
276
277
  - hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
277
278
  (`firewall.ts#Firewall`) ;
278
279
  - le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
@@ -79,7 +79,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
79
79
  | `issuers[].jwksUri` | découvert | Déclaré, aucune découverte n'a lieu — utile pour un émetteur sans métadonnées, ou un démarrage à froid sans requête sortante. |
80
80
  | `issuers[].algorithms` | `RS256`, `ES256`, `EdDSA` | Allowlist **serveur** : l'algorithme n'est jamais déduit de l'en-tête du jeton (RFC 8725 §3.1). À restreindre à ce que l'émetteur utilise réellement. |
81
81
  | `issuers[].typ` | non exigé | `at+jwt` pour un émetteur conforme RFC 9068. |
82
- | `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss` et `aud`. |
82
+ | `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss`, `aud`, `exp` et `iat` (toujours exigés, RFC 9068 §2.2 ; un `iat` daté du futur est refusé). |
83
83
  | `issuers[].subjectMapping` | `prefixed` | Comment le `sub` devient un identifiant local — **voir l'encadré ci-dessous**. |
84
84
  | `subjectPolicy` | `require` | `require` : un compte local est exigé. `ephemeral` : l'appelant vit le temps de la requête. |
85
85
  | `ephemeralRoles` | `[]` | Rôles accordés en mode `ephemeral`. Vide à dessein. |
@@ -98,6 +98,10 @@ rattaché au compte local du même nom.
98
98
  `subject` ne se déclare donc que si l'on maîtrise l'espace de noms de cet émetteur : typiquement
99
99
  parce qu'il **est** cette application, ou parce que ses sujets sont déjà des identifiants locaux.
100
100
 
101
+ `subjectMapping` ne règle que la **recherche d'un compte local** : sous `subjectPolicy: "ephemeral"`,
102
+ où aucun compte n'est cherché, l'identifiant de l'appelant est **toujours** `<issuer>#<sub>`, quel
103
+ que soit le réglage (`ExternalSubjectResolver.ts#ExternalSubjectResolver.resolve`).
104
+
101
105
  ### `subjectPolicy` — qui a le droit d'exister
102
106
 
103
107
  - **`require`** (défaut) — le sujet doit correspondre à un compte local (`loadUserByIdentifier`).
package/docs/firewall.md CHANGED
@@ -243,8 +243,9 @@ Credential = l'**identifiant** posé dans le blob de session (jamais un secret).
243
243
  régénère l'ID (anti-fixation).
244
244
  - **L'identité est re-résolue à CHAQUE requête** (`SessionAuthenticator.ts#SessionAuthenticator.authenticate`) → rôles frais,
245
245
  révocation et verrouillage effectifs immédiatement.
246
- - **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login
247
- (pas de popup Basic).
246
+ - **Pas de `challenge()`** : session absente = 401 portant le défi de repli du pare-feu,
247
+ `Session realm="nodefony"` (RFC 9110 §15.5.2) — schéma inconnu des navigateurs, donc pas de
248
+ popup ; le front redirige vers son écran de login.
248
249
 
249
250
  ### `userpassword` — HTTP Basic, avec throttle NIST
250
251