@nodefony/security 10.0.0-alpha.9 → 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 (68) hide show
  1. package/dist/nodefony/command/security-token.js +4 -4
  2. package/dist/nodefony/command/security-user-delete.js +2 -2
  3. package/dist/nodefony/command/security-user-list.js +1 -1
  4. package/dist/nodefony/service/apiKeys.js +2 -2
  5. package/dist/nodefony/service/auditService.js +3 -3
  6. package/dist/nodefony/service/authFlow.js +2 -2
  7. package/dist/nodefony/service/authorization.js +3 -4
  8. package/dist/nodefony/service/firewall.js +21 -9
  9. package/dist/nodefony/service/oauth2.js +5 -3
  10. package/dist/nodefony/service/securityHeaders.js +1 -1
  11. package/dist/nodefony/service/tokenService.js +27 -18
  12. package/dist/nodefony/service/webAuthn.js +38 -31
  13. package/dist/nodefony/service/webhooks.js +50 -41
  14. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  15. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  16. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  17. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  18. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  19. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  20. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  21. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  22. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  23. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  24. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  25. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  26. package/dist/nodefony/src/csp.js +4 -5
  27. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  28. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  29. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  30. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
  31. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  32. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  33. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  34. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  35. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
  36. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  37. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  38. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  39. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  40. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  41. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  42. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  43. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  44. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  45. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  46. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  47. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  48. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  49. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  50. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  51. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  52. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  53. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  54. package/docs/api-keys.md +15 -15
  55. package/docs/audit.md +18 -13
  56. package/docs/authenticators.md +6 -6
  57. package/docs/authorization.md +15 -15
  58. package/docs/cors.md +6 -6
  59. package/docs/csrf.md +7 -7
  60. package/docs/external-jwt.md +17 -9
  61. package/docs/firewall.md +7 -7
  62. package/docs/headers.md +13 -13
  63. package/docs/index.md +3 -1
  64. package/docs/oauth2.md +2 -2
  65. package/docs/tokens.md +6 -6
  66. package/docs/webauthn.md +13 -13
  67. package/docs/webhooks.md +9 -12
  68. package/package.json +13 -13
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
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
111
  - Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
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
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
228
228
  si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
229
229
  « canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
230
230
  - Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
231
- 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`).
232
232
  - **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
233
233
  défaut.
234
234
 
@@ -409,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
409
409
  Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
410
410
  invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
411
411
  client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
412
- en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
412
+ en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
413
413
 
414
414
  ## 🧩 Ajouter un authenticator maison
415
415
 
@@ -435,7 +435,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
435
435
  <!-- prettier-ignore -->
436
436
  | Domaine | Norme | Ancrage |
437
437
  | --- | --- | --- |
438
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
438
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
439
439
  | Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
440
440
  | JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
441
441
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
@@ -463,7 +463,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
463
463
  <!-- prettier-ignore -->
464
464
  | Symptôme | Cause (dans le code) | Correction |
465
465
  | --- | --- | --- |
466
- | 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 |
467
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 |
468
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) |
469
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` |
@@ -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,20 +360,20 @@ 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: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`) |
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:672-677`). 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
@@ -511,7 +511,7 @@ 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:335`) 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`).
@@ -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:358`) ; l'exigence est **figée une fois** par route et partagée entre
545
- requêtes (`SecurityRequirement`, `routerDecorators.ts:424`).
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
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`).
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
package/docs/cors.md CHANGED
@@ -323,7 +323,7 @@ sequenceDiagram
323
323
  ```
324
324
 
325
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
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.
@@ -354,7 +354,7 @@ 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:1361`) : 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
360
  Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
@@ -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:604`) 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 ;
@@ -409,7 +409,7 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
409
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:604`) |
412
+ | Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
413
413
 
414
414
  ## ⚡ Performance & mémoire
415
415
 
@@ -428,7 +428,7 @@ 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()`
431
+ `Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
432
432
  (`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
433
433
  `allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
434
434
  cette surface.
@@ -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:604`) — 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
package/docs/csrf.md CHANGED
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
93
93
  - **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
94
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:1479`) arrive **après** le
96
+ branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1524`) 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 —
@@ -148,7 +148,7 @@ 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:1620-1626`.)
151
+ `csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1650-1655`.)
152
152
 
153
153
  ### Comment le front obtient — puis rejoue — le token
154
154
 
