@nodefony/security 10.0.0-beta.6 → 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 (51) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +4 -2
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +17 -6
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  8. package/dist/nodefony/errors/SsrfError.js +9 -1
  9. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  10. package/dist/nodefony/errors/index.js +3 -1
  11. package/dist/nodefony/keycloak/scaffold.js +31 -14
  12. package/dist/nodefony/service/accessTokenVerifier.js +1 -1
  13. package/dist/nodefony/service/auditService.js +48 -3
  14. package/dist/nodefony/service/authFlow.js +6 -3
  15. package/dist/nodefony/service/firewall.js +48 -3
  16. package/dist/nodefony/service/tokenService.js +8 -3
  17. package/dist/nodefony/service/totp.js +7 -2
  18. package/dist/nodefony/service/webAuthn.js +45 -6
  19. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  20. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
  21. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
  22. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  23. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  24. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  25. package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
  26. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  27. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  28. package/dist/types/index.d.ts +4 -1
  29. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  30. package/dist/types/nodefony/config/config.d.ts +6 -0
  31. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  32. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  33. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  34. package/dist/types/nodefony/errors/index.d.ts +3 -0
  35. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  36. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  37. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  38. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  39. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  40. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  41. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  42. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  43. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
  44. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  45. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  46. package/docs/audit.md +112 -28
  47. package/docs/keycloak.md +4 -4
  48. package/docs/obtenir-un-jeton.md +17 -10
  49. package/docs/tokens.md +1 -1
  50. package/docs/webhooks.md +15 -7
  51. package/package.json +13 -13
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
package/docs/keycloak.md CHANGED
@@ -265,10 +265,10 @@ lui, en a un de test — Mailpit (`--profile keycloak`, interface `http://localh
265
265
  processus dès le démarrage, commandes en ligne comprises (`addDefaultCaCertificate()`,
266
266
  `certificates.ts#addDefaultCaCertificate`). La variable `NODE_EXTRA_CA_CERTS` n'est plus nécessaire.
267
267
 
268
- Le realm généré porte aussi, comme celui du dépôt, un client **machine** `<app>-machine` (compte de
269
- service, `client_credentials`). Ses jetons portent l'audience de la zone `partner` que le gabarit
270
- `complete` ouvre (`https://localhost:5152/api/partner`) : `npx nodefony app:partner:call --issuer
271
- keycloak` obtient un jeton et appelle `GET /api/partner/whoami` dès le premier démarrage. Quand tu
268
+ Une application créée avec `--demo` reçoit aussi, comme le dépôt, un client **machine** `<app>-machine`
269
+ (compte de service, `client_credentials`). Ses jetons portent l'audience de la zone `demo-partner`
270
+ que la démo ouvre (`https://localhost:5152/demo/api/partner`) : `npx nodefony demo:partner:call
271
+ --issuer keycloak` obtient un jeton et appelle `GET /demo/api/partner/whoami` dès le premier démarrage. Quand tu
272
272
  ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas),
273
273
  `npx nodefony security:keycloak:realm --write` ajoute le mapper qui inscrit sa `resource` dans les
274
274
  jetons.
@@ -125,15 +125,15 @@ Puis `npm run build` — le runtime lit le `dist`, pas la source.
125
125
 
126
126
  ### Les options, et ce que chacune décide
127
127
 
128
- | Option | Défaut | Ce qu'elle change |
129
- | ---------------------- | -------------------------------------------- | -------------------------------------------------------- |
130
- | `[identifier]` | `admin` | le compte porteur du jeton |
131
- | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
- | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** audience |
133
- | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
- | `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
- | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents |
136
- | `-j, --json` | — | sortie machine, sans invite |
128
+ | Option | Défaut | Ce qu'elle change |
129
+ | ---------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
130
+ | `[identifier]` | `admin` | le compte porteur du jeton |
131
+ | `-s, --scope <liste>` | `admin:read` | les pouvoirs demandés — ajouter `admin:write` pour muter |
132
+ | `-r, --resource <uri>` | la porte MCP de l'application | viser une **autre** porte — son jeton va dans l'en-tête de l'agent, jamais dans `NF_MCP_TOKEN` |
133
+ | `-a, --agent <noms>` | ceux détectés | quels agents servir — `none` pour aucun |
134
+ | `-t, --ttl <minutes>` | 8 h avec `--write`, sinon la config (15 min) | la durée de validité, **bornée à 30 jours** |
135
+ | `-w, --write` | proposé en terminal | poser `NF_MCP_TOKEN` chez les agents présents — **refusé** avec un `--resource` qui vise une autre porte |
136
+ | `-j, --json` | — | sortie machine, sans invite |
137
137
 
138
138
  Le plafond de `--ttl` n'est pas décoratif : un jeton posé dans un fichier **est une clé**, et une
139
139
  clé se remplace (`security-mcp-token.ts#ttlSeconds`).
