@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/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
|
|
@@ -217,7 +217,7 @@ export async function enrollPasskey(): Promise<string> {
|
|
|
217
217
|
return out.credentialId;
|
|
218
218
|
}
|
|
219
219
|
|
|
220
|
-
/** Se connecter sans mot de passe
|
|
220
|
+
/** Se connecter sans mot de passe : un anonyme reçoit toujours un défi découvrable. */
|
|
221
221
|
export async function loginWithPasskey(
|
|
222
222
|
username?: string,
|
|
223
223
|
): Promise<{ username: string }> {
|
|
@@ -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`) |
|
|
@@ -404,15 +404,16 @@ sans champ à remplir.
|
|
|
404
404
|
passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
|
|
408
|
-
le navigateur propose les comptes qu'il connaît.
|
|
409
|
-
|
|
407
|
+
Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
|
|
408
|
+
`allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
|
|
409
|
+
jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
|
|
410
|
+
`WebAuthnController.ts:171`).
|
|
410
411
|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
| `{}`
|
|
414
|
-
| `{"username":"alice"}`
|
|
415
|
-
|
|
|
412
|
+
| Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
|
|
413
|
+
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
|
|
414
|
+
| anonyme, `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
|
|
415
|
+
| anonyme, `{"username":"alice"}` | identique : le `username` est ignoré | aucun ciblage, rien à apprendre sur `alice` |
|
|
416
|
+
| session authentifiée (ré-auth, step-up) | défi + `allowCredentials` des clés **de ce porteur** | ciblage de ses propres passkeys |
|
|
416
417
|
|
|
417
418
|
## 🛡️ `rpId` et origines — l'ancre anti-phishing
|
|
418
419
|
|
|
@@ -439,14 +440,16 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
|
|
|
439
440
|
|
|
440
441
|
## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
|
|
441
442
|
|
|
442
|
-
`login/options` est **accessible à un anonyme** (`bypassFirewall`)
|
|
443
|
-
|
|
444
|
-
|
|
443
|
+
`login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
|
|
444
|
+
pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
|
|
445
|
+
reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
|
|
446
|
+
(`WebAuthnController.loginOptions()`, `WebAuthnController.ts:171`). Deux surfaces restent à
|
|
447
|
+
regarder en face.
|
|
445
448
|
|
|
446
449
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
447
450
|
|
|
448
|
-
|
|
449
|
-
`allowCredentials` (`webAuthn.ts:
|
|
451
|
+
Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
452
|
+
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:389`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
450
453
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
451
454
|
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
452
455
|
|
|
@@ -460,22 +463,17 @@ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `c
|
|
|
460
463
|
(3 comptages, 0 chargement) ;
|
|
461
464
|
- retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
|
|
462
465
|
|
|
463
|
-
### Énumération :
|
|
466
|
+
### Énumération : rien ne distingue deux comptes
|
|
464
467
|
|
|
465
|
-
Un
|
|
466
|
-
|
|
467
|
-
|
|
468
|
+
Un anonyme reçoit **toujours le même défi découvrable**, sans `allowCredentials`, qu'il poste ou non
|
|
469
|
+
un `username` : aucun statut, aucune liste, aucune latence ne dit si un compte existe ou porte une
|
|
470
|
+
passkey, et l'appel ne touche même pas le store. C'est le remède que la spécification W3C WebAuthn
|
|
471
|
+
propose contre la fuite des identifiants de credentials ; `webauthnLoginOptionsPrivacy.test.ts` le
|
|
472
|
+
verrouille.
|
|
468
473
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
> [!WARNING]
|
|
474
|
-
> C'est le comportement standard d'un `login/options` avec identifiant, et la parade est
|
|
475
|
-
> architecturale : **ne transmets pas `username`**. Avec `residentKey: "required"` et un client qui
|
|
476
|
-
> poste `{}`, `allowCredentials` est omis, aucun état de compte ne transparaît, et l'appel ne touche
|
|
477
|
-
> même pas le store. Si ton UX exige la saisie d'un identifiant, place un rate-limit devant
|
|
478
|
-
> `login/options` — le firewall ne le protège pas, c'est une route en `bypassFirewall`.
|
|
474
|
+
> [!NOTE]
|
|
475
|
+
> `login/options` reste une route en `bypassFirewall` : le firewall ne la protège pas. Un rate-limit
|
|
476
|
+
> en amont reste de bonne hygiène, pour borner le coût des défis émis.
|
|
479
477
|
|
|
480
478
|
Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
481
479
|
|
|
@@ -551,7 +549,7 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
551
549
|
- `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
|
|
552
550
|
(`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
|
|
553
551
|
- `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:
|
|
552
|
+
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:47`).
|
|
555
553
|
|
|
556
554
|
### `drizzle` — SQL, le durable par défaut
|
|
557
555
|
|
|
@@ -611,8 +609,8 @@ symbolique (`.ai/symbols.json`).
|
|
|
611
609
|
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
|
|
612
610
|
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
|
|
613
611
|
| `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:
|
|
612
|
+
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
|
|
613
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:492`) |
|
|
616
614
|
|
|
617
615
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
618
616
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -644,7 +642,7 @@ doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532
|
|
|
644
642
|
backend sans comptage (Redis).
|
|
645
643
|
|
|
646
644
|
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:
|
|
645
|
+
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:212`).
|
|
648
646
|
|
|
649
647
|
## 📜 Normes appliquées
|
|
650
648
|
|
|
@@ -658,7 +656,7 @@ passkey (`AuthStore.ts:209`).
|
|
|
658
656
|
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
|
|
659
657
|
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
|
|
660
658
|
| 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:
|
|
659
|
+
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:502`) |
|
|
662
660
|
|
|
663
661
|
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
664
662
|
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
@@ -673,26 +671,25 @@ requête. Le code en tire trois conséquences.
|
|
|
673
671
|
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — une app qui n'enrôle personne ne paie
|
|
674
672
|
jamais son coût de parse.
|
|
675
673
|
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
676
|
-
(`webAuthn.ts:
|
|
674
|
+
(`webAuthn.ts:119-130`) — pas de store, pas de `Map`.
|
|
677
675
|
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
678
676
|
store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
|
|
679
677
|
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
|
|
680
678
|
|
|
681
679
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
682
680
|
|
|
683
|
-
| Symptôme | Cause (dans le code) | Correction
|
|
684
|
-
| ---------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
685
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser`
|
|
686
|
-
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`)
|
|
687
|
-
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable
|
|
688
|
-
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>`
|
|
689
|
-
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`)
|
|
690
|
-
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil
|
|
691
|
-
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait
|
|
692
|
-
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré
|
|
693
|
-
|
|
|
694
|
-
|
|
|
695
|
-
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
681
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
682
|
+
| ---------------------------------------------------- | -------------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
683
|
+
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
|
|
684
|
+
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé | Un défi = un `verify` ; envoyer le cookie (`credentials`) |
|
|
685
|
+
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
|
|
686
|
+
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost` | Accéder par `https://localhost:<port>` |
|
|
687
|
+
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host` | Choisir un `rpId` parent commun (`example.com`) |
|
|
688
|
+
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
|
|
689
|
+
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`) | Déclarer une infra durable ; le `WARNING` boot le disait |
|
|
690
|
+
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
|
|
691
|
+
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
|
|
692
|
+
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:109`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
696
693
|
|
|
697
694
|
## 🧪 Tests & couverture
|
|
698
695
|
|
package/docs/webhooks.md
CHANGED
|
@@ -66,11 +66,11 @@ Le détail de chaque étape est plus bas, dans **Architecture interne**.
|
|
|
66
66
|
|
|
67
67
|
Trois usages courants, tous branchés sur des événements que Nodefony émet déjà :
|
|
68
68
|
|
|
69
|
-
| Ce que tu veux | Tu abonnes
|
|
70
|
-
| -------------------------------------------------------------------------- |
|
|
71
|
-
| Être prévenu quand quelqu'un s'acharne sur un compte | `login.failure`
|
|
72
|
-
| Garder une trace inviolable des accès, hors de l'application | `*`
|
|
73
|
-
| Couper l'accès d'un salarié partout ailleurs quand sa session est révoquée | `
|
|
69
|
+
| Ce que tu veux | Tu abonnes | Le tiers qui reçoit |
|
|
70
|
+
| -------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------- |
|
|
71
|
+
| Être prévenu quand quelqu'un s'acharne sur un compte | `login.failure` | un canal Slack, un SMS d'astreinte |
|
|
72
|
+
| Garder une trace inviolable des accès, hors de l'application | `*` | un SIEM, un bucket d'archives |
|
|
73
|
+
| Couper l'accès d'un salarié partout ailleurs quand sa session est révoquée | `session.revoked`, `token.reuse_detected` | ton annuaire, ton outil de tickets |
|
|
74
74
|
|
|
75
75
|
Le premier, en entier — un serveur qui prévient une équipe quand un compte est attaqué :
|
|
76
76
|
|
|
@@ -251,7 +251,7 @@ import { Buffer } from "node:buffer";
|
|
|
251
251
|
// prettier-ignore
|
|
252
252
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
253
253
|
|
|
254
|
-
const SECRET = (process.env.
|
|
254
|
+
const SECRET = (process.env.NF_HOOK_SECRET ?? "").replace(/^whsec_/, "");
|
|
255
255
|
|
|
256
256
|
@controller("/hooks")
|
|
257
257
|
class HookMiniController extends Controller {
|
|
@@ -308,7 +308,7 @@ import { Buffer } from "node:buffer";
|
|
|
308
308
|
import { Controller, controller, Post, Body, Headers, BypassFirewall } from "@nodefony/framework";
|
|
309
309
|
|
|
310
310
|
/** Secret `whsec_…` donné par l'émetteur — via l'environnement, jamais en dur. */
|
|
311
|
-
const SECRET = process.env.
|
|
311
|
+
const SECRET = process.env.NF_HOOK_SECRET ?? "";
|
|
312
312
|
/** Fenêtre anti-rejeu (s) : un message plus vieux est refusé. */
|
|
313
313
|
const TOLERANCE_S = 300;
|
|
314
314
|
|
|
@@ -418,8 +418,8 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donne
|
|
|
418
418
|
| ---------- | -------------------------------------------------------------------------- |
|
|
419
419
|
| `auth` | Login/logout, chaîne d'authentification (`login.success`, `login.failure`) |
|
|
420
420
|
| `authz` | Accès accordé/refusé, voters, `@IsGranted` (`access.denied`) |
|
|
421
|
-
| `token` | Jetons longue durée
|
|
422
|
-
| `session` | Cycle de vie de session (`session.
|
|
421
|
+
| `token` | Jetons longue durée et clés d'API (`token.issued`, `apikey.revoked`) |
|
|
422
|
+
| `session` | Cycle de vie de session (`logout`, `session.revoked`) |
|
|
423
423
|
| `oauth` | Login social : authorize, callback, provisioning JIT |
|
|
424
424
|
| `webauthn` | Passkeys : enregistrement, assertion |
|
|
425
425
|
| `csrf` | Défense CSRF déclenchée |
|
|
@@ -672,7 +672,7 @@ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
|
|
|
672
672
|
|
|
673
673
|
### Auto-désactivation d'un endpoint mort
|
|
674
674
|
|
|
675
|
-
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:
|
|
675
|
+
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:682`) : succès →
|
|
676
676
|
`failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
|
|
677
677
|
est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
|
|
678
678
|
meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
|
|
@@ -725,7 +725,7 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
|
|
|
725
725
|
|
|
726
726
|
> [!NOTE]
|
|
727
727
|
> `timestampToleranceS` est **transporté** dans la politique de livraison
|
|
728
|
-
> (`getDeliveryPolicy()`, `webhooks.ts:
|
|
728
|
+
> (`getDeliveryPolicy()`, `webhooks.ts:663`) mais l'émetteur ne l'applique jamais : la fenêtre
|
|
729
729
|
> anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
|
|
730
730
|
> tu documentes à tes destinataires — c'est celle du récepteur qui protège.
|
|
731
731
|
|
|
@@ -956,21 +956,21 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
|
|
|
956
956
|
|
|
957
957
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
958
958
|
|
|
959
|
-
| Symptôme
|
|
960
|
-
|
|
|
961
|
-
| Boot : « webhooks désactivés » en production
|
|
962
|
-
| Après redémarrage, les signatures ne valident plus
|
|
963
|
-
| `422` à la création d'un endpoint
|
|
964
|
-
| Rien n'arrive alors que l'endpoint est actif
|
|
965
|
-
| Un événement « métier » n'arrive jamais
|
|
966
|
-
| Signature invalide côté récepteur
|
|
967
|
-
| Le récepteur voit deux fois le même événement
|
|
968
|
-
| Endpoint passé `enabled: false` tout seul
|
|
969
|
-
| Livraisons « abandonnées » dans les logs
|
|
970
|
-
| Un `302` vers l'interne n'est pas suivi
|
|
971
|
-
| Le secret a été perdu
|
|
972
|
-
| Après un redémarrage, plus aucun endpoint
|
|
973
|
-
| Multi-pod : un endpoint créé ne livre
|
|
959
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
960
|
+
| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
961
|
+
| Boot : « webhooks désactivés » en production | Aucune `encryptionKey` — fail-safe (`webhooks.ts:299`) | `npx nodefony security:secrets` puis câbler `NF_WEBHOOK_KEY` |
|
|
962
|
+
| Après redémarrage, les signatures ne valident plus | Clé **éphémère** de dev : les secrets stockés sont illisibles | Poser une `encryptionKey` stable ; tourner les secrets des endpoints |
|
|
963
|
+
| `422` à la création d'un endpoint | `assertPublicUrl()` refuse la cible (IP interne, schéma, userinfo) | Viser une URL publique en `https://` ; en dev, `denyPrivateIps: false` |
|
|
964
|
+
| Rien n'arrive alors que l'endpoint est actif | Aucun `auditService` → dispatcher inactif (`webhooks.ts:191`) | Vérifier que l'audit est activé ; le CRUD seul ne livre rien |
|
|
965
|
+
| Un événement « métier » n'arrive jamais | La source est le **journal d'audit**, pas un bus applicatif | S'abonner à une action d'audit existante |
|
|
966
|
+
| Signature invalide côté récepteur | Corps re-sérialisé avant le HMAC, ou en-tête `webhook-id`/`-timestamp` ignoré | Lire le corps **brut** ; signer `{id}.{timestamp}.{body}` |
|
|
967
|
+
| Le récepteur voit deux fois le même événement | Un retry rejoue le **même** `webhook-id` | Dédupliquer par `webhook-id` côté récepteur |
|
|
968
|
+
| Endpoint passé `enabled: false` tout seul | 20 échecs consécutifs → auto-désactivation (`webhooks.ts:565`) | Réparer la destination, puis `PATCH … {"enabled":true}` |
|
|
969
|
+
| Livraisons « abandonnées » dans les logs | File pleine (`maxQueue`) — best-effort assumé | Augmenter `maxQueue`/`maxConcurrent`, ou réduire le volume souscrit |
|
|
970
|
+
| Un `302` vers l'interne n'est pas suivi | **Voulu** : `node:http(s)` ne suit jamais les 3xx | Rien à corriger — configurer l'URL finale côté destinataire |
|
|
971
|
+
| Le secret a été perdu | Il n'est montré qu'à la création/rotation | `POST …/reveal` (audité) ou rotation + redéploiement chez le tiers |
|
|
972
|
+
| Après un redémarrage, plus aucun endpoint | `store: "memory"` — registre volatil et par pod | Déclarer une infra durable (`store: "auto"` suffit alors) |
|
|
973
|
+
| Multi-pod : un endpoint créé ne livre pas tout de suite depuis les autres pods | Le snapshot est relu après `webhooks.snapshotTtlS` (30 s par défaut, `webhooks.ts:406`), pas immédiatement | Attendre au plus `snapshotTtlS`, ou le baisser |
|
|
974
974
|
|
|
975
975
|
## 🧪 Tests & couverture
|
|
976
976
|
|
|
@@ -983,7 +983,7 @@ depuis vitest, jamais figés ici :
|
|
|
983
983
|
historique, bornes de perf, arrêt) ; `webhookSignature` (vecteur officiel Standard Webhooks,
|
|
984
984
|
altération du corps/id/timestamp) ; `webhookDelivery` (pin d'IP, non-suivi des 3xx, timeout,
|
|
985
985
|
politique de protocole, capture du corps de réponse) ; `webhookStore` (CRUD mémoire + copie
|
|
986
|
-
défensive) ; `webhookAdminApi` (les
|
|
986
|
+
défensive) ; `webhookAdminApi` (les 9 endpoints, rôles, `400`/`404`/`422`/`503`, audit des
|
|
987
987
|
mutations) ; `webhookPagination` (harnais du banc de contrat sur le store mémoire).
|
|
988
988
|
- **attaque (red-team)** — `webhookSsrf.attack.test.ts` : matrice threat-first dérivée d'OWASP SSRF
|
|
989
989
|
et de CAPEC-664 (IPv4-mapped IPv6 sous 7 notations, confusion `userinfo`, IP encodée
|
|
@@ -1017,6 +1017,3 @@ Couverture : `npm run coverage` dans `@nodefony/security`. Revue de sécurité d
|
|
|
1017
1017
|
- La **source des événements** livrés → [audit](audit.md) · Le pare-feu qui les produit → [firewall](firewall.md)
|
|
1018
1018
|
- Secrets et jetons du module, mêmes principes de chiffrement au repos → [tokens](tokens.md)
|
|
1019
1019
|
- Vocabulaire transverse de la sécurité → [lexique](lexique.md)
|
|
1020
|
-
</content>
|
|
1021
|
-
|
|
1022
|
-
</invoke>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nodefony/security",
|
|
3
|
-
"version": "10.0.0-
|
|
3
|
+
"version": "10.0.0-beta.2",
|
|
4
4
|
"description": "Pare-feu applicatif par zones pour Nodefony : authentification, autorisation par rôles, protection CSRF, journal d'audit",
|
|
5
5
|
"author": "Christophe CAMENSULI <ccamensuli@gmail.com>",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -48,28 +48,28 @@
|
|
|
48
48
|
"directory": "src/packages/@nodefony/security"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@simplewebauthn/server": "14.0.
|
|
51
|
+
"@simplewebauthn/server": "14.0.3",
|
|
52
52
|
"jose": "6.2.12",
|
|
53
53
|
"tslib": "2.8.1",
|
|
54
54
|
"zod": "^4.6.5"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
|
-
"@nodefony/framework": "^10.0.0-
|
|
58
|
-
"@nodefony/http": "^10.0.0-
|
|
59
|
-
"@nodefony/user": "^10.0.0-
|
|
60
|
-
"@types/node": "26.6.
|
|
61
|
-
"@vitest/coverage-v8": "5.0.
|
|
62
|
-
"nodefony": "^10.0.0-
|
|
63
|
-
"vitest": "5.0.
|
|
57
|
+
"@nodefony/framework": "^10.0.0-beta.2",
|
|
58
|
+
"@nodefony/http": "^10.0.0-beta.2",
|
|
59
|
+
"@nodefony/user": "^10.0.0-beta.2",
|
|
60
|
+
"@types/node": "26.6.3",
|
|
61
|
+
"@vitest/coverage-v8": "5.0.3",
|
|
62
|
+
"nodefony": "^10.0.0-beta.2",
|
|
63
|
+
"vitest": "5.0.3"
|
|
64
64
|
},
|
|
65
65
|
"license": "Apache-2.0",
|
|
66
66
|
"readmeFilename": "README.md",
|
|
67
67
|
"contributors": [],
|
|
68
68
|
"peerDependencies": {
|
|
69
|
-
"@nodefony/framework": "^10.0.0-
|
|
70
|
-
"@nodefony/http": "^10.0.0-
|
|
71
|
-
"@nodefony/user": "^10.0.0-
|
|
72
|
-
"nodefony": "^10.0.0-
|
|
69
|
+
"@nodefony/framework": "^10.0.0-beta.2",
|
|
70
|
+
"@nodefony/http": "^10.0.0-beta.2",
|
|
71
|
+
"@nodefony/user": "^10.0.0-beta.2",
|
|
72
|
+
"nodefony": "^10.0.0-beta.2"
|
|
73
73
|
},
|
|
74
74
|
"files": [
|
|
75
75
|
"dist",
|