@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1

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 (83) 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-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
@@ -31,7 +31,7 @@ source: "src/packages/@nodefony/security/docs/authenticators.md"
31
31
  > Un **authenticator** répond à une seule question : _« qui es-tu, et peux-tu le prouver ? »_. Il ne
32
32
  > décide **pas** des droits (ça, c'est l'autorisation / les voters) — il établit une **identité**.
33
33
  > Le firewall enchaîne les authenticators déclarés par une zone jusqu'à obtenir une preuve valide,
34
- > sinon il ferme en 401 (Zero Trust). Nodefony en fournit **six** intégrés, tous ancrés ici sur le
34
+ > sinon il ferme en 401 (Zero Trust). Nodefony en fournit **sept** intégrés, tous ancrés ici sur le
35
35
  > code (`src/packages/@nodefony/security/nodefony/src/authenticator/`).
36
36
 
37
37
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Authenticators**
@@ -90,7 +90,7 @@ classe d'attaque précise — détaillées brique par brique dans le catalogue :
90
90
 
91
91
  ### Le contrat `IAuthenticator`
92
92
 
93
- Tout authenticator implémente le même cycle (`IAuthenticator.ts:18`), ce qui rend le firewall
93
+ Tout authenticator implémente le même cycle (`IAuthenticator.ts:23`), ce qui rend le firewall
94
94
  totalement agnostique de la stratégie :
95
95
 
96
96
  | Méthode | Rôle |
@@ -100,23 +100,23 @@ totalement agnostique de la stratégie :
100
100
  | `authenticate(token)` | **Vérifie** (signature/hash/session), applique la **révocation**, **re-résout le sujet** — ou lève un 401. |
