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

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 (73) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/index.js +60 -60
  3. package/dist/nodefony/config/config.js +13 -5
  4. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  5. package/dist/nodefony/errors/index.js +4 -4
  6. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  7. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  8. package/dist/nodefony/service/accessTokenVerifier.js +5 -5
  9. package/dist/nodefony/service/apiKeys.js +8 -4
  10. package/dist/nodefony/service/authFlow.js +3 -3
  11. package/dist/nodefony/service/authorization.js +1 -1
  12. package/dist/nodefony/service/firewall.js +233 -41
  13. package/dist/nodefony/service/keycloak.js +1 -1
  14. package/dist/nodefony/service/oauth2.js +8 -4
  15. package/dist/nodefony/service/securityHeaders.js +340 -47
  16. package/dist/nodefony/service/tokenService.js +8 -8
  17. package/dist/nodefony/service/totp.js +3 -3
  18. package/dist/nodefony/service/webAuthn.js +1 -1
  19. package/dist/nodefony/service/webhooks.js +11 -4
  20. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  21. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  22. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  23. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  24. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +5 -2
  25. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
  26. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  27. package/dist/nodefony/src/csp.js +265 -10
  28. package/dist/nodefony/src/cspReports.js +307 -0
  29. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  30. package/dist/nodefony/src/oauth/metadata.js +1 -1
  31. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  32. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  33. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  34. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -5
  35. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  36. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  37. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  38. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  39. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  40. package/dist/nodefony/src/webhook/webhookDelivery.js +3 -3
  41. package/dist/nodefony/testing/index.js +26 -36
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/nodefony/config/config.d.ts +2 -0
  44. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  45. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  46. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  47. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  48. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  49. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  50. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  51. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  52. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  53. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  54. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  55. package/dist/types/nodefony/src/csp.d.ts +142 -0
  56. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  57. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  58. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  59. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
  60. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  61. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  62. package/dist/types/nodefony/testing/index.d.ts +6 -0
  63. package/docs/api-keys.md +3 -1
  64. package/docs/authenticators.md +6 -4
  65. package/docs/csrf.md +3 -2
  66. package/docs/external-jwt.md +5 -1
  67. package/docs/firewall.md +3 -2
  68. package/docs/headers.md +427 -71
  69. package/docs/login-page.md +4 -3
  70. package/docs/totp.md +2 -2
  71. package/docs/webauthn.md +2 -2
  72. package/docs/webhooks.md +48 -11
  73. package/package.json +14 -14
@@ -118,8 +118,8 @@ redéfinissent pour les deux thèmes (voir « Pièges », plus bas) :
118
118
 
119
119
  ### Ce qu'on observe
120
120
 
121
- - `GET /login` rend la page, avec `Cache-Control: no-store` et une CSP
122
- `frame-ancestors 'none'` (`LoginPageController.ts#LoginPageController.constructor`) ;
121
+ - `GET /login` rend la page, avec `Cache-Control: no-store` et, par défaut, une CSP
122
+ `frame-ancestors 'none'` (réglée pour toute l’application par `http.securityHeaders.frameOptions`) ;
123
123
  - le titre de la carte vaut « Espace client », le panneau affiche votre accroche à la place de la
124
124
  vitrine Nodefony ;
125
125
  - votre feuille arrive en second `<link rel="stylesheet">`, après `login.css` : vos variables
@@ -303,7 +303,8 @@ nommant, au lieu d'être ignorée.
303
303
 
304
304
  ## 🔒 Sécurité
305
305
 
306
- - **Pas de cache partagé** (`no-store`) ni d'affichage dans un cadre (`frame-ancestors 'none'`) ;
306
+ - **Pas de cache partagé** (`no-store`) ; l’affichage dans un cadre suit `http.securityHeaders.frameOptions`,
307
+ refusé par défaut (`X-Frame-Options: DENY`, `frame-ancestors 'none'`) ;
307
308
  le script porte le nonce CSP de la requête.
308
309
  - **Mot de passe en clair refusé en production** : sur une requête qui n'arrive pas en HTTPS, la
309
310
  connexion répond 403 avant toute vérification, et la page affiche « doit passer par HTTPS » au
