@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/LICENSE +201 -543
- package/README.md +1 -1
- package/dist/index.js +2 -0
- package/dist/nodefony/command/security-user-add.js +19 -3
- package/dist/nodefony/command/security-user-password.js +111 -0
- package/dist/nodefony/src/token/JwtKeystore.js +37 -1
- package/dist/types/nodefony/command/security-user-add.d.ts +16 -0
- package/dist/types/nodefony/command/security-user-password.d.ts +31 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +20 -0
- package/docs/audit.md +5 -5
- package/docs/authenticators.md +10 -10
- package/docs/authorization.md +2 -2
- package/docs/cors.md +4 -4
- package/docs/csrf.md +5 -5
- package/docs/firewall.md +7 -7
- package/docs/headers.md +7 -7
- package/docs/index.md +24 -0
- package/docs/oauth2.md +28 -28
- package/docs/tokens.md +10 -9
- package/docs/totp.md +1 -1
- package/docs/webauthn.md +3 -3
- package/docs/webhooks.md +1 -1
- package/package.json +12 -12
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
37
|
-
> `OAuth2Controller` (`OAuth2Controller.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
211
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
295
|
-
`oauth2.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
482
|
-
(`config.ts:
|
|
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:
|
|
488
|
-
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
288
|
+
refresh invalidés, incohérente en cluster (`JwtKeystore.ts:179-186`).
|
|
289
289
|
|
|
290
|
-
Le JWKS servi par `getPublicJWKS()`
|
|
291
|
-
`d` est retirée à l'import par
|
|
292
|
-
|
|
293
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
50
|
+
"zod": "^4.6.1"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
54
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
55
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
56
|
-
"@types/node": "26.
|
|
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.
|
|
58
|
+
"nodefony": "^10.0.0-alpha.5",
|
|
59
59
|
"rimraf": "6.1.3",
|
|
60
60
|
"vitest": "5.0.0"
|
|
61
61
|
},
|
|
62
|
-
"license": "
|
|
62
|
+
"license": "Apache-2.0",
|
|
63
63
|
"readmeFilename": "README.md",
|
|
64
64
|
"contributors": [],
|
|
65
65
|
"peerDependencies": {
|
|
66
|
-
"@nodefony/framework": "^10.0.0-alpha.
|
|
67
|
-
"@nodefony/http": "^10.0.0-alpha.
|
|
68
|
-
"@nodefony/user": "^10.0.0-alpha.
|
|
69
|
-
"nodefony": "^10.0.0-alpha.
|
|
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",
|