@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
@@ -87,6 +87,35 @@ Nodefony **centralise** la décision dans un service unique, appelé par les dé
87
87
  (`@IsGranted`, `@RequireScope`) sur tous les transports, avec une posture **fail-closed** : au
88
88
  moindre doute (voter qui plante, silence du jury, moteur absent), c'est refusé — jamais accordé.
89
89
 
90
+ ## Où se situe Nodefony parmi les modèles de contrôle d'accès
91
+
92
+ Le contrôle d'accès a plusieurs façons de répondre à « as-tu le droit ? », selon **ce sur quoi**
93
+ la décision porte : un rôle porté par le compte (RBAC), des attributs du sujet, de la ressource et
94
+ du contexte (ABAC), une relation dans un graphe (ReBAC), ou une politique écrite hors du code
95
+ (XACML, et les moteurs qui s'en inspirent). Ce ne sont pas des concurrents : les systèmes réels en
96
+ empilent plusieurs.
97
+
98
+ Nodefony fait du **RBAC hiérarchique** — des rôles, et une hiérarchie qui dit lesquels en couvrent
99
+ d'autres, au sens des composantes _Core_ et _Hierarchical_ du modèle NIST (ANSI INCITS 359-2004,
100
+ révisée 2012). Le reste se **branche** au jury, décrit plus bas : un voter maison qui lit des
101
+ attributs fait de l'ABAC, un voter qui interroge un moteur de graphe (OpenFGA, SpiceDB, Ory Keto —
102
+ famille issue de l'article _Zanzibar_, Google, 2019) fait du ReBAC.
103
+
104
+ **Ce qui n'est PAS couvert**, et qu'il vaut mieux savoir avant de concevoir :
105
+
106
+ - **la séparation des devoirs** (deux rôles incompatibles sur un même compte) n'est pas outillée ;
107
+ elle se joue à l'affectation des rôles, pas à la décision ;
108
+ - **le filtrage d'une liste** — « montre-moi seulement les lignes que j'ai le droit de voir » — ne
109
+ passe pas par le jury : chaque décision porte sur **une** ressource. Ce filtrage appartient à ta
110
+ requête de dépôt ;
111
+ - **le contexte de la requête** (heure, adresse, en-têtes, corps) n'est pas au contrat d'un voter ;
112
+ ce qui lui parvient, c'est le jeton et le sujet ;
113
+ - **aucune décision « accordé, à condition que »** (les _obligations_ de XACML) : le verdict est
114
+ binaire.
115
+
116
+ En pratique, et c'est le cas dans 95 % des applications : des rôles, plus **un** voter d'ownership
117
+ pour les cas où le rôle ne suffit pas.
118
+
90
119
  ## La vision Nodefony — un jury découplé et fail-closed
91
120
 
92
121
  `Authorization.decide(token, attribute, subject?)` (`authorization.ts:70`) itère les voters, teste
@@ -302,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
302
331
 
303
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
304
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
305
- (`Resolver.ts:587-607`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:689-702`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
306
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
307
336
  `@IsGranted("doc.edit", { subject: "id" })`.
308
337
 
@@ -327,29 +356,29 @@ se voit au premier test.
327
356
  ## 🧰 Déclarer l'exigence — `@IsGranted`, `@RequireScope`, `@Anonymous`
328
357
 
329
358
  Les décorateurs n'écrivent **que des métadonnées** (0 import `@nodefony/security`, 0 cycle) ; le
330
- moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:577-578`) :
359
+ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`) :
331
360
 
332
361
  | Déclaration | Sémantique |
333
362
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
334
- | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:839`) |
363
+ | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:878`) |
335
364
  | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
336
365
  | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
337
- | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1500`) |
338
- | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:613-617`) |
339
- | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:760`) |
340
- | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:887`) |
341
- | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1236`) |
366
+ | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1514`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:743-747`) |
368
+ | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:975`) |
369
+ | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:926`) |
370
+ | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1250`) |
342
371
 
343
372
  La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
344
- 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:331-336`). Le
373
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:672-677`). Le
345
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
346
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
347
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:576-606`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
348
377
 
349
378
  > [!IMPORTANT]
350
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
351
380
  > chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