@@ -152,6 +152,13 @@ les agents dont la présence est **constatée** dans le projet — on ne crée p
152
152
  outil que personne n'utilise ici. La table des cibles vit au cœur
153
153
  (`src/nodefony/src/cli/agentTargets.ts#AGENT_TARGETS`) : Claude, Gemini, Vibe, Codex.
154
154
 
155
+ `NF_MCP_TOKEN` sert **la porte de l'outillage**, et elle seule. Un jeton émis pour une autre porte
156
+ (`--resource`) porte une autre audience : posé dans cette variable, il serait refusé par la porte de
157
+ l'outillage, dont les outils réservés disparaîtraient sans un mot. La commande refuse donc `--write`
158
+ dans ce cas, et n'affiche pas de ligne d'`export` (`security-mcp-token.ts#targetsOtherGate`) : ce
159
+ jeton va dans l'en-tête de l'agent qui parle à cette porte, par exemple
160
+ `claude mcp add --transport http <nom> <porte> --header "Authorization: Bearer <le jeton>"`.
161
+
155
162
  Deux garanties qui ne se négocient pas :
156
163
 
157
164
  - **jamais dans un fichier suivi par git** — un jeton commité est un jeton publié, et c'est la seule
@@ -246,7 +253,7 @@ audiences: ctx.isProd
246
253
  ? []
247
254
  : [
248
255
  // … les audiences du gabarit
249
- `${publicBaseUrl(ctx.env)}/api/mcp`,
256
+ `${publicBaseUrl(ctx.env)}/mcp`,
250
257
  ],
251
258
  ```
252
259
 
package/docs/tokens.md CHANGED
@@ -485,7 +485,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tok
485
485
  | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
486
486
  | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
487
487
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
488
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts#jwtSchema`) |
488
+ | `audiences` | string[] | `[]` | Liste blanche des `aud` ÉMETTABLES (RFC 8707), la 1ʳᵉ = défaut ; vide = `[issuer]`. Une zone `jwt` n’accepte que SA `resource`, sinon l’audience par défaut (`JwtAuthenticator.ts#JwtAuthenticator.authenticate`) |
489
489
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
490
490
  | `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts#jwtSchema`) |
491
491
  | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
package/docs/webhooks.md CHANGED
@@ -582,22 +582,30 @@ appliqué **deux fois** : à l'enregistrement, et **à nouveau juste avant chaqu
582
582
  | IPv4-mapped IPv6 sous toutes ses notations | `::ffff:7f00:1`, `0:0:0:0:0:ffff:127.0.0.1` | rabattu nativement sur les règles IPv4 par `BlockList` (`isBlockedAddress()`, `ssrfGuard.ts#isBlockedAddress`) |
583
583
  | IP encodée en dword/hex/octal dans le nom d'hôte | `http://2130706433/` | on passe l'IP **résolue** à `isBlockedAddress()`, jamais la chaîne d'hôte (`ssrfGuard.ts#isBlockedAddress`) |
584
584
  | Nom DNS public qui **résout** vers du privé | `hook.evil.com → 10.0.0.5` | toutes les IP résolues sont contrôlées, une seule interne = refus |
585
- | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`webhookDelivery.ts#deliverWebhook`) |
586
- | **Redirection** `302 → 169.254.169.254` | — | `node:http(s)` ne suit **jamais** les 3xx ; le 3xx est rendu tel quel |
585
+ | **DNS rebinding** entre le contrôle et la connexion | — | l'IP validée est **pinnée** à la connexion TCP (`fetchPublic.ts#fetchPublic`) |
586
+ | **Redirection** `302 → 169.254.169.254` | — | la livraison ne suit **aucune** redirection (`maxRedirects` à 0) ; le 3xx est rendu tel quel |
587
+ | **Goutte-à-goutte** : un octet toutes les 3 s | — | délai **total** de la livraison, pas seulement d'inactivité (`fetchPublic.ts#IFetchPublicOptions.timeoutMs`) |
588
+ | **Corps de réponse infini** | — | lecture coupée à 2 Kio et connexion détruite (`fetchPublic.ts#IFetchPublicOptions.maxBytes`) |
587
589
 
588
590
  ### Les deux subtilités qui font la différence
589
591
 
590
592
  **Le pin d'IP.** Valider puis se reconnecter, c'est laisser une fenêtre : le DNS peut répondre une IP
591
593
  publique au contrôle et une IP privée 50 ms plus tard. `deliverWebhook()` (`webhookDelivery.ts#deliverWebhook`)
592
- installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
594
+ appelle `fetchPublic()` (`fetchPublic.ts#fetchPublic`), qui installe un `lookup` qui force la connexion vers l'IP **déjà validée**, tout en conservant le nom
593
595
  d'hôte pour le SNI/TLS et l'en-tête `Host`. Le second contrôle SSRF avant livraison —