101
101
  | `onSuccess(ctx,tok)` | Effet de bord au succès (poser l'identité en session, audit). |
102
102
  | `onFailure(ctx,err)` | Slot d'audit (le 401 + challenge sont posés par le firewall). |
103
- | `challenge()` | **Optionnel** (`IAuthenticator.ts:42`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
103
+ | `challenge()` | **Optionnel** (`IAuthenticator.ts:55`) — la valeur `WWW-Authenticate` (RFC 7235) des 401 de la zone. |
104
104
 
105
105
  ### Le registre pluggable
106
106
 
107
107
  Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
108
- (`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
108
+ (`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
109
109
  un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
110
110
 
111
- - Les **cinq builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `apikey`)
111
+ - Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
112
112
  s'enregistrent à l'import du module via `registerAuthenticatorFactory()`
113
- (`authenticatorRegistry.ts:72-125`) — donc toujours avant le boot.
114
- - Le sixième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
115
- lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:268`).
113
+ (`authenticatorRegistry.ts:73-145`) — donc toujours avant le boot.
114
+ - Le septième, `firewall-realtime`, n'est **pas dans le registre** : c'est le firewall qui le câble
115
+ lui-même au handshake WS des zones protégées (`Firewall.#wireRealtime()`, `firewall.ts:279`).
116
116
  - La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
117
117
  `tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
118
118
  - Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
119
- (`firewall.ts:582`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
119
+ (`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
120
120
  faute de frappe.
121
121
 
122
122
  ## 🚀 Démarrage rapide
@@ -205,16 +205,17 @@ Requête par requête, qui répond :
205
205
  | `Authorization: Bearer nf_…` | `apikey` | identifié — clé vérifiée au store, révocable |
206
206
  | rien | aucun | **401** + `WWW-Authenticate: Bearer` (`firewall.ts:1200`) |
207
207
 
208
- ## 🔐 Les six authenticators intégrés
208
+ ## 🔐 Les authenticators intégrés
209
209
 
210
- | Nom | Credential | Vérité | Révocable | Pour… |
211
- | ------------------- | ---------------------------------------- | ---------- | :-------: | ---------------------------------- |
212
- | `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
213
- | `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
214
- | `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
215
- | `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
216
- | `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
217
- | `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
210
+ | Nom | Credential | Vérité | Révocable | Pour… |
211
+ | ------------------- | --------------------------------------------------- | ---------- | :------------: | ------------------------------------------ |
212
+ | `anonymous` | (aucun) | — | — | accepter l'anonymat explicitement |
213
+ | `userpassword` | `Authorization: Basic base64(id:mdp)` | verifier | n/a | outils/scripts, brique login |
214
+ | `session` | cookie de session (identifiant en blob) | serveur | immédiate | le **web** après login (BFF) |
215
+ | `jwt` | `Authorization: Bearer <a.b.c>` | auto-porté | via état | API service↔service, agents |
216
+ | `external-jwt` | `Authorization: Bearer <a.b.c>` d'un émetteur TIERS | émetteur | via l'émetteur | jetons d'un serveur d'autorisation externe |
217
+ | `apikey` | `Authorization: Bearer nf_…` | serveur | immédiate | API/CI/scripts d'un user |
218
+ | `firewall-realtime` | identité déjà résolue au handshake (ALS) | serveur | 1 fenêtre | le **WebSocket** de toute identité |
218
219
 
219
220
  ### `anonymous` — accepter explicitement l'anonymat
220
221
 
@@ -227,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
227
228
  si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
228
229
  « canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
229
230
  - Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
230
- token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:827`).
231
+ token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
231
232
  - **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
232
233
  défaut.
233
234
 
@@ -235,7 +236,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
235
236
 
236
237
  Schéma **HTTP Basic** (RFC 7617) : `Authorization: Basic base64(id:mdp)`, charset UTF-8, scheme
237
238
  case-insensitive (`UserPasswordAuthenticator.ts:11`) ; `createToken()` split au **premier** `:` —
238
- le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
239
+ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:68`).
239
240
 
240
241
  - **La vérification est déléguée** au `IPasswordVerifier` (le `UserService`) : hash, comparaison,
241
242
  leurre anti-timing, re-hash transparent — l'authenticator ne voit que le verdict.
@@ -245,7 +246,7 @@ le mot de passe peut en contenir (`UserPasswordAuthenticator.ts:74`).
245
246
  (`UserPasswordAuthenticator.ts:101-103`) — un identifiant bloqué ne coûte **aucun hash** → le
246
247
  throttle protège aussi le serveur du **DoS argon2**. Échec compté, succès remis à zéro
247
248
  (`UserPasswordAuthenticator.ts:111-114`). `ThrottledError` → **429 + `Retry-After`**
248
- (`firewall.ts:764`).
249
+ (`firewall.ts:780`).
249
250
  - **Le throttler est PARTAGÉ** avec le login JSON du BFF — même `loginThrottler` du container : un
250
251
  attaquant ne contourne pas le backoff en changeant de porte (`authenticatorRegistry.ts:75-79`).
251
252
  - Challenge : `Basic realm="nodefony", charset="UTF-8"` (`UserPasswordAuthenticator.ts:130`).
@@ -258,7 +259,7 @@ Après le login, chaque requête web prouve son identité par la **session serve
258
259
  Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
259
260
 
260
261
  - **N'ouvre jamais la session lui-même** : `supports()` exige une session **déjà reprise** porteuse
261
- d'un utilisateur (`SessionAuthenticator.ts:43-46`) — le pipeline http démarre la session _avant_
262
+ d'un utilisateur (`SessionAuthenticator.ts:75-78`) — le pipeline http démarre la session _avant_
262
263
  le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
263
264
  - **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
264
265
  (`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
@@ -272,21 +273,32 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
272
273
 
273
274
  Réservé aux **API service↔service / agents** (le web reste sur la session). Vérifie un access token
274
275
  **EdDSA** signé par le keystore du serveur ; `supports()` ne réclame que la structure compacte
275
- `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:20`). Les défenses **dures** du JWT BCP, toutes
276
+ `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`). Les défenses **dures** du JWT BCP, toutes
276
277
  prouvées en test :
277
278
 
278
279
  | Défense | Comment | Attaque fermée |
279
280
  | -------------------------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------ |
280
281
  | **Allowlist d'algorithmes** | `algorithms: ["EdDSA"]` — jamais l'algo de l'en-tête du token (`JwtAuthenticator.ts:120`) | `alg=none`, algorithm confusion (§3.1) |
281
- | **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:158`) | injection de clé / SSRF (§3.5) |
282
- | **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:105-107`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
283
- | **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:123-131`) | jeton auto-porté volé, logout global |
284
- | **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:174-186`) | compte banni encore « valide » via son token (§3.10) |
282
+ | **Clé par `kid` du keyset LOCAL** | `createLocalJWKSet` — jamais `jku`/`jwk` de l'en-tête (`JwtAuthenticator.ts:174`) | injection de clé / SSRF (§3.5) |
283
+ | **`aud` + `iss` + `typ` obligatoires** | `typ: "at+jwt"` (§3.11) sépare access et refresh (`JwtAuthenticator.ts:119-124`) | refresh présenté comme access, token d'un autre service (§3.8-3.9) |
284
+ | **Révocation** | denylist `isJtiDenied` + seuil `invalidBefore` par porteur (`JwtAuthenticator.ts:138-148`) | jeton auto-porté volé, logout global |
285
+ | **Sujet revérifié** | `loadUserByIdentifier(sub)` → disparu/inactif/verrouillé = rejet (`JwtAuthenticator.ts:190-203`) | compte banni encore « valide » via son token (§3.10) |
285
286
 
286
- Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:24`) : la cause fine
287
+ Le **message d'échec est uniforme** (`INVALID_TOKEN`, `JwtAuthenticator.ts:25`) : la cause fine
287
288
  (expiré, `aud`, signature, sujet banni) part en **audit**, jamais au client — anti-oracle. Le token
