@nodefony/security 10.0.0-beta.1 → 10.0.0-beta.3
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.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +44 -10
- package/dist/nodefony/command/security-oauth-doctor.js +66 -0
- package/dist/nodefony/command/security-secrets.js +42 -25
- package/dist/nodefony/command/security-token.js +46 -15
- package/dist/nodefony/command/security-user-delete.js +5 -4
- package/dist/nodefony/command/security-user-password.js +4 -11
- package/dist/nodefony/config/config.js +80 -5
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +24 -0
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
- package/dist/nodefony/scaffold/contribute.js +20 -0
- package/dist/nodefony/service/authFlow.js +111 -11
- package/dist/nodefony/service/firewall.js +111 -21
- package/dist/nodefony/service/keycloak.js +209 -0
- package/dist/nodefony/service/oauth2.js +379 -26
- package/dist/nodefony/service/securityHeaders.js +12 -1
- package/dist/nodefony/service/tokenService.js +57 -6
- package/dist/nodefony/service/totp.js +51 -6
- package/dist/nodefony/service/webAuthn.js +20 -2
- package/dist/nodefony/src/SecuredArea.js +14 -3
- package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
- package/dist/nodefony/src/admin/adminAudit.js +0 -1
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
- package/dist/nodefony/src/entryPoint.js +39 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
- package/dist/nodefony/src/oauth/metadata.js +36 -9
- package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
- package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
- package/dist/nodefony/src/oauth/providerRoles.js +79 -0
- package/dist/nodefony/src/oauth/providers/github.js +32 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
- package/dist/nodefony/src/openToSignup.js +47 -0
- package/dist/nodefony/src/sessionAuthentication.js +26 -0
- package/dist/nodefony/src/token/JwtKeystore.js +78 -46
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
- package/dist/nodefony/src/token/secretFile.js +59 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-token.d.ts +9 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +63 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +125 -9
- package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +18 -18
- package/docs/audit.md +24 -22
- package/docs/authenticators.md +62 -29
- package/docs/authorization.md +16 -16
- package/docs/cors.md +30 -30
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +68 -5
- package/docs/firewall.md +78 -45
- package/docs/headers.md +35 -35
- package/docs/index.md +15 -7
- package/docs/keycloak.md +818 -0
- package/docs/lexique.md +15 -15
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +242 -125
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +138 -86
- package/docs/totp.md +113 -71
- package/docs/webauthn.md +115 -115
- package/docs/webhooks.md +21 -21
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +17 -15
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:131`) 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:
|
|
55
|
+
(`WebAuthnController.registerOptions()`, `WebAuthnController.ts:122`). 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:294`) : 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:294`).
|
|
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,29 +99,29 @@ 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:131`) **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:578`) — 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:355`). 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:
|
|
117
|
+
`WebAuthnController.ts:323`) : 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:
|
|
124
|
+
`passkeys.enabled` vaut `true` par défaut (`config.ts:1460`). 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
126
|
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts:141`).
|
|
127
127
|
|
|
@@ -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:323`) :
|
|
159
159
|
|
|
160
160
|
| Route (`POST` sauf mention) | Rôle | Firewall |
|
|
161
161
|
| -------------------------------------------------- | --------------------------------------------- | ------------------------- |
|
|
@@ -167,10 +167,10 @@ Dès que `@nodefony/security` est chargé, le framework monte six routes
|
|
|
167
167
|
| `DELETE …/webauthn/credentials/{id}` | Révoquer **sa** passkey | zone protégée |
|
|
168
168
|
|
|
169
169
|
> [!IMPORTANT]
|
|
170
|
-
> Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:
|
|
170
|
+
> Les quatre routes de cérémonie sont `bypassFirewall: true` (`WebAuthnController.ts:384`) : 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:128`). 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 }> {
|
|
@@ -234,9 +234,9 @@ export async function loginWithPasskey(
|
|
|
234
234
|
}
|
|
235
235
|
```
|
|
236
236
|
|
|
237
|
-
Référence vivante dans le dépôt :
|
|
238
|
-
`
|
|
239
|
-
(`AuthService.ts:
|
|
237
|
+
Référence vivante dans le dépôt : la connexion passe par le déroulé partagé
|
|
238
|
+
`NodefonyLogin.loginWithPasskey()` (`NodefonyLogin.ts:409`, `nodefony/client`), que Studio
|
|
239
|
+
emploie tel quel ; l'enrôlement est `AuthService.registerPasskey()` (`AuthService.ts:36`).
|
|
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:314`) construit le défi et les
|
|
287
|
+
contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts:329`) : le même
|
|
288
|
+
authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts:333`),
|
|
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:355`) enchaîne dans cet ordre :
|
|
293
293
|
|
|
294
294
|
1. **Vérification déléguée** à `verifyRegistrationResponse` — défi, origine, rpIdHash, flags,
|
|
295
|
-
attestation (`webAuthn.ts:
|
|
295
|
+
attestation (`webAuthn.ts:368`). Tout échec devient un message uniforme
|
|
296
296
|
`WebAuthn registration failed` (`webAuthn.ts:345`).
|
|
297
297
|
2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
|
|
298
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:397`) — `backupEligible` dérive
|
|
302
|
+
de `credentialDeviceType === "multiDevice"` (`webAuthn.ts:397`).
|
|
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:
|
|
326
|
+
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts:452`) résout le credential par son id
|
|
327
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:
|
|
328
|
+
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts:492`). Le
|
|
329
329
|
controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
330
330
|
(`WebAuthnController.ts:64`).
|
|
331
331
|
|
|
@@ -340,12 +340,15 @@ 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:535`). 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}`
|
|
347
|
-
(`SecurityAdminApi.ts:
|
|
348
|
-
l'utilisateur visé, même pour un admin.
|
|
347
|
+
(`SecurityAdminApi.ts:649`) — audité, et **404 identique** si la passkey n'appartient pas à
|
|
348
|
+
l'utilisateur visé, même pour un admin. L'`id` de la route est d'abord traduit en
|
|
349
|
+
`identifier` : c'est sous lui que les passkeys sont rangées (`IWebAuthnCredentialStore.ts:81`).
|
|
350
|
+
- **Suppression du compte** : la cascade `onUserRevoked` (`reason: "deleted"`) retire toutes ses
|
|
351
|
+
passkeys (`userRevocationCascade.ts:51`) — un compte recréé sous le même identifiant n'en hérite pas.
|
|
349
352
|
|
|
350
353
|
Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
|
|
351
354
|
`backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts:47`), qui liste
|
|
@@ -353,23 +356,23 @@ exactement les porteurs à risque de verrouillage.
|
|
|
353
356
|
|
|
354
357
|
## ⚙️ Configuration
|
|
355
358
|
|
|
356
|
-
Table dérivée du schéma Zod `passkeysSchema` (`config.ts:
|
|
357
|
-
(`config.ts:
|
|
359
|
+
Table dérivée du schéma Zod `passkeysSchema` (`config.ts:528`), monté sous la clé `passkeys`
|
|
360
|
+
(`config.ts:1460`).
|
|
358
361
|
|
|
359
362
|
| Option | Type | Défaut | Effet |
|
|
360
363
|
| ------------------------- | ------------------------------------------ | ------------ | ------------------------------------------------------------------------------ |
|
|
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:
|
|
364
|
+
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts:530`) |
|
|
365
|
+
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts:536`) |
|
|
366
|
+
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts:540`) |
|
|
367
|
+
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts:536`) |
|
|
368
|
+
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts:550`) |
|
|
369
|
+
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts:556`) |
|
|
370
|
+
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts:562`) |
|
|
371
|
+
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts:568`) |
|
|
372
|
+
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts:574`) |
|
|
373
|
+
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts:582`) |
|
|
374
|
+
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts:590`) |
|
|
375
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:552`) |
|
|
373
376
|
|
|
374
377
|
### Mise en situation — trois politiques, trois publics
|
|
375
378
|
|
|
@@ -393,7 +396,7 @@ passkeys: {
|
|
|
393
396
|
|
|
394
397
|
> [!WARNING]
|
|
395
398
|
> `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:
|
|
399
|
+
> l'AAGUID ni la chaîne contre la MDS FIDO (`config.ts:568`). Tant que cette vérification n'est pas
|
|
397
400
|
> faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
|
|
398
401
|
> privée (l'attestation identifie le modèle d'authenticator).
|
|
399
402
|
|
|
@@ -404,30 +407,31 @@ sans champ à remplir.
|
|
|
404
407
|
passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
|
|
405
408
|
```
|
|
406
409
|
|
|
407
|
-
|
|
408
|
-
le navigateur propose les comptes qu'il connaît.
|
|
409
|
-
|
|
410
|
+
Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
|
|
411
|
+
`allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
|
|
412
|
+
jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
|
|
413
|
+
`WebAuthnController.ts:171`).
|
|
410
414
|
|
|
411
|
-
|
|
|
412
|
-
|
|
|
413
|
-
| `{}`
|
|
414
|
-
| `{"username":"alice"}`
|
|
415
|
-
|
|
|
415
|
+
| Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
|
|
416
|
+
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
|
|
417
|
+
| anonyme, `{}` | défi seul, **sans** `allowCredentials` | l'authenticator propose ses comptes |
|
|
418
|
+
| anonyme, `{"username":"alice"}` | identique : le `username` est ignoré | aucun ciblage, rien à apprendre sur `alice` |
|
|
419
|
+
| session authentifiée (ré-auth, step-up) | défi + `allowCredentials` des clés **de ce porteur** | ciblage de ses propres passkeys |
|
|
416
420
|
|
|
417
421
|
## 🛡️ `rpId` et origines — l'ancre anti-phishing
|
|
418
422
|
|
|
419
423
|
Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
|
|
420
424
|
domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
|
|
421
425
|
|
|
422
|
-
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:
|
|
426
|
+
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:152`) : `passkeys.rpId` sinon le domaine
|
|
423
427
|
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
|
|
424
428
|
host non-domaine que la spécification autorise. En développement, accède donc au serveur par
|
|
425
429
|
`https://localhost:5152`, jamais par `127.0.0.1`.
|
|
426
430
|
|
|
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:
|
|
431
|
+
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:559`) en trois
|
|
432
|
+
temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts:560`, la voie de
|
|
429
433
|
production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
|
|
430
|
-
`rpId`** (`webAuthn.ts:
|
|
434
|
+
`rpId`** (`webAuthn.ts:568` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
|
|
431
435
|
est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
|
|
432
436
|
(`webAuthn.ts:542`).
|
|
433
437
|
|
|
@@ -439,18 +443,20 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
|
|
|
439
443
|
|
|
440
444
|
## 🔐 Le point à ne pas rater — plafond d'enrôlement et `login/options` ouvert
|
|
441
445
|
|
|
442
|
-
`login/options` est **accessible à un anonyme** (`bypassFirewall`)
|
|
443
|
-
|
|
444
|
-
|
|
446
|
+
`login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
|
|
447
|
+
pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
|
|
448
|
+
reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
|
|
449
|
+
(`WebAuthnController.loginOptions()`, `WebAuthnController.ts:171`). Deux surfaces restent à
|
|
450
|
+
regarder en face.
|
|
445
451
|
|
|
446
452
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
447
453
|
|
|
448
|
-
|
|
449
|
-
`allowCredentials` (`webAuthn.ts:
|
|
454
|
+
Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
455
|
+
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:422`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
450
456
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
451
457
|
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
452
458
|
|
|
453
|
-
Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:
|
|
459
|
+
Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `config.ts:582`) :
|
|
454
460
|
|
|
455
461
|
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
|
|
456
462
|
client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
|
|
@@ -460,22 +466,17 @@ Ce qui borne donc cette lecture, c'est **`passkeys.maxPerUser`** (défaut 20, `c
|
|
|
460
466
|
(3 comptages, 0 chargement) ;
|
|
461
467
|
- retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
|
|
462
468
|
|
|
463
|
-
### Énumération :
|
|
469
|
+
### Énumération : rien ne distingue deux comptes
|
|
464
470
|
|
|
465
|
-
Un
|
|
466
|
-
|
|
467
|
-
|
|
471
|
+
Un anonyme reçoit **toujours le même défi découvrable**, sans `allowCredentials`, qu'il poste ou non
|
|
472
|
+
un `username` : aucun statut, aucune liste, aucune latence ne dit si un compte existe ou porte une
|
|
473
|
+
passkey, et l'appel ne touche même pas le store. C'est le remède que la spécification W3C WebAuthn
|
|
474
|
+
propose contre la fuite des identifiants de credentials ; `webauthnLoginOptionsPrivacy.test.ts` le
|
|
475
|
+
verrouille.
|
|
468
476
|
|
|
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`.
|
|
477
|
+
> [!NOTE]
|
|
478
|
+
> `login/options` reste une route en `bypassFirewall` : le firewall ne la protège pas. Un rate-limit
|
|
479
|
+
> en amont reste de bonne hygiène, pour borner le coût des défis émis.
|
|
479
480
|
|
|
480
481
|
Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
481
482
|
|
|
@@ -483,7 +484,7 @@ Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
|
483
484
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
484
485
|
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:277`) — E1/E1bis |
|
|
485
486
|
| 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:
|
|
487
|
+
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts:309`) — E4 |
|
|
487
488
|
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:513`) |
|
|
488
489
|
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
|
|
489
490
|
|
|
@@ -495,7 +496,7 @@ clé privée n'existe que dans l'authenticator.
|
|
|
495
496
|
| Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
|
|
496
497
|
| ---------------- | ---------------------------------------------------- | ------------------- | ---------------- | ----------------------- |
|
|
497
498
|
| `id` | Identifiant du credential, base64url — clé naturelle | `text` PK | `_id: String` | clé `nf:wac:cred:<id>` |
|
|
498
|
-
| `userId` | Porteur
|
|
499
|
+
| `userId` | Porteur = `IUser.identifier` (jamais `IUser.id`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
|
|
499
500
|
| `publicKey` | Clé publique **COSE**, base64url | `text` notNull | `String` requis | champ |
|
|
500
501
|
| `signCount` | Compteur anti-clone (§6.1.1) | `int64` notNull | `Number` requis | champ |
|
|
501
502
|
| `transports` | `usb`\|`nfc`\|`ble`\|`internal`\|`hybrid` | `json` notNull | `[String]` | champ JSON |
|
|
@@ -516,7 +517,7 @@ explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredenti
|
|
|
516
517
|
**`nickname` est
|
|
517
518
|
stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
|
|
518
519
|
existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
|
|
519
|
-
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:
|
|
520
|
+
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:28`) — renommer un compte
|
|
520
521
|
orphelinise donc ses passkeys.
|
|
521
522
|
|
|
522
523
|
## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
|
|
@@ -534,7 +535,7 @@ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est ré
|
|
|
534
535
|
| Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
|
|
535
536
|
|
|
536
537
|
**`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
|
|
537
|
-
(`webAuthn.ts:
|
|
538
|
+
(`webAuthn.ts:134`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
|
|
538
539
|
Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
539
540
|
|
|
540
541
|
- un store **explicitement** configuré mais inconnu **avorte le boot en production**
|
|
@@ -572,10 +573,10 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
572
573
|
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:62`) ;
|
|
573
574
|
`RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts:93`) stocke un **HASH** par
|
|
574
575
|
credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
|
|
575
|
-
l'enregistrement (`RedisWebAuthnCredentialStore.ts:
|
|
576
|
-
- Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:
|
|
576
|
+
l'enregistrement (`RedisWebAuthnCredentialStore.ts:240`).
|
|
577
|
+
- Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts:299`) :
|
|
577
578
|
**ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
|
|
578
|
-
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:
|
|
579
|
+
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:339`).
|
|
579
580
|
|
|
580
581
|
### Brancher son propre store
|
|
581
582
|
|
|
@@ -603,16 +604,16 @@ symbolique (`.ai/symbols.json`).
|
|
|
603
604
|
|
|
604
605
|
| Méthode | Quand tu l'appelles |
|
|
605
606
|
| --------------------------------- | ------------------------------------------------------------------------ |
|
|
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:
|
|
607
|
+
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:300`) |
|
|
608
|
+
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:314`) |
|
|
609
|
+
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:355`) |
|
|
610
|
+
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:422`) |
|
|
611
|
+
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:452`) |
|
|
612
|
+
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:498`) |
|
|
613
|
+
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:510`) |
|
|
614
|
+
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:520`) |
|
|
615
|
+
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:535`) |
|
|
616
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts:525`) |
|
|
616
617
|
|
|
617
618
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
618
619
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -637,28 +638,28 @@ Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:308`), t
|
|
|
637
638
|
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:590`) |
|
|
638
639
|
|
|
639
640
|
Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
|
|
640
|
-
publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:
|
|
641
|
+
publique et le `userId` déjà présent dans le chemin (`toCredentialView()`, `SecurityAdminApi.ts:308`),
|
|
641
642
|
et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
|
|
642
643
|
défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
|
|
643
644
|
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532`). `total: -1` signale un
|
|
644
645
|
backend sans comptage (Redis).
|
|
645
646
|
|
|
646
647
|
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.loginWithPasskey()`, `AuthStore.ts:
|
|
648
|
+
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:316`, qui délègue au déroulé partagé).
|
|
648
649
|
|
|
649
650
|
## 📜 Normes appliquées
|
|
650
651
|
|
|
651
652
|
| Domaine | Norme | Ancrage |
|
|
652
653
|
| ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
|
|
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:
|
|
654
|
+
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:355`) |
|
|
655
|
+
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts:452`) |
|
|
655
656
|
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
|
|
656
657
|
| 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:
|
|
658
|
+
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:559`) |
|
|
658
659
|
| 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:
|
|
660
|
+
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:582`) |
|
|
661
|
+
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:550`) |
|
|
662
|
+
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts:535`) |
|
|
662
663
|
|
|
663
664
|
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
664
665
|
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
@@ -670,29 +671,28 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
|
|
|
670
671
|
requête. Le code en tire trois conséquences.
|
|
671
672
|
|
|
672
673
|
- **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
|
|
673
|
-
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:
|
|
674
|
+
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:578`) — une app qui n'enrôle personne ne paie
|
|
674
675
|
jamais son coût de parse.
|
|
675
676
|
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
676
|
-
(`webAuthn.ts:
|
|
677
|
+
(`webAuthn.ts:152-164`) — pas de store, pas de `Map`.
|
|
677
678
|
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
678
679
|
store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
|
|
679
680
|
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
|
|
680
681
|
|
|
681
682
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
682
683
|
|
|
683
|
-
| Symptôme | Cause (dans le code)
|
|
684
|
-
| ---------------------------------------------------- |
|
|
685
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`)
|
|
686
|
-
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé
|
|
687
|
-
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi
|
|
688
|
-
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost`
|
|
689
|
-
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host`
|
|
690
|
-
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1)
|
|
691
|
-
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`)
|
|
692
|
-
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué
|
|
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 |
|
|
684
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
685
|
+
| ---------------------------------------------------- | ------------------------------------------------------------------------- | --------------------------------------------------------- |
|
|
686
|
+
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`) | Retirer un appareil, ou relever `maxPerUser` |
|
|
687
|
+
| `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`) |
|
|
688
|
+
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi | Renseigner `passkeys.origins` + `rpId` enregistrable |
|
|
689
|
+
| 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>` |
|
|
690
|
+
| 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`) |
|
|
691
|
+
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1) | Comportement voulu — ré-enrôler l'appareil |
|
|
692
|
+
| 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 |
|
|
693
|
+
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué | Activer `passkeys` ; vérifier le store configuré |
|
|
694
|
+
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1` | Paginer par `nextCursor`, ne pas afficher de total |
|
|
695
|
+
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:28`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
696
696
|
|
|
697
697
|
## 🧪 Tests & couverture
|
|
698
698
|
|
package/docs/webhooks.md
CHANGED
|
@@ -160,7 +160,7 @@ 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:809`).
|
|
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
|
|
@@ -168,7 +168,7 @@ sans elle, une clé éphémère est générée en dev (avec un WARNING), et en p
|
|
|
168
168
|
|
|
169
169
|
```bash
|
|
170
170
|
# Génère les clés du module security et guide le câblage en 3 fichiers.
|
|
171
|
-
npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env
|
|
171
|
+
npx nodefony security:secrets --write # écrit NF_WEBHOOK_KEY dans .env
|
|
172
172
|
```
|
|
173
173
|
|
|
174
174
|
```typescript
|
|
@@ -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
|
|
|
@@ -705,7 +705,7 @@ sont `unref()` (`webhooks.ts:222`), donc ils n'empêchent jamais Node de sortir.
|
|
|
705
705
|
|
|
706
706
|
## ⚙️ Configuration
|
|
707
707
|
|
|
708
|
-
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:870`), lue via
|
|
709
709
|
`use("@nodefony/security", { webhooks: … })`.
|
|
710
710
|
|
|
711
711
|
| Option | Type | Défaut | Effet |
|
|
@@ -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
|