352
- > (`Resolver.ts:582-584`). Une route gardée doit être couverte par une zone — voir
381
+ > (`Resolver.ts:678-688`). Une route gardée doit être couverte par une zone — voir
353
382
  > [firewall](./firewall.md).
354
383
 
355
384
  ## 🧑‍⚖️ Les voters intégrés — deux axes, un même jury
@@ -370,9 +399,28 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
370
399
  default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
371
400
  (`@IsGranted(["A","B"])`) possible.
372
401
  - La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
373
- (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:206`).
402
+ (`RoleVoter.ts:30-32`), posée par le firewall au boot (`firewall.ts:209`).
374
403
  - Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
375
404
 
405
+ #### Deux échelles de rôles — plateforme et organisation
406
+
407
+ Une **convention de nom** sépare deux échelles, et c'est elle qui prépare le jour où plusieurs
408
+ organisations partageront une instance :
409
+
410
+ | Préfixe | Échelle | Qui le porte | Scopable ? |
411
+ | ----------------- | ---------------- | -------------------------- | ---------- |
412
+ | `ROLE_NODEFONY_*` | **plateforme** | l'exploitant de l'instance | **jamais** |
413
+ | `ROLE_*` | **organisation** | un compte de l'application | oui |
414
+
415
+ L'échelle plateforme est la seule qui traverse l'isolation entre organisations. Les ressources du
416
+ **processus** — journaux, base, supervision, ramasse-miettes — sont donc gardées par elle, jamais
417
+ par un rôle d'organisation : un administrateur applicatif n'a pas à lire l'état du serveur qui
418
+ héberge aussi les autres.
419
+
420
+ > ⚠️ **Ne donne jamais un `ROLE_NODEFONY_*` à un compte client.** Un « administrateur » chez le
421
+ > client, c'est `ROLE_ADMIN`. C'est une convention : **rien dans le code ne l'impose** pour tes
422
+ > propres rôles — seules les politiques de plateforme du framework sont vérifiées par un test.
423
+
376
424
  ### `scope` — l'axe « ce qu'une clé déléguée peut faire »
377
425
 
378
426
  Frère du `role` sur l'autre axe. Capte la forme conventionnée `api:action` — un `:`, jamais
@@ -400,6 +448,49 @@ voir [firewall](./firewall.md)) et fait deux choses au boot :
400
448
  le boot **jette avec le chemin complet** `A → B → A` (`RoleHierarchyWalker.ts:69-95`) : jamais
401
449
  de boucle infinie silencieuse en production.
402
450
 
451
+ ## 🛡️ Le rôle par défaut d'une zone — et ce qu'il n'écrase pas
452
+
453
+ Une zone du pare-feu peut exiger un rôle **par défaut** chez elle (`roles`, voir
454
+ [firewall](./firewall.md)). C'est le filet d'un espace d'administration : une route que personne
455
+ n'a pensé à garder hérite du rôle de sa zone, au lieu d'être ouverte à tout compte connecté.
456
+
457
+ La précédence est un **remplacement, jamais un cumul de rôles** : si l'action déclare sa propre
458
+ garde de rôle, c'est elle qui décide — y compris pour ouvrir plus largement que la zone. C'est
459
+ voulu : une page réservée à l'exploitant (`ROLE_SUPERVISOR`) doit rester atteignable dans une zone
460
+ fermée à l'administrateur de plateforme.
461
+
462
+ Quatre cas gardent la main, et chacun est une déclaration explicite :
463
+
464
+ | Ce qui décide à la place | Pourquoi |
465
+ | ----------------------------------------- | ------------------------------------------------------------------------------------------------- |
466
+ | une garde de **rôle** (`@IsGranted`) | l'action a dit ce qu'elle exige |
467
+ | le **pont du plan d'administration** | il résout un rôle par point d'entrée, sous une route unique — d'où l'existence de `me`, `mine` |
468
+ | une route qui **est** le mécanisme d'auth | lui imposer un rôle serait un verrou dont la clé est à l'intérieur |
469
+ | l'**ouverture** d'une connexion WebSocket | une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée séparément |
470
+
471
+ > 🔴 **`@RequireScope` seul n'est pas une décision d'identité.** Un scope dit ce qu'une clé
472
+ > déléguée peut faire en ton nom ; sur une session humaine, le voter de scope accorde sans
473
+ > condition. Une action gardée par un scope seul reste donc soumise au rôle de sa zone — les deux
474
+ > axes se **cumulent**. Les traiter comme interchangeables ouvrait la route à tout compte connecté.
475
+
476
+ Le dernier cas mérite d'être compris, parce qu'il ressemble à un trou sans en être un : une frame
477
+ `api.request {path}` repasse par la **même** décision que la requête HTTP équivalente. L'invariant
478
+ tenu est `api.request {path}` ≤ `GET {path}`.
479
+
480
+ ## 🚪 Trois portes décident — et deux ne passent pas par le jury
481
+
482
+ C'est la chose la plus utile à savoir avant d'écrire un voter, et la plus facile à ignorer :
483
+
484
+ | Porte | Qui tranche | Le jury est-il consulté ? |
485
+ | ------------------------------------------ | ----------------------------------- | ------------------------- |
486
+ | une action gardée (`@IsGranted`), la zone | `Authorization.decide()` | **oui** |
487
+ | un point d'entrée du plan d'administration | son rôle déclaré (`isAdminGranted`) | non |
488
+ | une frame WebSocket (canal, `api.request`) | la politique de canal | non |
489
+
490
+ Les trois appliquent la **hiérarchie** de rôles, donc un même rôle y rend le même verdict. Mais un
491
+ voter maison — ABAC, ReBAC, ownership — n'est consulté que par la première. Une règle métier qui
492
+ doit valoir partout se place donc dans le code de l'action, pas seulement dans un voter.
493
+
403
494
  ## 🧩 Étendre le jury — le contrat et le registre
404
495
 
405
496
  Le contrat `IAccessVoter` (`IAccessVoter.ts:20-26`) tient en deux méthodes :
@@ -420,31 +511,40 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
420
511
 
421
512
  ## 🔌 HTTP et WebSocket — une garde, N transports
422
513
 
423
- - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:317`) est le point unique
514
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:335`) est le point unique
424
515
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
425
516
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
426
517
  `ws-isgranted-jwt`).
427
518
  - **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
428
519
  et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
429
520
  - **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
430
- hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
521
+ hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
431
522
  (`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