594
596
  `resolveTarget()` (`WebhookDispatcher.ts#IWebhookDispatcherDeps.resolveTarget`) — sert exactement à produire ces adresses ; s'il échoue, la livraison est
595
597
  **abandonnée sans retry** (une cible devenue interne ne redeviendra pas légitime au réessai).
596
598
 
597
599
  **Le non-suivi des redirections.** `fetch()` suit les 3xx par défaut — ce qui annulerait tout le
598
- travail précédent. Le choix de `node:http(s)` natif n'est donc pas seulement une économie de
599
- dépendance : c'est une **propriété de sécurité**, couverte par un test dédié
600
- (`webhookDelivery.test.ts`, « 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi »).
600
+ travail précédent. `fetchPublic()` n'en suit aucune tant que `maxRedirects` reste à 0 (la livraison ne le change
601
+ jamais), et quand un appelant en autorise, chaque cible est revérifiée comme la première. Couvert par
602
+ `webhookDelivery.test.ts` (« 302 vers 169.254.169.254 → rendu tel quel, JAMAIS suivi ») et
603
+ `fetchPublic.attack.test.ts`.
604
+
605
+ **La même brique pour tout appel sortant.** `fetchPublic()` est exportée : tout code qui lit une adresse venue d'un
606
+ humain (aperçu de lien, agent, RAG) l'appelle au lieu de `fetch()` — IP vérifiée puis épinglée à chaque saut,
607
+ délai total, corps borné et non décodé. Refus de cible : `SsrfError` (422) ; site muet ou trop lent :
608
+ `FetchPublicError` (`reason`).
601
609
 
602
610
  > [!CAUTION]
603
611
  > `assertPublicUrl()` fait un `Fail-closed` sur l'inconnu : une adresse syntaxiquement invalide est
@@ -1016,7 +1024,7 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
1016
1024
  | Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
1017
1025
  | Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts#WebhookService.rotateSecret`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
1018
1026
  | Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
1019
- | Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
1027
+ | Un `302` vers l'interne n'est pas suivi | **Voulu** : la livraison ne suit aucune redirection | Rien à corriger — configurer l'URL finale côté destinataire |
1020
1028
  | Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
1021
1029
  | Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
1022
1030
  | Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts#WebhookService.#touchSnapshot`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-beta.6",
3
+ "version": "10.0.0-beta.7",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "private": false,
37
37
  "engines": {
38
- "node": ">=24.14.0"
38
+ "node": "^24.16.0 || >=26.0.0"
39
39
  },
40
40
  "keywords": [
41
41
  "nodefony",
@@ -55,28 +55,28 @@
55
55
  "directory": "src/packages/@nodefony/security"
56
56
  },
57
57
  "dependencies": {
58
- "@simplewebauthn/server": "14.0.3",
59
- "jose": "6.2.15",
58
+ "@simplewebauthn/server": "14.1.0",
59
+ "jose": "6.2.17",
60
60
  "tslib": "2.9.1",
61
61
  "zod": "^4.6.7"
62
62
  },
63
63
  "devDependencies": {
64
- "@nodefony/framework": "^10.0.0-beta.6",
65
- "@nodefony/http": "^10.0.0-beta.6",
66
- "@nodefony/user": "^10.0.0-beta.6",
64
+ "@nodefony/framework": "^10.0.0-beta.7",
65
+ "@nodefony/http": "^10.0.0-beta.7",
66
+ "@nodefony/user": "^10.0.0-beta.7",
67
67
  "@types/node": "26.6.6",
68
68
  "@vitest/coverage-v8": "5.1.0",
69
- "nodefony": "^10.0.0-beta.6",
69
+ "nodefony": "^10.0.0-beta.7",
70
70
  "vitest": "5.1.0"
71
71
  },
72
72
  "license": "Apache-2.0",
73
73
  "readmeFilename": "README.md",
74
74
  "contributors": [],
75
75
  "peerDependencies": {
76
- "@nodefony/framework": "^10.0.0-beta.6",
77
- "@nodefony/http": "^10.0.0-beta.6",
78
- "@nodefony/user": "^10.0.0-beta.6",
79
- "nodefony": "^10.0.0-beta.6"
76
+ "@nodefony/framework": "^10.0.0-beta.7",
77
+ "@nodefony/http": "^10.0.0-beta.7",
78
+ "@nodefony/user": "^10.0.0-beta.7",
79
+ "nodefony": "^10.0.0-beta.7"
80
80
  },
81
81
  "files": [
82
82
  "dist",
@@ -86,7 +86,7 @@
86
86
  "publishConfig": {
87
87
  "access": "public"
88
88
  },
89
- "homepage": "https://nodefony.github.io/nodefony-core/",
89
+ "homepage": "https://nodefony.net/",
90
90
  "bugs": {
91
91
  "url": "https://github.com/nodefony/nodefony-core/issues"
92
92
  }