288
- promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:162-171`). `jose` est
289
- importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:96`).
289
+ promu porte `scopes`, `jti`, `claims` en attributs (`JwtAuthenticator.ts:178-188`). `jose` est
290
+ importé **lazy** — dépendance lourde (`JwtAuthenticator.ts:112`).
291
+
292
+ Les six premiers se déclarent dans une zone (registre `authenticatorRegistry.ts:73`) ;
293
+ `firewall-realtime` ne se déclare pas — le firewall le câble lui-même au handshake WebSocket.
294
+
295
+ ### `external-jwt` — un jeton émis par un serveur d'autorisation TIERS
296
+
297
+ Là où `jwt` ne vérifie que les jetons signés par Nodefony, `external-jwt` accepte un jeton d'un
298
+ émetteur de confiance déclaré (`resourceServer.issuers`), vérifié contre ses clés publiques, et
299
+ rattache son sujet à un compte local. `jwt` et `external-jwt` s'aiguillent par l'émetteur (`iss`) :
300
+ leur ordre dans la zone ne décide de rien. Tout le détail — audience, espace de noms du sujet, panne
301
+ de l'émetteur en 503 — est dans **[Jetons d'un émetteur tiers](external-jwt.md)**.
290
302
 
291
303
  ### `apikey` — PAT opaque révocable
292
304
 
@@ -294,17 +306,17 @@ Clé API personnelle en `Authorization: Bearer nf_…` (préfixe `apiKeys.prefix
294
306
  Contrairement au JWT (auto-porté), un PAT est un **bearer opaque** dont la vérité vit **côté
295
307
  serveur** (`ITokenStore`) → **révocable immédiatement**. Défenses :
296
308
 
297
- - **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:99-101`)
309
+ - **Forme + CRC validés AVANT tout accès au store** (`parseApiKey`, `ApiKeyAuthenticator.ts:103-106`)
298
310
  → une valeur malformée n'atteint jamais la base (**anti-DoS**).
