@nodefony/security 10.0.0-alpha.8 → 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.
Files changed (83) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
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:92`) et fournit les endpoints BFF prêts à l'emploi — tu n'écris que l'appel
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,9 +52,9 @@ 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:100`). Chaque `verify*` reçoit
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:277`) : un défi ne sert qu'une fois.
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:277`).
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
 
@@ -99,31 +99,31 @@ matérielle attestée).
99
99
 
100
100
  ## La vision Nodefony — le serveur ne détient aucun secret
101
101
 
102
- `WebAuthnService` (`webAuthn.ts:92`) **orchestre**, il ne fait pas de cryptographie : le parsing
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:540`) — l'enrôlement et le login sont des
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:317`). Conséquence pratique : le service se teste sans transport, et un défi n'est
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.
116
116
  - **Les endpoints sont fournis**, pas à réécrire (`mountWebAuthnRoutes()`,
117
- `WebAuthnController.ts:308`) : c'est là que vivent les gardes délicates (session du défi, usage
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
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:1137`). Ce que tu déclares vraiment, c'est **ton
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:134`).
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
@@ -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:308`) :
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:106`). Les deux routes self-service, elles, restent dans
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
@@ -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:108`) et `AuthService.registerPasskey()`
239
- (`AuthService.ts:130`).
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:276`) construit le défi et les
287
- contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:291`) : le même
288
- authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:299`),
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:317`) enchaîne dans cet ordre :
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:305`).
296
+ `WebAuthn registration failed` (`webAuthn.ts:345`).
297
297
  2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
298
- (`webAuthn.ts:313`). Volontairement **après** la cryptographie et **avant** le `save` : un client
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:359`) — `backupEligible` dérive
302
- de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:359`).
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:415`) résout le credential par son id
327
- (`webAuthn.ts:415`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
328
- `signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:455`). Le
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:461`). La suppression n'aboutit que si le credential **appartient** au demandeur
344
- (`webAuthn.ts:467`) ; sinon **404 indiscernable** (`WebAuthnController.ts:207`) — on ne révèle
343
+ (`webAuthn.ts:502`). 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:560`) — audité, et **404 identique** si la passkey n'appartient pas à
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:447`), monté sous la clé `passkeys`
357
- (`config.ts:1137`).
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:449`) |
362
- | `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:455`) |
363
- | `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:459`) |
364
- | `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:463`) |
365
- | `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:469`) |
366
- | `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:483`) |
367
- | `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:481`) |
368
- | `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:487`) |
369
- | `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:493`) |
370
- | `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:501`) |
371
- | `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:509`) |
372
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:514`) |
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:469`) |
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:487`). Tant que cette vérification n'est pas
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:113`) : `passkeys.rpId` sinon le domaine
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:523`) en trois
428
- temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:484`, la voie de
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:134` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
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:537`).
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:146`). C'est nécessaire — on ne peut pas exiger d'être connecté pour se
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:394`). Cet appel `findByUser` est **volontairement non paginé** —
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:509`) :
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:264`) ;
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:251`) — E1/E1bis |
485
- | Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:68`) — E2 |
486
- | Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:262`) — E4 |
487
- | Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:467`) |
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) | `int` notNull | `Number` requis | champ |
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:54`). Schéma documentaire :
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:131`). C'est lui qui pilote le banc de contrat partagé.
553
- - `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts:153`) ; le service
554
- déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts:254`).
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:47`).
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:188`).
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:262`) |
607
- | `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:276`) |
608
- | `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:317`) |
609
- | `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:384`) |
610
- | `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:415`) |
611
- | `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:461`) |
612
- | `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:474`) |
613
- | `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:485`) |
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
614
  | `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:502`) |
615
- | `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:491`) |
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`,
@@ -626,38 +626,38 @@ 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:301`), toutes en
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:473`) |
636
- | `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts:528`) |
637
- | `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:560`) |
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:501`). `total: -1` signale un
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
647
- passkey (`AuthStore.ts:209`).
647
+ passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:212`).
648
648
 
649
649
  ## 📜 Normes appliquées
650
650
 
651
651
  | Domaine | Norme | Ancrage |
652
652
  | ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
653
- | Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:317`) |
654
- | Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:415`) |
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:523`) |
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:509`) |
660
- | Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:449`) |
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
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
@@ -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:540`) — une app qui n'enrôle personne ne paie
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:122`) — pas de store, pas de `Map`.
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`).
@@ -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:313`) | Retirer un appareil, ou relever `maxPerUser` |
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>` |