@nodefony/security 10.0.0-alpha.4 → 10.0.0-alpha.5

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.
package/docs/headers.md CHANGED
@@ -39,7 +39,7 @@ source: "src/packages/@nodefony/security/docs/headers.md"
39
39
  > (`@nodefony/http`, dès l'entrée brute — couvre aussi les fichiers statiques et les erreurs) et la
40
40
  > couche **applicative** (`@nodefony/security`, dans le pipeline — CSP, Referrer-Policy, isolation
41
41
  > cross-origin). Ancré sur `SecurityHeaders` (`securityHeaders.ts:42`) et
42
- > `Firewall.applySecurityHeaders()` (`firewall.ts:1029`).
42
+ > `Firewall.applySecurityHeaders()` (`firewall.ts:1045`).
43
43
 
44
44
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **En-têtes de sécurité**
45
45
 
@@ -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:1334`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
290
+ (`http-kernel.ts:963`). 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
 
@@ -452,7 +452,7 @@ rester imprévisible, jamais pilotable par le client — contrairement au `reque
452
452
  une corrélation entrante.
453
453
 
454
454
  **Placement dans le pipeline** : `applySecurityHeaders` est appelé **après le resolve** et **avant**
455
- le repli statique et le `writeHead` (`http-kernel.ts:1334`). Cet ordre n'est pas cosmétique : il
455
+ le repli statique et le `writeHead` (`http-kernel.ts:1372`). Cet ordre n'est pas cosmétique : il
456
456
  faut que le routeur ait posé les directives `@Csp` de la route pour pouvoir les fusionner, et il faut
457
457
  être avant l'écriture des en-têtes pour pouvoir en poser.
458
458
 
@@ -510,7 +510,7 @@ Trois propriétés à retenir :
510
510
 
511
511
  L'exemple de référence vit dans le framework : en développement, `@nodefony/frontend` déclare les
512
512
  origines du serveur Vite et `'unsafe-eval'` (exigé par le Fast Refresh de React) via
513
- `FrontendService.#viteCspFragment()` (`FrontendService.ts:909`) — ce qui explique qu'un CSP observé
513
+ `FrontendService.#viteCspFragment()` (`FrontendService.ts:1012`) — ce qui explique qu'un CSP observé
514
514
  en dev soit plus large qu'en production, où ce fragment n'existe pas.
515
515
 
516
516
  ## 📜 Normes appliquées
@@ -523,7 +523,7 @@ 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:239`) |
525
525
  | Isolation cross-origin | WHATWG HTML (COOP/COEP/CORP) | `securityHeaders.ts:71` |
526
- | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1334`) |
526
+ | Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:963`) |
527
527
  | Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:330`) |
528
528
 
529
529
  ## ⚡ Performance & mémoire
@@ -539,7 +539,7 @@ Le coût est concentré au boot, par construction :
539
539
  protège en plus les chemins internes qui n'atteignent jamais le firewall.
540
540
  - **Merge CSP** : jamais dans le chemin chaud. Le fragment d'un module est fusionné à
541
541
  l'enregistrement (`firewall.ts:1067`) ; celui d'une route ne coûte que sur les routes `@Csp`.