432
523
 
433
524
  ## 📜 Normes appliquées
434
525
 
435
- | Domaine | Norme / posture | Ancrage |
436
- | -------------------------- | -------------------------------------- | --------------------------------------------------------- |
437
- | Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts:100-108`) |
438
- | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:582-584`) |
439
- | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
526
+ | Domaine | Norme / posture | Ancrage |
527
+ | -------------------------- | ----------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- |
528
+ | Contrôle d'accès | OWASP Top 10 **A01** (IDOR, élévation) | défaut `DENY` du jury (`authorization.ts:100-108`) |
529
+ | Modèle de rôles | RBAC **hiérarchique**, au sens des composantes _Core_ et _Hierarchical_ d'ANSI INCITS 359 — sans séparation des devoirs | `RoleHierarchyWalker.reachableRoles()` |
530
+ | Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
531
+ | Scopes délégués | **RFC 6749 §3.3** — définis par le serveur d'autorisation ; sans effet sur une session humaine | `ScopeVoter.vote()` |
532
+ | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:678-688`) |
533
+ | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
534
+
535
+ > Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
536
+ > ANSI 359 » (ni sessions de rôles, ni séparation des devoirs), pas « conforme XACML » (un seul
537
+ > algorithme de combinaison, aucune obligation), et pas « compatible Zanzibar » (pas d'expansion,
538
+ > pas de liste, pas de jeton de cohérence). Ce qu'il offre, c'est de quoi **brancher** ces moteurs
539
+ > pour une décision ponctuelle.
440
540
 
441
541
  ## ⚡ Performance & mémoire
442
542
 
443
543
  - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
444
- alloc (`Resolver.ts:334-336`) ; l'exigence est **figée une fois** par route et partagée entre
544
+ alloc (`Resolver.ts:358`) ; l'exigence est **figée une fois** par route et partagée entre
445
545
  requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
446
- - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:78-80`),
447
- instanciés **une seule fois** au boot (`authorization.ts:55-64`).
546
+ - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
547
+ instanciés **une seule fois** au boot (`authorization.ts:51`).
448
548
  - **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
