@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +1 -1
- 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 +5 -5
- 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 +15 -15
- package/docs/audit.md +18 -13
- package/docs/authenticators.md +6 -6
- package/docs/authorization.md +15 -15
- package/docs/cors.md +6 -6
- package/docs/csrf.md +7 -7
- package/docs/external-jwt.md +17 -9
- package/docs/firewall.md +7 -7
- package/docs/headers.md +13 -13
- package/docs/index.md +3 -1
- package/docs/oauth2.md +2 -2
- package/docs/tokens.md +6 -6
- package/docs/webauthn.md +13 -13
- package/docs/webhooks.md +9 -12
- package/package.json +13 -13
package/docs/authenticators.md
CHANGED
|
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:435`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
111
|
- Les **six builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `external-jwt`, `apikey`)
|
|
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
|
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:588`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -228,7 +228,7 @@ Le seul authenticator autorisé à produire un token **non authentifié** sans d
|
|
|
228
228
|
si preuve présente, sinon visiteur anonyme accepté ». En mode `all`, utile en **dernier** :
|
|
229
229
|
« canal prouvé (ex. mTLS), identité utilisateur optionnelle ».
|
|
230
230
|
- Sans lui, zone protégée + aucune preuve = 401 : la défense en profondeur du firewall n'accepte un
|
|
231
|
-
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:
|
|
231
|
+
token non authentifié que si `anonymous` est le maillon déclaré (`firewall.ts:850`).
|
|
232
232
|
- **Faille fermée** : l'anonymat _implicite_ — ici il est un choix explicite et auditable, jamais un
|
|
233
233
|
défaut.
|
|
234
234
|
|
|
@@ -409,7 +409,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
409
409
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
410
410
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
411
411
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
412
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
412
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1216`).
|
|
413
413
|
|
|
414
414
|
## 🧩 Ajouter un authenticator maison
|
|
415
415
|
|
|
@@ -435,7 +435,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
435
435
|
<!-- prettier-ignore -->
|
|
436
436
|
| Domaine | Norme | Ancrage |
|
|
437
437
|
| --- | --- | --- |
|
|
438
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
438
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1216`) |
|
|
439
439
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
440
440
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
441
441
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
|
@@ -463,7 +463,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
463
463
|
<!-- prettier-ignore -->
|
|
464
464
|
| Symptôme | Cause (dans le code) | Correction |
|
|
465
465
|
| --- | --- | --- |
|
|
466
|
-
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:
|
|
466
|
+
| 401 systématique sur une zone protégée | Aucune preuve + `anonymous` non listé — Zero Trust (`firewall.ts:850`) | Ajouter `anonymous` en dernier si l'anonymat est voulu |
|
|
467
467
|
| 401 générique + log ERROR « service `users` » | Câblage : pas de `UserService` au container (`authenticatorRegistry.ts:85-88`) | Enregistrer un `UserService` au boot de l'app |
|
|
468
468
|
| JWT rejeté alors qu'il « semble » valide | `aud`/`iss`/`typ` non conformes, ou `alg` ≠ EdDSA (`JwtAuthenticator.ts:103-107`) | Émettre via le `TokenService` (mêmes iss/aud/typ) |
|
|
469
469
|
| Clé API révoquée encore acceptée quelques secondes | Confusion avec un JWT (auto-porté) — `revokedAt` est lu à chaque requête (`ApiKeyAuthenticator.ts:110`) | Un PAT est révoqué immédiatement ; vérifier `revokedAt` |
|
package/docs/authorization.md
CHANGED
|
@@ -331,7 +331,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
331
331
|
|
|
332
332
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
333
333
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
334
|
-
(`Resolver.ts:
|
|
334
|
+
(`Resolver.ts:700-715`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
335
335
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
336
336
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
337
337
|
|
|
@@ -360,20 +360,20 @@ moteur `authorization` est résolu **par nom** au runtime (`Resolver.ts:673-674`
|
|
|
360
360
|
|
|
361
361
|
| Déclaration | Sémantique |
|
|
362
362
|
| ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
363
|
-
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:
|
|
364
|
-
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:
|
|
365
|
-
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:
|
|
366
|
-
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:
|
|
367
|
-
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:
|
|
368
|
-
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:
|
|
369
|
-
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:
|
|
370
|
-
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:
|
|
363
|
+
| `@IsGranted("ROLE_ADMIN")` | un attribut — rôle, scope ou verbe métier (`IsGranted()`, `routerDecorators.ts:903`) |
|
|
364
|
+
| `@IsGranted(["A", "B"])` | **OR interne** — un attribut accordé suffit (`SecurityClause.anyOf`, `routerDecorators.ts:424-433`) |
|
|
365
|
+
| empiler `@IsGranted` / `@RequireScope` | **AND** — toutes les clauses doivent passer (`SecurityRequirement.clauses`, `routerDecorators.ts:1579`) |
|
|
366
|
+
| décorateur de classe + de méthode | fusion en **AND**, figée UNE fois par route (`computeSecurityRequirement()`, `routerDecorators.ts:1544`) |
|
|
367
|
+
| `@IsGranted("doc.edit", { subject: "id" })` | le param de route `id` est passé au voter (`Resolver._resolveSubject()`, `Resolver.ts:789`) |
|
|
368
|
+
| `@RequireScope("orders:read")` | axe scope — metadata dédiée, fusionnée dans le même `SecurityRequirement` (`RequireScope()`, `routerDecorators.ts:1002`) |
|
|
369
|
+
| `@Anonymous()` | action **publique** — override les gardes de classe (`security: null`) + skip l'authn (`Anonymous()`, `routerDecorators.ts:953`) |
|
|
370
|
+
| `@CurrentUser()` | injecte l'utilisateur de l'ALS — jamais le credential (`CurrentUser`, `routerDecorators.ts:1283`) |
|
|
371
371
|
|
|
372
372
|
La garde s'évalue dans `Resolver.executeAction()` **AVANT** l'instanciation DI du controller — un
|
|
373
|
-
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:
|
|
373
|
+
403 court-circuite tout, y compris `initialize()` (`_enforceSecurity`, `Resolver.ts:406-408`). Le
|
|
374
374
|
même `executeAction` sert le pipeline HTTP **et** l'invoke WS-RPC : une garde, tous les
|
|
375
375
|
transports. L'enforcement déroule chaque clause : OR interne via un `decide()` par attribut, AND
|
|
376
|
-
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:
|
|
376
|
+
entre clauses (`Resolver._enforceSecurity()`, `Resolver.ts:719`).
|
|
377
377
|
|
|
378
378
|
> [!IMPORTANT]
|
|
379
379
|
> **Fail-closed intégral** : route gardée mais moteur `authorization` absent (module security non
|
|
@@ -511,7 +511,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
511
511
|
|
|
512
512
|
## 🔌 HTTP et WebSocket — une garde, N transports
|
|
513
513
|
|
|
514
|
-
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:
|
|
514
|
+
- **La même garde** : `Resolver.executeAction()` (`Resolver.ts:372`) est le point unique
|
|
515
515
|
d'enforcement — pipeline HTTP classique **et** invoke WS-RPC (pont `api.request`). Un
|
|
516
516
|
`@IsGranted` protège donc l'action quel que soit le transport (prouvé bout en bout par le banc
|
|
517
517
|
`ws-isgranted-jwt`).
|
|
@@ -541,10 +541,10 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
541
541
|
## ⚡ Performance & mémoire
|
|
542
542
|
|
|
543
543
|
- **Hot path à coût nul** : une route non gardée porte `security: null` → 0 lookup, 0 await, 0
|
|
544
|
-
alloc (`Resolver.ts:
|
|
545
|
-
requêtes (`SecurityRequirement`, `routerDecorators.ts:
|
|
544
|
+
alloc (`Resolver.ts:406`) ; l'exigence est **figée une fois** par route et partagée entre
|
|
545
|
+
requêtes (`SecurityRequirement`, `routerDecorators.ts:444`).
|
|
546
546
|
- **`decide()` sans allocation** : itération en place des voters (`authorization.ts:70-72`),
|
|
547
|
-
instanciés **une seule fois** au boot (`authorization.ts:
|
|
547
|
+
instanciés **une seule fois** au boot (`authorization.ts:70`).
|
|
548
548
|
- **`hasRole()` O(1)** : hiérarchie aplatie au boot, rien de récursif par requête
|
|
549
549
|
(`RoleHierarchyWalker.ts:23-30`).
|
|
550
550
|
- **Audit = cold path** : uniquement sur refus, avec un descripteur léger du sujet — jamais de
|
package/docs/cors.md
CHANGED
|
@@ -323,7 +323,7 @@ sequenceDiagram
|
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`Firewall.handleCors()` (`firewall.ts:1007`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
-
(`http-kernel.ts:
|
|
326
|
+
(`http-kernel.ts:1333`), à la ligne `http-kernel.ts:1333` — **avant le routing**. La raison est
|
|
327
327
|
concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
|
|
328
328
|
router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
|
|
329
329
|
credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
|
|
@@ -354,7 +354,7 @@ Deux nuances utiles :
|
|
|
354
354
|
`Access-Control-Request-Headers` du client (`cors.ts:78`). Ce que tu déclares est ce qui est annoncé,
|
|
355
355
|
point. Un en-tête custom non déclaré fait échouer le preflight côté navigateur.
|
|
356
356
|
- **Les fichiers statiques sont couverts.** `handleCors` s'exécute avant le fallback `serve-static`
|
|
357
|
-
(`http-kernel.ts:
|
|
357
|
+
(`http-kernel.ts:1382`) : une police ou une image servie cross-origin reçoit les mêmes en-têtes que
|
|
358
358
|
tes routes.
|
|
359
359
|
|
|
360
360
|
Le contrat est publié dans l'interface du firewall (`IFirewall.ts:35`) : `number | undefined` — `204`
|
|
@@ -387,7 +387,7 @@ victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un con
|
|
|
387
387
|
(`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
|
|
388
388
|
|
|
389
389
|
La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
|
|
390
|
-
(`http-kernel.ts:
|
|
390
|
+
(`http-kernel.ts:621`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
391
391
|
si elle est refusée. Sa doctrine :
|
|
392
392
|
|
|
393
393
|
- **same-origin par défaut** : l'`Origin` du handshake doit correspondre au `Host` servi ;
|
|
@@ -409,7 +409,7 @@ Deux réglages distincts, parce que deux mécanismes navigateur distincts.
|
|
|
409
409
|
| `*` incompatible avec credentials | Fetch Standard · OWASP CORS | rejet au boot (`config.ts:156-159`) + reflet défensif (`cors.ts:58`) |
|
|
410
410
|
| Correction de cache | RFC 9110 (`Vary`) | `Vary: Origin` dès que l'origine est reflétée (`cors.ts:81`, `cors.ts:94`) |
|
|
411
411
|
| Comparaison d'origines | RFC 6454 (Web Origin) | match **exact** `scheme://host:port` — `Cors.#allowOrigin()` (`cors.ts:57`) |
|
|
412
|
-
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:
|
|
412
|
+
| Anti-CSWSH | OWASP WSTG-CLNT-10 | `HttpKernel.checkWebsocketOrigin()` (`http-kernel.ts:621`) |
|
|
413
413
|
|
|
414
414
|
## ⚡ Performance & mémoire
|
|
415
415
|
|
|
@@ -428,7 +428,7 @@ Le coût par requête est donc :
|
|
|
428
428
|
## 📡 Observabilité — Studio
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
|
-
`Firewall.describe()` (`firewall.ts:
|
|
431
|
+
`Firewall.describe()` (`firewall.ts:555`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
432
|
(`firewall.ts:591`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
433
|
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:610`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
|
@@ -455,7 +455,7 @@ secondes les « pourtant j'ai bien mis l'origine ».
|
|
|
455
455
|
| Le preflight échoue sur un en-tête custom | `allowedHeaders` est statique, il ne reflète pas la demande du client (`cors.ts:78`) | Déclarer l'en-tête dans `cors.allowedHeaders` |
|
|
456
456
|
| Un cache sert la réponse d'une origine à une autre | `Vary: Origin` écrasé en aval (la politique le pose, `cors.ts:81`) | Ne pas `setHeader("Vary", …)` dans un controller — utiliser `appendHeader` |
|
|
457
457
|
| `OPTIONS` renvoie 405 au lieu de 204 | Requête `OPTIONS` **sans** `Access-Control-Request-Method` : ce n'est pas un preflight | Envoyer l'en-tête, ou déclarer une route `OPTIONS` |
|
|
458
|
-
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:
|
|
458
|
+
| Page tierce qui ouvre un WebSocket authentifié | CORS ne couvre pas le WS | C'est `checkWebsocketOrigin` qui garde (`http-kernel.ts:621`) — vérifier `allowedOrigins` |
|
|
459
459
|
| Ouverture CORS « temporaire » restée en production | `origins:["*"]` posé en dev | Vérifier la valeur **résolue** dans Studio, pas le fichier source |
|
|
460
460
|
|
|
461
461
|
## 🧪 Tests & couverture
|
package/docs/csrf.md
CHANGED
|
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
93
93
|
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
94
|
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:163-168`).
|
|
95
95
|
- **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
|
|
96
|
-
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1524`) arrive **après** le
|
|
97
97
|
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
98
|
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
99
|
- **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
|
|
@@ -148,7 +148,7 @@ export default ProfileController;
|
|
|
148
148
|
|
|
149
149
|
(Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
|
|
150
150
|
le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
|
|
151
|
-
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:
|
|
151
|
+
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1650-1655`.)
|
|
152
152
|
|
|
153
153
|
### Comment le front obtient — puis rejoue — le token
|
|
154
154
|
|
|
@@ -241,7 +241,7 @@ autorisation :
|
|
|
241
241
|
```
|
|
242
242
|
|
|
243
243
|
> [!WARNING]
|
|
244
|
-
> `@CsrfExempt` (`routerDecorators.ts:
|
|
244
|
+
> `@CsrfExempt` (`routerDecorators.ts:1142`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
|
|
245
245
|
> webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
|
|
246
246
|
|
|
247
247
|
Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
|
|
@@ -296,7 +296,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
296
296
|
1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
|
|
297
297
|
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
|
|
298
298
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
|
-
(`Resolver.ts:
|
|
299
|
+
(`Resolver.ts:156-186`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
300
|
(`Context.ts:241-243`).
|
|
301
301
|
3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
|
|
302
302
|
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
@@ -315,7 +315,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
315
315
|
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:169-174`). |
|
|
316
316
|
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:176-181`). |
|
|
317
317
|
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:182-187`). |
|
|
318
|
-
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:
|
|
318
|
+
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:604`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:486`). |
|
|
319
319
|
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:188-193`). |
|
|
320
320
|
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:194-200`). |
|
|
321
321
|
|
|
@@ -325,7 +325,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
325
325
|
| ------------------------------ | --------------------------------- | -------------------------------------- |
|
|
326
326
|
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
|
|
327
327
|
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
|
|
328
|
-
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:
|
|
328
|
+
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:96` |
|
|
329
329
|
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
|
|
330
330
|
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
|
|
331
331
|
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
|
|
@@ -346,7 +346,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
346
346
|
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
347
347
|
`Firewall.tsx:313-314`). La projection est **sans secret par construction** :
|
|
348
348
|
`Firewall.#describeDefenses()` (`firewall.ts:591`) publie la config résolue, et `synchronizerToken`
|
|
349
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:
|
|
349
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:607`).
|
|
350
350
|
|
|
351
351
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
352
352
|
|
package/docs/external-jwt.md
CHANGED
|
@@ -44,16 +44,24 @@ d'accès » :
|
|
|
44
44
|
|
|
45
45
|
```ts
|
|
46
46
|
// nodefony.config.ts
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
47
|
+
import { defineConfig, use } from "nodefony";
|
|
48
|
+
|
|
49
|
+
export default defineConfig({
|
|
50
|
+
modules: [
|
|
51
|
+
"@nodefony/http",
|
|
52
|
+
"@nodefony/framework",
|
|
53
|
+
use("@nodefony/security", {
|
|
54
|
+
resourceServer: {
|
|
55
|
+
issuers: [
|
|
56
|
+
{
|
|
57
|
+
issuer: "https://auth.example.com/realms/mon-royaume",
|
|
58
|
+
algorithms: ["RS256"],
|
|
59
|
+
},
|
|
60
|
+
],
|
|
53
61
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
62
|
+
}),
|
|
63
|
+
],
|
|
64
|
+
});
|
|
57
65
|
```
|
|
58
66
|
|
|
59
67
|
Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
|
package/docs/firewall.md
CHANGED
|
@@ -52,8 +52,8 @@ flowchart TD
|
|
|
52
52
|
AU -->|succès| OK["user + token dans l'ALS → contrôleur"]
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
`Firewall.isSecure()` (`firewall.ts:
|
|
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
|
|
|
@@ -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 ? »)
|
|
@@ -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` |
|
package/docs/headers.md
CHANGED
|
@@ -122,8 +122,8 @@ fait l'inverse : **tout ce qui est constant est calculé une fois au démarrage*
|
|
|
122
122
|
statique) et la **gèle** avec `Object.freeze` (`securityHeaders.ts:77`). Par requête, le firewall
|
|
123
123
|
se contente de la parcourir et de la poser : zéro concaténation, zéro objet créé.
|
|
124
124
|
- Côté transport, même principe : `HttpKernel.computeSecurityHeaderCaches()`
|
|
125
|
-
(`http-kernel.ts:
|
|
126
|
-
boot ; `onHttpRequest` (`http-kernel.ts:
|
|
125
|
+
(`http-kernel.ts:348`) précalcule la chaîne HSTS (`max-age`, `includeSubDomains`, `preload`) au
|
|
126
|
+
boot ; `onHttpRequest` (`http-kernel.ts:972`) ne fait plus que trois `setHeader`.
|
|
127
127
|
- Le seul coût variable est le **nonce CSP**, et il est **paresseux** : `Context.cspNonce`
|
|
128
128
|
(`Context.ts:253`) ne génère ses 128 bits (`randomBytes(16)` en base64) qu'à la première lecture,
|
|
129
129
|
puis mémoïse. Une réponse qui n'a aucun script inline à signer ne paie aucun appel crypto.
|
|
@@ -275,7 +275,7 @@ privilégient `frame-ancestors`, `X-Frame-Options` reste le filet pour les ancie
|
|
|
275
275
|
victime est capté par ton interface.
|
|
276
276
|
|
|
277
277
|
Posé par le **transport** depuis un cache calculé au boot — `secFrameOptions`
|
|
278
|
-
(`http-kernel.ts:
|
|
278
|
+
(`http-kernel.ts:989`) — et configuré côté `@nodefony/http` avec `frameOptions`
|
|
279
279
|
(`http/nodefony/config/config.ts:136`), qui vaut `DENY` par défaut. `SAMEORIGIN` si ton propre site
|
|
280
280
|
s'auto-encadre. C'est un des trois en-têtes que security **ne ré-émet pas** : il doit valoir aussi
|
|
281
281
|
pour un HTML statique servi directement depuis `public/`.
|
|
@@ -287,7 +287,7 @@ pour un HTML statique servi directement depuis `public/`.
|
|
|
287
287
|
avec tes cookies.
|
|
288
288
|
|
|
289
289
|
Valeur unique reconnue : `nosniff`, posée depuis le cache `secContentTypeOptions`
|
|
290
|
-
(`http-kernel.ts:
|
|
290
|
+
(`http-kernel.ts:986`). C'est **l'en-tête qui justifie le mieux la couche transport** : le danger
|
|
291
291
|
vient précisément des fichiers servis hors pipeline applicatif — un banc live le prouve sur une 404
|
|
292
292
|
(`security-headers.test.ts:38`).
|
|
293
293
|
|
|
@@ -297,10 +297,10 @@ vient précisément des fichiers servis hors pipeline applicatif — un banc liv
|
|
|
297
297
|
installer un intercepteur.
|
|
298
298
|
|
|
299
299
|
La chaîne est assemblée au boot par `HttpKernel.computeSecurityHeaderCaches()`
|
|
300
|
-
(`http-kernel.ts:
|
|
300
|
+
(`http-kernel.ts:348`) : `max-age`, puis `includeSubDomains` et `preload` selon la config.
|
|
301
301
|
|
|
302
302
|
Elle n'est posée que **sur une réponse HTTPS ou HTTP/2** — le cache `secHsts` est conditionné au type
|
|
303
|
-
de serveur (`http-kernel.ts:
|
|
303
|
+
de serveur (`http-kernel.ts:992`). C'est conforme à la RFC 6797, qui veut qu'un HSTS reçu en clair
|
|
304
304
|
soit ignoré : l'émettre sur du HTTP simple ne ferait que polluer. Défaut : un an, sous-domaines
|
|
305
305
|
inclus.
|
|
306
306
|
|
|
@@ -397,7 +397,7 @@ Dérivé du schéma Zod `headersSchema` (`config.ts:206`).
|
|
|
397
397
|
### Socle transport — `use("@nodefony/http", { securityHeaders })`
|
|
398
398
|
|
|
399
399
|
Dérivé de `securityHeadersSchema` (`http/nodefony/config/config.ts:123`). Ces trois réglages sont
|
|
400
|
-
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:
|
|
400
|
+
**éditables à chaud** (`runtimeMutable`) : `HttpKernel.onConfigChanged()` (`http-kernel.ts:398`)
|
|
401
401
|
recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
402
402
|
|
|
403
403
|
| Option | Type | Défaut | Effet |
|
|
@@ -411,7 +411,7 @@ recalcule les caches, donc la valeur suivante s'applique sans redémarrage.
|
|
|
411
411
|
|
|
412
412
|
> [!WARNING]
|
|
413
413
|
> Les clés `hsts`, `hstsMaxAgeS`, `frameguard` et `noSniff` **existent** dans la config security
|
|
414
|
-
> (`config.ts:
|
|
414
|
+
> (`config.ts:257`, `config.ts:257`, `config.ts:257`, `config.ts:257`) mais **ne pilotent rien** : la couche
|
|
415
415
|
> applicative ne les lit pas (`securityHeaders.ts:6`), elles ne servent qu'à l'introspection
|
|
416
416
|
> affichée dans Studio. Pour changer réellement `X-Frame-Options`, c'est `securityHeaders.frameOptions`
|
|
417
417
|
> **du module http**. Même remarque pour `hidePoweredBy` (`config.ts:282`) : Nodefony n'émet aucun
|
|
@@ -502,10 +502,10 @@ Trois propriétés à retenir :
|
|
|
502
502
|
|
|
503
503
|
- **Aucun couplage** : la résolution par nom de service évite un cycle de dépendances, et
|
|
504
504
|
`registerCspOrigins` est optionnel — un module fonctionne dans une app **sans** security.
|
|
505
|
-
- **Réversible** : `Firewall.unregisterCspOrigins()` (`firewall.ts:
|
|
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:986`) |
|
|
527
|
+
| Durcissement en-têtes | OWASP Secure Headers | `computeSecurityHeaderCaches()` (`http-kernel.ts:348`) |
|
|
528
528
|
|
|
529
529
|
## ⚡ Performance & mémoire
|
|
530
530
|
|
|
@@ -550,7 +550,7 @@ dans le périmètre du gate mémoire, qu'il ne peut structurellement pas dégrad
|
|
|
550
550
|
L'écran **Firewall** de Studio affiche la section « En-têtes de sécurité » — pilotée par
|
|
551
551
|
`headers.enabled` (`FirewallDefenses.tsx:219`) — avec le CSP effectif, l'état du nonce par requête, la
|
|
552
552
|
Referrer-Policy et les valeurs d'isolation. Les données
|
|
553
|
-
viennent de `Firewall.describe()` (`firewall.ts:
|
|
553
|
+
viennent de `Firewall.describe()` (`firewall.ts:555`), qui projette la config **sans aucun secret**,
|
|
554
554
|
exposée par `GET /nodefony/security/api/firewall`.
|
|
555
555
|
|
|
556
556
|
L'onglet **Configuration** de Studio rend les mêmes options depuis le schéma Zod — chaque champ y
|
package/docs/index.md
CHANGED
|
@@ -126,7 +126,9 @@ flowchart LR
|
|
|
126
126
|
```
|
|
127
127
|
|
|
128
128
|
Le module consomme `@nodefony/user` (jamais l'inverse) et n'importe `@nodefony/http` / `framework`
|
|
129
|
-
qu'en **type-only
|
|
129
|
+
qu'en **type-only**, à une exception près : `schemaMismatchOf` (`webhooks.ts:49`), importé de
|
|
130
|
+
`@nodefony/http` pour reconnaître une base au schéma en retard avec la même règle que le cœur HTTP,
|
|
131
|
+
plutôt qu'avec une copie.
|
|
130
132
|
|
|
131
133
|
## 🧰 Surface publique
|
|
132
134
|
|
package/docs/oauth2.md
CHANGED
|
@@ -146,7 +146,7 @@ c'est l'authenticator `session` qui identifie chaque requête, comme après un m
|
|
|
146
146
|
**Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
|
|
147
147
|
dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
|
|
148
148
|
l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
|
|
149
|
-
mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:
|
|
149
|
+
mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
|
|
150
150
|
processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
|
|
151
151
|
que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
|
|
152
152
|
est **404**, pas « désactivée ».
|
|
@@ -287,7 +287,7 @@ défense :
|
|
|
287
287
|
1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
|
|
288
288
|
`state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
|
|
289
289
|
2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
|
|
290
|
-
callback ≡ fournisseur démarré (`OAuth2Controller.ts:
|
|
290
|
+
callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
|
|
291
291
|
`failureRedirect`, **sans jamais contacter le fournisseur** ;
|
|
292
292
|
3. seulement ensuite, `exchangeAndProvision()`.
|
|
293
293
|
|
package/docs/tokens.md
CHANGED
|
@@ -250,9 +250,9 @@ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`).
|
|
|
250
250
|
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
|
|
251
251
|
2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
|
|
252
252
|
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
|
|
253
|
-
3. Expiration `expiresAt` vérifiée (`tokenService.ts:
|
|
253
|
+
3. Expiration `expiresAt` vérifiée (`tokenService.ts:711`).
|
|
254
254
|
4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
|
|
255
|
-
`#resolveUserForRefresh()` (`tokenService.ts:
|
|
255
|
+
`#resolveUserForRefresh()` (`tokenService.ts:770`).
|
|
256
256
|
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
257
257
|
(`tokenService.ts:600`).
|
|
258
258
|
6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
|
|
@@ -359,7 +359,7 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
|
|
|
359
359
|
### `redis` — cluster, TTL natif
|
|
360
360
|
|
|
361
361
|
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
|
|
362
|
-
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:
|
|
362
|
+
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:41`) — l'expiration ne
|
|
363
363
|
dépend pas du gc.
|
|
364
364
|
- Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
|
|
365
365
|
(`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
|
|
@@ -390,7 +390,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
|
|
|
390
390
|
- `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
|
|
391
391
|
(`ITokenStore.ts:222`).
|
|
392
392
|
- Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
|
|
393
|
-
(`apiKeys.ts:
|
|
393
|
+
(`apiKeys.ts:209`), jamais un listAll matérialisé.
|
|
394
394
|
|
|
395
395
|
### Révoquer — trois portées
|
|
396
396
|
|
|
@@ -429,7 +429,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
429
429
|
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
|
|
430
430
|
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
|
|
431
431
|
| `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
|
|
432
|
-
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:
|
|
432
|
+
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:396`) |
|
|
433
433
|
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
434
434
|
| `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
|
|
435
435
|
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
|
|
@@ -458,7 +458,7 @@ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `token
|
|
|
458
458
|
|
|
459
459
|
## ⚡ Performance & mémoire
|
|
460
460
|
|
|
461
|
-
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:
|
|
461
|
+
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:744`)
|
|
462
462
|
— le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
|
|
463
463
|
- **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
|
|
464
464
|
vérification (hot path) vit chez le `JwtAuthenticator`.
|
package/docs/webauthn.md
CHANGED
|
@@ -54,7 +54,7 @@ flowchart LR
|
|
|
54
54
|
Le défi vit **hors du service**, en session BFF, posé par le controller
|
|
55
55
|
(`WebAuthnController.registerOptions()`, `WebAuthnController.ts:113`). Chaque `verify*` reçoit
|
|
56
56
|
l'`expectedChallenge` qu'il a émis, et le controller **l'invalide dès sa lecture**
|
|
57
|
-
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:
|
|
57
|
+
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`) : un défi ne sert qu'une fois.
|
|
58
58
|
|
|
59
59
|
## 📖 Lexique
|
|
60
60
|
|
|
@@ -90,7 +90,7 @@ Quatre failles fermées **par construction** :
|
|
|
90
90
|
2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
|
|
91
91
|
`IWebAuthnCredential.ts:16`). Une base volée ne donne aucun accès.
|
|
92
92
|
3. **Rejeu** — le défi est à usage unique, invalidé en session dès sa lecture
|
|
93
|
-
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:
|
|
93
|
+
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:281`).
|
|
94
94
|
4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
|
|
95
95
|
(`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts:22`).
|
|
96
96
|
|
|
@@ -114,7 +114,7 @@ Trois partis pris assumés :
|
|
|
114
114
|
- **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts:75`) :
|
|
115
115
|
mémoire par défaut, ORM ou Redis en production, avec le **même banc de contrat** pour tous.
|
|
116
116
|
- **Les endpoints sont fournis**, pas à réécrire (`mountWebAuthnRoutes()`,
|
|
117
|
-
`WebAuthnController.ts:
|
|
117
|
+
`WebAuthnController.ts:314`) : c'est là que vivent les gardes délicates (session du défi, usage
|
|
118
118
|
unique, messages uniformes, anti-IDOR).
|
|
119
119
|
|
|
120
120
|
## 🚀 Démarrage rapide
|
|
@@ -155,7 +155,7 @@ export default defineConfig(() => ({
|
|
|
155
155
|
### 2. Les endpoints BFF sont FOURNIS
|
|
156
156
|
|
|
157
157
|
Dès que `@nodefony/security` est chargé, le framework monte six routes
|
|
158
|
-
(`mountWebAuthnRoutes()`, `WebAuthnController.ts:
|
|
158
|
+
(`mountWebAuthnRoutes()`, `WebAuthnController.ts:314`) :
|
|
159
159
|
|
|
160
160
|
| Route (`POST` sauf mention) | Rôle | Firewall |
|
|
161
161
|
| -------------------------------------------------- | --------------------------------------------- | ------------------------- |
|
|
@@ -170,7 +170,7 @@ Dès que `@nodefony/security` est chargé, le framework monte six routes
|
|
|
170
170
|
> Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:365`) : elles
|
|
171
171
|
> **sont** le mécanisme d'authentification — les protéger exigerait d'être déjà connecté pour se
|
|
172
172
|
> connecter. Le contrôle d'accès de `register/*` est fait **dans le controller**
|
|
173
|
-
> (`flow.me()` → 401, `WebAuthnController.ts:
|
|
173
|
+
> (`flow.me()` → 401, `WebAuthnController.ts:119`). Les deux routes self-service, elles, restent dans
|
|
174
174
|
> la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts:314`).
|
|
175
175
|
|
|
176
176
|
### 3. Ce que TU écris : l'appel navigateur
|
|
@@ -340,7 +340,7 @@ controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
|
340
340
|
Deux chemins, deux portées :
|
|
341
341
|
|
|
342
342
|
- **Self-service** : `DELETE …/webauthn/credentials/{id}` → `WebAuthnService.removeUserCredential()`
|
|
343
|
-
(`webAuthn.ts:
|
|
343
|
+
(`webAuthn.ts:502`). La suppression n'aboutit que si le credential **appartient** au demandeur
|
|
344
344
|
(`webAuthn.ts:513`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
|
|
345
345
|
jamais l'existence de la passkey d'autrui.
|
|
346
346
|
- **Reset administrateur** : `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}`
|
|
@@ -361,7 +361,7 @@ Table dérivée du schéma Zod `passkeysSchema` (`config.ts:461`), monté sous l
|
|
|
361
361
|
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:463`) |
|
|
362
362
|
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:469`) |
|
|
363
363
|
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:473`) |
|
|
364
|
-
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:
|
|
364
|
+
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:469`) |
|
|
365
365
|
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:483`) |
|
|
366
366
|
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:489`) |
|
|
367
367
|
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:495`) |
|
|
@@ -551,7 +551,7 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
551
551
|
- `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
|
|
552
552
|
(`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
|
|
553
553
|
- `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:181`) ; le service
|
|
554
|
-
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:
|
|
554
|
+
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:47`).
|
|
555
555
|
|
|
556
556
|
### `drizzle` — SQL, le durable par défaut
|
|
557
557
|
|
|
@@ -611,8 +611,8 @@ symbolique (`.ai/symbols.json`).
|
|
|
611
611
|
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
|
|
612
612
|
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
|
|
613
613
|
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:490`) |
|
|
614
|
-
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:
|
|
615
|
-
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:
|
|
614
|
+
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
|
|
615
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:492`) |
|
|
616
616
|
|
|
617
617
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
618
618
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -644,7 +644,7 @@ doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532
|
|
|
644
644
|
backend sans comptage (Redis).
|
|
645
645
|
|
|
646
646
|
Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
|
|
647
|
-
passkey (`AuthStore.ts:
|
|
647
|
+
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:212`).
|
|
648
648
|
|
|
649
649
|
## 📜 Normes appliquées
|
|
650
650
|
|
|
@@ -658,7 +658,7 @@ passkey (`AuthStore.ts:209`).
|
|
|
658
658
|
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
|
|
659
659
|
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
|
|
660
660
|
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:483`) |
|
|
661
|
-
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:
|
|
661
|
+
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:502`) |
|
|
662
662
|
|
|
663
663
|
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
664
664
|
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
@@ -673,7 +673,7 @@ requête. Le code en tire trois conséquences.
|
|
|
673
673
|
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — une app qui n'enrôle personne ne paie
|
|
674
674
|
jamais son coût de parse.
|
|
675
675
|
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
676
|
-
(`webAuthn.ts:
|
|
676
|
+
(`webAuthn.ts:119-130`) — pas de store, pas de `Map`.
|
|
677
677
|
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
678
678
|
store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
|
|
679
679
|
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
|