package/docs/totp.md CHANGED
@@ -581,7 +581,7 @@ TOTP et les passkeys (`cascadeUserRevocation`, `userRevocationCascade.ts#cascade
581
581
  recréé sous le même identifiant hériterait du second facteur de l'ancien titulaire. Un compte
582
582
  seulement désactivé ou verrouillé garde ses facteurs.
583
583
 
584
- Spécification logique de la table (`TOTP_SECRET_TABLE_SPEC`, `totpSecretEntity.ts#TOTP_SECRET_TABLE_SPEC`), déclinée par
584
+ Spécification logique de la table (`TOTP_SECRET_TABLE`, `totpSecretEntity.ts#TOTP_SECRET_TABLE`), déclinée par
585
585
  dialecte via le colKit :
586
586
 
587
587
  | Colonne | Rôle | SQLite | PostgreSQL | MySQL / MariaDB |
@@ -600,7 +600,7 @@ dialecte via le colKit :
600
600
  Deux pièges de lecture, signalés dans l'entité elle-même :
601
601
 
602
602
  - `lastUsedStep` est un **numéro de tranche RFC 6238**, pas un horodatage — d'où le type `int`
603
- partout, quand les vraies dates sont en `epochMs` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
603
+ partout, quand les vraies dates sont en `int64` (`totpSecretEntity.ts#TotpSecretRow.algorithm`).
604
604
  - Un epoch en millisecondes **déborde** un `integer` 32 bits → `bigint` en PostgreSQL et MySQL
605
605
  (SQLite, lui, a des INTEGER 64 bits).
606
606
 
package/docs/webauthn.md CHANGED
@@ -504,8 +504,8 @@ clé privée n'existe que dans l'authenticator.
504
504
  | `backupState` | BS flag — la passkey **est** sauvegardée | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
505
505
  | `uvInitialized` | Une vérification humaine a déjà eu lieu | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
506
506
  | `nickname` | Surnom d'appareil, optionnel | `text` nullable | `String` (null) | champ absent si vide |
507
- | `createdAt` | Enrôlement (epoch ms) | `epochMs` notNull | `Number` requis | champ |
508
- | `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
507
+ | `createdAt` | Enrôlement (epoch ms) | `int64` notNull | `Number` requis | champ |
508
+ | `lastUsedAt` | Dernière authentification réussie, ou `null` | `int64` nullable | `Number` (null) | champ absent si null |
509
509
 
510
510
  Spécification SQL : `webAuthnCredentialEntity.ts#webAuthnCredentialSchema`, index sur `userId`
511
511
  (`drizzle/nodefony/entity/webAuthnCredentialEntity.ts#WebAuthnCredentialRow.userId`). Schéma documentaire :
package/docs/webhooks.md CHANGED
@@ -89,6 +89,7 @@ part (les autres événements ne sont pas souscrits) :
89
89
  ```json
90
90
  {
91
91
  "id": "msg_7Yb1kQ2pR8sT",
92
+ "timestamp": "2026-10-29T09:14:03.000Z",
92
93
  "type": "login.failure",
93
94
  "data": {
94
95
  "actor": "alice",
@@ -98,6 +99,13 @@ part (les autres événements ne sont pas souscrits) :
98
99
  }
99
100
  ```
100
101
 
102
+ > [!NOTE]
103
+ > **`timestamp` date la TENTATIVE, pas l'événement** : un réessai deux heures plus tard porte une
104
+ > heure plus tardive. L'heure de l'événement vit dans `data` (`ts` pour l'audit). Et `id` est
105
+ > exactement l'en-tête `webhook-id`, stable d'un réessai à l'autre : c'est la clé de déduplication.
106
+ > Le corps se type par `IWebhookPayload` (exporté par `@nodefony/security`,
107
+ > `IWebhookPayload.ts#IWebhookPayload`).
108
+
101
109
  > [!NOTE]
102
110
  > Ce qui peut partir est **ce que le journal d'audit de sécurité enregistre** : connexions, refus
103
111
  > d'accès, jetons, sessions, passkeys. Tes propres événements applicatifs — « commande payée »,
@@ -250,6 +258,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
250
258
  import { Buffer } from "node:buffer";
251
259
  // prettier-ignore
252
260
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
261
+ import type { IWebhookPayload } from "@nodefony/security";
253
262
  // NF_HOOK_SECRET se déclare dans env.ts (`envString`) : seul lecteur de l'environnement.
254
263
  import { env } from "../../env";
255
264
 
@@ -276,7 +285,7 @@ class HookMiniController extends Controller {
276
285
  if (got.length !== want.length || !timingSafeEqual(got, want)) {
277
286
  return this.renderJson({ error: "bad signature" }, 401);
278
287
  }
279
- this.log(`reçu : ${(JSON.parse(raw) as { type: string }).type}`, "INFO");
288
+ this.log(`reçu : ${(JSON.parse(raw) as IWebhookPayload).type}`, "INFO");
280
289
  return this.renderJson({ ok: true });
281
290
  }
282
291
  }
@@ -308,6 +317,7 @@ import { createHmac, timingSafeEqual } from "node:crypto";
308
317
  import { Buffer } from "node:buffer";
309
318
  // prettier-ignore
310
319
  import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
320
+ import type { IWebhookPayload } from "@nodefony/security";
311
321
  import { env } from "../../env";
312
322
 
313
323
  /** Secret `whsec_…` donné par l'émetteur — déclaré dans env.ts, jamais en dur. */
@@ -369,7 +379,7 @@ class HookController extends Controller {
369
379
 
370
380
  // 4. Dédupliquer par `webhook-id` AVANT d'agir (un retry rejoue le MÊME id),
371
381
  // puis répondre 2xx vite : le travail long part en tâche de fond.
372
- const event = JSON.parse(raw) as { id: string; type: string };
382
+ const event = JSON.parse(raw) as IWebhookPayload;
373
383
  this.log(`webhook ${event.id} — ${event.type}`, "INFO");
374
384
  return this.renderJson({ ok: true });
375
385
  }
@@ -431,20 +441,47 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts#AuditCateg
431
441
  | `webhook` | Vie des webhooks eux-mêmes (`webhook.created`, `webhook.disabled`) |
432
442
  | `config` | Mutation de config runtime depuis Studio |
433
443
 
444
+ > [!IMPORTANT]
445
+ > **Les noms de l'audit sont RÉSERVÉS.** Une émission applicative d'événements, quand elle viendra,
446
+ > refusera tout type qui appartient à l'audit (`login.*`, `token.*` et les autres familles
447
+ > ci-dessus) : sans cette réserve, un code applicatif pourrait fabriquer un `login.success` signé
448
+ > par le serveur, qu'un centre de sécurité abonné ne distinguerait pas d'un vrai
449
+ > ([ADR-0023](../../../../../docs/adr/0023-webhooks-evenements-et-abonnements.md), W3). Ne nommez
450
+ > pas vos futurs événements comme ceux de l'audit.
451
+
434
452
  > [!NOTE]