299
- - **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:105`) — le secret n'existe
311
+ - **Lookup par hash SHA-256** (`findByHash`, `ApiKeyAuthenticator.ts:109`) — le secret n'existe
300
312
  **nulle part au repos**.
301
- - **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:109-111`) +
302
- **ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:117-118`).
303
- - **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:123`).
313
+ - **Révocation** (`revokedAt`) + **expiration** (`expiresAt`) (`ApiKeyAuthenticator.ts:113-116`) +
314
+ **ban en masse** du porteur (`invalidBefore` vs `createdAt`, `ApiKeyAuthenticator.ts:121-122`).
315
+ - **Sujet revérifié** à chaque requête → rôles frais (`ApiKeyAuthenticator.ts:127`).
304
316
  - **`lastUsedAt` throttlé** : aucune écriture sur le hot path tant que la fenêtre
305
- `apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:127-133`).
317
+ `apiKeys.lastUsedThrottleS` n'est pas dépassée (`ApiKeyAuthenticator.ts:136-143`).
306
318
 
307
- Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:138-140`).
319
+ Le token promu porte `scopes`, `apiKeyId`, `tenantId` (`ApiKeyAuthenticator.ts:146-149`).
308
320
 
309
321
  ### `firewall-realtime` — la promotion, en WebSocket, de l'identité déjà posée
310
322
 
@@ -323,7 +335,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
323
335
  - **Perf : il ne relit pas la base.** `authenticate()` réutilise l'identité de l'ALS
324
336
  (`FirewallRealtimeAuthenticator.ts:91`) au lieu de refaire deux lectures base par connexion —
325
337
  un coût évitable sur le différenciateur temps réel.
326
- - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:268`) pour toute zone
338
+ - **Câblé automatiquement** par `Firewall.#wireRealtime()` (`firewall.ts:279`) pour toute zone
327
339
  protégée `realtime !== false` — une instance par zone au handshake (`firewall.ts:289`).
328
340
  - **Le mode de preuve suit le jeton du firewall**, il n'est pas deviné : absent (zone historique),
329
341
  on retombe sur le mode le plus strict, la session (`FirewallRealtimeAuthenticator.ts:101-103`).
@@ -333,7 +345,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
333
345
  `buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
334
346
  lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts:227`). En mode jeton porteur, la
335
347
  preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
336
- (`FirewallRealtimeAuthenticator.ts:127`).
348
+ (`FirewallRealtimeAuthenticator.ts:179`).
337
349
 
338
350
  > [!NOTE]
339
351
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les
@@ -344,7 +356,7 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
344
356
  ## ⚙️ Composer une zone — ordre, mode, cohabitation
345
357
 
346
358
  La liste `area.authenticators` se lit **dans l'ordre**, déroulée par `Firewall.#authenticate()`
