@nodefony/security 10.0.0-alpha.8 → 10.0.0-alpha.9
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/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -2
- package/dist/nodefony/service/tokenService.js +3 -2
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +3 -2
- package/dist/nodefony/service/webhooks.js +5 -3
- package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +62 -60
- package/docs/audit.md +77 -77
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +13 -13
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +29 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +60 -60
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +55 -55
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +81 -81
- package/docs/webhooks.md +43 -37
- package/package.json +15 -12
package/docs/webauthn.md
CHANGED
|
@@ -32,7 +32,7 @@ source: "src/packages/@nodefony/security/docs/webauthn.md"
|
|
|
32
32
|
> l'authenticator (Touch ID, Windows Hello, clé FIDO). Le serveur ne détient que des **clés
|
|
33
33
|
> publiques** et ne fait que **vérifier des signatures** : rien à hameçonner, rien à rejouer, rien à
|
|
34
34
|
> voler dans la base. Nodefony orchestre les deux cérémonies FIDO2 dans `WebAuthnService`
|
|
35
|
-
> (`webAuthn.ts:
|
|
35
|
+
> (`webAuthn.ts:99`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
|
|
36
36
|
> navigateur.
|
|
37
37
|
|
|
38
38
|
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **WebAuthn / Passkeys**
|
|
@@ -52,7 +52,7 @@ flowchart LR
|
|
|
52
52
|
```
|
|
53
53
|
|
|
54
54
|
Le défi vit **hors du service**, en session BFF, posé par le controller
|
|
55
|
-
(`WebAuthnController.registerOptions()`, `WebAuthnController.ts:
|
|
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
57
|
(`WebAuthnController.#takeChallenge()`, `WebAuthnController.ts:277`) : un défi ne sert qu'une fois.
|
|
58
58
|
|
|
@@ -99,17 +99,17 @@ matérielle attestée).
|
|
|
99
99
|
|
|
100
100
|
## La vision Nodefony — le serveur ne détient aucun secret
|
|
101
101
|
|
|
102
|
-
`WebAuthnService` (`webAuthn.ts:
|
|
102
|
+
`WebAuthnService` (`webAuthn.ts:99`) **orchestre**, il ne fait pas de cryptographie : le parsing
|
|
103
103
|
CBOR/COSE et la vérification des signatures (ES256/RS256/EdDSA) sont délégués à
|
|
104
104
|
`@simplewebauthn/server`, une bibliothèque auditée de l'écosystème, **importée paresseusement** au
|
|
105
|
-
premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts:
|
|
105
|
+
premier usage (`WebAuthnService.#ensureLib()`, `webAuthn.ts:545`) — l'enrôlement et le login sont des
|
|
106
106
|
chemins froids, ils ne doivent rien coûter aux requêtes ordinaires.
|
|
107
107
|
|
|
108
108
|
Trois partis pris assumés :
|
|
109
109
|
|
|
110
110
|
- **Le service est sans état de session.** Le défi est porté par le controller BFF ; le service reçoit
|
|
111
111
|
toujours l'`expectedChallenge` en paramètre (`WebAuthnService.verifyRegistration()`,
|
|
112
|
-
`webAuthn.ts:
|
|
112
|
+
`webAuthn.ts:323`). Conséquence pratique : le service se teste sans transport, et un défi n'est
|
|
113
113
|
jamais « oublié » quelque part côté serveur.
|
|
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.
|
|
@@ -121,9 +121,9 @@ Trois partis pris assumés :
|
|
|
121
121
|
|
|
122
122
|
### 1. Les passkeys sont déjà actives — la config utile
|
|
123
123
|
|
|
124
|
-
`passkeys.enabled` vaut `true` par défaut (`config.ts:
|
|
124
|
+
`passkeys.enabled` vaut `true` par défaut (`config.ts:463`). Ce que tu déclares vraiment, c'est **ton
|
|
125
125
|
domaine** : sans `rpId`, le service prend le domaine de l'app, et bascule sur `localhost` si c'est une
|
|
126
|
-
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:
|
|
126
|
+
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:141`).
|
|
127
127
|
|
|
128
128
|
```typescript
|
|
129
129
|
// nodefony.config.ts (extrait) — activer les passkeys pour TON domaine
|
|
@@ -235,8 +235,8 @@ export async function loginWithPasskey(
|
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
Référence vivante dans le dépôt : Studio fait exactement ces deux appels —
|
|
238
|
-
`AuthService.loginWithPasskey()` (`AuthService.ts:
|
|
239
|
-
(`AuthService.ts:
|
|
238
|
+
`AuthService.loginWithPasskey()` (`AuthService.ts:111`) et `AuthService.registerPasskey()`
|
|
239
|
+
(`AuthService.ts:133`).
|
|
240
240
|
|
|
241
241
|
### 4. Ce qu'on observe
|
|
242
242
|
|
|
@@ -283,23 +283,23 @@ sequenceDiagram
|
|
|
283
283
|
|
|
284
284
|
### Enrôler une passkey sur un compte existant
|
|
285
285
|
|
|
286
|
-
`WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:
|
|
287
|
-
contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:
|
|
288
|
-
authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:
|
|
286
|
+
`WebAuthnService.generateRegistrationOptions()` (`webAuthn.ts:282`) construit le défi et les
|
|
287
|
+
contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:297`) : le même
|
|
288
|
+
authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:301`),
|
|
289
289
|
`authenticatorAttachment` n'est transmis **que** s'il vaut autre chose que `"any"` — `"any"` rend la
|
|
290
290
|
main au navigateur, téléphone par QR compris.
|
|
291
291
|
|
|
292
|
-
`WebAuthnService.verifyRegistration()` (`webAuthn.ts:
|
|
292
|
+
`WebAuthnService.verifyRegistration()` (`webAuthn.ts:323`) enchaîne dans cet ordre :
|
|
293
293
|
|
|
294
294
|
1. **Vérification déléguée** à `verifyRegistrationResponse` — défi, origine, rpIdHash, flags,
|
|
295
295
|
attestation (`webAuthn.ts:330`). Tout échec devient un message uniforme
|
|
296
|
-
`WebAuthn registration failed` (`webAuthn.ts:
|
|
296
|
+
`WebAuthn registration failed` (`webAuthn.ts:345`).
|
|
297
297
|
2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
|
|
298
|
-
(`webAuthn.ts:
|
|
298
|
+
(`webAuthn.ts:355`). Volontairement **après** la cryptographie et **avant** le `save` : un client
|
|
299
299
|
peut poster `register/verify` sans jamais appeler `register/options` — c'est l'écriture qu'il faut
|
|
300
300
|
garder, pas la génération du défi.
|
|
301
|
-
3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts:
|
|
302
|
-
de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:
|
|
301
|
+
3. **Persistance** de la clé publique + l'état initial (`webAuthn.ts:365`) — `backupEligible` dérive
|
|
302
|
+
de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:365`).
|
|
303
303
|
|
|
304
304
|
### Se connecter sans mot de passe
|
|
305
305
|
|
|
@@ -323,9 +323,9 @@ est ignoré.
|
|
|
323
323
|
> produit des credentials non découvrables — leurs porteurs ne pourront plus se connecter, et le
|
|
324
324
|
> service l'avertit au boot.
|
|
325
325
|
|
|
326
|
-
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts:
|
|
327
|
-
(`webAuthn.ts:
|
|
328
|
-
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:
|
|
326
|
+
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts:420`) résout le credential par son id
|
|
327
|
+
(`webAuthn.ts:420`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
|
|
328
|
+
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:460`). Le
|
|
329
329
|
controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
330
330
|
(`WebAuthnController.ts:64`).
|
|
331
331
|
|
|
@@ -340,11 +340,11 @@ 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:
|
|
344
|
-
(`webAuthn.ts:
|
|
343
|
+
(`webAuthn.ts:507`). La suppression n'aboutit que si le credential **appartient** au demandeur
|
|
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}`
|
|
347
|
-
(`SecurityAdminApi.ts:
|
|
347
|
+
(`SecurityAdminApi.ts:590`) — audité, et **404 identique** si la passkey n'appartient pas à
|
|
348
348
|
l'utilisateur visé, même pour un admin.
|
|
349
349
|
|
|
350
350
|
Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
|
|
@@ -353,23 +353,23 @@ exactement les porteurs à risque de verrouillage.
|
|
|
353
353
|
|
|
354
354
|
## ⚙️ Configuration
|
|
355
355
|
|
|
356
|
-
Table dérivée du schéma Zod `passkeysSchema` (`config.ts:
|
|
357
|
-
(`config.ts:
|
|
356
|
+
Table dérivée du schéma Zod `passkeysSchema` (`config.ts:461`), monté sous la clé `passkeys`
|
|
357
|
+
(`config.ts:1143`).
|
|
358
358
|
|
|
359
359
|
| Option | Type | Défaut | Effet |
|
|
360
360
|
| ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
|
|
361
|
-
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:
|
|
362
|
-
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:
|
|
363
|
-
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:
|
|
364
|
-
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:
|
|
365
|
-
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:
|
|
366
|
-
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:
|
|
367
|
-
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:
|
|
368
|
-
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:
|
|
369
|
-
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:
|
|
370
|
-
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:
|
|
371
|
-
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:
|
|
372
|
-
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:
|
|
361
|
+
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:463`) |
|
|
362
|
+
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:469`) |
|
|
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:479`) |
|
|
365
|
+
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:483`) |
|
|
366
|
+
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:489`) |
|
|
367
|
+
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:495`) |
|
|
368
|
+
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:501`) |
|
|
369
|
+
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:507`) |
|
|
370
|
+
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:515`) |
|
|
371
|
+
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:523`) |
|
|
372
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:528`) |
|
|
373
373
|
|
|
374
374
|
### Mise en situation — trois politiques, trois publics
|
|
375
375
|
|
|
@@ -393,7 +393,7 @@ passkeys: {
|
|
|
393
393
|
|
|
394
394
|
> [!WARNING]
|
|
395
395
|
> `attestation: "direct"` **récupère** le certificat, il ne le **valide pas** : Nodefony ne vérifie ni
|
|
396
|
-
> l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:
|
|
396
|
+
> l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:501`). Tant que cette vérification n'est pas
|
|
397
397
|
> faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
|
|
398
398
|
> privée (l'attestation identifie le modèle d'authenticator).
|
|
399
399
|
|
|
@@ -419,17 +419,17 @@ privée — voir la section suivante.
|
|
|
419
419
|
Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
|
|
420
420
|
domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
|
|
421
421
|
|
|
422
|
-
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:
|
|
422
|
+
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:120`) : `passkeys.rpId` sinon le domaine
|
|
423
423
|
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
|
|
424
424
|
host non-domaine que la spécification autorise. En développement, accède donc au serveur par
|
|
425
425
|
`https://localhost:5152`, jamais par `127.0.0.1`.
|
|
426
426
|
|
|
427
|
-
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:
|
|
428
|
-
temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:
|
|
427
|
+
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:528`) en trois
|
|
428
|
+
temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:529`, la voie de
|
|
429
429
|
production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
|
|
430
|
-
`rpId`** (`webAuthn.ts:
|
|
430
|
+
`rpId`** (`webAuthn.ts:535` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
|
|
431
431
|
est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
|
|
432
|
-
(`webAuthn.ts:
|
|
432
|
+
(`webAuthn.ts:542`).
|
|
433
433
|
|
|
434
434
|
> [!WARNING]
|
|
435
435
|
> **Un seul `rpId` par instance.** Il est résolu une fois au boot et stocké dans le service ; il n'y a
|
|
@@ -440,21 +440,21 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
|
|
|
440
440
|
## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
|
|
441
441
|
|
|
442
442
|
`login/options` est **accessible à un anonyme** (`bypassFirewall`) et accepte un `username`
|
|
443
|
-
(`WebAuthnController.ts:
|
|
443
|
+
(`WebAuthnController.ts:168`). C'est nécessaire — on ne peut pas exiger d'être connecté pour se
|
|
444
444
|
connecter — mais cela ouvre deux surfaces qu'il faut regarder en face.
|
|
445
445
|
|
|
446
446
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
447
447
|
|
|
448
448
|
Avec un `username`, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
449
|
-
`allowCredentials` (`webAuthn.ts:
|
|
449
|
+
`allowCredentials` (`webAuthn.ts:399`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
450
450
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
451
451
|
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
452
452
|
|
|
453
|
-
Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:
|
|
453
|
+
Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:515`) :
|
|
454
454
|
|
|
455
455
|
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
|
|
456
456
|
client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
|
|
457
|
-
appareil (`WebAuthnController.ts:
|
|
457
|
+
appareil (`WebAuthnController.ts:294-298`) ;
|
|
458
458
|
- le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts:97`) : `COUNT` / `SCARD`,
|
|
459
459
|
jamais un `findByUser().length` — `webAuthnEnrollmentLimit.test.ts` le prouve avec un store espion
|
|
460
460
|
(3 comptages, 0 chargement) ;
|
|
@@ -481,10 +481,10 @@ Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
|
481
481
|
|
|
482
482
|
| Vecteur | Garde |
|
|
483
483
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
484
|
-
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:
|
|
485
|
-
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:
|
|
486
|
-
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:
|
|
487
|
-
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:
|
|
484
|
+
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:277`) — E1/E1bis |
|
|
485
|
+
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:74-75`) — E2 |
|
|
486
|
+
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:292`) — E4 |
|
|
487
|
+
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:513`) |
|
|
488
488
|
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
|
|
489
489
|
|
|
490
490
|
## Entité de persistance — ce qui est écrit, et en quels types
|
|
@@ -497,7 +497,7 @@ clé privée n'existe que dans l'authenticator.
|
|
|
497
497
|
| `id` | Identifiant du credential, base64url — clé naturelle | `text` PK | `_id: String` | clé `nf:wac:cred:<id>` |
|
|
498
498
|
| `userId` | Porteur (= identifiant applicatif / `userHandle`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
|
|
499
499
|
| `publicKey` | Clé publique **COSE**, base64url | `text` notNull | `String` requis | champ |
|
|
500
|
-
| `signCount` | Compteur anti-clone (§6.1.1) | `
|
|
500
|
+
| `signCount` | Compteur anti-clone (§6.1.1) | `int64` notNull | `Number` requis | champ |
|
|
501
501
|
| `transports` | `usb`\|`nfc`\|`ble`\|`internal`\|`hybrid` | `json` notNull | `[String]` | champ JSON |
|
|
502
502
|
| `backupEligible` | BE flag — fixé à l'enrôlement, **immuable** | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
|
|
503
503
|
| `backupState` | BS flag — la passkey **est** sauvegardée | `bool` notNull | `Boolean` requis | `"1"`/`"0"` |
|
|
@@ -507,7 +507,7 @@ clé privée n'existe que dans l'authenticator.
|
|
|
507
507
|
| `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
|
|
508
508
|
|
|
509
509
|
Spécification SQL : `webAuthnCredentialEntity.ts:28`, index sur `userId`
|
|
510
|
-
(`drizzle/nodefony/entity/webAuthnCredentialEntity.ts:
|
|
510
|
+
(`drizzle/nodefony/entity/webAuthnCredentialEntity.ts:34`). Schéma documentaire :
|
|
511
511
|
`mongoose/nodefony/entity/webAuthnCredentialEntity.ts:25` — `_id` **est** le credentialId (String, pas
|
|
512
512
|
un ObjectId), les horodatages sont des `Number` epoch ms.
|
|
513
513
|
|
|
@@ -549,9 +549,9 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
549
549
|
- Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts:53`). Deux index :
|
|
550
550
|
`#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts:47`.
|
|
551
551
|
- `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
|
|
552
|
-
(`MemoryWebAuthnCredentialStore.ts:
|
|
553
|
-
- `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:
|
|
554
|
-
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:
|
|
552
|
+
(`MemoryWebAuthnCredentialStore.ts:26`). C'est lui qui pilote le banc de contrat partagé.
|
|
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:257`).
|
|
555
555
|
|
|
556
556
|
### `drizzle` — SQL, le durable par défaut
|
|
557
557
|
|
|
@@ -559,7 +559,7 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
559
559
|
`DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts:37`) est **100 % portable** —
|
|
560
560
|
aucune requête SQL native, tout passe par `IRepository` d'`orm-core`.
|
|
561
561
|
- Trois dialectes sur le même banc : **sqlite** (toujours, `:memory:`), **postgres** et **mysql**
|
|
562
|
-
(gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts:
|
|
562
|
+
(gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts:199`).
|
|
563
563
|
|
|
564
564
|
### `mongoose` — MongoDB
|
|
565
565
|
|
|
@@ -603,16 +603,16 @@ symbolique (`.ai/symbols.json`).
|
|
|
603
603
|
|
|
604
604
|
| Méthode | Quand tu l'appelles |
|
|
605
605
|
| --------------------------------- | ------------------------------------------------------------------------ |
|
|
606
|
-
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:
|
|
607
|
-
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:
|
|
608
|
-
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:
|
|
609
|
-
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:
|
|
610
|
-
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:
|
|
611
|
-
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:
|
|
612
|
-
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:
|
|
613
|
-
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:
|
|
614
|
-
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:
|
|
615
|
-
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:
|
|
606
|
+
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:268`) |
|
|
607
|
+
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:282`) |
|
|
608
|
+
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:323`) |
|
|
609
|
+
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:390`) |
|
|
610
|
+
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:420`) |
|
|
611
|
+
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:466`) |
|
|
612
|
+
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:479`) |
|
|
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:507`) |
|
|
615
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:496`) |
|
|
616
616
|
|
|
617
617
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
618
618
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -626,21 +626,21 @@ Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
|
626
626
|
|
|
627
627
|
## 📡 Observabilité — Studio
|
|
628
628
|
|
|
629
|
-
Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:
|
|
629
|
+
Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:308`), toutes en
|
|
630
630
|
`ROLE_NODEFONY_ADMIN` :
|
|
631
631
|
|
|
632
632
|
<!-- prettier-ignore -->
|
|
633
633
|
| Route | Ce qu'elle montre |
|
|
634
634
|
| --- | --- |
|
|
635
|
-
| `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts:
|
|
636
|
-
| `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts:
|
|
637
|
-
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:
|
|
635
|
+
| `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts:499`) |
|
|
636
|
+
| `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts:558`) |
|
|
637
|
+
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:590`) |
|
|
638
638
|
|
|
639
639
|
Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
|
|
640
640
|
publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:267`),
|
|
641
641
|
et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
|
|
642
642
|
défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
|
|
643
|
-
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:
|
|
643
|
+
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532`). `total: -1` signale un
|
|
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
|
|
@@ -650,15 +650,15 @@ passkey (`AuthStore.ts:209`).
|
|
|
650
650
|
|
|
651
651
|
| Domaine | Norme | Ancrage |
|
|
652
652
|
| ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
|
|
653
|
-
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:
|
|
654
|
-
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:
|
|
653
|
+
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:323`) |
|
|
654
|
+
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:420`) |
|
|
655
655
|
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
|
|
656
656
|
| Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts:29`) |
|
|
657
|
-
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:
|
|
657
|
+
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:528`) |
|
|
658
658
|
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
|
|
659
|
-
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:
|
|
660
|
-
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:
|
|
661
|
-
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:
|
|
659
|
+
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:515`) |
|
|
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:507`) |
|
|
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
|
|
@@ -670,10 +670,10 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
|
|
|
670
670
|
requête. Le code en tire trois conséquences.
|
|
671
671
|
|
|
672
672
|
- **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
|
|
673
|
-
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:
|
|
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:129-131`) — 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`).
|
|
@@ -682,7 +682,7 @@ requête. Le code en tire trois conséquences.
|
|
|
682
682
|
|
|
683
683
|
| Symptôme | Cause (dans le code) | Correction |
|
|
684
684
|
| ---------------------------------------------------- | -------------------------------------------------------------------------- | -------------------------------------------------------------- |
|
|
685
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:
|
|
685
|
+
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
|
|
686
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
687
|
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
|
|
688
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>` |
|
package/docs/webhooks.md
CHANGED
|
@@ -146,7 +146,7 @@ propre serveur**.
|
|
|
146
146
|
Trois partis pris, tous vérifiables dans le code :
|
|
147
147
|
|
|
148
148
|
1. **La source est le journal d'audit, pas un bus métier.** `WebhookService.#attachDispatcher()`
|
|
149
|
-
(`webhooks.ts:
|
|
149
|
+
(`webhooks.ts:198`) s'abonne au service `auditService` et à lui seul. Si l'audit est absent, le
|
|
150
150
|
dispatcher n'existe pas — le CRUD d'endpoints reste disponible, mais **rien ne part**. C'est une
|
|
151
151
|
limite assumée : les webhooks notifient des **événements de sécurité**.
|
|
152
152
|
2. **Le secret est chiffré, jamais haché.** Contrairement à une clé d'API (qu'on vérifie donc qu'on
|
|
@@ -160,11 +160,11 @@ Trois partis pris, tous vérifiables dans le code :
|
|
|
160
160
|
|
|
161
161
|
### 1. Activer les webhooks et poser la clé de chiffrement
|
|
162
162
|
|
|
163
|
-
Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:
|
|
163
|
+
Les webhooks sont **actifs par défaut** (`enabled: true` dans le schéma Zod, `security/nodefony/config/config.ts:785`).
|
|
164
164
|
La seule chose que tu dois vraiment fournir, c'est la **clé de chiffrement des secrets de signature** :
|
|
165
165
|
sans elle, une clé éphémère est générée en dev (avec un WARNING), et en production les webhooks sont
|
|
166
166
|
**désactivés** — un secret chiffré par une clé perdue au redémarrage serait illisible
|
|
167
|
-
(`WebhookService.#resolveKey()`, `webhooks.ts:
|
|
167
|
+
(`WebhookService.#resolveKey()`, `webhooks.ts:316`).
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
# Génère les clés du module security et guide le câblage en 3 fichiers.
|
|
@@ -210,7 +210,7 @@ export default defineConfig<typeof env>((ctx) => ({
|
|
|
210
210
|
|
|
211
211
|
Il n'y a **pas de déclaration d'endpoint en config** : un abonnement est une donnée, créée à
|
|
212
212
|
l'exécution via le data plane admin `/nodefony/security/api/webhooks`, gardé
|
|
213
|
-
`ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts:
|
|
213
|
+
`ROLE_NODEFONY_ADMIN` (`webhookAdminEndpoints()`, `WebhookAdminApi.ts:219`).
|
|
214
214
|
|
|
215
215
|
```bash
|
|
216
216
|
# Session admin (le BFF de login pose le cookie)
|
|
@@ -408,11 +408,11 @@ retries** : les tentatives intermédiaires ne sont pas tracées, seule l'issue p
|
|
|
408
408
|
## Quels événements partent en webhook ?
|
|
409
409
|
|
|
410
410
|
Réponse honnête et vérifiable : **les actions du journal d'audit de sécurité, et rien d'autre**. Le
|
|
411
|
-
dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts:
|
|
412
|
-
fire-and-forget de `AuditService.record()` (`auditService.ts:
|
|
411
|
+
dispatcher est branché sur `AuditService.subscribe()` (`auditService.ts:212`), appelé dans le
|
|
412
|
+
fire-and-forget de `AuditService.record()` (`auditService.ts:187`). Aucun autre point d'émission
|
|
413
413
|
n'existe dans le code.
|
|
414
414
|
|
|
415
|
-
Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:
|
|
415
|
+
Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:24`) donnent la surface réelle :
|
|
416
416
|
|
|
417
417
|
| Catégorie | Ce qu'elle trace (exemples d'`action`) |
|
|
418
418
|
| ---------- | -------------------------------------------------------------------------- |
|
|
@@ -428,6 +428,10 @@ Les catégories d'audit disponibles (`AuditCategory`, `IAuditEvent.ts:16`) donne
|
|
|
428
428
|
| `webhook` | Vie des webhooks eux-mêmes (`webhook.created`, `webhook.disabled`) |
|
|
429
429
|
| `config` | Mutation de config runtime depuis Studio |
|
|
430
430
|
|
|
431
|
+
> [!NOTE]
|
|
432
|
+
> Trois catégories sont **réservées** au contrat sans émetteur à ce jour : `oauth`, `csrf`, `cors`
|
|
433
|
+
> (`IAuditEvent.ts:16`). S'y abonner est accepté, mais ne livre jamais rien.
|
|
434
|
+
|
|
431
435
|
### La syntaxe d'abonnement
|
|
432
436
|
|
|
433
437
|
Le champ `events` d'un endpoint accepte trois formes, résolues par `matchesSubscription()`
|
|
@@ -480,7 +484,7 @@ qui comptent :
|
|
|
480
484
|
| `webhook-timestamp` | epoch **secondes** | fenêtre **anti-rejeu** |
|
|
481
485
|
| `webhook-signature` | `v1,<base64(HMAC-SHA256)>` | preuve de l'émetteur |
|
|
482
486
|
|
|
483
|
-
Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts:
|
|
487
|
+
Le secret est un `whsec_<base64 de 256 bits>` généré par `generateSecret()` (`webhooks.ts:117`) ;
|
|
484
488
|
`parseWebhookSecret()` (`webhookSignature.ts:22`) décode la partie base64 — le préfixe n'entre pas
|
|
485
489
|
dans la clé HMAC, et un secret déjà sans préfixe est toléré.
|
|
486
490
|
|
|
@@ -510,7 +514,7 @@ curl -sk -b /tmp/jar -X POST \
|
|
|
510
514
|
# {"endpoint":{…}, "secret":"whsec_NOUVEAU…"}
|
|
511
515
|
```
|
|
512
516
|
|
|
513
|
-
`WebhookService.rotateSecret()` (`webhooks.ts:
|
|
517
|
+
`WebhookService.rotateSecret()` (`webhooks.ts:560`) régénère et rechiffre. Comportement à connaître
|
|
514
518
|
**avant** de cliquer :
|
|
515
519
|
|
|
516
520
|
- l'ancien secret cesse d'être valide **immédiatement** — il n'y a pas de fenêtre de recouvrement
|
|
@@ -668,7 +672,7 @@ abandon. Chaque retry **repasse par la file bornée** (`#scheduleRetry()`,
|
|
|
668
672
|
|
|
669
673
|
### Auto-désactivation d'un endpoint mort
|
|
670
674
|
|
|
671
|
-
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:
|
|
675
|
+
Chaque issue finale passe par `WebhookService.markDelivery()` (`webhooks.ts:687`) : succès →
|
|
672
676
|
`failureCount = 0` ; échec → incrément. Au-delà de `autoDisableThreshold` (défaut **20**), l'endpoint
|
|
673
677
|
est **désactivé** et un unique événement d'audit `webhook.disabled` est émis — **un par endpoint qui
|
|
674
678
|
meurt**, jamais un par échec (le volume resterait ingérable). Mettre le seuil à `0` désactive
|
|
@@ -681,7 +685,7 @@ Le scénario vécu, du début à la fin :
|
|
|
681
685
|
1. **Tentative 1** → `ECONNREFUSED`. Classé `retry` ; rien n'est encore écrit en base.
|
|
682
686
|
2. **Tentatives 2 à 6** sur ~4 min. Toujours rien de persisté (seule l'issue finale l'est).
|
|
683
687
|
3. **Abandon.** `markDelivery` écrit `lastDeliveryStatus: null`, `lastDeliveryError`, et incrémente
|
|
684
|
-
`failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts:
|
|
688
|
+
`failureCount`. Une trace part dans l'historique RAM (`#recordDelivery()`, `webhooks.ts:612`).
|
|
685
689
|
4. **L'événement est PERDU.** Il n'y a pas de file persistée : un webhook est **best-effort**. Rien
|
|
686
690
|
ne sera rejoué quand le destinataire reviendra.
|
|
687
691
|
5. **Après 20 échecs consécutifs**, l'endpoint passe `enabled: false` et cesse de consommer des
|
|
@@ -694,14 +698,14 @@ Le scénario vécu, du début à la fin :
|
|
|
694
698
|
|
|
695
699
|
### Arrêt propre
|
|
696
700
|
|
|
697
|
-
`WebhookService.#shutdown()` (`webhooks.ts:
|
|
701
|
+
`WebhookService.#shutdown()` (`webhooks.ts:234`) se désabonne de l'audit puis appelle
|
|
698
702
|
`WebhookDispatcher.shutdown()` (`WebhookDispatcher.ts:280`) : admission stoppée, **tous les timers de
|
|
699
703
|
retry annulés**, file relâchée. Aucun timer orphelin ne retient le process — et les timers de retry
|
|
700
|
-
sont `unref()` (`webhooks.ts:
|
|
704
|
+
sont `unref()` (`webhooks.ts:222`), donc ils n'empêchent jamais Node de sortir.
|
|
701
705
|
|
|
702
706
|
## ⚙️ Configuration
|
|
703
707
|
|
|
704
|
-
Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:
|
|
708
|
+
Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/config.ts:783`), lue via
|
|
705
709
|
`use("@nodefony/security", { webhooks: … })`.
|
|
706
710
|
|
|
707
711
|
| Option | Type | Défaut | Effet |
|
|
@@ -721,7 +725,7 @@ Section `webhooks` du schéma Zod (`webhooksSchema`, `security/nodefony/config/c
|
|
|
721
725
|
|
|
722
726
|
> [!NOTE]
|
|
723
727
|
> `timestampToleranceS` est **transporté** dans la politique de livraison
|
|
724
|
-
> (`getDeliveryPolicy()`, `webhooks.ts:
|
|
728
|
+
> (`getDeliveryPolicy()`, `webhooks.ts:667`) mais l'émetteur ne l'applique jamais : la fenêtre
|
|
725
729
|
> anti-rejeu est par nature un contrôle du **récepteur**. Traite cette valeur comme la tolérance que
|
|
726
730
|
> tu documentes à tes destinataires — c'est celle du récepteur qui protège.
|
|
727
731
|
|
|
@@ -756,7 +760,7 @@ par dialecte via le `colKit`. Côté documentaire, le schéma force `_id: String
|
|
|
756
760
|
(`webhookEndpointSchema`, `mongoose/nodefony/entity/webhookEndpointEntity.ts:23`).
|
|
757
761
|
|
|
758
762
|
Ce qui **n'est pas** persisté : l'historique des livraisons. Il vit en RAM, borné à 20 entrées par
|
|
759
|
-
endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts:
|
|
763
|
+
endpoint (`MAX_DELIVERIES_PER_ENDPOINT`, `webhooks.ts:63`), corps de requête tronqué à 8 Ko, corps
|
|
760
764
|
de réponse à 2 Ko (`RESPONSE_BODY_CAP`, `webhookDelivery.ts:21`) — et il est **par pod**. C'est de
|
|
761
765
|
l'observabilité éphémère de mise au point, pas un journal d'audit.
|
|
762
766
|
|
|
@@ -770,9 +774,9 @@ l'observabilité éphémère de mise au point, pas un journal d'audit.
|
|
|
770
774
|
| `redis` | **volontairement absent** | — | — | un registre n'est pas un cache |
|
|
771
775
|
|
|
772
776
|
Redis n'est pas une omission : un endpoint est de la **configuration durable**, sa place n'est pas
|
|
773
|
-
dans un magasin volatil (`IWebhookStore.ts:
|
|
777
|
+
dans un magasin volatil (`IWebhookStore.ts:43`).
|
|
774
778
|
|
|
775
|
-
La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts:
|
|
779
|
+
La résolution est explicite et **annoncée**. `WebhookService.#resolveStore()` (`webhooks.ts:244`)
|
|
776
780
|
privilégie un adapter déjà posé au container, puis résout `auto` d'après l'infra déclarée, et
|
|
777
781
|
**enregistre sa décision** (visible dans Studio). Deux garde-fous :
|
|
778
782
|
|
|
@@ -782,7 +786,7 @@ privilégie un adapter déjà posé au container, puis résout `auto` d'après l
|
|
|
782
786
|
|
|
783
787
|
### Pagination du registre
|
|
784
788
|
|
|
785
|
-
`WebhookService.listPage()` (`webhooks.ts:
|
|
789
|
+
`WebhookService.listPage()` (`webhooks.ts:475`) délègue au store — la console n'a **jamais** tout le
|
|
786
790
|
registre en RAM. Ce contrat est vérifié par un **banc unique** rejoué sur tous les backends
|
|
787
791
|
(`webhookPaginationContract.ts`) : mêmes 12 endpoints de seed, mêmes assertions.
|
|
788
792
|
|
|
@@ -800,14 +804,14 @@ registre en RAM. Ce contrat est vérifié par un **banc unique** rejoué sur tou
|
|
|
800
804
|
consécutif courant — « qu'est-ce qui casse ? », `IWebhookStore.ts:35`), `q` (sous-chaîne
|
|
801
805
|
insensible à la casse sur `url` **ou** `description`).
|
|
802
806
|
- Mode unique **offset** : tous les backends d'endpoints savent le faire, aucune capacité n'est donc
|
|
803
|
-
à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts:
|
|
804
|
-
`DrizzleWebhookStore.ts:
|
|
807
|
+
à déclarer (`MemoryWebhookStore.listPage()`, `MemoryWebhookStore.ts:90` ;
|
|
808
|
+
`DrizzleWebhookStore.ts:185` ; `MongooseWebhookStore.ts:227`).
|
|
805
809
|
- **Les compteurs suivent la recherche.** `GET webhooks/stats` déclare `search`
|
|
806
810
|
(`WebhookAdminApi.ts:307`) et descend le même `q` jusqu'au store : un terme sans correspondance
|
|
807
811
|
vide les cartes autant que le tableau. Sans cela, la console afficherait « 12 endpoints » au-dessus
|
|
808
812
|
d'une liste filtrée à 2 — un chiffre qui ne répond plus à la question posée à l'écran.
|
|
809
813
|
|
|
810
|
-
`IWebhookStore.listAll()` (`IWebhookStore.ts:
|
|
814
|
+
`IWebhookStore.listAll()` (`IWebhookStore.ts:72`) existe toujours, mais il est **réservé au snapshot
|
|
811
815
|
du dispatcher** : celui-ci doit connaître tous les abonnements pour ne rater aucune livraison. C'est
|
|
812
816
|
un cold-path (boot + après écriture CRUD), jamais un chemin d'affichage.
|
|
813
817
|
|
|
@@ -820,17 +824,17 @@ mention.
|
|
|
820
824
|
|
|
821
825
|
| Méthode | Rôle | Ancrage |
|
|
822
826
|
| ----------------------------- | ------------------------------------------------------------------ | ----------------- |
|
|
823
|
-
| `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts:
|
|
824
|
-
| `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts:
|
|
825
|
-
| `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts:
|
|
826
|
-
| `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts:
|
|
827
|
-
| `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:
|
|
828
|
-
| `setEnabled(id, bool)` | Révocation douce | `webhooks.ts:
|
|
829
|
-
| `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts:
|
|
830
|
-
| `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts:
|
|
831
|
-
| `delete(id)` | Supprime ; `false` si absent | `webhooks.ts:
|
|
832
|
-
| `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts:
|
|
833
|
-
| `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts:
|
|
827
|
+
| `register(input)` | Crée un endpoint (SSRF validé) → endpoint **+ secret en clair** | `webhooks.ts:441` |
|
|
828
|
+
| `listPage(query)` | Page d'endpoints (vue publique, sans secret) | `webhooks.ts:475` |
|
|
829
|
+
| `countEndpoints(query)` | `COUNT` natif ; `-1` si le backend ne sait pas compter | `webhooks.ts:489` |
|
|
830
|
+
| `getEndpoint(id)` | Un endpoint (vue publique) ou `null` | `webhooks.ts:517` |
|
|
831
|
+
| `update(id, patch)` | `url`/`events`/`enabled`/`description`/`metadata` ; URL re-validée | `webhooks.ts:528` |
|
|
832
|
+
| `setEnabled(id, bool)` | Révocation douce | `webhooks.ts:549` |
|
|
833
|
+
| `rotateSecret(id)` | Nouveau secret ; l'ancien meurt immédiatement | `webhooks.ts:560` |
|
|
834
|
+
| `revealSecret(id)` | Secret en clair (action sensible, à auditer par l'appelant) | `webhooks.ts:579` |
|
|
835
|
+
| `delete(id)` | Supprime ; `false` si absent | `webhooks.ts:587` |
|
|
836
|
+
| `listDeliveries(id)` _(sync)_ | Historique RAM des dernières livraisons | `webhooks.ts:602` |
|
|
837
|
+
| `isReady()` _(sync)_ | Activé **et** store **et** clé résolus | `webhooks.ts:414` |
|
|
834
838
|
|
|
835
839
|
Types et briques réutilisables exportés par `@nodefony/security` : `IWebhookEndpoint`,
|
|
836
840
|
`WebhookEndpointSummary`, `IWebhookStore`, `IWebhookListQuery`, `MemoryWebhookStore`,
|
|
@@ -839,13 +843,14 @@ Types et briques réutilisables exportés par `@nodefony/security` : `IWebhookEn
|
|
|
839
843
|
|
|
840
844
|
### Le data plane d'administration
|
|
841
845
|
|
|
842
|
-
|
|
846
|
+
Neuf endpoints sous `/nodefony/security/api/webhooks`, tous `ROLE_NODEFONY_ADMIN`, composés dans le
|
|
843
847
|
producteur `security` — ils héritent gratuitement du RBAC fail-closed du broker, de l'audit et de la
|
|
844
848
|
porte d'idempotence sur les mutations.
|
|
845
849
|
|
|
846
850
|
| Méthode + chemin | Rôle | Audit |
|
|
847
851
|
| ------------------------------ | --------------------------------------------------------- | ------------------ |
|
|
848
852
|
| `GET webhooks` | Page d'endpoints + driver du store. **Jamais de secret.** | — |
|
|
853
|
+
| `GET webhooks/stats` | Compteurs sur la collection entière, mêmes filtres | — |
|
|
849
854
|
| `POST webhooks` | Crée ; secret renvoyé **une seule fois** ; `422` si SSRF | `webhook.created` |
|
|
850
855
|
| `GET webhooks/{id}` | Un endpoint (vue publique), `404` sinon | — |
|
|
851
856
|
| `GET webhooks/{id}/deliveries` | Historique RAM des livraisons de cet endpoint | — |
|
|
@@ -863,7 +868,7 @@ Deux détails de conception qui se voient à l'usage :
|
|
|
863
868
|
honnête (`enabled: false`) et une liste vide, plutôt qu'une erreur. Les **mutations**, elles,
|
|
864
869
|
rendent bien `503` si le service n'est pas prêt.
|
|
865
870
|
- **Le listing est borné** : `limit` par défaut 50, plafond dur **200**
|
|
866
|
-
(`parseWebhookListQuery()`, `WebhookAdminApi.ts:
|
|
871
|
+
(`parseWebhookListQuery()`, `WebhookAdminApi.ts:161`) — un client ne peut pas demander « tout ».
|
|
867
872
|
|
|
868
873
|
## 🧩 Extension — brancher son propre registre
|
|
869
874
|
|
|
@@ -910,7 +915,7 @@ qu'il y en a.
|
|
|
910
915
|
| File d'attente | `maxQueue` (1000) puis **abandon** + log | `WebhookDispatcher.ts:149` |
|
|
911
916
|
| Sockets / FD simultanés | `maxConcurrent` (8) | `WebhookDispatcher.#pump()` (`WebhookDispatcher.ts:173`) |
|
|
912
917
|
| Durée d'une tentative | 10 s puis `req.destroy()` | `webhookDelivery.ts:136` |
|
|
913
|
-
| Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts:
|
|
918
|
+
| Historique par endpoint | 20 entrées, corps requête 8 Ko, réponse 2 Ko | `webhooks.ts:63` · `webhookDelivery.ts:21` |
|
|
914
919
|
| Allocations paresseuses | file, `Set` de timers, historique : `null` tant qu'inutilisés | `WebhookDispatcher.ts:114` |
|
|
915
920
|
|
|
916
921
|
La preuve n'est pas déclarative : un banc d'attaque envoie **5000 événements vers un endpoint mort**
|
|
@@ -933,7 +938,7 @@ exactement le data plane décrit plus haut (`WEBHOOKS_ENDPOINT`, `webhooksModel.
|
|
|
933
938
|
- **panneau des livraisons récentes** — ce qui a été envoyé et ce que le destinataire a répondu
|
|
934
939
|
(`DeliveriesPanel.tsx`) ;
|
|
935
940
|
- **badge « où on écrit »** : `memory` ou `orm`, dérivé du nom de classe réel du store
|
|
936
|
-
(`webhookStoreDriver()`, `WebhookAdminApi.ts:
|
|
941
|
+
(`webhookStoreDriver()`, `WebhookAdminApi.ts:113`) — un store tiers inconnu affiche `null` plutôt
|
|
937
942
|
qu'un driver inventé.
|
|
938
943
|
|
|
939
944
|
En développement, le module `test` fournit un **récepteur local** à demeure — le remplaçant
|
|
@@ -947,6 +952,7 @@ secret, et sait **simuler des pannes** pour observer retries et auto-désactivat
|
|
|
947
952
|
| `POST /nodefony/test/webhooks/sink/status/{code}` | Répond le code demandé → simule un récepteur en erreur |
|
|
948
953
|
| `POST /nodefony/test/webhooks/sink/slow?ms=…` | Répond lentement → provoque le timeout de livraison |
|
|
949
954
|
| `GET /nodefony/test/webhooks/received` | Inspecte les livraisons reçues |
|
|
955
|
+
| `DELETE /nodefony/test/webhooks/received` | Vide le journal des livraisons reçues |
|
|
950
956
|
|
|
951
957
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
952
958
|
|