435
453
  > Trois catégories sont **réservées** au contrat sans émetteur à ce jour : `oauth`, `csrf`, `cors`
436
454
  > (`IAuditEvent.ts#AuditCategory`). S'y abonner est accepté, mais ne livre jamais rien.
437
455
 
438
456
  ### La syntaxe d'abonnement
439
457
 
440
- Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
441
- (`WebhookDispatcher.ts#matchesSubscription`) :
458
+ Le champ `events` d'un endpoint prend des **motifs**, dans la même grammaire que les canaux de la
459
+ socket : `*` vaut n'importe quelle suite de caractères, tout le reste est littéral, trois `*` au
460
+ plus. Un motif plus large est **refusé** à l'enregistrement, avec une erreur qui le nomme. La règle
461
+ vit une seule fois, au cœur (`channelPattern.ts#compileChannelPattern`), et
462
+ `matchesSubscription()` (`WebhookDispatcher.ts#matchesSubscription`) l'appelle :
442
463
 
443
- | Motif | Matche | Usage typique |
444
- | ----------------- | ----------------------------------------------------- | ------------------------------- |
445
- | `"*"` | **toutes** les actions | un SIEM qui veut tout |
446
- | `"login.success"` | l'action exacte, et elle seule | une alerte ciblée |
447
- | `"login.*"` | toute action **préfixée** `login.` (`login.failure`…) | suivre une famille d'événements |
464
+ | Motif | Désigne | Usage typique |
465
+ | ----------------- | ----------------------------------------------------- | --------------------------------- |
466
+ | `"*"` | **toutes** les actions | un SIEM qui veut tout |
467
+ | `"login.success"` | l'action exacte, et elle seule | une alerte ciblée |
468
+ | `"login.*"` | toute action **préfixée** `login.` (`login.failure`…) | suivre une famille d'événements |
469
+ | `"commande:*"` | `commande:42`, `commande:créée`… | un espace de noms applicatif |
470
+ | `"*:créée"` | toute action qui finit par `:créée` | un même geste sur tous les objets |
471
+
472
+ > [!IMPORTANT]
473
+ > **`"*"` veut dire tout, y compris ce qui viendra.** Aujourd'hui, seuls les événements d'audit
474
+ > sont livrés ; le jour où une application pourra émettre les siens, un abonnement `"*"` les
475
+ > recevra aussi. Qui ne veut que la sécurité liste ses familles (`login.*`, `token.*`…). Cette
476
+ > grammaire ne changera pas de sens dans la série 10
477
+ > ([ADR-0023](../../../../../docs/adr/0023-webhooks-evenements-et-abonnements.md), W2).
478
+ >
479
+ > **Le périmètre.** Le périmètre d'un événement est le locataire — l'organisation cliente — auquel
480
+ > il est rattaché. Un abonnement sans périmètre — le cas de tous aujourd'hui — a le périmètre de
481
+ > la **plateforme** : il reçoit tout, y compris les événements qu'une version ultérieure rattachera
482
+ > à un locataire (ADR-0023, W2). Le multi-tenant n'existant pas encore, `WebhookService.register`
483
+ > **refuse** un `tenantId` rempli (`webhooks.ts#WebhookService.register`) : l'accepter le ferait
484
+ > passer pour un cloisonnement qu'il n'est pas.
448
485
 