@@ -241,7 +241,7 @@ autorisation :
241
241
  ```
242
242
 
243
243
  > [!WARNING]
244
- > `@CsrfExempt` (`routerDecorators.ts:1113`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
244
+ > `@CsrfExempt` (`routerDecorators.ts:1142`) 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) :
@@ -296,7 +296,7 @@ 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:173-174`) — champs portés par le `Context` de base, HTTP comme WS
299
+ (`Resolver.ts:156-186`) — champs portés par le `Context` de base, HTTP comme WS
300
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
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
315
315
  | `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
316
316
  | `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
317
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`). |
318
+ | `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:486`). |
319
319
  | `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
320
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
 
@@ -325,7 +325,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
325
325
  | ------------------------------ | --------------------------------- | -------------------------------------- |
326
326
  | Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
327
327
  | Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
328
- | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:107` |
328
+ | Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
329
329
  | Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
330
330
  | Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
331
331
  | Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
@@ -346,7 +346,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
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
348
  `Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
349
- n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
349
+ n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
350
350
 
351
351
  ## ⚠️ Pièges (symptôme → cause → correction)
352
352
 
@@ -44,16 +44,24 @@ d'accès » :
44
44
 
45
45
  ```ts
46
46
  // nodefony.config.ts
47
- security: {
48
- resourceServer: {
49
- issuers: [
50
- {
51
- issuer: "https://auth.example.com/realms/mon-royaume",
52
- algorithms: ["RS256"],
47
+ import { defineConfig, use } from "nodefony";
48
+
49
+ export default defineConfig({
50
+ modules: [
51
+ "@nodefony/http",
52
+ "@nodefony/framework",
53
+ use("@nodefony/security", {
54
+ resourceServer: {
55
+ issuers: [
56
+ {
57
+ issuer: "https://auth.example.com/realms/mon-royaume",
58
+ algorithms: ["RS256"],
59
+ },
60
+ ],
53
61
  },
54
- ],
55
- },
56
- }
62
+ }),
63
+ ],
64
+ });
57
65
  ```
58
66
 
59
67
  Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
package/docs/firewall.md CHANGED
@@ -52,8 +52,8 @@ flowchart TD
52
52
  AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
53
53
  ```
54
54
 
55
- `Firewall.isSecure()` (`firewall.ts:721`) rattache la requête à une **zone** via
56
- `Firewall.matchPath()` (`firewall.ts:712`) ; `Firewall.handleSecurity()` (`firewall.ts:754`) décide.
55
+ `Firewall.isSecure()` (`firewall.ts:728`) rattache la requête à une **zone** via
56
+ `Firewall.matchPath()` (`firewall.ts:719`) ; `Firewall.handleSecurity()` (`firewall.ts:761`) décide.
57
57
  Les zones sont triées par **spécificité** dans `#build()` — `list.sort` par longueur de motif :
58
58
  le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
59
59
 
@@ -280,7 +280,7 @@ Sans lui, zone protégée + aucune preuve = 401.
280
280
 
281
281
  Il promeut en jeton realtime **toute** identité que le firewall a résolue — session BFF comme jeton
282
282
  porteur (JWT, clé d'API). **Enregistré automatiquement** par `Firewall.#wireRealtime()` au
283
- handshake des zones protégées `realtime` (`firewall.ts:300`).
283
+ handshake des zones protégées `realtime` (`firewall.ts:279`).
284
284
 
285
285
  - **Perf : il ne relit pas la base.** Handshake et frames tournent dans la même bulle ALS —
286
286
  l'identité déjà posée est réutilisée, 2 lectures base économisées par connexion
@@ -377,7 +377,7 @@ Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte
377
377
 
378
378
  > [!TIP]
379
379
  > Un nom d'authenticator inconnu en config **fait échouer le boot** —
380
- > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:429`) : jamais de zone
380
+ > `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:435`) : jamais de zone
381
381
  > « protégée » silencieusement ouverte à cause d'une faute de frappe.
382
382
 
383
383
  ## 🧑‍⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
@@ -517,7 +517,7 @@ scopes, métier), un même jury, combinables.
517
517
  (opt-out, `firewall.ts:277`), le `FirewallRealtimeAuthenticator` au handshake (`firewall.ts:300`)
518
518
  **et** un `frameAuthorizer` (RBAC par canal, `firewall.ts:348`). Même résolution de zone que HTTP.
519
519
  Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setChallenge()`,
520
- `firewall.ts:1207`) : le **code de fermeture** suffit.
520
+ `firewall.ts:1216`) : le **code de fermeture** suffit.
521
521
 
522
522
  ## 🛡️ En-têtes de sécurité, CSRF, CORS
523
523
 
@@ -527,13 +527,13 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
527
527
  - **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
528
528
  (`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:971`), puis double-submit `x-csrf-token` ≡
529
529
  cookie + HMAC (`firewall.ts:778`).