449
549
  (`RoleHierarchyWalker.ts:23-30`).
450
550
  - **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
@@ -452,10 +552,15 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
452
552
 
453
553
  ## ⚠️ Pièges (symptôme → cause → correction)
454
554
 
555
+ | Symptôme | Cause | Correction |
556
+ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
557
+ | Une action gardée par `@RequireScope` seul reste accessible à un humain | le voter de scope accorde sans condition à une session — il ne contraint que les clés déléguées | ajouter un `@IsGranted`, ou compter sur le rôle par défaut de la zone |
558
+ | `@RequireScope("openid")` refuse tout, sans message | un scope sans `:` n'est capté par aucun voter → le jury ferme par défaut | employer la forme `api:action`, ou déclarer un voter pour cette famille |
559
+
455
560
  | Symptôme | Cause (dans le code) | Correction |
456
561
  | -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
457
562
  | Accès refusé alors que le rôle existe | Attribut mal formé (pas `ROLE_…`) → le `RoleVoter` n'entre pas | Respecter le préfixe `ROLE_` |
458
- | 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:582-584`) | Couvrir la route par une zone firewall |
563
+ | 403 systématique sur une route gardée | Moteur absent OU identité non résolue — route **hors zone** (`Resolver.ts:678-688`) | Couvrir la route par une zone firewall |
459
564
  | Un voter métier bloque tout | Il renvoie `DENY` au lieu d'`ABSTAIN` quand il ne s'applique pas | Renvoyer `ABSTAIN` hors de son domaine |
460
565
  | Un `DENY` n'a pas bloqué | Attributs d'une clause = jurys **séparés** (OR) — un autre attribut a accordé | Porter l'interdit en clause AND (empiler les `@IsGranted`) |
461
566
  | Clé API accède à une action non prévue | Type de jeton traité comme humain (allowlist) | Vérifier que le type n'est pas dans `NON_SCOPABLE_TOKEN_TYPES` |
package/docs/cors.md CHANGED
@@ -103,13 +103,13 @@ Trois conséquences que beaucoup de développeurs découvrent trop tard :
103
103
  `Cors` (`cors.ts:33`) est une classe **pure et synchrone** : elle ne touche ni au réseau, ni à la
104
104
  requête — elle prend une origine et renvoie la table des en-têtes à poser, ou `null`. Elle est
105
105
  instanciée **une seule fois au boot** par le firewall, si et seulement si la section est activée
106
- (`firewall.ts:213`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
106
+ (`firewall.ts:239-240`). Conséquence directe : la politique est **testable sans serveur**, et son coût par
107
107
  requête se réduit à une lecture de `Set` (voir Performance).
108
108
 
109
109
  Trois invariants de sécurité, tenus par construction :
110
110
 
111
111
  - **Jamais `*` avec credentials.** La combinaison est **rejetée au boot** par un `refine` Zod
112
- (`config.ts:144`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
112
+ (`config.ts:156-159`) — le navigateur la refuserait de toute façon. Défense en profondeur : même
113
113
  instanciée à la main avec cette combinaison, `Cors.#allowOrigin()` **reflète l'origine** au lieu
114
114
  d'émettre `*` (`cors.ts:58`).
115
115
  - **Reflet d'origine ⇒ `Vary: Origin`.** Dès que la valeur `Allow-Origin` n'est pas `*`, la politique
@@ -204,8 +204,8 @@ curl -si http://localhost:5151/api/articles -H 'Origin: https://evil.com'
204
204
 
205
205
  ## ⚙️ Configuration et mises en situation
206
206
 