542
- - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:1334`), avec
542
+ - **Socle transport** : trois `setHeader` sur des chaînes précalculées (`http-kernel.ts:958-966`), avec
543
543
  un test `!== null` qui annule le coût des en-têtes désactivés.
544
544
 
545
545
  Le module n'attache aucun écouteur d'événement et ne conserve aucun état par requête : il n'entre pas
@@ -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:505`), qui projette la config **sans aucun secret**,
553
+ viennent de `Firewall.describe()` (`firewall.ts:549`), 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
@@ -138,6 +138,30 @@ Services `Firewall`, `AuthFlow`, `TokenService`, `ApiKeyService`, `Authorization
138
138
  Les signatures exactes vivent dans le graphe généré — `jq '.symbols.Firewall' .ai/symbols.json` —
139
139
  jamais recopiées ici (elles divergeraient).
140
140
 
141
+ ## 🛠️ Gérer les comptes en exploitation
142
+
143
+ Sur un serveur, la console d'administration suppose d'y être déjà entré — ce qui est précisément
144
+ impossible quand on a perdu le mot de passe. Ces commandes sont l'autre porte, et elles s'exécutent
145
+ sans ouvrir de port (profil console) :
146
+
147
+ <!-- prettier-ignore -->
148
+ | Commande | Ce qu'elle fait |
149
+ | --- | --- |
150
+ | `nodefony security:user:add <id> [--admin]` | crée un compte, puis dit comment s'authentifier |
151
+ | `nodefony security:user:list [-q <motif>]` | identifiant, rôles, état |
152
+ | `nodefony security:user:password <id>` | change le mot de passe — **et révoque sessions et jetons** |
153
+ | `nodefony security:user:delete <id>` | supprime, après confirmation ; refuse le dernier administrateur |
154
+ | `nodefony security:secrets [--write]` | engendre les clés attendues et guide leur câblage |
155
+ | `nodefony security:token` | émet un jeton d'accès pour la porte MCP |
156
+
157
+ En terminal, le mot de passe est **demandé masqué** et confirmé ; pour un script, `--password <pwd>`
158
+ l'accepte en argument — au prix de l'historique du shell, que la commande rappelle.
159
+
160
+ Deux garde-fous qui se constatent plutôt qu'ils ne se supposent : le **dernier administrateur actif
161
+ ne se supprime pas** (`security-user-delete.ts:101`), et un changement de mot de passe **éjecte les
162
+ accès en cours** par la même cascade que la suppression (`userRevocationCascade.ts:37`) — on change
163
+ un mot de passe parce qu'il est perdu ou compromis.
164
+
141
165
  ## ⚙️ Configuration
142
166
 
143
167
  Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
package/docs/oauth2.md CHANGED
@@ -33,8 +33,8 @@ source: "src/packages/@nodefony/security/docs/oauth2.md"
33
33
  > connecté à **ton** application. Nodefony orchestre ce voyage avec la posture **OAuth 2.1**
34
34
  > (RFC 9700) : Authorization Code, PKCE, `state` anti-CSRF, `iss` anti-mix-up. Point clé :
35
35
  > **aucun jeton n'atteint le navigateur** — le retour produit une **session BFF**, exactement la même
36
- > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:55`) et le controller BFF
37
- > `OAuth2Controller` (`OAuth2Controller.ts:81`).
36
+ > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:116`) et le controller BFF
37
+ > `OAuth2Controller` (`OAuth2Controller.ts:89`).
38
38
 
39
39
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
40
40
 
@@ -114,13 +114,13 @@ qui les ferme ici :
114
114
  de session opaque (`OAuth2Controller.ts:155`).
115
115
  - **Interception du `code`** — un `code` capté (log de proxy, historique, redirection ouverte) est
116
116
  échangeable par l'attaquant. _Fermé par **PKCE**_ : l'échange exige le `code_verifier` resté en
117
- session (`OAuth2Service.createAuthorization()`, `oauth2.ts:143-145`).
117
+ session (`OAuth2Service.createAuthorization()`, `oauth2.ts:232-238`).
118
118
  - **CSRF de login** — un tiers force ta victime à terminer **son** flux à lui : elle se retrouve
119
119
  connectée sur le compte de l'attaquant, qui lit ensuite ce qu'elle y dépose. _Fermé par le `state`_
120
120
  comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts:137-145`).
121
121
  - **Mix-up d'IdP** — un `code` obtenu chez un fournisseur malveillant est présenté au callback d'un
122
122
  fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:170-174`) **et** par
123
- l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:142`).
123
+ l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
124
124
 
125
125
  > [!IMPORTANT]
126
126
  > Le fournisseur social te dit **qui** est la personne. Il ne te dit **rien** de ses droits.
@@ -146,14 +146,14 @@ 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:207`), 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:190`) : c'est là, une seule fois par
149
+ mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:290`) : 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:379`) : sans social login configuré, la surface HTTP
152
152
  est **404**, pas « désactivée ».
153
153
 
154
154
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
155
155
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
156
- configurés à `listOAuthProviders()` (`oauth2.ts:86-95`) et le
156
+ configurés à `listOAuthProviders()` (`oauth2.ts:135-155`) et le
157
157
  reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
158
158
 
159
159
  ## 🚀 Démarrage rapide