347
- (`firewall.ts:1112`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
359
+ (`firewall.ts:1128`) selon le `mode` de la zone (`first` par défaut, `config.ts:87-92`).
348
360
 
349
361
  ### Situation 1 — humains ET machines sur la même API (`first`)
350
362
 
@@ -377,14 +389,14 @@ authenticators: ["anonymous", "session"], // ❌ anonymous accepte TOUT le mon
377
389
  ### Situation 3 — empiler les preuves (`all`)
378
390
 
379
391
  En mode `all`, **chaque** maillon est obligatoire : `supports()` faux = 401 immédiat
380
- (`firewall.ts:937`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:973`) —
392
+ (`firewall.ts:1144-1149`) et le **dernier** token de la chaîne porte l'identité (`firewall.ts:1198-1199`) —
381
393
  utile pour exiger une preuve de canal (mTLS) **et** une identité, ou un « sudo mode » session +
382
394
  re-saisie du mot de passe. Scénario complet côté zones : [firewall](./firewall.md).
383
395
 
384
396
  ### Cohabitation JWT + clé API dans une même zone
385
397
 
386
398
  Les deux sont des `Bearer`, mais Nodefony les **discrimine sur la forme** — un JWT a la structure
387
- compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:20`), un PAT porte le préfixe `nf_` sans
399
+ compacte `a.b.c` (`COMPACT_JWS`, `JwtAuthenticator.ts:21`), un PAT porte le préfixe `nf_` sans
388
400
  point (`looksLikeApiKey`, `ApiKeyAuthenticator.ts:72`). Chaque `supports()` ne réclame que _son_
389
401
  format → aucun conflit, aucune double vérification.
390
402
 
@@ -397,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
397
409
  Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
398
410
  invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
399
411
  client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
400
- en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
412
+ en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
401
413
 
402
414
  ## 🧩 Ajouter un authenticator maison
403
415
 
@@ -423,13 +435,13 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
423
435
  <!-- prettier-ignore -->
424
436
  | Domaine | Norme | Ancrage |
425
437
  | --- | --- | --- |
426
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
438
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
427
439
  | Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
428
440
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
429
441
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
430
442
  | Backoff de login | NIST SP 800-63B | `#throttler.check()` avant le verifier (`UserPasswordAuthenticator.ts:101-103`) |
431
- | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:764`) |
432
- | Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:24`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
443
+ | Rate limit (429) | RFC 6585 | `Retry-After` posé par le firewall (`firewall.ts:780`) |
444
+ | Anti-énumération | OWASP | `INVALID_TOKEN` (`JwtAuthenticator.ts:25`) · `INVALID_CREDENTIALS` (`UserPasswordAuthenticator.ts:16`) |
433
445
 
434
446
  ## ⚡ Performance & mémoire
435
447
 
@@ -437,7 +449,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
437
449
  (le chemin chaud/froid vit dans le [firewall](./firewall.md)).
438
450
  - Résolutions **lazy** : le verifier `#resolveVerifier` est mémoïsé au premier login
439
451
  (`UserPasswordAuthenticator.ts:105`) ; keystore, `tokenStore` et `users` sont résolus du
440
- container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:96`).
452
+ container au premier usage ; `jose` est importé lazy (`JwtAuthenticator.ts:112`).
441
453
  - `anonymous` : singleton gelé `anonymousUser`, zéro allocation d'utilisateur
442
454
  (`AnonymousToken.ts:9`).
443
455
  - `apikey` : écriture `lastUsedAt` **coalescée** — pas une écriture par requête
@@ -451,12 +463,12 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
451
463
  <!-- prettier-ignore -->
452
464
  | Symptôme | Cause (dans le code) | Correction |
453
465
  | --- | --- | --- |
454
- | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:827`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
466
+ | 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
455
467
  | 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
456
468
  | JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
457
469
  | Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
458
470
  | Révocation WS pas immédiate | Identité figée au handshake — asymétrie assumée du `FirewallRealtimeAuthenticator` (`FirewallRealtimeAuthenticator.ts:51-55`) | Attendre la fenêtre de re-validation, ou utiliser JWT + canal révocation |
459
- | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:617`) | Configurer `rateLimit` pour poser le throttler |
471
+ | Brute-force pas ralenti | `loginThrottler` absent du container — `rateLimit.enabled` off (`firewall.ts:613`) | Configurer `rateLimit` pour poser le throttler |
460
472
 
461
473
  ## 🧪 Tests & couverture
462
474
 
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
331
331
 
332
332
  **Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
333
333
  `@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
334
- (`Resolver.ts:689-702`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
334
+ (`Resolver.ts:700-715`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
335
335
  consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
336
336
  `@IsGranted("doc.edit", { subject: "id" })`.
337
337
 
@@ -360,25 +360,25 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
360
360
 
361
361
  | Déclaration | Sémantique |
362
362
  | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
363
- | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:878`) |
364
- | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:407-412`) |
365
- | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:426`) |
366
- | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1500`) |
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:887`) |
370
- | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1236`) |
363
+ | `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
364
+ | `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
365
+ | empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
366
+ | décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
367
+ | `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:789`) |
368
+ | `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
369
+ | `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
370
+ | `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
371
371
 
372
372
  La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
373
- 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:331-336`). Le
373
+ 403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:406-408`). Le
374
374
  même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
375
375
  transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
376
- entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:672-702`).
376
+ entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:719`).
377
377
 
378
378
  > [!IMPORTANT]
379
379
  > **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
380
380
  > chargé) OU aucune identité résolue (route **hors zone** firewall) → **403** direct
381
- > (`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
382
382
  > [firewall](./firewall.md).