207
- La section `cors` de la config du module (`corsSchema`, `config.ts:117` ; branchée à la racine en
208
- `config.ts:117`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
207
+ La section `cors` de la config du module (`corsSchema`, `config.ts:129` ; branchée à la racine en
208
+ `config.ts:1137`). Toutes les clés ont un défaut sûr — une section omise donne une politique **fermée**.
209
209
 
210
210
  <!-- prettier-ignore -->
211
211
  | Option | Type | Défaut | Effet |
@@ -242,7 +242,7 @@ cors: {
242
242
  | `Origin: https://app.example.com:8443` | rien — le **port** fait partie de l'origine, match exact |
243
243
  | `Origin: https://sub.app.example.com` | rien — un sous-domaine n'est **pas** couvert par le parent |
244
244
  | `Origin: http://app.example.com` | rien — le **scheme** fait partie de l'origine (downgrade refusé) |
245
- | aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:997`) |
245
+ | aucun `Origin` (même origine, ou `curl`) | rien — la requête suit le pipeline normal (`firewall.ts:1013`) |
246
246
 
247
247
  ### Situation 2 — une API publique en lecture seule (le joker `*`)
248
248
 
@@ -273,7 +273,7 @@ cors: { origins: ["https://app.example.com", "https://admin.example.com"], crede
273
273
 
274
274
  > [!WARNING]
275
275
  > `origins: ["*"]` **avec** `credentials: true` est rejeté par le schéma Zod au démarrage
276
- > (`config.ts:144`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
276
+ > (`config.ts:156-159`), avec un message qui dit quoi faire. La raison n'est pas cosmétique : cette
277
277
  > combinaison, si un serveur la contournait en reflétant chaque origine, laisserait **tout site du
278
278
  > web** lire les réponses authentifiées de tes utilisateurs. Ne « corrige » jamais cette erreur en
279
279
  > reflétant l'origine reçue sans la valider.
@@ -322,23 +322,23 @@ sequenceDiagram
322
322
  R-->>B: 200 + Access-Control-*
323
323
  ```
324
324
 
325
- `Firewall.handleCors()` (`firewall.ts:991`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
- (`http-kernel.ts:1301`), à la ligne `http-kernel.ts:1301` — **avant le routing**. La raison est
325
+ `Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
326
+ (`http-kernel.ts:1310`), à la ligne `http-kernel.ts:1361` — **avant le routing**. La raison est
327
327
  concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
328
328
  router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
329
329
  credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
330
330
 
331
- Quatre sorties en no-op, dans cet ordre (`firewall.ts:797`) :
331
+ Quatre sorties en no-op, dans cet ordre (`firewall.ts:1007`) :
332
332
 
333
333
  1. CORS désactivé ⇒ `#cors` est `null`, retour immédiat ;
334
- 2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:587`) ;
334
+ 2. pas d'en-tête `Origin` ⇒ requête same-origin ou client non-navigateur (`firewall.ts:1013`) ;
335
335
  3. la réponse n'expose pas `setHeader` ⇒ c'est un **WebSocket**, il n'y a pas d'en-tête HTTP à poser
336
- (`firewall.ts:1013`) ;
336
+ (`firewall.ts:83`) ;
337
337
  4. origine hors allowlist ⇒ la table est `null`, aucun en-tête n'est posé — mais un preflight reste
338
- court-circuité en 204 (`firewall.ts:822`).
338
+ court-circuité en 204 (`firewall.ts:1032`).
339
339
 
340
340
  **La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
341
- `Access-Control-Request-Method` (`firewall.ts:999`). Un `OPTIONS` nu — celui d'un client qui interroge
341
+ `Access-Control-Request-Method` (`firewall.ts:1018-1020`). Un `OPTIONS` nu — celui d'un client qui interroge
342
342
  les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
343
343
 
344
344
  ### Ce que chaque moment pose
@@ -354,10 +354,10 @@ Deux nuances utiles :
354
354
  `Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
355
355
  point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
356
356
  - **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
357
- (`http-kernel.ts:1312`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
357
+ (`http-kernel.ts:1361`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
358
358
  tes routes.
359
359
 
360
- Le contrat est publié dans l'interface du firewall (`IFirewall.ts:34`) : `number | undefined` — `204`
360
+ Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
361
361
  signifie « je suis un preflight, réponds et arrête-toi ».
362
362
 
363
363
  ## 🛡️ CORS, CSRF et en-têtes de sécurité — qui protège quoi
@@ -371,12 +371,12 @@ Trois briques voisines, souvent confondues. Une seule ligne chacune :
371
371
  | **[En-têtes](./headers.md)** (CSP, COOP) | ce que la **page** a le droit de faire | dans le navigateur | XSS, injection, fenêtres croisées |
372
372
 
373
373
  Les deux premières se parlent. Au boot, la liste des origines de confiance CSRF est l'**union** de
374
- `csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:589`) : ce que tu autorises explicitement en
374
+ `csrf.trustedOrigins` et de `cors.origins` (`firewall.ts:605`) : ce que tu autorises explicitement en
375
375
  CORS ne peut pas être, au même instant, traité comme une tentative CSRF.
376
376
 
377
377
  L'inverse n'est pas vrai, et c'est délibéré : `csrf.trustedOrigins` déclare un **alias de domaine**
378
378
  légitime de ton app (une façade multi-domaine) sans pour autant exposer tes réponses au JS de cette
379
- origine (`config.ts:180`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
379
+ origine (`config.ts:188`). Ajouter une origine à `cors.origins` est **plus** permissif que l'ajouter à
380
380
  `csrf.trustedOrigins`.
381
381
 
382
382
  ## 🔌 Et le WebSocket ?
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
387
387
  (`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
388
388
 
389
389
  La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
390
- (`http-kernel.ts:599`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
390
+ (`http-kernel.ts:604`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
391
391
  si elle est refusée. Sa doctrine :
392
392
 
393
393
  - **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
@@ -405,22 +405,22 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
405
405
  | Sujet | Norme | Comment le code s'y conforme |
406
406
  | --------------------------------- | --------------------------- | --------------------------------------------------------------------------- |
407
407
  | Protocole CORS | Fetch Standard (WHATWG) | `Cors` (`cors.ts:33`) — preflight vs requête réelle séparés |
408
- | Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:822`) |
409
- | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:144`) + reflet défensif (`cors.ts:58`) |
408
+ | Preflight sans credentials | Fetch Standard | court-circuit en 204 avant auth/routing (`firewall.ts:1032`) |
409
+ | `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
410
410
  | Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
411
411
  | Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
412
- | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:599`) |
412
+ | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:604`) |
413
413
 
414
414
  ## ⚡ Performance & mémoire
415
415
 
416
416
  La politique est **précalculée au boot** : les listes `methods`, `allowedHeaders`, `exposedHeaders` et
417
- `maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42`), jamais par requête. Il
417
+ `maxAgeS` sont jointes/converties une fois dans le constructeur (`cors.ts:42-49`), jamais par requête. Il
418
418
  ne reste à l'exécution qu'un `Set.has()` sur l'origine.
419
419
 
420
420
  Le coût par requête est donc :
421
421
 
422
- - **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:997`) ;
423
- - **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:1013`) ;
422
+ - **0 pour une requête same-origin** — pas d'en-tête `Origin`, sortie immédiate (`firewall.ts:1013`) ;
423
+ - **0 pour un WebSocket** — sortie sur l'absence de `setHeader` (`firewall.ts:83`) ;
424
424
  - **0 si la section est désactivée** — `#cors` reste `null`, aucun objet n'est alloué (`firewall.ts:166`) ;
425
425
  - **une petite table d'en-têtes** allouée uniquement pour une requête cross-origin autorisée. Une
426
426
  origine refusée n'alloue rien du tout (retour `null` avant construction de la table, `cors.ts:74`).
@@ -429,11 +429,11 @@ Le coût par requête est donc :
429
429
 
430
430
  La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
431
431
  `Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
432
- (`firewall.ts:575`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
- `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:594`) — aucun secret ne transite par
432
+ (`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
+ `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
434
434
  cette surface.
435
435
 
436
- - **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:348`), protégé
436
+ - **Data plane** : `GET /nodefony/security/api/firewall` (`SecurityAdminApi.ts:328`), protégé
437
437
  `ROLE_NODEFONY_ADMIN`.
438
438
  - **Écran** : console **Firewall** → section _Défenses_ (`FirewallDefenses`,
439
439
  `FirewallDefenses.tsx:114`), carte CORS à côté des cartes CSRF, en-têtes et throttle.
@@ -447,7 +447,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
447
447
 
448
448
  | Symptôme | Cause | Correction |
449
449
  | --------------------------------------------------------- | -------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
450
- | Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:144`) | Lister les origines, ou passer `credentials:false` |
450
+ | Le boot échoue sur la config CORS | `origins:["*"]` **et** `credentials:true` (`config.ts:156-159`) | Lister les origines, ou passer `credentials:false` |
451
451
  | Requête bloquée alors que l'origine « est » dans la liste | Match **exact** : port, scheme ou sous-domaine divergent | Écrire l'origine complète `scheme://host:port`, une entrée par variante |
452
452
  | `curl` fonctionne, le navigateur non | CORS s'applique dans le navigateur, pas au serveur | Normal — reproduire avec un `Origin` explicite (`curl -H 'Origin: …'`) |
453
453
  | Cookie non envoyé malgré `credentials:true` | Opt-in serveur seul : le client n'a pas `credentials: "include"` | Activer les deux côtés (et un cookie `SameSite=None; Secure` en cross-site) |
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
455
455
  | Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
456
456
  | Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
457
457
  | `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
458
- | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:599`) — vérifier `allowedOrigins` |
458
+ | Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:604`) — vérifier `allowedOrigins` |
459
459
  | Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
460
460
 
461
461
  ## 🧪 Tests & couverture
package/docs/csrf.md CHANGED
@@ -91,9 +91,9 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
91
91
  ## La vision Nodefony
92
92
 
93
93
  - **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
94
- couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:151-156`).
94
+ couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
95
95
  - **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