@@ -207,8 +207,8 @@ export default defineConfig<typeof env>((ctx) => ({
207
207
 
208
208
  ### Les routes sont FOURNIES — tu n'écris aucun controller
209
209
 
210
- `mountOAuth2Routes()` (`OAuth2Controller.ts:208`) monte trois routes sous
211
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:187`), et **seulement si** le service `oauth2`
210
+ `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
211
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:234`), et **seulement si** le service `oauth2`
212
212
  est présent (`framework/index.ts:379`) :
213
213
 
214
214
  | Route | Rôle |
@@ -226,7 +226,7 @@ Ton écran de login n'a donc qu'un lien à poser :
226
226
  ```
227
227
 
228
228
  > [!WARNING]
229
- > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:236`) — elles **sont** le mécanisme
229
+ > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:262`) — elles **sont** le mécanisme
230
230
  > d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
231
231
  > un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
232
232
  > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
@@ -261,11 +261,11 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
261
261
 
262
262
  ### Étape 1 — `createAuthorization(provider)`
263
263
 
264
- `OAuth2Service.createAuthorization()` (`oauth2.ts:139`) fabrique trois choses :
264
+ `OAuth2Service.createAuthorization()` (`oauth2.ts:232`) fabrique trois choses :
265
265
 
266
266
  1. un **`state`** aléatoire (anti-CSRF) ;
267
267
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
268
- `oauth2.ts:143-145`) ; `null` sinon (GitHub) ;
268
+ `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
269
269
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
270
270
  (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:216`).
271
271
 
@@ -274,7 +274,7 @@ mémoire, `OAuth2Controller.ts:105-108`), puis redirige en 302.
274
274
 
275
275
  ### Étape 2 — le retour, validé avant tout appel réseau
276
276
 
277
- `OAuth2Controller.callback()` (`OAuth2Controller.ts:132`) travaille dans cet ordre, et l'ordre est la
277
+ `OAuth2Controller.callback()` (`OAuth2Controller.ts:150`) travaille dans cet ordre, et l'ordre est la
278
278
  défense :
279
279
 
280
280
  1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:126-129`) — le
@@ -286,15 +286,15 @@ défense :
286
286
 
287
287
  ### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
288
288
 
289
- `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:162`) enchaîne :
289
+ `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
290
290
 
291
291
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
292
292
  et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:170-174`) ;
293
293
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
294
- (`validateAuthorizationCode`, `oauth2.ts:181`), puis lecture du profil (`fetchProfile`,
295
- `oauth2.ts:176`) ;
294
+ (`validateAuthorizationCode`, `oauth2.ts:256`), puis lecture du profil (`fetchProfile`,
295
+ `oauth2.ts:275`) ;
296
296
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
297
- **par fournisseur** (`oauth2.ts:180-181`), `allowSignup` global (`oauth2.ts:182-185`).
297
+ **par fournisseur** (`oauth2.ts:279-280`), `allowSignup` global (`oauth2.ts:283`).
298
298
 
299
299
  Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
300
300
  failureRedirect`, `OAuth2Controller.ts:157-160`) : le client ne distingue pas un `iss` invalide d'un
@@ -337,7 +337,7 @@ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`Us
337
337
  `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:348`). Un second login
338
338
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
339
339
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
340
- authentification, pas autorisation » (`oauth2.ts:178-181`, `config.ts:822-827`).
340
+ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1042-1046`).
341
341
 
342
342
  > [!TIP]
343
343
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -347,7 +347,7 @@ authentification, pas autorisation » (`oauth2.ts:178-181`, `config.ts:822-827`)
347
347
  ### Brancher sa propre politique
348
348
 
349
349
  Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
350
- (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:224-231`). S'il ne l'implémente pas, le login
350
+ (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:327-333`). S'il ne l'implémente pas, le login
351
351
  **échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
352
352
  (quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
353
353
  `provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
@@ -478,17 +478,17 @@ que le mapping du profil. Exemple sans réseau dans le dépôt :
478
478
 
479
479
  ## ⚙️ Configuration
480
480
 
481
- Section `oauth2` du schéma Zod (`config.ts:990`), branchée sur la config du module
482
- (`config.ts:990`). Table dérivée du schéma — les défauts sont ceux du code.
481
+ Section `oauth2` du schéma Zod (`config.ts:1034`), branchée sur la config du module
482
+ (`config.ts:1149`). Table dérivée du schéma — les défauts sont ceux du code.
483
483
 
484
484
  | Option | Type | Défaut | Effet |
485
485
  | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
486
486
  | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
487
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:989`). |
488
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1015`). |
487
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1042`). |
488
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1048`). |
489
489
  | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
490
490
  | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
491
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1031`). |
491
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1064`). |
492
492
 
493
493
  Par fournisseur (`oauthProviderSchema`, `config.ts:948`) :
494
494
 
@@ -500,7 +500,7 @@ Par fournisseur (`oauthProviderSchema`, `config.ts:948`) :
500
500
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
501
501
  | `clientAuthMethod` | | Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). Omis = `client_secret_basic`, ce que la RFC demande de préférer. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans `token_endpoint_auth_methods_supported`. |
502
502
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
503
- | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:124-131`). |
503
+ | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:218-222`). |
504
504
 
505
505
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
506
506
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -510,7 +510,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
510
510
  ### Les jetons du fournisseur ne sont pas conservés
511
511
 
512
512
  C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
513
- portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:181-182`) :
513
+ portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:274-275`) :
514
514
  ils ne sont ni retournés, ni mis en
515
515
  session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
516
516
  (`IOAuthUserProvisioner.ts:8-10`).
@@ -556,7 +556,7 @@ ou détruire les sessions), pas chez le fournisseur.
556
556
  | --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
557
557
  | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
558
558
  | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
559
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:56`) · `oauth2Schema` (`config.ts:1001`) |
559
+ | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1034`) |
560
560
  | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:61`) · `oauth2.ts:170-181` |