383
383
 
384
384
  ## 🧑‍⚖️ Les voters intégrés — deux axes, un même jury
@@ -399,7 +399,7 @@ Capte les attributs `ROLE_*` (`RoleVoter.supports()`, `RoleVoter.ts:25-27`) et v
399
399
  default-DENY du jury qui ferme, pas ce voter. C'est aussi ce qui rend l'OR
400
400
  (`@IsGranted(["A","B"])`) possible.
401
401
  - La hiérarchie est lue **en lazy** depuis le container — clé `roleHierarchy`
402
- (`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`).
403
403
  - Sync par nature → `Promise.resolve`, pas de wrapper `async` inutile (`RoleVoter.ts:36-38`).
404
404
 
405
405
  #### Deux échelles de rôles — plateforme et organisation
@@ -511,14 +511,14 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
511
511
 
512
512
  ## 🔌 HTTP et WebSocket — une garde, N transports
513
513
 
514
- - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:317`) est le point unique
514
+ - **La même garde** : `Resolver.executeAction()` (`Resolver.ts:372`) est le point unique
515
515
  d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
516
516
  `@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
517
517
  `ws-isgranted-jwt`).
518
518
  - **Le service ignore le transport** : l'audit lit `getUserIdentifier()`, commun à `IToken` (HTTP)
519
519
  et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
520
520
  - **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
521
- hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
521
+ hiérarchie** : `satisfies()` (`frameAuthorizer.ts:290`) délègue à `Firewall.hasRole()`
522
522
  (`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
523
523
 
524
524
  ## 📜 Normes appliquées
@@ -529,7 +529,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
529
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
530
  | Combinaison des votes | _deny-overrides_ : un refus l'emporte, le silence ferme | `Authorization.decide()` |
531
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:582-584`) |
532
+ | Modèle | **Zero Trust** (fermé par défaut) | 403 fail-closed du Resolver (`Resolver.ts:678-688`) |
533
533
  | Journalisation de sécurité | audit des refus, jamais des octrois | `#auditDeny` → `recordAudit` (`authorization.ts:113-142`) |
534
534
 
535
535
  > Ce tableau dit ce que le code **fait**, pas ce qu'il revendique : Nodefony n'est pas « conforme
@@ -541,10 +541,10 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
541
541
  ## ⚡ Performance & mémoire
542
542
 
543
543
  - **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
544
- alloc (`Resolver.ts:334-336`) ; l'exigence est **figée une fois** par route et partagée entre
545
- requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
546
- - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:78-80`),
547
- instanciés **une seule fois** au boot (`authorization.ts:55-64`).
544
+ alloc (`Resolver.ts:406`) ; l'exigence est **figée une fois** par route et partagée entre
545
+ requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
546
+ - **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
547
+ instanciés **une seule fois** au boot (`authorization.ts:70`).
548
548
  - **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
549
549
  (`RoleHierarchyWalker.ts:23-30`).
550
550
  - **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
@@ -560,7 +560,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
560
560
  | Symptôme | Cause (dans le code) | Correction |
561
561
  | -------------------------------------- | ----------------------------------------------------------------------------------- | -------------------------------------------------------------- |
562
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_` |
563
- | 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 |
564
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 |
565
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`) |
566
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:1333`), à la ligne `http-kernel.ts:1333` — **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:1382`) : 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:621`) 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:621`) |
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`).
@@ -428,12 +428,12 @@ Le coût par requête est donc :
428
428
  ## 📡 Observabilité — Studio
429
429
 
430
430
  La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
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
431
+ `Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
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:621`) — 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