96
- branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1470`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1479`) arrive **après** le
97
97
  resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
98
98
  attaquant ne coûte ni lecture de session ni authentification).
99
99
  - **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
@@ -111,7 +111,7 @@ cookies d'une victime, ils sont hors vecteur (`csrf.ts:113`).
111
111
 
112
112
  ### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
113
113
 
114
- Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:86-87`) :
114
+ Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:101-102`) :
115
115
 
116
116
  ```typescript
117
117
  // nodefony/controllers/ProfileController.ts — complet, compile tel quel
@@ -148,13 +148,13 @@ export default ProfileController;
148
148
 
149
149
  (Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
150
150
  le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
151
- `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1605-1611`.)
151
+ `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1620-1626`.)
152
152
 
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
155
155
  1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
156
156
  (`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
157
- exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
157
+ exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:482-497`).
158
158
  2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
159
159
  `x-csrf-token` sur chaque mutation.
160
160
 
@@ -199,7 +199,7 @@ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
199
199
  > [!IMPORTANT]
200
200
  > En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
201
201
  > absent, un secret **éphémère** est généré (dev) : un redémarrage invalide les tokens en cours, et
202
- > chaque pod rejette les tokens des autres (`firewall.ts:199-209`). Générer et câbler :
202
+ > chaque pod rejette les tokens des autres (`firewall.ts:225-234`). Générer et câbler :
203
203
  > `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