530
- - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
530
+ - **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:1014`).
531
531
 
532
532
  ## 📜 Normes appliquées
533
533
 
534
534
  | Domaine | Norme | Ancrage |
535
535
  | ---------------------- | --------------- | ------------------------------------------------------ |
536
- | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
536
+ | Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
537
537
  | Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
538
538
  | JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
539
539
  | HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
package/docs/headers.md CHANGED
@@ -122,8 +122,8 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
122
122
  statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
123
123
  se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
124
124
  - Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
125
- (`http-kernel.ts:335`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
- boot ; `onHttpRequest` (`http-kernel.ts:949`) ne fait plus que trois `setHeader`.
125
+ (`http-kernel.ts:348`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
126
+ boot ; `onHttpRequest` (`http-kernel.ts:972`) ne fait plus que trois `setHeader`.
127
127
  - Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
128
128
  (`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) qu'à la première lecture,
129
129
  puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
@@ -275,7 +275,7 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
275
275
  victime est capté par ton interface.
276
276
 
277
277
  Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
278
- (`http-kernel.ts:267`) — et configuré côté `@nodefony/http` avec `frameOptions`
278
+ (`http-kernel.ts:989`) — et configuré côté `@nodefony/http` avec `frameOptions`
279
279
  (`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
280
280
  s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
281
281
  pour un HTML statique servi directement depuis `public/`.
@@ -287,7 +287,7 @@ pour un HTML statique servi directement depuis `public/`.
287
287
  avec tes cookies.
288
288
 
289
289
  Valeur unique reconnue : `nosniff`, posée depuis le cache `secContentTypeOptions`
290
- (`http-kernel.ts:963`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
290
+ (`http-kernel.ts:986`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
291
291
  vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404
292
292
  (`security-headers.test.ts:38`).
293
293
 
@@ -297,10 +297,10 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
297
297
  installer un intercepteur.
298
298
 
299
299
  La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
300
- (`http-kernel.ts:335`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
300
+ (`http-kernel.ts:348`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
301
301
 
302
302
  Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
303
- de serveur (`http-kernel.ts:969`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
303
+ de serveur (`http-kernel.ts:992`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
304
304
  soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
305
305
  inclus.
306
306
 
@@ -397,7 +397,7 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
397
397
  ### Socle transport — `use("@nodefony/http", { securityHeaders })`
398
398
 
399
399
  Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
400
- **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:385`)
400
+ **éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:398`)
401
401
  recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
402
402
 
403
403
  | Option | Type | Défaut | Effet |
@@ -411,7 +411,7 @@ recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
411
411
 
412
412
  > [!WARNING]
413
413
  > Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
414
- > (`config.ts:212`, `config.ts:223`, `config.ts:247`, `config.ts:257`) mais **ne pilotent rien** : la couche
414
+ > (`config.ts:257`, `config.ts:257`, `config.ts:257`, `config.ts:257`) mais **ne pilotent rien** : la couche
415
415
  > applicative ne les lit pas (`securityHeaders.ts:6`), elles ne servent qu'à l'introspection
416
416
  > affichée dans Studio. Pour changer réellement `X-Frame-Options`, c'est `securityHeaders.frameOptions`
417
417
  > **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:282`) : Nodefony n'émet aucun
@@ -502,10 +502,10 @@ Trois propriétés à retenir :
502
502
 
503
503
  - **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
504
504
  `registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
505
- - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1090`) retire le fragment et
505
+ - **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:1099`) retire le fragment et
506
506
  reconstruit le CSP de base. C'est ce que fait `@nodefony/frontend` à l'arrêt du serveur Vite.
507
507
  - **Idempotent** : la reconstruction repart **toujours** du `headers.csp` d'origine
508
- (`firewall.ts:1104`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
508
+ (`firewall.ts:1113`), jamais d'un CSP déjà fusionné — pas d'accumulation entre deux
509
509
  enregistrements.
510
510
 
511
511
  L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
@@ -523,8 +523,8 @@ en dev soit plus large qu'en production, où ce fragment n'existe pas.
523
523
  | Champ structuré booléen | RFC 8941 | `Origin-Agent-Cluster: ?1` (`securityHeaders.ts:75`) |
524
524
  | Referrer-Policy | W3C Referrer Policy (enum fermé) | 8 valeurs validées au boot (`config.ts:267`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
- | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
527
- | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:335`) |
526
+ | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:986`) |
527
+ | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:348`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
530
530
 
@@ -550,7 +550,7 @@ dans le périmètre du gate mémoire, qu'il ne peut structurellement pas dégrad
550
550
  L'écran **Firewall** de Studio affiche la section « En-têtes de sécurité » — pilotée par