561
561
  | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:958`) |
562
562
  | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
@@ -610,7 +610,7 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
610
610
  | Symptôme | Cause (dans le code) | Correction |
611
611
  | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
612
612
  | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
613
- | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:86-95`) | `registerOAuthProvider()` au chargement du module, ou builtin |
613
+ | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
614
614
  | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:97`) | Vérifier le nom exact **et** la présence des secrets |
615
615
  | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
616
616
  | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:958`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
@@ -618,7 +618,7 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
618
618
  | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:126-129`) | Refaire le flux depuis `authorize` — comportement attendu |
619
619
  | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:170-181`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
620
620
  | Keycloak : erreur dès le premier login | `issuer` absent en config (`oauthProviderRegistry.ts:89-93`) | Renseigner l'URL du realm |
621
- | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:224-231`) | Implémenter `provisionOAuthUser()` sur le service `users` |
621
+ | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:327-333`) | Implémenter `provisionOAuthUser()` sur le service `users` |
622
622
  | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:363`) | Activer `allowSignup` ou lier le compte au préalable |
623
623
  | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
624
624
  | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`UserService.ts:348`) | Modifier les rôles en base ; `defaultRoles` ne réécrit rien |
package/docs/tokens.md CHANGED
@@ -277,24 +277,25 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
277
277
 
278
278
  ## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
279
279
 
280
- `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:97-128`), pensée
280
+ `JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:151-187`), pensée
281
281
  pour ne jamais auto-générer une clé en clair silencieusement en prod :
282
282
 
283
283
  1. **env** — `keySetJson` (JWK Set injecté depuis le catalogue d'env) : prod cloud, secret géré
284
- hors-app, même clé sur tous les pods (`JwtKeystore.ts:100-106`).
284
+ hors-app, même clé sur tous les pods (`JwtKeystore.ts:154-160`).
285
285
  2. **fichier** — `dir/keyset.json`, généré si absent, écriture atomique tmp+rename en mode 600 —
286
- `#writeAtomic()` (`JwtKeystore.ts:208-217`) : opt-in dev/VPS mono-machine.
286
+ `#writeAtomic()` (`JwtKeystore.ts:272-280`) : opt-in dev/VPS mono-machine.
287
287
  3. **mémoire** — aucune source → clé **éphémère + WARNING** explicite : perdue au redémarrage =
288
- refresh invalidés, incohérente en cluster (`JwtKeystore.ts:121-127`).
288
+ refresh invalidés, incohérente en cluster (`JwtKeystore.ts:179-186`).
289
289
 
290
- Le JWKS servi par `getPublicJWKS()` (`JwtKeystore.ts:87-90`) est **public** : la composante privée
291
- `d` est retirée à l'import par `#importKeyset()` (`JwtKeystore.ts:156-158`, RFC 8037/7517) — c'est
292
- lui qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`), jamais
293
- une clé venue du token. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:93-95`).
290
+ Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts:141-145`.
291
+ La composante privée `d` en est retirée à l'import, par liste BLANCHE de paramètres
292
+ (`#importKeyset()`, `JwtKeystore.ts:206-228`, RFC 8037/7517).
293
+ C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`),
294
+ jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:147-149`).
294
295
 
295
296
  > [!WARNING]
296
297
  > **Race au 1ᵉʳ boot d'un cluster sans clé pré-provisionnée** : deux workers peuvent générer des
297
- > clés différentes — le dernier `rename` gagne (`JwtKeystore.ts:61-64`). En prod, provisionner
298
+ > clés différentes — le dernier `rename` gagne (`JwtKeystore.ts:272-280`). En prod, provisionner
298
299
  > `keySetJson` hors-bande élimine ce cas : c'est la source recommandée.
299
300
 
300
301
  ## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
package/docs/totp.md CHANGED
@@ -452,7 +452,7 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
452
452
 
