@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.2
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/README.md +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +13 -2
- package/dist/nodefony/service/tokenService.js +27 -18
- package/dist/nodefony/service/webAuthn.js +38 -31
- package/dist/nodefony/service/webhooks.js +50 -41
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -1
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +7 -6
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/docs/api-keys.md +16 -16
- package/docs/audit.md +27 -20
- package/docs/authenticators.md +8 -8
- package/docs/authorization.md +15 -15
- package/docs/cors.md +7 -7
- package/docs/csrf.md +8 -8
- package/docs/external-jwt.md +19 -10
- package/docs/firewall.md +28 -28
- package/docs/headers.md +20 -20
- package/docs/index.md +6 -4
- package/docs/lexique.md +15 -15
- package/docs/oauth2.md +23 -20
- package/docs/tokens.md +11 -10
- package/docs/totp.md +15 -14
- package/docs/webauthn.md +51 -54
- package/docs/webhooks.md +27 -30
- package/package.json +13 -13
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:
|
|
56
|
-
`Firewall.matchPath()` (`firewall.ts:
|
|
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
|
|
|
@@ -73,11 +73,11 @@ le plus long gagne, pas le premier déclaré (`firewall.ts:257`).
|
|
|
73
73
|
|
|
74
74
|
### Dans une app `nodefony create app`, le firewall est DÉJÀ actif
|
|
75
75
|
|
|
76
|
-
Le scaffold déclare
|
|
76
|
+
Le scaffold déclare trois zones (`main`, `secure`, `machine`) dans `nodefony/config/security.ts`, importé par `nodefony.config.ts` ; en voici deux — c'est la forme canonique (un **objet par
|
|
77
77
|
nom**, validé Zod au boot : `areas: z.record(...)`, `config.ts:1126-1127`) :
|
|
78
78
|
|
|
79
79
|
```typescript
|
|
80
|
-
// nodefony
|
|
80
|
+
// nodefony/config/security.ts (extrait généré par `nodefony create app`)
|
|
81
81
|
use("@nodefony/security", {
|
|
82
82
|
areas: {
|
|
83
83
|
// Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
|
|
@@ -151,7 +151,7 @@ curl -si http://localhost:5151/api/secure/account/me | head -1
|
|
|
151
151
|
|
|
152
152
|
# 2) Login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
|
|
153
153
|
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
|
|
154
|
-
-d '{"username":"admin","password":"
|
|
154
|
+
-d '{"username":"admin","password":"nodefony-dev-42"}' \
|
|
155
155
|
http://localhost:5151/nodefony/security/api/auth/login | head -1
|
|
156
156
|
# HTTP/1.1 200 OK
|
|
157
157
|
|
|
@@ -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:
|
|
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:
|
|
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 ? »)
|
|
@@ -463,12 +463,12 @@ inchangé : la zone n'exige qu'une identité.
|
|
|
463
463
|
C'est un **défaut**, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration
|
|
464
464
|
explicite du code — jamais un contournement :
|
|
465
465
|
|
|
466
|
-
| Ce qui décide à la place
|
|
467
|
-
|
|
|
468
|
-
| Une **garde
|
|
469
|
-
| Une route **`
|
|
470
|
-
| Un **`bypassFirewall`**
|
|
471
|
-
| L'**ouverture d'une connexion WebSocket**
|
|
466
|
+
| Ce qui décide à la place | Pourquoi |
|
|
467
|
+
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
468
|
+
| Une **garde de rôle** (`@IsGranted`) | L'action a déjà dit quel rôle elle exige (un `@RequireScope` seul ne dispense pas du rôle de zone). Sans cela, une page réservée à l'exploitant deviendrait inatteignable pour lui. |
|
|
469
|
+
| Une route **`areaRoleExempt`** | Elle tranche son autorisation dans son code — typiquement parce qu'elle ne sert QUE le porteur courant. |
|
|
470
|
+
| Un **`bypassFirewall`** | La route EST le mécanisme d'authentification : lui imposer un rôle serait un verrou dont la clé est à l'intérieur. |
|
|
471
|
+
| L'**ouverture d'une connexion WebSocket** | Une connexion n'accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte. |
|
|
472
472
|
|
|
473
473
|
Le dernier cas mérite d'être compris, parce qu'il pourrait passer pour un trou. Il n'en est pas un :
|
|
474
474
|
une frame `api.request {path}` **repasse par la même décision** que la requête HTTP équivalente.
|
|
@@ -477,7 +477,7 @@ que la porte HTTP refuse. Ce que la dispense évite, c'est qu'un compte sans rô
|
|
|
477
477
|
puisse plus ouvrir **aucune** socket, et perde du même coup le self-service dont il est le seul
|
|
478
478
|
destinataire.
|
|
479
479
|
|
|
480
|
-
> ⚠️ **`
|
|
480
|
+
> ⚠️ **`areaRoleExempt` n'est pas un interrupteur pour faire taire un refus.** Le poser sur une route
|
|
481
481
|
> qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce
|
|
482
482
|
> que fait le CODE de l'action : scoper au porteur courant, ou résoudre un rôle par point d'entrée.
|
|
483
483
|
> Dans le doute, une garde d'action explicite est toujours préférable.
|
|
@@ -505,7 +505,7 @@ registerVoterFactory(
|
|
|
505
505
|
// l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.
|
|
506
506
|
```
|
|
507
507
|
|
|
508
|
-
Le voter est **découvert automatiquement** par
|
|
508
|
+
Le voter est **découvert automatiquement** par le service `authorization` — aucun changement dans le
|
|
509
509
|
cœur (`registerVoterFactory()`, `voterRegistry.ts:40`). Pourquoi un registre et pas un scan DI des
|
|
510
510
|
`@injectable` : les interfaces TS sont **effacées à la compilation** — rien à scanner ; le registre
|
|
511
511
|
**est** le marqueur explicite (TSDoc du registre, `voterRegistry.ts:6-16`). Trois axes (rôles,
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
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` |
|
|
@@ -553,16 +553,16 @@ ne paie quasiment rien.
|
|
|
553
553
|
|
|
554
554
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
555
555
|
|
|
556
|
-
| Symptôme | Cause (dans le code) | Correction
|
|
557
|
-
| ---------------------------------------- | ------------------------------------------------------- |
|
|
558
|
-
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }`
|
|
559
|
-
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator
|
|
560
|
-
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu
|
|
561
|
-
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]`
|
|
562
|
-
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`)
|
|
563
|
-
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore`
|
|
564
|
-
| WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet
|
|
565
|
-
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque
|
|
556
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
557
|
+
| ---------------------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------- |
|
|
558
|
+
| Boot rejette la config (`areas`) | `areas` déclaré en **tableau** — c'est un objet par nom | `areas: { monNom: { pattern, authenticators } }` |
|
|
559
|
+
| Boot « authenticator inconnu » | Nom absent du registre (fail-closed) | Corriger le nom / enregistrer l'authenticator |
|
|
560
|
+
| 401 alors qu'un credential est envoyé | Mode `first` : credential invalide échoue sans fallback | Vérifier le format/authenticator attendu |
|
|
561
|
+
| Route « publique » ne voit jamais l'user | Hors zone, l'identité n'est **jamais** résolue | Couvrir la route par une zone `["session", "anonymous"]` |
|
|
562
|
+
| API : JWT et clé API se marchent dessus | — | Rien à faire : discriminés par la forme (`a.b.c` vs `prefix_…`) |
|
|
563
|
+
| JWT révoqué encore accepté | Auto-portage : révocation = état serveur | S'assurer que `tokenStore` porte la denylist/`invalidBefore` |
|
|
564
|
+
| WS : révocation pas immédiate | Jeton figé au handshake (asymétrie assumée) | Effet en une fenêtre (tick du hub + avant chaque `api.request`, fermeture 4001) |
|
|
565
|
+
| 429 au login | Throttle NIST (backoff par identifiant) | Respecter `Retry-After` ; attendu sous attaque |
|
|
566
566
|
|
|
567
567
|
## 📡 Observabilité — Studio
|
|
568
568
|
|
package/docs/headers.md
CHANGED
|
@@ -122,10 +122,10 @@ 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:
|
|
126
|
-
boot ; `onHttpRequest` (`http-kernel.ts:
|
|
125
|
+
(`http-kernel.ts:358`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
|
|
126
|
+
boot ; `onHttpRequest` (`http-kernel.ts:1009`) ne fait plus que trois `setHeader`.
|
|
127
127
|
- Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
|
|
128
|
-
(`Context.ts:253`) ne
|
|
128
|
+
(`Context.ts:253`) ne tire ses 128 bits (16 octets CSPRNG, en base64) d'un pool amorti (`randomFillSync`, 4 Ko pour 256 nonces) 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.
|
|
130
130
|
|
|
131
131
|
Le second parti pris est la **séparation d'autorité** décrite plus haut : un seul émetteur par
|
|
@@ -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:
|
|
278
|
+
(`http-kernel.ts:1049`) — 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:
|
|
290
|
+
(`http-kernel.ts:1046`). 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:
|
|
300
|
+
(`http-kernel.ts:358`) : `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:
|
|
303
|
+
de serveur (`http-kernel.ts:1052`). 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:
|
|
400
|
+
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:408`)
|
|
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:
|
|
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
|
|
@@ -446,13 +446,13 @@ Le chemin complet, sans surprise :
|
|
|
446
446
|
`<script nonce="…">` portent forcément la même valeur. C'est le motif employé par le contrôleur
|
|
447
447
|
de Studio (`StudioController.ts:62`).
|
|
448
448
|
|
|
449
|
-
`SecurityHeaders.hasNonce` (`securityHeaders.ts:
|
|
449
|
+
`SecurityHeaders.hasNonce` (`securityHeaders.ts:100`) est le drapeau qui décide du régime : à `false`,
|
|
450
450
|
pas une seule opération crypto. Il n'y a **aucun setter** pour `cspNonce` : un jeton serveur doit
|
|
451
451
|
rester imprévisible, jamais pilotable par le client — contrairement au `requestId`, qui, lui, accepte
|
|
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:1624`). 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
|
|
|
@@ -478,7 +478,7 @@ les tests fiables.
|
|
|
478
478
|
**Coût** : le merge d'un module est payé **une fois**, au (dés)enregistrement
|
|
479
479
|
(`Firewall.#rebuildSecurityHeaders()`, `firewall.ts:1101`), jamais par requête. Le merge d'une route
|
|
480
480
|
`@Csp` est payé **uniquement sur les routes décorées** (`SecurityHeaders.cspForExtra()`,
|
|
481
|
-
`securityHeaders.ts:
|
|
481
|
+
`securityHeaders.ts:131`) ; le cas courant reste le simple `join`.
|
|
482
482
|
|
|
483
483
|
## 🧩 Extension — déclarer un fragment CSP depuis son module
|
|
484
484
|
|
|
@@ -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:
|
|
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:
|
|
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:
|
|
527
|
-
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:
|
|
526
|
+
| Anti-MIME-sniffing | WHATWG Fetch (`nosniff`) | `secContentTypeOptions` (`http-kernel.ts:1046`) |
|
|
527
|
+
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:358`) |
|
|
528
528
|
|
|
529
529
|
## ⚡ Performance & mémoire
|
|
530
530
|
|
|
@@ -533,13 +533,13 @@ Le coût est concentré au boot, par construction :
|
|
|
533
533
|
- **En-têtes constants** : une seule table, gelée (`securityHeaders.ts:77`). Par requête, une boucle
|
|
534
534
|
`for…in` sur un objet de 1 à 6 entrées et autant de `setHeader`. Aucune allocation.
|
|
535
535
|
- **CSP statique** : rien de plus — la chaîne est dans la table.
|
|
536
|
-
- **CSP à nonce** :
|
|
537
|
-
n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:
|
|
536
|
+
- **CSP à nonce** : un tirage dans le pool plus une concaténation `préfixe + nonce + suffixe` par requête. C'est le seul coût variable, et il
|
|
537
|
+
n'existe **que** si le CSP porte un placeholder : `hasNonce` (`securityHeaders.ts:100`)
|
|
538
538
|
court-circuite entièrement ce chemin sinon. La paresse de `Context.cspNonce` (`Context.ts:253`)
|
|
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:1086`) ; 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:1047-1053`), 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: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
|
@@ -43,7 +43,7 @@ Quatre parcours selon ce que tu viens faire. L'ordre compte : chaque étape supp
|
|
|
43
43
|
**Je découvre la sécurité Nodefony** — comprendre le modèle avant de configurer quoi que ce soit.
|
|
44
44
|
|
|
45
45
|
1. [Firewall](firewall.md) — zones, Zero Trust, la chaîne de décision. **Tout part d'ici.**
|
|
46
|
-
2. [Authenticators](authenticators.md) — les
|
|
46
|
+
2. [Authenticators](authenticators.md) — les sept façons de prouver **qui** appelle.
|
|
47
47
|
3. [Autorisation](authorization.md) — rôles, scopes, voters : **ce qu'il a le droit** de faire.
|
|
48
48
|
4. [Jetons](tokens.md) — ce qui matérialise une identité prouvée, et comment on la révoque.
|
|
49
49
|
|
|
@@ -98,7 +98,7 @@ Le tableau pour choisir en cinq secondes ; les cards en dessous pour le détail.
|
|
|
98
98
|
"desc": "Le pare-feu applicatif : trois questions pour chaque requête — est-ce une zone protégée ? qui es-tu ? as-tu le droit ? Le chemin chaud (détecter) est séparé du chemin froid (décider), donc une route publique ne paie rien.",
|
|
99
99
|
"meta": "à lire en premier — toutes les autres briques s'y branchent" },
|
|
100
100
|
{ "icon": "🪪", "title": "authenticators", "href": "authenticators.md",
|
|
101
|
-
"desc": "Prouver qui appelle :
|
|
101
|
+
"desc": "Prouver qui appelle : sept stratégies au même contrat — session, userpassword, jwt, external-jwt, apikey, anonymous, firewall-realtime — qui se composent dans l'ordre au sein d'une zone.",
|
|
102
102
|
"meta": "commence par « Ordre et modes » : les pièges de configuration sont là" },
|
|
103
103
|
{ "icon": "⚖️", "title": "authorization", "href": "authorization.md",
|
|
104
104
|
"desc": "Décider des droits : rôles hiérarchisés, scopes, et voters métier qui portent le vrai pouvoir applicatif. Le jury vote, la stratégie tranche.",
|
|
@@ -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
|
|
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
|
|
|
@@ -165,7 +167,7 @@ un mot de passe parce qu'il est perdu ou compromis.
|
|
|
165
167
|
## ⚙️ Configuration
|
|
166
168
|
|
|
167
169
|
Un seul point d'entrée : `use("@nodefony/security", { … })` dans `nodefony.config.ts`, validé par Zod
|
|
168
|
-
au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `
|
|
170
|
+
au boot. Blocs : `areas` (zones + authenticators), `cors`, `csrf`, `headers`, `rateLimit`
|
|
169
171
|
(backoff NIST), puis un bloc par brique (`jwt`, `apiKeys`, `totp`, `webhooks`, `audit`, `passkeys`,
|
|
170
172
|
`oauth2`). **Chaque page de brique détaille son bloc**, avec une table dérivée du schéma.
|
|
171
173
|
|
package/docs/lexique.md
CHANGED
|
@@ -126,21 +126,21 @@ source: "src/packages/@nodefony/security/docs/lexique.md"
|
|
|
126
126
|
> L'**autorisation** (authz) répond « as-tu le DROIT de faire ça ? » (les voters, `@IsGranted`).
|
|
127
127
|
> On peut être authentifié ET refusé (un `user` connecté sur une route `ROLE_ADMIN` → **403**, pas 401).
|
|
128
128
|
|
|
129
|
-
| Sigle / terme
|
|
130
|
-
|
|
|
131
|
-
| **RBAC**
|
|
132
|
-
| **ABAC**
|
|
133
|
-
| **Voter**
|
|
134
|
-
| **GRANT / DENY / ABSTAIN**
|
|
135
|
-
| **`
|
|
136
|
-
| **Stratégie affirmative**
|
|
137
|
-
| **Veto (DENY)**
|
|
138
|
-
| **Default DENY**
|
|
139
|
-
| **Fail-closed (voter)**
|
|
140
|
-
| **`voterRegistry`**
|
|
141
|
-
| **`RoleVoter`**
|
|
142
|
-
| **Attribute**
|
|
143
|
-
| **Subject**
|
|
129
|
+
| Sigle / terme | Développé | En clair |
|
|
130
|
+
| ---------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
131
|
+
| **RBAC** | Role-Based Access Control | Droits par RÔLES (`ROLE_ADMIN` peut X). Simple, couvre 80 % des besoins. Niveau **A** chez Nodefony (`RoleVoter`). |
|
|
132
|
+
| **ABAC** | Attribute-Based Access Control | Droits par ATTRIBUTS contextuels (le **propriétaire** du document peut le modifier) → les voters reçoivent le `subject`. Niveau **C**. |
|
|
133
|
+
| **Voter** | « votant / juré » | Petit juge spécialisé : pour une décision donnée il rend **un** verdict parmi 3 (GRANT / DENY / ABSTAIN). On en empile autant qu'on veut (rôles, propriété, horaires…), chacun sur son domaine. |
|
|
134
|
+
| **GRANT / DENY / ABSTAIN** | accorde / refuse / s'abstient | Les 3 verdicts d'un voter. **ABSTAIN** = « pas mon rayon » (ne compte pas). C'est la combinaison des votes qui tranche, pas un seul. |
|
|
135
|
+
| **`Authorization.decide()`** | — | Le **juge en chef** (`authorization.ts`, J6) : interroge tous les voters et applique la stratégie de vote → un seul booléen GRANT/DENY. |
|
|
136
|
+
| **Stratégie affirmative** | — | « **un seul GRANT suffit** » (tant qu'aucun DENY) — la stratégie de `decide()`. Permissive sur l'accord, stricte sur le refus. |
|
|
137
|
+
| **Veto (DENY)** | — | « **un seul DENY refuse tout** », même si d'autres votent GRANT. Le refus l'emporte toujours (sécurité d'abord). |
|
|
138
|
+
| **Default DENY** | refus par défaut | Tous ABSTAIN, ou **zéro** voter, ou aucun GRANT → **refusé** (Zero Trust). Il faut un OUI explicite, le silence ne suffit pas. |
|
|
139
|
+
| **Fail-closed (voter)** | « échoue fermé » | Si un voter **plante** (throw) → compté comme DENY + log ERROR, jamais une 500 qui laisserait passer. L'erreur ne doit jamais ouvrir la porte (cf lexique général `fail-closed`). |
|
|
140
|
+
| **`voterRegistry`** | registre de voters | Fabrique pluggable (convention-frère de `authenticatorRegistry`) : on **ajoute** un voter sans toucher `decide()`. Le futur `PermissionVoter` (RBAC ORM, niveau B/J6b) s'y branchera. |
|
|
141
|
+
| **`RoleVoter`** | — | Le voter niveau **A** : GRANT si le token porte le rôle demandé (via `RoleHierarchyWalker`), ABSTAIN sinon — **jamais de veto** (il ne bloque pas les attributs qu'il ne connaît pas). |
|
|
142
|
+
| **Attribute** | attribut (de décision) | La chose demandée passée à `decide()` : un rôle (`ROLE_ADMIN`), une permission (`post:edit`)… Ne PAS confondre avec l'ABAC « attribut contextuel » (le `subject`). |
|
|
143
|
+
| **Subject** | sujet / ressource ciblée | L'objet **sur lequel** porte l'action (le document à éditer), passé au voter pour les règles ABAC (« est-il le propriétaire ? »). Optionnel — souvent un param de route via `@IsGranted(..., { subject })`. |
|
|
144
144
|
|
|
145
145
|
### Décorateurs sécurité (panoplie J7)
|
|
146
146
|
|
package/docs/oauth2.md
CHANGED
|
@@ -144,12 +144,13 @@ d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewal
|
|
|
144
144
|
c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
|
|
145
145
|
|
|
146
146
|
**Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
|
|
147
|
-
dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
|
|
147
|
+
dans le module (`OAuth2Client`, `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:
|
|
150
|
-
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes
|
|
151
|
-
que
|
|
152
|
-
|
|
149
|
+
mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
|
|
150
|
+
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes sont montées dès
|
|
151
|
+
que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
|
|
152
|
+
`…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
|
|
153
|
+
rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
|
|
153
154
|
|
|
154
155
|
Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
|
|
155
156
|
inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
|
|
@@ -215,8 +216,8 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
215
216
|
### Les routes sont FOURNIES — tu n'écris aucun controller
|
|
216
217
|
|
|
217
218
|
`mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
|
|
218
|
-
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`)
|
|
219
|
-
est
|
|
219
|
+
`/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
|
|
220
|
+
enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
|
|
220
221
|
|
|
221
222
|
| Route | Rôle |
|
|
222
223
|
| ---------------------------- | --------------------------------------------------------------------- |
|
|
@@ -287,7 +288,7 @@ défense :
|
|
|
287
288
|
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
|
|
288
289
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
289
290
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
290
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts:
|
|
291
|
+
callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
|
|
291
292
|
`failureRedirect`, **sans jamais contacter le fournisseur** ;
|
|
292
293
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
293
294
|
|
|
@@ -488,14 +489,14 @@ que le mapping du profil. Exemple sans réseau dans le dépôt :
|
|
|
488
489
|
Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
|
|
489
490
|
(`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
|
|
490
491
|
|
|
491
|
-
| Option | Type | Défaut | Effet
|
|
492
|
-
| ----------------- | -------------------- | --------------- |
|
|
493
|
-
| `enabled` | booléen | `true` | Coupe le social login
|
|
494
|
-
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`).
|
|
495
|
-
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`).
|
|
496
|
-
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès.
|
|
497
|
-
| `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail).
|
|
498
|
-
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`).
|
|
492
|
+
| Option | Type | Défaut | Effet |
|
|
493
|
+
| ----------------- | -------------------- | --------------- | ----------------------------------------------------------- |
|
|
494
|
+
| `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
|
|
495
|
+
| `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
|
|
496
|
+
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
|
|
497
|
+
| `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
|
|
498
|
+
| `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
|
|
499
|
+
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
|
|
499
500
|
|
|
500
501
|
Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
|
|
501
502
|
|
|
@@ -608,15 +609,17 @@ session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l
|
|
|
608
609
|
provisionné dans l'écran **Users**, avec ses rôles réels.
|
|
609
610
|
|
|
610
611
|
> [!NOTE]
|
|
611
|
-
> L'événement d'audit du login social porte la raison
|
|
612
|
-
> (`
|
|
613
|
-
>
|
|
612
|
+
> L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
|
|
613
|
+
> `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:194`), comme WebAuthn passe `webauthn`.
|
|
614
|
+
> `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
|
|
615
|
+
> (`authFlow.ts:215`).
|
|
614
616
|
|
|
615
617
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
616
618
|
|
|
617
619
|
| Symptôme | Cause (dans le code) | Correction |
|
|
618
620
|
| ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
|
|
619
|
-
| `404` sur `…/oauth2/…` |
|
|
621
|
+
| `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
|
|
622
|
+
| `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
|
|
620
623
|
| WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
|
|
621
624
|
| `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
|
|
622
625
|
| Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
|
package/docs/tokens.md
CHANGED
|
@@ -25,7 +25,7 @@ tags:
|
|
|
25
25
|
]
|
|
26
26
|
version: "doc"
|
|
27
27
|
status: stable
|
|
28
|
-
updated: 2026-
|
|
28
|
+
updated: 2026-10-02
|
|
29
29
|
source: "src/packages/@nodefony/security/docs/tokens.md"
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -142,8 +142,9 @@ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.t
|
|
|
142
142
|
- `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
|
|
143
143
|
|
|
144
144
|
> [!IMPORTANT]
|
|
145
|
-
> Ces routes n'existent **que si**
|
|
146
|
-
> (`
|
|
145
|
+
> Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
|
|
146
|
+
> (`framework/index.ts:415`). Module chargé mais JWT désactivé : elles existent et répondent
|
|
147
|
+
> `503 Token issuance unavailable` (`TokenAuthController.ts:69`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
|
|
147
148
|
> elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
|
|
148
149
|
> authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
|
|
149
150
|
|
|
@@ -250,9 +251,9 @@ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`).
|
|
|
250
251
|
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
|
|
251
252
|
2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
|
|
252
253
|
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
|
|
253
|
-
3. Expiration `expiresAt` vérifiée (`tokenService.ts:
|
|
254
|
+
3. Expiration `expiresAt` vérifiée (`tokenService.ts:711`).
|
|
254
255
|
4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
|
|
255
|
-
`#resolveUserForRefresh()` (`tokenService.ts:
|
|
256
|
+
`#resolveUserForRefresh()` (`tokenService.ts:770`).
|
|
256
257
|
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
257
258
|
(`tokenService.ts:600`).
|
|
258
259
|
6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
|
|
@@ -359,7 +360,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
|
|
|
359
360
|
### `redis` — cluster, TTL natif
|
|
360
361
|
|
|
361
362
|
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
|
|
362
|
-
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:
|
|
363
|
+
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:41`) — l'expiration ne
|
|
363
364
|
dépend pas du gc.
|
|
364
365
|
- Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
|
|
365
366
|
(`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
|
|
@@ -390,7 +391,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
|
|
|
390
391
|
- `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
|
|
391
392
|
(`ITokenStore.ts:222`).
|
|
392
393
|
- Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
|
|
393
|
-
(`apiKeys.ts:
|
|
394
|
+
(`apiKeys.ts:209`), jamais un listAll matérialisé.
|
|
394
395
|
|
|
395
396
|
### Révoquer — trois portées
|
|
396
397
|
|
|
@@ -429,7 +430,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
429
430
|
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
|
|
430
431
|
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
|
|
431
432
|
| `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:
|
|
433
|
+
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:396`) |
|
|
433
434
|
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
434
435
|
| `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
|
|
435
436
|
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
|
|
@@ -458,7 +459,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
458
459
|
|
|
459
460
|
## ⚡ Performance & mémoire
|
|
460
461
|
|
|
461
|
-
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:
|
|
462
|
+
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
|
|
462
463
|
— le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
|
|
463
464
|
- **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
|
|
464
465
|
vérification (hot path) vit chez le `JwtAuthenticator`.
|
|
@@ -479,7 +480,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
479
480
|
|
|
480
481
|
| Symptôme | Cause (dans le code) | Correction |
|
|
481
482
|
| --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
|
|
482
|
-
| 404 sur `/nodefony/security/api/token` |
|
|
483
|
+
| 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
|
|
483
484
|
| 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
|
|
484
485
|
| Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
|
|
485
486
|
| JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
|
package/docs/totp.md
CHANGED
|
@@ -190,8 +190,9 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
190
190
|
|
|
191
191
|
### 3. Les endpoints sont FOURNIS — tu n'écris aucun controller
|
|
192
192
|
|
|
193
|
-
`mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service
|
|
194
|
-
le service `totp`
|
|
193
|
+
`mountTotpRoutes()` (`TotpController.ts:154`) monte quatre routes self-service dès que
|
|
194
|
+
`@nodefony/security` est chargé (le service `totp` est alors toujours enregistré). 2FA désactivé, ou
|
|
195
|
+
service non initialisé : elles répondent `503 2FA unavailable` :
|
|
195
196
|
|
|
196
197
|
| Route | Corps | Réponse |
|
|
197
198
|
| ------------------------------------------ | ---------- | ---------------------------------------------- |
|
|
@@ -211,7 +212,7 @@ Et côté login, deux routes du flux de session BFF (`mountSessionAuthRoutes()`,
|
|
|
211
212
|
> [!WARNING]
|
|
212
213
|
> Les routes `totp/*` **n'ont pas** `bypassFirewall` (`TotpController.ts:52`) : elles vivent dans la
|
|
213
214
|
> zone data plane et exigent une session BFF. Le sujet est **toujours** l'utilisateur courant, lu
|
|
214
|
-
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:
|
|
215
|
+
> depuis la session (`TotpController.#currentSubject()`, `TotpController.ts:142`) — jamais un
|
|
215
216
|
> paramètre : on n'active ni ne désactive le 2FA d'autrui (anti-IDOR).
|
|
216
217
|
|
|
217
218
|
### 4. Ce qu'on observe
|
|
@@ -455,17 +456,17 @@ La saisie est tolérante — casse et tirets ignorés à la normalisation (`totp
|
|
|
455
456
|
La section `totp` du schéma Zod (`config.ts:1144`) — validée au boot, donc une valeur hors bornes
|
|
456
457
|
échoue **au démarrage**, pas au premier login :
|
|
457
458
|
|
|
458
|
-
| Option | Type | Défaut | Effet
|
|
459
|
-
| --------------- | ---------------------------- | ------ |
|
|
460
|
-
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes
|
|
461
|
-
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app.
|
|
462
|
-
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`).
|
|
463
|
-
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum).
|
|
464
|
-
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes.
|
|
465
|
-
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`).
|
|
466
|
-
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`).
|
|
467
|
-
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`).
|
|
468
|
-
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`).
|
|
459
|
+
| Option | Type | Défaut | Effet |
|
|
460
|
+
| --------------- | ---------------------------- | ------ | --------------------------------------------------------------- |
|
|
461
|
+
| `enabled` | `boolean` | `true` | Coupe le 2FA : service inerte, routes en `503` (`totp.ts:101`). |
|
|
462
|
+
| `issuer` | `string?` | — | Nom affiché dans l'app d'authentification. Omis = nom de l'app. |
|
|
463
|
+
| `algorithm` | `"SHA1"\|"SHA256"\|"SHA512"` | `SHA1` | Fonction HMAC. `SHA1` = compat maximale (`config.ts:551`). |
|
|
464
|
+
| `digits` | `int` 6–8 | `6` | Longueur du code (RFC 4226 §5.3 : 6 minimum). |
|
|
465
|
+
| `period` | `int` > 0 | `30` | Durée de vie d'un code, en secondes. |
|
|
466
|
+
| `window` | `int` ≥ 0 | `1` | Tolérance de dérive, en pas (`config.ts:572`). |
|
|
467
|
+
| `recoveryCodes` | `int` > 0 | `10` | Nombre de codes générés à l'activation (`config.ts:580`). |
|
|
468
|
+
| `encryptionKey` | `string?` | — | Clé de chiffrement du secret au repos (`config.ts:588`). |
|
|
469
|
+
| `store` | `string` | `auto` | Backend de persistance du secret (`config.ts:594`). |
|
|
469
470
|
|
|
470
471
|
### Situation 1 — un utilisateur active la 2FA sur son compte
|
|
471
472
|
|