551
551
  `headers.enabled` (`FirewallDefenses.tsx:219`) — avec le CSP effectif, l'état du nonce par requête, la
552
552
  Referrer-Policy et les valeurs d'isolation. Les données
553
- viennent de `Firewall.describe()` (`firewall.ts:549`), qui projette la config **sans aucun secret**,
553
+ viennent de `Firewall.describe()` (`firewall.ts:555`), qui projette la config **sans aucun secret**,
554
554
  exposée par `GET /nodefony/security/api/firewall`.
555
555
 
556
556
  L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Zod — chaque champ y
package/docs/index.md CHANGED
@@ -126,7 +126,9 @@ flowchart LR
126
126
  ```
127
127
 
128
128
  Le module consomme `@nodefony/user` (jamais l'inverse) et n'importe `@nodefony/http` / `framework`
129
- qu'en **type-only** — le couplage runtime resterait une dette.
129
+ qu'en **type-only**, à une exception près : `schemaMismatchOf` (`webhooks.ts:49`), importé de
130
+ `@nodefony/http` pour reconnaître une base au schéma en retard avec la même règle que le cœur HTTP,
131
+ plutôt qu'avec une copie.
130
132
 
131
133
  ## 🧰 Surface publique
132
134
 
package/docs/oauth2.md CHANGED
@@ -146,7 +146,7 @@ c'est l'authenticator `session` qui identifie chaque requête, comme après un m
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
147
  dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
148
148
  l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
149
- mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:290`) : c'est là, une seule fois par
149
+ mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
150
150
  processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
151
151
  que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
152
152
  est **404**, pas « désactivée ».
@@ -287,7 +287,7 @@ défense :
287
287
  1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
288
288
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
289
289
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
290
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:174-182`). Un seul écart → `302` vers
290
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
291
291
  `failureRedirect`, **sans jamais contacter le fournisseur** ;
292
292
  3. seulement ensuite, `exchangeAndProvision()`.
293
293
 
package/docs/tokens.md CHANGED
@@ -250,9 +250,9 @@ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`).
250
250
  1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
251
251
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
252
252
  famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
253
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:594`).
253
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts:711`).
254
254
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:752`).
255
+ `#resolveUserForRefresh()` (`tokenService.ts:770`).
256
256
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
257
257
  (`tokenService.ts:600`).
258
258
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
@@ -359,7 +359,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
359
359
  ### `redis` — cluster, TTL natif
360
360
 
361
361
  - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
362
- - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:308`) — l'expiration ne
362
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:41`) — l'expiration ne
363
363
  dépend pas du gc.
364
364
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
365
365
  (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
@@ -390,7 +390,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
390
390
  - `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
391
391
  (`ITokenStore.ts:222`).
392
392
  - Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
393
- (`apiKeys.ts:216`), jamais un listAll matérialisé.
393
+ (`apiKeys.ts:209`), jamais un listAll matérialisé.
394
394
 
395
395
  ### Révoquer — trois portées
396
396
 
@@ -429,7 +429,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
429
429
  | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
430
430
  | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
431
431
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
432
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:390`) |
432
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:396`) |
433
433
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
434
434
  | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
435
435
  | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
@@ -458,7 +458,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
458
458
 
459
459
  ## ⚡ Performance & mémoire
460
460
 
461
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:726`)
461
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
462
462
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
463
463
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
464
464
  vérification (hot path) vit chez le `JwtAuthenticator`.
package/docs/webauthn.md CHANGED
@@ -54,7 +54,7 @@ flowchart LR
54
54
  Le défi vit **hors du service**, en session BFF, posé par le controller
55
55
  (`WebAuthnController.registerOptions()`, `WebAuthnController.ts:113`). Chaque `verify*` reçoit
56
56
  l'`expectedChallenge` qu'il a émis, et le controller **l'invalide dès sa lecture**
57
- (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`) : un défi ne sert qu'une fois.
57
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`) : un défi ne sert qu'une fois.
58
58
 
59
59
  ## 📖 Lexique
60
60
 
@@ -90,7 +90,7 @@ Quatre failles fermées **par construction** :
90
90
  2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
91
91
  `IWebAuthnCredential.ts:16`). Une base volée ne donne aucun accès.
92
92
  3. **Rejeu** — le défi est à usage unique, invalidé en session dès sa lecture
93
- (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`).
93
+ (`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`).
94
94
  4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
95
95
  (`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts:22`).
96
96
 
@@ -114,7 +114,7 @@ Trois partis pris assumés :
114
114
  - **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts:75`) :