449
486
  > [!CAUTION]
450
487
  > Les événements de catégorie `webhook` **ne déclenchent jamais de livraison**
@@ -747,7 +784,7 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
747
784
  | `events` | actions souscrites | `text mode:json` · `jsonb` · `json` | `[String]` |
748
785
  | `enabled` | actif ? | `integer mode:bool` · `boolean` · `boolean` | `Boolean` |
749
786
  | `description` | libellé console | `text` | `String` |
750
- | `tenantId` | slot multi-tenant (réservé) | `text` | `String` |
787
+ | `tenantId` | périmètre d’un locataire (réservé, toujours `null`) | `text` | `String` |
751
788
  | `createdBy` | admin créateur (traçabilité) | `text` | `String` |
752
789
  | `createdAt` / `updatedAt` | epoch **ms** | `integer` · `bigint` · `bigint` | `Number` |
753
790
  | `lastDeliveryAt` | dernière tentative (epoch ms) | `integer` · `bigint` · `bigint` | `Number` |
@@ -757,7 +794,7 @@ partage entre pods. Les colonnes suivent `IWebhookEndpoint` (`IWebhookEndpoint.t
757
794
  | `metadata` | extras applicatifs (jamais de secret) | `text mode:json` · `jsonb` · `json` | `Object` |
758
795
 
759
796
  Côté SQL, la table est décrite **une seule fois** en spec logique
760
- (`WEBHOOK_ENDPOINT_TABLE_SPEC`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE_SPEC`) puis déclinée
797
+ (`WEBHOOK_ENDPOINT_TABLE`, `drizzle/nodefony/entity/webhookEndpointEntity.ts#WEBHOOK_ENDPOINT_TABLE`) puis déclinée
761
798
  par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String` — l'identifiant
762
799
  `wh_…` **est** la clé primaire, pas un `ObjectId` généré
763
800
  (`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts#webhookEndpointSchema`).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-beta.5",
3
+ "version": "10.0.0-beta.6",
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",
@@ -56,27 +56,27 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@simplewebauthn/server": "14.0.3",
59
- "jose": "6.2.13",
60
- "tslib": "2.8.1",
59
+ "jose": "6.2.15",
60
+ "tslib": "2.9.1",
61
61
  "zod": "^4.6.7"
62
62
  },
63
63
  "devDependencies": {
64
- "@nodefony/framework": "^10.0.0-beta.5",
65
- "@nodefony/http": "^10.0.0-beta.5",
66
- "@nodefony/user": "^10.0.0-beta.5",
67
- "@types/node": "26.6.5",
68
- "@vitest/coverage-v8": "5.0.5",
69
- "nodefony": "^10.0.0-beta.5",
70
- "vitest": "5.0.5"
64
+ "@nodefony/framework": "^10.0.0-beta.6",
65
+ "@nodefony/http": "^10.0.0-beta.6",
66
+ "@nodefony/user": "^10.0.0-beta.6",
67
+ "@types/node": "26.6.6",
68
+ "@vitest/coverage-v8": "5.1.0",
69
+ "nodefony": "^10.0.0-beta.6",
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.5",
77
- "@nodefony/http": "^10.0.0-beta.5",
78
- "@nodefony/user": "^10.0.0-beta.5",
79
- "nodefony": "^10.0.0-beta.5"
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"
80
80
  },
81
81
  "files": [
82
82
  "dist",