204
204
  > `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
205
205
 
@@ -241,12 +241,12 @@ autorisation :
241
241
  ```
242
242
 
243
243
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1099`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
244
+ > `@CsrfExempt` (`routerDecorators.ts:1113`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
245
245
  > webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
246
246
 
247
247
  Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
248
248
  déclarer l'alias dans `csrf.trustedOrigins` (match exact d'origine), pas dans `cors.origins` — CORS
249
- ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:176-181`).
249
+ ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:188-193`).
250
250
 
251
251
  ## 🏗️ Architecture interne
252
252
 
@@ -257,7 +257,7 @@ sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans
257
257
 
258
258
  1. **Origine de confiance** — `csrf.trustedOrigins` ∪ whitelist CORS → passe même en cross-site : ce
259
259
  que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts:45`, union construite par le
260
- firewall au boot, `firewall.ts:190-194`).
260
+ firewall au boot, `firewall.ts:217-220`).
261
261
  2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
262
262
  `same-site` → OK sauf `strictSameSite` (`csrf.ts:102-104`) ; `cross-site` → **403** ; valeur
263
263
  inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts:98-109`).
@@ -269,9 +269,9 @@ Lectures durcies côté firewall :
269
269
 
270
270
  - en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
271
271
  `headerValue()`, `firewall.ts:103`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
272
- parse du contexte (`cookieValue()`, `firewall.ts:90-103`) ;
272
+ parse du contexte (`cookieValue()`, `firewall.ts:116-129`) ;
273
273
  - hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
274
- (`firewall.ts:772-775`) ;
274
+ (`firewall.ts:977-982`) ;
275
275
  - le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
276
276
  whitelist) ne fuite jamais au client (`CsrfError.ts:17-21`).
277
277
 
@@ -296,28 +296,28 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
296
296
  1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
297
297
  `@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