453
453
  ## ⚙️ Configuration et mises en situation
454
454
 
455
- La section `totp` du schéma Zod (`config.ts:1107`) — validée au boot, donc une valeur hors bornes
455
+ La section `totp` du schéma Zod (`config.ts:1138`) — validée au boot, donc une valeur hors bornes
456
456
  échoue **au démarrage**, pas au premier login :
457
457
 
458
458
  | Option | Type | Défaut | Effet |
package/docs/webauthn.md CHANGED
@@ -121,7 +121,7 @@ Trois partis pris assumés :
121
121
 
122
122
  ### 1. Les passkeys sont déjà actives — la config utile
123
123
 
124
- `passkeys.enabled` vaut `true` par défaut (`config.ts:1106`). Ce que tu déclares vraiment, c'est **ton
124
+ `passkeys.enabled` vaut `true` par défaut (`config.ts:1137`). Ce que tu déclares vraiment, c'est **ton
125
125
  domaine** : sans `rpId`, le service prend le domaine de l'app, et bascule sur `localhost` si c'est une
126
126
  adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:134`).
127
127
 
@@ -285,7 +285,7 @@ sequenceDiagram
285
285
 
286
286
  `WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:276`) construit le défi et les
287
287
  contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:291`) : le même
288
- authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:261`),
288
+ authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:299`),
289
289
  `authenticatorAttachment` n'est transmis **que** s'il vaut autre chose que `"any"` — `"any"` rend la
290
290
  main au navigateur, téléphone par QR compris.
291
291
 
@@ -354,7 +354,7 @@ exactement les porteurs à risque de verrouillage.
354
354
  ## ⚙️ Configuration
355
355
 
356
356
  Table dérivée du schéma Zod `passkeysSchema` (`config.ts:447`), monté sous la clé `passkeys`
357
- (`config.ts:1106`).
357
+ (`config.ts:1137`).
358
358
 
359
359
  | Option | Type | Défaut | Effet |
360
360
  | ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
package/docs/webhooks.md CHANGED
@@ -824,7 +824,7 @@ mention.
824
824
  | `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts:468` |
825
825
  | `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts:456` |
826
826
  | `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts:510` |
827
- | `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:414` |
827
+ | `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:458` |
828
828
  | `setEnabled(id, bool)` | Révocation douce | `webhooks.ts:542` |
829
829
  | `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts:553` |
830
830
  | `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts:572` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodefony/security",
3
- "version": "10.0.0-alpha.4",
3
+ "version": "10.0.0-alpha.5",
4
4
  "description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
5
5
  "author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
6
6
  "main": "./dist/index.js",
@@ -47,26 +47,26 @@
47
47
  "@simplewebauthn/server": "14.0.1",
48
48
  "jose": "6.2.12",
49
49
  "tslib": "2.8.1",
50
- "zod": "^4.4.3"
50
+ "zod": "^4.6.1"
51
51
  },
52
52
  "devDependencies": {
53
- "@nodefony/framework": "^10.0.0-alpha.4",
54
- "@nodefony/http": "^10.0.0-alpha.4",
55
- "@nodefony/user": "^10.0.0-alpha.4",
56
- "@types/node": "26.4.1",
53
+ "@nodefony/framework": "^10.0.0-alpha.5",
54
+ "@nodefony/http": "^10.0.0-alpha.5",
55
+ "@nodefony/user": "^10.0.0-alpha.5",
56
+ "@types/node": "26.5.1",
57
57
  "@vitest/coverage-v8": "5.0.0",
58
- "nodefony": "^10.0.0-alpha.4",
58
+ "nodefony": "^10.0.0-alpha.5",
59
59
  "rimraf": "6.1.3",
60
60
  "vitest": "5.0.0"
61
61
  },
62
- "license": "CECILL-B",
62
+ "license": "Apache-2.0",
63
63
  "readmeFilename": "README.md",
64
64
  "contributors": [],
65
65
  "peerDependencies": {
66
- "@nodefony/framework": "^10.0.0-alpha.4",
67
- "@nodefony/http": "^10.0.0-alpha.4",
68
- "@nodefony/user": "^10.0.0-alpha.4",
69
- "nodefony": "^10.0.0-alpha.4"
66
+ "@nodefony/framework": "^10.0.0-alpha.5",
67
+ "@nodefony/http": "^10.0.0-alpha.5",
68
+ "@nodefony/user": "^10.0.0-alpha.5",
69
+ "nodefony": "^10.0.0-alpha.5"
70
70
  },
71
71
  "files": [
72
72
  "dist",