@nodefony/security 10.0.0-beta.2 → 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/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.152.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/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/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 +17 -17
- package/docs/audit.md +16 -16
- package/docs/authenticators.md +60 -27
- package/docs/authorization.md +16 -16
- package/docs/cors.md +29 -29
- package/docs/csrf.md +35 -33
- package/docs/external-jwt.md +66 -4
- package/docs/firewall.md +57 -24
- package/docs/headers.md +30 -30
- package/docs/index.md +12 -4
- package/docs/keycloak.md +818 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +236 -122
- package/docs/obtenir-un-jeton.md +18 -11
- package/docs/tokens.md +136 -85
- package/docs/totp.md +105 -64
- package/docs/webauthn.md +90 -87
- package/docs/webhooks.md +3 -3
- 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
|
|
@@ -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
|
|
|
@@ -420,15 +423,15 @@ jamais du corps de la requête, seulement de la session (`WebAuthnController.log
|
|
|
420
423
|
Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
|
|
421
424
|
domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
|
|
422
425
|
|
|
423
|
-
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:
|
|
426
|
+
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts:152`) : `passkeys.rpId` sinon le domaine
|
|
424
427
|
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts:134`), seul
|
|
425
428
|
host non-domaine que la spécification autorise. En développement, accède donc au serveur par
|
|
426
429
|
`https://localhost:5152`, jamais par `127.0.0.1`.
|
|
427
430
|
|
|
428
|
-
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts:
|
|
429
|
-
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
|
|
430
433
|
production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
|
|
431
|
-
`rpId`** (`webAuthn.ts:
|
|
434
|
+
`rpId`** (`webAuthn.ts:568` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
|
|
432
435
|
est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
|
|
433
436
|
(`webAuthn.ts:542`).
|
|
434
437
|
|
|
@@ -449,11 +452,11 @@ regarder en face.
|
|
|
449
452
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
450
453
|
|
|
451
454
|
Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
452
|
-
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:
|
|
455
|
+
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts:422`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
453
456
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
454
457
|
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts:88`).
|
|
455
458
|
|
|
456
|
-
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`) :
|
|
457
460
|
|
|
458
461
|
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts:15`), rendu **tel quel** au
|
|
459
462
|
client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
|
|
@@ -481,7 +484,7 @@ Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
|
481
484
|
| ----------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
482
485
|
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts:277`) — E1/E1bis |
|
|
483
486
|
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts:74-75`) — E2 |
|
|
484
|
-
| 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 |
|
|
485
488
|
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts:513`) |
|
|
486
489
|
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts:58`) — A1 |
|
|
487
490
|
|
|
@@ -493,7 +496,7 @@ clé privée n'existe que dans l'authenticator.
|
|
|
493
496
|
| Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
|
|
494
497
|
| ---------------- | ---------------------------------------------------- | ------------------- | ---------------- | ----------------------- |
|
|
495
498
|
| `id` | Identifiant du credential, base64url — clé naturelle | `text` PK | `_id: String` | clé `nf:wac:cred:<id>` |
|
|
496
|
-
| `userId` | Porteur
|
|
499
|
+
| `userId` | Porteur = `IUser.identifier` (jamais `IUser.id`) | `text` notNull, idx | `String` indexé | champ + SET `user:<id>` |
|
|
497
500
|
| `publicKey` | Clé publique **COSE**, base64url | `text` notNull | `String` requis | champ |
|
|
498
501
|
| `signCount` | Compteur anti-clone (§6.1.1) | `int64` notNull | `Number` requis | champ |
|
|
499
502
|
| `transports` | `usb`\|`nfc`\|`ble`\|`internal`\|`hybrid` | `json` notNull | `[String]` | champ JSON |
|
|
@@ -514,7 +517,7 @@ explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredenti
|
|
|
514
517
|
**`nickname` est
|
|
515
518
|
stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
|
|
516
519
|
existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
|
|
517
|
-
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:
|
|
520
|
+
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts:28`) — renommer un compte
|
|
518
521
|
orphelinise donc ses passkeys.
|
|
519
522
|
|
|
520
523
|
## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
|
|
@@ -532,7 +535,7 @@ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est ré
|
|
|
532
535
|
| Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
|
|
533
536
|
|
|
534
537
|
**`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
|
|
535
|
-
(`webAuthn.ts:
|
|
538
|
+
(`webAuthn.ts:134`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
|
|
536
539
|
Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
537
540
|
|
|
538
541
|
- un store **explicitement** configuré mais inconnu **avorte le boot en production**
|
|
@@ -570,10 +573,10 @@ Studio (`webAuthn.ts:195`). Deux garde-fous de production :
|
|
|
570
573
|
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:62`) ;
|
|
571
574
|
`RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts:93`) stocke un **HASH** par
|
|
572
575
|
credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
|
|
573
|
-
l'enregistrement (`RedisWebAuthnCredentialStore.ts:
|
|
574
|
-
- 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`) :
|
|
575
578
|
**ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
|
|
576
|
-
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:
|
|
579
|
+
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts:339`).
|
|
577
580
|
|
|
578
581
|
### Brancher son propre store
|
|
579
582
|
|
|
@@ -601,16 +604,16 @@ symbolique (`.ai/symbols.json`).
|
|
|
601
604
|
|
|
602
605
|
| Méthode | Quand tu l'appelles |
|
|
603
606
|
| --------------------------------- | ------------------------------------------------------------------------ |
|
|
604
|
-
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts:
|
|
605
|
-
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts:
|
|
606
|
-
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts:
|
|
607
|
-
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts:
|
|
608
|
-
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts:
|
|
609
|
-
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts:
|
|
610
|
-
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts:
|
|
611
|
-
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts:
|
|
612
|
-
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts:
|
|
613
|
-
| `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`) |
|
|
614
617
|
|
|
615
618
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
616
619
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -635,28 +638,28 @@ Le data plane admin du module expose trois routes (`SecurityAdminApi.ts:308`), t
|
|
|
635
638
|
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts:590`) |
|
|
636
639
|
|
|
637
640
|
Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
|
|
638
|
-
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`),
|
|
639
642
|
et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
|
|
640
643
|
défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
|
|
641
644
|
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts:532`). `total: -1` signale un
|
|
642
645
|
backend sans comptage (Redis).
|
|
643
646
|
|
|
644
647
|
Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
|
|
645
|
-
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:
|
|
648
|
+
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts:316`, qui délègue au déroulé partagé).
|
|
646
649
|
|
|
647
650
|
## 📜 Normes appliquées
|
|
648
651
|
|
|
649
652
|
| Domaine | Norme | Ancrage |
|
|
650
653
|
| ----------------------------------- | ---------------------------------- | ------------------------------------------------------------------ |
|
|
651
|
-
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts:
|
|
652
|
-
| 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`) |
|
|
653
656
|
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts:22`) |
|
|
654
657
|
| Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts:29`) |
|
|
655
|
-
| 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`) |
|
|
656
659
|
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts:16`) |
|
|
657
|
-
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts:
|
|
658
|
-
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts:
|
|
659
|
-
| 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`) |
|
|
660
663
|
|
|
661
664
|
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
662
665
|
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
@@ -668,28 +671,28 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
|
|
|
668
671
|
requête. Le code en tire trois conséquences.
|
|
669
672
|
|
|
670
673
|
- **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
|
|
671
|
-
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:
|
|
674
|
+
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts:578`) — une app qui n'enrôle personne ne paie
|
|
672
675
|
jamais son coût de parse.
|
|
673
676
|
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
674
|
-
(`webAuthn.ts:
|
|
677
|
+
(`webAuthn.ts:152-164`) — pas de store, pas de `Map`.
|
|
675
678
|
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
676
679
|
store (`IWebAuthnCredentialStore.ts:114`). Le seul appel non paginé, `findByUser`, est borné par
|
|
677
680
|
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts:88`).
|
|
678
681
|
|
|
679
682
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
680
683
|
|
|
681
|
-
| Symptôme | Cause (dans le code)
|
|
682
|
-
| ---------------------------------------------------- |
|
|
683
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts:355`)
|
|
684
|
-
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé
|
|
685
|
-
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi
|
|
686
|
-
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost`
|
|
687
|
-
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host`
|
|
688
|
-
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1)
|
|
689
|
-
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts:183`)
|
|
690
|
-
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué
|
|
691
|
-
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1`
|
|
692
|
-
| Passkeys orphelines après renommage d'un compte | `userId` = `me.username` figé à l'enrôlement (`WebAuthnController.ts:
|
|
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 |
|
|
693
696
|
|
|
694
697
|
## 🧪 Tests & couverture
|
|
695
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
|
|
@@ -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 |
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Consoles Keycloak (compte de l'utilisateur, administration) aux couleurs de
|
|
3
|
+
* Nodefony Studio.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ Ce fichier existe en DEUX exemplaires identiques, `account/` et `admin/` :
|
|
6
|
+
* Keycloak sert les ressources d'un type depuis son propre dossier, aucun
|
|
7
|
+
* type ne lit celles d'un autre. Un test du paquet les tient identiques.
|
|
8
|
+
*
|
|
9
|
+
* Seules des VARIABLES PatternFly 5 sont redéfinies : les consoles sont des
|
|
10
|
+
* applications React livrées par Keycloak (`keycloak.v3`, `keycloak.v2`), leur
|
|
11
|
+
* structure reste la leur et suit chaque mise à jour.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
/* Teintes `brand` de Studio (`@nodefony/studio/frontend/src/theme.ts`). */
|
|
16
|
+
--nf-brand-4: #4792cd;
|
|
17
|
+
--nf-brand-6: #0067ba;
|
|
18
|
+
--nf-brand-7: #00579c;
|
|
19
|
+
--nf-font:
|
|
20
|
+
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
21
|
+
Roboto, sans-serif;
|
|
22
|
+
|
|
23
|
+
--pf-v5-global--FontFamily--text: var(--nf-font);
|
|
24
|
+
--pf-v5-global--FontFamily--heading: var(--nf-font);
|
|
25
|
+
--pf-v5-global--primary-color--100: var(--nf-brand-6);
|
|
26
|
+
--pf-v5-global--primary-color--200: var(--nf-brand-7);
|
|
27
|
+
--pf-v5-global--link--Color: var(--nf-brand-6);
|
|
28
|
+
--pf-v5-global--link--Color--hover: var(--nf-brand-7);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Schéma SOMBRE — posé par le parent (`kcDarkModeClass`) selon le système.
|
|
32
|
+
brand-6 sur le fond sombre des consoles (#1b1d21) = 2,93:1, sous AA : les
|
|
33
|
+
textes de lien passent en brand-4 (≈ 5:1). Les boutons pleins gardent la
|
|
34
|
+
teinte sombre de PatternFly (blanc sur #0066cc = 5,57:1). */
|
|
35
|
+
:root.pf-v5-theme-dark {
|
|
36
|
+
--pf-v5-global--primary-color--100: var(--nf-brand-4);
|
|
37
|
+
--pf-v5-global--primary-color--200: #74b3e3;
|
|
38
|
+
--pf-v5-global--link--Color: var(--nf-brand-4);
|
|
39
|
+
--pf-v5-global--link--Color--hover: #74b3e3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Logo : la marque est un pictogramme vertical, pas un bandeau horizontal.
|
|
43
|
+
Le nom est ajouté À CÔTÉ par la feuille : les consoles n'affichent qu'une
|
|
44
|
+
image (propriété `logo`), et réécrire leur `index.ftl` obligerait à le
|
|
45
|
+
resynchroniser à chaque version de Keycloak. Le texte généré entre dans le
|
|
46
|
+
nom accessible du lien. */
|
|
47
|
+
.pf-v5-c-masthead__brand {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 0.625rem;
|
|
51
|
+
text-decoration: none;
|
|
52
|
+
}
|
|
53
|
+
.pf-v5-c-masthead .pf-v5-c-brand,
|
|
54
|
+
.pf-v5-c-masthead__brand img {
|
|
55
|
+
height: 36px;
|
|
56
|
+
width: auto;
|
|
57
|
+
}
|
|
58
|
+
.pf-v5-c-masthead__brand::after {
|
|
59
|
+
content: "Nodefony";
|
|
60
|
+
font-family: var(--nf-font);
|
|
61
|
+
font-size: 1.25rem;
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
letter-spacing: 0.01em;
|
|
64
|
+
color: var(--pf-v5-global--Color--light-100, #fff);
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Console du compte de l'utilisateur (Keycloak) aux couleurs de Nodefony.
|
|
2
|
+
#
|
|
3
|
+
# HÉRITE de `keycloak.v3` : la console est une application React livrée par
|
|
4
|
+
# Keycloak. Seuls le logo, l'icône d'onglet et une feuille de VARIABLES sont
|
|
5
|
+
# posés ici — la console reste celle de Keycloak, juste à chaque mise à jour.
|
|
6
|
+
# Le mode sombre (`darkMode`, `kcDarkModeClass`) est hérité du parent.
|
|
7
|
+
parent=keycloak.v3
|
|
8
|
+
# Chemins depuis les ressources du thème (`resources/`), barre INITIALE
|
|
9
|
+
# comprise : la console d'administration les colle à leur URL sans séparateur.
|
|
10
|
+
logo=/img/logo.png
|
|
11
|
+
favIcon=/img/favicon.ico
|
|
12
|
+
favIconType=image/x-icon
|
|
13
|
+
title=Nodefony · Account
|
|
14
|
+
styles=css/nodefony.css
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Consoles Keycloak (compte de l'utilisateur, administration) aux couleurs de
|
|
3
|
+
* Nodefony Studio.
|
|
4
|
+
*
|
|
5
|
+
* ⚠️ Ce fichier existe en DEUX exemplaires identiques, `account/` et `admin/` :
|
|
6
|
+
* Keycloak sert les ressources d'un type depuis son propre dossier, aucun
|
|
7
|
+
* type ne lit celles d'un autre. Un test du paquet les tient identiques.
|
|
8
|
+
*
|
|
9
|
+
* Seules des VARIABLES PatternFly 5 sont redéfinies : les consoles sont des
|
|
10
|
+
* applications React livrées par Keycloak (`keycloak.v3`, `keycloak.v2`), leur
|
|
11
|
+
* structure reste la leur et suit chaque mise à jour.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
:root {
|
|
15
|
+
/* Teintes `brand` de Studio (`@nodefony/studio/frontend/src/theme.ts`). */
|
|
16
|
+
--nf-brand-4: #4792cd;
|
|
17
|
+
--nf-brand-6: #0067ba;
|
|
18
|
+
--nf-brand-7: #00579c;
|
|
19
|
+
--nf-font:
|
|
20
|
+
ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
|
21
|
+
Roboto, sans-serif;
|
|
22
|
+
|
|
23
|
+
--pf-v5-global--FontFamily--text: var(--nf-font);
|
|
24
|
+
--pf-v5-global--FontFamily--heading: var(--nf-font);
|
|
25
|
+
--pf-v5-global--primary-color--100: var(--nf-brand-6);
|
|
26
|
+
--pf-v5-global--primary-color--200: var(--nf-brand-7);
|
|
27
|
+
--pf-v5-global--link--Color: var(--nf-brand-6);
|
|
28
|
+
--pf-v5-global--link--Color--hover: var(--nf-brand-7);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/* Schéma SOMBRE — posé par le parent (`kcDarkModeClass`) selon le système.
|
|
32
|
+
brand-6 sur le fond sombre des consoles (#1b1d21) = 2,93:1, sous AA : les
|
|
33
|
+
textes de lien passent en brand-4 (≈ 5:1). Les boutons pleins gardent la
|
|
34
|
+
teinte sombre de PatternFly (blanc sur #0066cc = 5,57:1). */
|
|
35
|
+
:root.pf-v5-theme-dark {
|
|
36
|
+
--pf-v5-global--primary-color--100: var(--nf-brand-4);
|
|
37
|
+
--pf-v5-global--primary-color--200: #74b3e3;
|
|
38
|
+
--pf-v5-global--link--Color: var(--nf-brand-4);
|
|
39
|
+
--pf-v5-global--link--Color--hover: #74b3e3;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/* Logo : la marque est un pictogramme vertical, pas un bandeau horizontal.
|
|
43
|
+
Le nom est ajouté À CÔTÉ par la feuille : les consoles n'affichent qu'une
|
|
44
|
+
image (propriété `logo`), et réécrire leur `index.ftl` obligerait à le
|
|
45
|
+
resynchroniser à chaque version de Keycloak. Le texte généré entre dans le
|
|
46
|
+
nom accessible du lien. */
|
|
47
|
+
.pf-v5-c-masthead__brand {
|
|
48
|
+
display: inline-flex;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 0.625rem;
|
|
51
|
+
text-decoration: none;
|
|
52
|
+
}
|
|
53
|
+
.pf-v5-c-masthead .pf-v5-c-brand,
|
|
54
|
+
.pf-v5-c-masthead__brand img {
|
|
55
|
+
height: 36px;
|
|
56
|
+
width: auto;
|
|
57
|
+
}
|
|
58
|
+
.pf-v5-c-masthead__brand::after {
|
|
59
|
+
content: "Nodefony";
|
|
60
|
+
font-family: var(--nf-font);
|
|
61
|
+
font-size: 1.25rem;
|
|
62
|
+
font-weight: 600;
|
|
63
|
+
letter-spacing: 0.01em;
|
|
64
|
+
color: var(--pf-v5-global--Color--light-100, #fff);
|
|
65
|
+
white-space: nowrap;
|
|
66
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# Console d'administration du realm (Keycloak) aux couleurs de Nodefony.
|
|
2
|
+
#
|
|
3
|
+
# HÉRITE de `keycloak.v2` (nom de la console d'administration actuelle) : même
|
|
4
|
+
# mécanique que le type `account` — logo, icône d'onglet, feuille de
|
|
5
|
+
# VARIABLES, rien de la console elle-même. Utile à l'opérateur du realm, pas à
|
|
6
|
+
# l'utilisateur final.
|
|
7
|
+
parent=keycloak.v2
|
|
8
|
+
logo=/img/logo.png
|
|
9
|
+
favIcon=/img/favicon.ico
|
|
10
|
+
favIconType=image/x-icon
|
|
11
|
+
title=Nodefony · Keycloak Administration
|
|
12
|
+
styles=css/nodefony.css
|