298
298
  2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
299
- (`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
300
- (`Context.ts:181-183`).
299
+ (`Resolver.ts:173-174`) — champs portés par le `Context` de base, HTTP comme WS
300
+ (`Context.ts:241-243`).
301
301
  3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
302
302
  requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
303
303
  `@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
304
304
  (`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
305
- (`firewall.ts:951`).
305
+ (`firewall.ts:967`).
306
306
  4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
307
- avec le cookie de session (`HttpContext.ts:419-432`).
307
+ avec le cookie de session (`HttpContext.ts:482-497`).
308
308
 
309
- ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:149-192`)
309
+ ## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:161-204`)
310
310
 
311
311
  <!-- prettier-ignore -->
312
312
  | Option | Type · défaut | Effet |
313
313
  | --- | --- | --- |
314
- | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:151-156`). |
315
- | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:157-162`). |
316
- | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:164-169`). |
317
- | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:170-175`). |
318
- | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:469`). |
319
- | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:176-181`). |
320
- | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:182-188`). |
314
+ | `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:163-168`). |
315
+ | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
316
+ | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
317
+ | `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
318
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:497`). |
319
+ | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
320
+ | `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
321
321
 
322
322
  ## 📜 Normes appliquées
323
323
 
@@ -339,13 +339,13 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
339
339
  - **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
340
340
  allouée « au cas où » (`firewall.ts:164`).
341
341
  - Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
342
- marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:142`).
342
+ marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:162`).
343
343
 
344
344
  ## 📡 Observabilité — Studio
345
345
 
346
346
  L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
347
347
  `Firewall.tsx:313-314`). La projection est **sans secret par construction** :
348
- `Firewall.#describeDefenses()` (`firewall.ts:575`) publie la config résolue, et `synchronizerToken`
348
+ `Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
349
349
  n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
350
350
 
351
351
  ## ⚠️ Pièges (symptôme → cause → correction)
@@ -355,7 +355,7 @@ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:60
355
355
  | Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
356
356
  | Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts:113`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
357
357
  | `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts:778-783`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
358
- | Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:199-209`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
358
+ | Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:227-237`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
359
359
  | `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
360
360
  | `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts:130-136`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
361
361
  | Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
@@ -33,9 +33,9 @@ Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions r
33
33
  sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
34
34
  d'accès » :
35
35
 
36
- 1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:660`). Un jeton dont l'`iss` n'y figure pas est
36
+ 1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:666-671`). Un jeton dont l'`iss` n'y figure pas est
37
37
  refusé **avant toute requête sortante** — l'application ne va pas interroger un émetteur inconnu.
38
- 2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:666`). Par défaut, il
38
+ 2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:672-677`). Par défaut, il
39
39
  doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
40
40
  toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
41
41
  du pare-feu.
@@ -81,7 +81,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
81
81
  Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
82
82
  paire `(émetteur, sujet)`, jamais le sujet seul.
83
83
 
84
- En mode `prefixed` (`config.ts:650`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
84
+ En mode `prefixed` (`config.ts:656-660`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
85
85
  pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
86
86
  identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
87
87
  l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
@@ -96,7 +96,7 @@ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des i
96
96
  Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
97
97
  utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
98
98
  - **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
99
- avec les rôles d'`ephemeralRoles` (`config.ts:672`). C'est le mode de l'appelant **purement machine** : un agent, un
99
+ avec les rôles d'`ephemeralRoles` (`config.ts:678`). C'est le mode de l'appelant **purement machine** : un agent, un
100
100
  service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
101
101
  — qui viennent du jeton, donc bornés par le serveur d'autorisation.
102
102