115
115
  mémoire par défaut, ORM ou Redis en production, avec le **même banc de contrat** pour tous.
116
116
  - **Les endpoints sont fournis**, pas à réécrire (`mountWebAuthnRoutes()`,
117
- `WebAuthnController.ts:308`) : c'est là que vivent les gardes délicates (session du défi, usage
117
+ `WebAuthnController.ts:314`) : c'est là que vivent les gardes délicates (session du défi, usage
118
118
  unique, messages uniformes, anti-IDOR).
119
119
 
120
120
  ## 🚀 Démarrage rapide
@@ -155,7 +155,7 @@ export default defineConfig(() => ({
155
155
  ### 2. Les endpoints BFF sont FOURNIS
156
156
 
157
157
  Dès que `@nodefony/security` est chargé, le framework monte six routes
158
- (`mountWebAuthnRoutes()`, `WebAuthnController.ts:308`) :
158
+ (`mountWebAuthnRoutes()`, `WebAuthnController.ts:314`) :
159
159
 
160
160
  | Route (`POST` sauf mention) | Rôle | Firewall |
161
161
  | -------------------------------------------------- | --------------------------------------------- | ------------------------- |
@@ -170,7 +170,7 @@ Dès que `@nodefony/security` est chargé, le framework monte six routes
170
170
  > Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:365`) : elles
171
171
  > **sont** le mécanisme d'authentification — les protéger exigerait d'être déjà connecté pour se
172
172
  > connecter. Le contrôle d'accès de `register/*` est fait **dans le controller**
173
- > (`flow.me()` → 401, `WebAuthnController.ts:106`). Les deux routes self-service, elles, restent dans
173
+ > (`flow.me()` → 401, `WebAuthnController.ts:119`). Les deux routes self-service, elles, restent dans
174
174
  > la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts:314`).
175
175
 
176
176
  ### 3. Ce que TU écris : l'appel navigateur
@@ -340,7 +340,7 @@ controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
340
340
  Deux chemins, deux portées :
341
341
 
342
342
  - **Self-service** : `DELETE …/webauthn/credentials/{id}` → `WebAuthnService.removeUserCredential()`
343
- (`webAuthn.ts:507`). La suppression n'aboutit que si le credential **appartient** au demandeur
343
+ (`webAuthn.ts:502`). La suppression n'aboutit que si le credential **appartient** au demandeur
344
344
  (`webAuthn.ts:513`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
345
345
  jamais l'existence de la passkey d'autrui.
346
346
  - **Reset administrateur** : `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}`
@@ -361,7 +361,7 @@ Table dérivée du schéma Zod `passkeysSchema` (`config.ts:461`), monté sous l
361
361
  | `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:463`) |
362
362
  | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:469`) |
363
363
  | `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:473`) |
364
- | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:479`) |
364
+ | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:469`) |
365
365
  | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:483`) |
366
366
  | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:489`) |
367
367
  | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:495`) |
@@ -551,7 +551,7 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
551
551
  - `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
552
552
  (`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
553
553
  - `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:181`) ; le service
554
- déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:257`).
554
+ déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:47`).
555
555
 
556
556
  ### `drizzle` — SQL, le durable par défaut
557
557
 
@@ -611,8 +611,8 @@ symbolique (`.ai/symbols.json`).
611
611
  | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
612
612
  | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
613
613
  | `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:490`) |
614
- | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:507`) |
615
- | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:496`) |
614
+ | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
615
+ | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:492`) |
616
616
 
617
617
  Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
618
618
  `IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
@@ -644,7 +644,7 @@ doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532
644
644
  backend sans comptage (Redis).
645
645
 
646
646
  Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
647
- passkey (`AuthStore.ts:209`).
647
+ passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:212`).
648
648
 
649
649
  ## 📜 Normes appliquées
650
650
 
@@ -658,7 +658,7 @@ passkey (`AuthStore.ts:209`).
658
658
  | Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
659
659
  | FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
660
660
  | Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:483`) |
661
- | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:507`) |
661
+ | Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:502`) |
662
662
 
663
663
  La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
664
664
  ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
@@ -673,7 +673,7 @@ requête. Le code en tire trois conséquences.
673
673
  réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — une app qui n'enrôle personne ne paie
674
674
  jamais son coût de parse.
675
675
  - **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
676
- (`webAuthn.ts:129-131`) — pas de store, pas de `Map`.
676
+ (`webAuthn.ts:119-130`) — pas de store, pas de `Map`.
677
677
  - **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
678
678
  store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
679
679
  `maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).