@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5
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.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/nodefony/testing/index.js +108 -0
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/testing/index.d.ts +51 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +136 -124
- package/package.json +19 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
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#WebAuthnService`) 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#WebAuthnController.registerOptions`). 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#WebAuthnController.#takeChallenge`) : un défi ne sert qu'une fois.
|
|
58
58
|
|
|
59
59
|
## 📖 Lexique
|
|
60
60
|
|
|
@@ -88,42 +88,42 @@ Quatre failles fermées **par construction** :
|
|
|
88
88
|
1. **Hameçonnage** — la signature est liée à l'origine et au `rpId`. Un faux domaine ne peut pas
|
|
89
89
|
obtenir de signature valide : le navigateur refuse de la produire.
|
|
90
90
|
2. **Fuite de base** — le serveur ne stocke que des clés **publiques** (`IWebAuthnCredential.publicKey`,
|
|
91
|
-
`IWebAuthnCredential.ts
|
|
91
|
+
`IWebAuthnCredential.ts#IWebAuthnCredential.publicKey`). 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#WebAuthnController.#takeChallenge`).
|
|
94
94
|
4. **Clonage d'authenticator** — le `signCount` doit croître ; une régression signale une copie
|
|
95
|
-
(`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts
|
|
95
|
+
(`IWebAuthnCredential.signCount`, `IWebAuthnCredential.ts#IWebAuthnCredential.signCount`).
|
|
96
96
|
|
|
97
97
|
C'est le facteur d'authentification le plus fort disponible aujourd'hui (NIST AAL2, AAL3 avec une clé
|
|
98
98
|
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#WebAuthnService`) **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#WebAuthnService.#ensureLib`) — 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#WebAuthnService.verifyRegistration`). 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
|
-
- **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts
|
|
114
|
+
- **Le stockage est pluggable** (`IWebAuthnCredentialStore`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) :
|
|
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#mountWebAuthnRoutes`) : 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#securityConfigSchema`). Ce que tu déclares vraiment, c'est **ton
|
|
125
125
|
domaine** : sans `rpId`, le service prend le domaine de l'app, et bascule sur `localhost` si c'est une
|
|
126
|
-
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts
|
|
126
|
+
adresse IP (un navigateur refuse une IP comme `rpId`, `webAuthn.ts#WebAuthnService.#rpID`).
|
|
127
127
|
|
|
128
128
|
```typescript
|
|
129
129
|
// nodefony.config.ts (extrait) — activer les passkeys pour TON domaine
|
|
@@ -155,7 +155,7 @@ export default defineConfig(() => ({
|
|
|
155
155
|
### 2. Les endpoints BFF sont FOURNIS
|
|
156
156
|
|
|
157
157
|
Dès que `@nodefony/security` est chargé, le framework monte six routes
|
|
158
|
-
(`mountWebAuthnRoutes()`, `WebAuthnController.ts
|
|
158
|
+
(`mountWebAuthnRoutes()`, `WebAuthnController.ts#mountWebAuthnRoutes`) :
|
|
159
159
|
|
|
160
160
|
| Route (`POST` sauf mention) | Rôle | Firewall |
|
|
161
161
|
| -------------------------------------------------- | --------------------------------------------- | ------------------------- |
|
|
@@ -167,11 +167,11 @@ 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#mountWebAuthnRoutes`) : 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
|
|
174
|
-
> la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts
|
|
173
|
+
> (`flow.me()` → 401, `WebAuthnController.ts#IWebAuthnBffFlow.me`). Les deux routes self-service, elles, restent dans
|
|
174
|
+
> la zone protégée (`security.webauthn.credentials.list`, `WebAuthnController.ts#WebAuthnController.#renderAuthError`).
|
|
175
175
|
|
|
176
176
|
### 3. Ce que TU écris : l'appel navigateur
|
|
177
177
|
|
|
@@ -235,8 +235,8 @@ export async function loginWithPasskey(
|
|
|
235
235
|
```
|
|
236
236
|
|
|
237
237
|
Référence vivante dans le dépôt : la connexion passe par le déroulé partagé
|
|
238
|
-
`NodefonyLogin.loginWithPasskey()` (`NodefonyLogin.ts
|
|
239
|
-
emploie tel quel ; l'enrôlement est `AuthService.registerPasskey()` (`AuthService.ts
|
|
238
|
+
`NodefonyLogin.loginWithPasskey()` (`NodefonyLogin.ts#NodefonyLogin.loginWithPasskey`, `nodefony/client`), que Studio
|
|
239
|
+
emploie tel quel ; l'enrôlement est `AuthService.registerPasskey()` (`AuthService.ts#AuthService.registerPasskey`).
|
|
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#WebAuthnService.generateRegistrationOptions`) construit le défi et les
|
|
287
|
+
contraintes. **`excludeCredentials`** y liste les passkeys déjà enrôlées (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`) : le même
|
|
288
|
+
authenticator ne peut pas s'inscrire deux fois. Dans `authenticatorSelection` (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`),
|
|
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#WebAuthnService.verifyRegistration`) 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
|
|
296
|
-
`WebAuthn registration failed` (`webAuthn.ts
|
|
295
|
+
attestation (`webAuthn.ts#WebAuthnService.verifyRegistration`). Tout échec devient un message uniforme
|
|
296
|
+
`WebAuthn registration failed` (`webAuthn.ts#WebAuthnService.verifyRegistration`).
|
|
297
297
|
2. **Plafond d'enrôlement** — `countByUser`, refus `409` si `maxPerUser` est atteint
|
|
298
|
-
(`webAuthn.ts
|
|
298
|
+
(`webAuthn.ts#WebAuthnService.verifyRegistration`). 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#WebAuthnService.verifyRegistration`) — `backupEligible` dérive
|
|
302
|
+
de `credentialDeviceType === "multiDevice"` (`webAuthn.ts#WebAuthnService.verifyRegistration`).
|
|
303
303
|
|
|
304
304
|
### Se connecter sans mot de passe
|
|
305
305
|
|
|
@@ -323,11 +323,11 @@ est ignoré.
|
|
|
323
323
|
> produit des credentials non découvrables — leurs porteurs ne pourront plus se connecter, et le
|
|
324
324
|
> service l'avertit au boot.
|
|
325
325
|
|
|
326
|
-
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts
|
|
327
|
-
(`webAuthn.ts
|
|
328
|
-
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts
|
|
326
|
+
`WebAuthnService.verifyAuthentication()` (`webAuthn.ts#WebAuthnService.verifyAuthentication`) résout le credential par son id
|
|
327
|
+
(`webAuthn.ts#WebAuthnService.generateAuthenticationOptions`), vérifie la signature contre la clé publique stockée, puis **applique l'état** :
|
|
328
|
+
`signCount`, `backupState`, `uvInitialized` (jamais rétrogradé), `lastUsedAt` (`webAuthn.ts#WebAuthnService.verifyAuthentication`). Le
|
|
329
329
|
controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
330
|
-
(`WebAuthnController.ts
|
|
330
|
+
(`WebAuthnController.ts#IWebAuthnBffFlow.establishSessionFor`).
|
|
331
331
|
|
|
332
332
|
> [!IMPORTANT]
|
|
333
333
|
> **La détection de clone est ici, pas dans le store.** La monotonie du `signCount` est vérifiée
|
|
@@ -340,39 +340,39 @@ controller ouvre alors la session BFF avec `authFlow.establishSessionFor()`
|
|
|
340
340
|
Deux chemins, deux portées :
|
|
341
341
|
|
|
342
342
|
- **Self-service** : `DELETE …/webauthn/credentials/{id}` → `WebAuthnService.removeUserCredential()`
|
|
343
|
-
(`webAuthn.ts
|
|
344
|
-
(`webAuthn.ts
|
|
343
|
+
(`webAuthn.ts#WebAuthnService.removeUserCredential`). La suppression n'aboutit que si le credential **appartient** au demandeur
|
|
344
|
+
(`webAuthn.ts#WebAuthnService.listCredentialsPage`) ; sinon **404 indiscernable** (`WebAuthnController.ts#WebAuthnController.loginVerify`) — on ne révèle
|
|
345
345
|
jamais l'existence de la passkey d'autrui.
|
|
346
346
|
- **Reset administrateur** : `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}`
|
|
347
|
-
(`SecurityAdminApi.ts
|
|
347
|
+
(`SecurityAdminApi.ts#createSecurityAdminApi`) — audité, et **404 identique** si la passkey n'appartient pas à
|
|
348
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
|
|
349
|
+
`identifier` : c'est sous lui que les passkeys sont rangées (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`).
|
|
350
350
|
- **Suppression du compte** : la cascade `onUserRevoked` (`reason: "deleted"`) retire toutes ses
|
|
351
|
-
passkeys (`userRevocationCascade.ts
|
|
351
|
+
passkeys (`userRevocationCascade.ts#cascadeUserRevocation`) — un compte recréé sous le même identifiant n'en hérite pas.
|
|
352
352
|
|
|
353
353
|
Une passkey **non sauvegardée** (`backupState: false`) meurt avec son appareil — d'où le filtre
|
|
354
|
-
`backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts
|
|
354
|
+
`backedUp` du listing admin (`IWebAuthnListQuery`, `IWebAuthnCredentialStore.ts#IWebAuthnListQuery`), qui liste
|
|
355
355
|
exactement les porteurs à risque de verrouillage.
|
|
356
356
|
|
|
357
357
|
## ⚙️ Configuration
|
|
358
358
|
|
|
359
|
-
Table dérivée du schéma Zod `passkeysSchema` (`config.ts
|
|
360
|
-
(`config.ts
|
|
361
|
-
|
|
362
|
-
| Option | Type | Défaut | Effet
|
|
363
|
-
| ------------------------- | ------------------------------------------ | ------------ |
|
|
364
|
-
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts
|
|
365
|
-
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts
|
|
366
|
-
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts
|
|
367
|
-
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts
|
|
368
|
-
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts
|
|
369
|
-
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts
|
|
370
|
-
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts
|
|
371
|
-
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts
|
|
372
|
-
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts
|
|
373
|
-
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts
|
|
374
|
-
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts
|
|
375
|
-
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts
|
|
359
|
+
Table dérivée du schéma Zod `passkeysSchema` (`config.ts#passkeysSchema`), monté sous la clé `passkeys`
|
|
360
|
+
(`config.ts#securityConfigSchema`).
|
|
361
|
+
|
|
362
|
+
| Option | Type | Défaut | Effet |
|
|
363
|
+
| ------------------------- | ------------------------------------------ | ------------ | ----------------------------------------------------------------------------------------- |
|
|
364
|
+
| `enabled` | boolean | `true` | Active les cérémonies ; `false` → endpoints en 503 (`config.ts#passkeysSchema`) |
|
|
365
|
+
| `rpId` | string? | domaine app | Domaine de liaison des passkeys ; IP → `localhost` (`config.ts#passkeysSchema`) |
|
|
366
|
+
| `rpName` | string? | `"Nodefony"` | Nom affiché dans l'invite OS/navigateur (`config.ts#passkeysSchema`) |
|
|
367
|
+
| `origins` | string[] | `[]` | Liste blanche d'origines ; vide = déduction depuis `rpId` (`config.ts#passkeysSchema`) |
|
|
368
|
+
| `userVerification` | `required` \| `preferred` \| `discouraged` | `preferred` | Exiger biométrie/PIN — `required` = AAL2 (`config.ts#passkeysSchema`) |
|
|
369
|
+
| `residentKey` | `required` \| `preferred` \| `discouraged` | `preferred` | Passkey découvrable → login sans identifiant (`config.ts#passkeysSchema`) |
|
|
370
|
+
| `authenticatorAttachment` | `platform` \| `cross-platform` \| `any` | `platform` | Biométrie intégrée / clé externe / les deux (`config.ts#passkeysSchema`) |
|
|
371
|
+
| `attestation` | `none` \| `direct` \| `enterprise` | `none` | Conveyance du certificat fabricant (`config.ts#passkeysSchema`) |
|
|
372
|
+
| `timeoutMs` | number (ms) | `60000` | Délai laissé à l'utilisateur pour la cérémonie (`config.ts#passkeysSchema`) |
|
|
373
|
+
| `maxPerUser` | number | `20` | Plafond de passkeys par porteur, `409` au-delà (`config.ts#passkeysSchema`) |
|
|
374
|
+
| `challengeTtlS` | number (s) | `300` | **RÉSERVÉ, non câblé** : le défi suit la session (`config.ts#passkeysSchema`) |
|
|
375
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts#passkeysSchema`) |
|
|
376
376
|
|
|
377
377
|
### Mise en situation — trois politiques, trois publics
|
|
378
378
|
|
|
@@ -396,7 +396,7 @@ passkeys: {
|
|
|
396
396
|
|
|
397
397
|
> [!WARNING]
|
|
398
398
|
> `attestation: "direct"` **récupère** le certificat, il ne le **valide pas** : Nodefony ne vérifie ni
|
|
399
|
-
> 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#passkeysSchema`). Tant que cette vérification n'est pas
|
|
400
400
|
> faite dans ton application, tu as la donnée, pas la garantie AAL3 — et tu paies un coût de vie
|
|
401
401
|
> privée (l'attestation identifie le modèle d'authenticator).
|
|
402
402
|
|
|
@@ -410,7 +410,7 @@ passkeys: { residentKey: "required" }, // la passkey DOIT être découvrable
|
|
|
410
410
|
Côté client, rien à faire de plus : le serveur **ignore** tout `username` posté par un anonyme.
|
|
411
411
|
`allowCredentials` est omis et le navigateur propose les comptes qu'il connaît. Le ciblage ne vient
|
|
412
412
|
jamais du corps de la requête, seulement de la session (`WebAuthnController.loginOptions()`,
|
|
413
|
-
`WebAuthnController.ts
|
|
413
|
+
`WebAuthnController.ts#WebAuthnController.loginOptions`).
|
|
414
414
|
|
|
415
415
|
| Qui appelle `login/options` | Ce que renvoie le serveur | Conséquence |
|
|
416
416
|
| --------------------------------------- | ---------------------------------------------------- | ------------------------------------------- |
|
|
@@ -423,17 +423,17 @@ jamais du corps de la requête, seulement de la session (`WebAuthnController.log
|
|
|
423
423
|
Le `rpId` est ce à quoi la passkey est **soudée**. Le navigateur refuse de signer pour un autre
|
|
424
424
|
domaine : c'est ce lien, et pas une vérification côté serveur, qui rend l'hameçonnage impossible.
|
|
425
425
|
|
|
426
|
-
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts
|
|
427
|
-
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts
|
|
426
|
+
Résolution au boot (`WebAuthnService.#build()`, `webAuthn.ts#WebAuthnService.#build`) : `passkeys.rpId` sinon le domaine
|
|
427
|
+
de l'app ; une **adresse IP ou une adresse IPv6 bascule sur `localhost`** (`webAuthn.ts#WebAuthnService.#store`), seul
|
|
428
428
|
host non-domaine que la spécification autorise. En développement, accède donc au serveur par
|
|
429
429
|
`https://localhost:5152`, jamais par `127.0.0.1`.
|
|
430
430
|
|
|
431
|
-
L'origine attendue est calculée par `WebAuthnService.#expectedOrigin()` (`webAuthn.ts
|
|
432
|
-
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#WebAuthnService.#expectedOrigin`) en trois
|
|
432
|
+
temps : la **liste blanche `passkeys.origins`** si elle est non vide (`webAuthn.ts#WebAuthnService.#expectedOrigin`, la voie de
|
|
433
433
|
production) ; sinon **l'origine de la requête, mais seulement si son hostname est exactement le
|
|
434
|
-
`rpId`** (`webAuthn.ts
|
|
434
|
+
`rpId`** (`webAuthn.ts#WebAuthnService.#rpID` — en dev, `localhost:5173` et `localhost:5152` passent tous deux, le port
|
|
435
435
|
est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https://{rpId}`
|
|
436
|
-
(`webAuthn.ts
|
|
436
|
+
(`webAuthn.ts#WebAuthnService.removeUserCredential`).
|
|
437
437
|
|
|
438
438
|
> [!WARNING]
|
|
439
439
|
> **Un seul `rpId` par instance.** Il est résolu une fois au boot et stocké dans le service ; il n'y a
|
|
@@ -446,22 +446,22 @@ est ignoré, sans jamais ouvrir à un domaine tiers) ; en dernier recours `https
|
|
|
446
446
|
`login/options` est **accessible à un anonyme** (`bypassFirewall`). C'est nécessaire — on ne peut
|
|
447
447
|
pas exiger d'être connecté pour se connecter. Le ciblage, lui, ne vient jamais du corps : un anonyme
|
|
448
448
|
reçoit toujours un défi découvrable, et seule une session authentifiée cible ses propres passkeys
|
|
449
|
-
(`WebAuthnController.loginOptions()`, `WebAuthnController.ts
|
|
449
|
+
(`WebAuthnController.loginOptions()`, `WebAuthnController.ts#WebAuthnController.loginOptions`). Deux surfaces restent à
|
|
450
450
|
regarder en face.
|
|
451
451
|
|
|
452
452
|
### Amplification : bornée par le plafond, pas par une pagination
|
|
453
453
|
|
|
454
454
|
Pour une session authentifiée, le serveur charge **toutes** les passkeys du porteur pour construire
|
|
455
|
-
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts
|
|
455
|
+
`allowCredentials` (`WebAuthnService.generateAuthenticationOptions()`, `webAuthn.ts#WebAuthnService.generateAuthenticationOptions`). Cet appel `findByUser` est **volontairement non paginé** —
|
|
456
456
|
`allowCredentials` doit être complet ou il est faux : un authenticator absent de la liste ne peut pas
|
|
457
|
-
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts
|
|
457
|
+
répondre, et le protocole n'offre aucune « page suivante » (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.findByUser`).
|
|
458
458
|
|
|
459
|
-
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#passkeysSchema`) :
|
|
460
460
|
|
|
461
|
-
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts
|
|
461
|
+
- le refus est un `409` porté par `WebAuthnError` (`WebAuthnError.ts#WebAuthnError`), rendu **tel quel** au
|
|
462
462
|
client parce qu'il est authentifié — rien à énumérer, et il doit comprendre qu'il faut retirer un
|
|
463
|
-
appareil (`WebAuthnController.ts
|
|
464
|
-
- le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts
|
|
463
|
+
appareil (`WebAuthnController.ts#WebAuthnController.#takeChallenge`) ;
|
|
464
|
+
- le comptage est **natif** (`countByUser`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.countByUser`) : `COUNT` / `SCARD`,
|
|
465
465
|
jamais un `findByUser().length` — `webAuthnEnrollmentLimit.test.ts` le prouve avec un store espion
|
|
466
466
|
(3 comptages, 0 chargement) ;
|
|
467
467
|
- retirer un appareil **libère une place** : le plafond est une borne, pas un compteur qui dérive.
|
|
@@ -480,17 +480,17 @@ verrouille.
|
|
|
480
480
|
|
|
481
481
|
Les autres gardes de cette surface, toutes couvertes par des tests d'attaque :
|
|
482
482
|
|
|
483
|
-
| Vecteur | Garde
|
|
484
|
-
| ----------------------------------------- |
|
|
485
|
-
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts
|
|
486
|
-
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts
|
|
487
|
-
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts
|
|
488
|
-
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts
|
|
489
|
-
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts
|
|
483
|
+
| Vecteur | Garde |
|
|
484
|
+
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
485
|
+
| Rejeu d'un défi | Invalidé à la lecture, avant la crypto (`WebAuthnController.ts#WebAuthnController.#body`) — E1/E1bis |
|
|
486
|
+
| Défi d'enrôlement réutilisé pour un login | Clés de session **disjointes** (`WebAuthnController.ts#REG_CHALLENGE`) — E2 |
|
|
487
|
+
| Lecture de la cause d'échec | Message uniforme `WebAuthn verification failed` (`WebAuthnController.ts#WebAuthnController.#renderAuthError`) — E4 |
|
|
488
|
+
| Suppression de la passkey d'autrui | Ownership vérifié → 404 indiscernable (`webAuthn.ts#WebAuthnService.listCredentialsPage`) |
|
|
489
|
+
| Réassignation du porteur via `update` | Le patch ne porte que l'état mutable (`WebAuthnAuthUpdate`, `IWebAuthnCredentialStore.ts#WebAuthnAuthUpdate`) — A1 |
|
|
490
490
|
|
|
491
491
|
## Entité de persistance — ce qui est écrit, et en quels types
|
|
492
492
|
|
|
493
|
-
Un credential (`IWebAuthnCredential`, `IWebAuthnCredential.ts
|
|
493
|
+
Un credential (`IWebAuthnCredential`, `IWebAuthnCredential.ts#IWebAuthnCredential`) ne contient **aucun secret** : la
|
|
494
494
|
clé privée n'existe que dans l'authenticator.
|
|
495
495
|
|
|
496
496
|
| Champ | Sens | SQL (`colKit`) | Mongoose | Redis (HASH) |
|
|
@@ -507,25 +507,25 @@ clé privée n'existe que dans l'authenticator.
|
|
|
507
507
|
| `createdAt` | Enrôlement (epoch ms) | `epochMs` notNull | `Number` requis | champ |
|
|
508
508
|
| `lastUsedAt` | Dernière authentification réussie, ou `null` | `epochMs` nullable | `Number` (null) | champ absent si null |
|
|
509
509
|
|
|
510
|
-
Spécification SQL : `webAuthnCredentialEntity.ts
|
|
511
|
-
(`drizzle/nodefony/entity/webAuthnCredentialEntity.ts
|
|
512
|
-
`mongoose/nodefony/entity/webAuthnCredentialEntity.ts
|
|
510
|
+
Spécification SQL : `webAuthnCredentialEntity.ts#webAuthnCredentialSchema`, index sur `userId`
|
|
511
|
+
(`drizzle/nodefony/entity/webAuthnCredentialEntity.ts#WebAuthnCredentialRow.userId`). Schéma documentaire :
|
|
512
|
+
`mongoose/nodefony/entity/webAuthnCredentialEntity.ts#webAuthnCredentialSchema` — `_id` **est** le credentialId (String, pas
|
|
513
513
|
un ObjectId), les horodatages sont des `Number` epoch ms.
|
|
514
514
|
|
|
515
515
|
Trois constats de conception : **aucun TTL ni `gc`** — une passkey est permanente jusqu'à révocation
|
|
516
|
-
explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredentialStore.ts
|
|
516
|
+
explicite, `IWebAuthnCredentialStore` n'a pas de maintenance (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) ;
|
|
517
517
|
**`nickname` est
|
|
518
518
|
stocké et rendu, jamais écrit par le framework** — aucune API publique ne le renseigne, le champ
|
|
519
519
|
existe pour une UX « renommer cet appareil » côté application ; **`userId` est l'identifiant
|
|
520
|
-
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts
|
|
520
|
+
applicatif** (`me.username`, figé à l'enrôlement, `WebAuthnController.ts#IWebAuthnService.verifyRegistration`) — renommer un compte
|
|
521
521
|
orphelinise donc ses passkeys.
|
|
522
522
|
|
|
523
523
|
## 🧩 Backends de stockage — quatre enregistrés, et comment brancher le tien
|
|
524
524
|
|
|
525
|
-
Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts
|
|
526
|
-
(`webAuthn.ts
|
|
525
|
+
Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore`) est résolu au boot
|
|
526
|
+
(`webAuthn.ts#WebAuthnService.constructor`) : un adapter déjà posé au container gagne, sinon la fabrique nommée par
|
|
527
527
|
`passkeys.store` est appelée via le registre (`registerWebAuthnStore()`,
|
|
528
|
-
`webAuthnCredentialStoreRegistry.ts
|
|
528
|
+
`webAuthnCredentialStoreRegistry.ts#registerWebAuthnStore`).
|
|
529
529
|
|
|
530
530
|
| Ta situation | Store | Ce que tu gagnes / perds |
|
|
531
531
|
| ------------------------------------------- | ------------------ | ---------------------------------------------------------------------------- |
|
|
@@ -535,48 +535,48 @@ Le contrat `IWebAuthnCredentialStore` (`IWebAuthnCredentialStore.ts:75`) est ré
|
|
|
535
535
|
| Flotte de pods, Redis déjà présent | `redis` | O(1) par credential ; listing **par curseur**, sans total (capacité réduite) |
|
|
536
536
|
|
|
537
537
|
**`store: "auto"` (défaut)** suit l'infra déclarée, borné aux backends réellement enregistrés
|
|
538
|
-
(`webAuthn.ts
|
|
539
|
-
Studio (`webAuthn.ts
|
|
538
|
+
(`webAuthn.ts#WebAuthnService.#store`) — la décision (configuré → résolu, raison) est publiée au kernel et visible dans
|
|
539
|
+
Studio (`webAuthn.ts#WebAuthnService.#build`). Deux garde-fous de production :
|
|
540
540
|
|
|
541
541
|
- un store **explicitement** configuré mais inconnu **avorte le boot en production**
|
|
542
|
-
(`webAuthn.ts
|
|
542
|
+
(`webAuthn.ts#WebAuthnService.#build`) — jamais de repli silencieux vers un store volatil ;
|
|
543
543
|
- `memory` **en production** déclenche un `WARNING` qui nomme l'impact : passkeys perdues au
|
|
544
|
-
redémarrage, utilisateurs verrouillés hors de leur compte (`webAuthn.ts
|
|
544
|
+
redémarrage, utilisateurs verrouillés hors de leur compte (`webAuthn.ts#WebAuthnService.#build`).
|
|
545
545
|
|
|
546
546
|
### Les backends, en détail
|
|
547
547
|
|
|
548
548
|
### `memory` — la référence, 0 dépendance
|
|
549
549
|
|
|
550
|
-
- Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts
|
|
551
|
-
`#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts
|
|
550
|
+
- Builtin, enregistré à l'import du module (`webAuthnCredentialStoreRegistry.ts`). Deux index :
|
|
551
|
+
`#byId` (vérité) et `#idsByUser` (`allowCredentials`) — `MemoryWebAuthnCredentialStore.ts#cloneCredential`.
|
|
552
552
|
- `listPage` trie `createdAt` DESC avec `id` en départage → offset déterministe, parité SQL
|
|
553
|
-
(`MemoryWebAuthnCredentialStore.ts
|
|
554
|
-
- `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts
|
|
555
|
-
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts
|
|
553
|
+
(`MemoryWebAuthnCredentialStore.ts#toWebAuthnSummary`). C'est lui qui pilote le banc de contrat partagé.
|
|
554
|
+
- `snapshot()` / `restore()` sérialisables (`MemoryWebAuthnCredentialStore.ts#MemoryWebAuthnCredentialStore.restore`) ; le service
|
|
555
|
+
déclenche un `flushNow()` à l'arrêt si le store sait le faire (`webAuthn.ts#IFlushableStore.flushNow`).
|
|
556
556
|
|
|
557
557
|
### `drizzle` — SQL, le durable par défaut
|
|
558
558
|
|
|
559
|
-
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts
|
|
560
|
-
`DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts
|
|
559
|
+
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores`) ;
|
|
560
|
+
`DrizzleWebAuthnCredentialStore` (`DrizzleWebAuthnCredentialStore.ts#DrizzleWebAuthnCredentialStore`) est **100 % portable** —
|
|
561
561
|
aucune requête SQL native, tout passe par `IRepository` d'`orm-core`.
|
|
562
562
|
- Trois dialectes sur le même banc : **sqlite** (toujours, `:memory:`), **postgres** et **mysql**
|
|
563
|
-
(gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts
|
|
563
|
+
(gatés par l'infra). Pagination offset + `total` (`DrizzleWebAuthnCredentialStore.ts#DrizzleWebAuthnCredentialStore.listPage`).
|
|
564
564
|
|
|
565
565
|
### `mongoose` — MongoDB
|
|
566
566
|
|
|
567
|
-
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts
|
|
568
|
-
`MongooseWebAuthnCredentialStore` (`MongooseWebAuthnCredentialStore.ts
|
|
569
|
-
`paginate()` — offset + `total`, départage sur `_id` (`MongooseWebAuthnCredentialStore.ts
|
|
567
|
+
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores`) ;
|
|
568
|
+
`MongooseWebAuthnCredentialStore` (`MongooseWebAuthnCredentialStore.ts#MongooseWebAuthnCredentialStore`) partage le helper
|
|
569
|
+
`paginate()` — offset + `total`, départage sur `_id` (`MongooseWebAuthnCredentialStore.ts#MongooseWebAuthnCredentialStore.#listCriteria`).
|
|
570
570
|
|
|
571
571
|
### `redis` — cluster, lecture O(1)
|
|
572
572
|
|
|
573
|
-
- Enregistré par le module redis (`redis/nodefony/registerStores.ts
|
|
574
|
-
`RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts
|
|
573
|
+
- Enregistré par le module redis (`redis/nodefony/registerStores.ts#resolveRedisService`) ;
|
|
574
|
+
`RedisWebAuthnCredentialStore` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.#resolvePrefix`) stocke un **HASH** par
|
|
575
575
|
credential + un **SET** d'ids par porteur — `update` réécrit 1 à 4 champs sans relire
|
|
576
|
-
l'enregistrement (`RedisWebAuthnCredentialStore.ts
|
|
577
|
-
- Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts
|
|
576
|
+
l'enregistrement (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.update`).
|
|
577
|
+
- Listing par `SCAN`, curseur composite `skip:scanCursor` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.listPage`) :
|
|
578
578
|
**ni ordre global ni total**, pages de taille variable — capacité réduite **déclarée**, pas un
|
|
579
|
-
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts
|
|
579
|
+
défaut. `countCredentials()` renvoie `-1` (`RedisWebAuthnCredentialStore.ts#RedisWebAuthnCredentialStore.countCredentials`).
|
|
580
580
|
|
|
581
581
|
### Brancher son propre store
|
|
582
582
|
|
|
@@ -602,18 +602,18 @@ la vue admin **ne porte jamais la clé publique**.
|
|
|
602
602
|
Le point d'entrée est le service `webauthn` du container ; les signatures vivent dans le graphe
|
|
603
603
|
symbolique (`.ai/symbols.json`).
|
|
604
604
|
|
|
605
|
-
| Méthode | Quand tu l'appelles
|
|
606
|
-
| --------------------------------- |
|
|
607
|
-
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts
|
|
608
|
-
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts
|
|
609
|
-
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts
|
|
610
|
-
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts
|
|
611
|
-
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts
|
|
612
|
-
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts
|
|
613
|
-
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts
|
|
614
|
-
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts
|
|
615
|
-
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts
|
|
616
|
-
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts
|
|
605
|
+
| Méthode | Quand tu l'appelles |
|
|
606
|
+
| --------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
607
|
+
| `isEnabled()` | Savoir si les cérémonies sont opérationnelles (`webAuthn.ts#WebAuthnService.isEnabled`) |
|
|
608
|
+
| `generateRegistrationOptions()` | Défi d'enrôlement pour un porteur (`webAuthn.ts#WebAuthnService.generateRegistrationOptions`) |
|
|
609
|
+
| `verifyRegistration()` | Vérifier + stocker, plafond appliqué (`webAuthn.ts#WebAuthnService.verifyRegistration`) |
|
|
610
|
+
| `generateAuthenticationOptions()` | Défi de login, ciblé ou découvrable (`webAuthn.ts#WebAuthnService.generateAuthenticationOptions`) |
|
|
611
|
+
| `verifyAuthentication()` | Vérifier l'assertion + appliquer l'état (`webAuthn.ts#WebAuthnService.verifyAuthentication`) |
|
|
612
|
+
| `listUserCredentials()` | « Mes appareils » — chemin chaud, non paginé (`webAuthn.ts#WebAuthnService.listUserCredentials`) |
|
|
613
|
+
| `listCredentialsPage()` | Vue **transverse** admin, paginée, sans clé publique (`webAuthn.ts#WebAuthnService.listCredentialsPage`) |
|
|
614
|
+
| `countCredentials()` | Total filtré, ou `-1` si le backend ne compte pas (`webAuthn.ts#WebAuthnService.countCredentials`) |
|
|
615
|
+
| `removeUserCredential()` | Révocation self-service, owner-scopée (`webAuthn.ts#WebAuthnService.removeUserCredential`) |
|
|
616
|
+
| `removeCredential()` | Révocation inconditionnelle, usage admin (`webAuthn.ts#WebAuthnService.removeCredential`) |
|
|
617
617
|
|
|
618
618
|
Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
619
619
|
`IWebAuthnCredentialStore`, `IWebAuthnCredentialSummary`, `IWebAuthnListQuery`,
|
|
@@ -623,43 +623,43 @@ Types publics ré-exportés par `@nodefony/security` : `IWebAuthnCredential`,
|
|
|
623
623
|
> `listUserCredentials()` et `listCredentialsPage()` ne se remplacent pas. Le premier sert la fiche
|
|
624
624
|
> d'**un** porteur (borné par `maxPerUser`) et le login ; le second est le chemin **froid** d'une
|
|
625
625
|
> console d'administration, qui ne matérialise jamais plus d'une page et dont la projection exclut la
|
|
626
|
-
> clé publique par construction (`IWebAuthnCredentialSummary`, `IWebAuthnCredentialStore.ts
|
|
626
|
+
> clé publique par construction (`IWebAuthnCredentialSummary`, `IWebAuthnCredentialStore.ts#IWebAuthnCredentialSummary`).
|
|
627
627
|
|
|
628
628
|
## 📡 Observabilité — Studio
|
|
629
629
|
|
|
630
|
-
Le data plane admin du module expose trois routes (`SecurityAdminApi.ts
|
|
630
|
+
Le data plane admin du module expose trois routes (`SecurityAdminApi.ts#toCredentialView`), toutes en
|
|
631
631
|
`ROLE_NODEFONY_ADMIN` :
|
|
632
632
|
|
|
633
633
|
<!-- prettier-ignore -->
|
|
634
634
|
| Route | Ce qu'elle montre |
|
|
635
635
|
| --- | --- |
|
|
636
|
-
| `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts
|
|
637
|
-
| `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts
|
|
638
|
-
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts
|
|
636
|
+
| `GET /nodefony/security/api/webauthn/list` | Vue **transverse** paginée : quels appareils portent des passkeys, lesquelles meurent avec leur appareil (`SecurityAdminApi.ts#createSecurityAdminApi`) |
|
|
637
|
+
| `GET /nodefony/security/api/users/{id}/passkeys` | Les passkeys d'un porteur (`SecurityAdminApi.ts#createSecurityAdminApi`) |
|
|
638
|
+
| `DELETE /nodefony/security/api/users/{id}/passkeys/{credentialId}` | Reset administrateur, audité (`SecurityAdminApi.ts#createSecurityAdminApi`) |
|
|
639
639
|
|
|
640
640
|
Deux comportements à connaître : la **redaction est par construction** — la vue admin omet la clé
|
|
641
|
-
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#toCredentialView`),
|
|
642
642
|
et ce n'est pas un masquage tardif, le contrat de store ne la produit jamais ; la **lecture est
|
|
643
643
|
défensive** — passkeys désactivées → `{ enabled: false, items: [] }` et non une erreur, la console
|
|
644
|
-
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts
|
|
644
|
+
doit afficher « passkeys désactivées », pas un 503 (`SecurityAdminApi.ts#createSecurityAdminApi`). `total: -1` signale un
|
|
645
645
|
backend sans comptage (Redis).
|
|
646
646
|
|
|
647
647
|
Côté UI, l'écran **Profil** (`/nodefony/profile`) de Studio porte l'enrôlement self-service et l'écran de login le bouton
|
|
648
|
-
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts
|
|
648
|
+
passkey (`AuthStore.loginWithPasskey()`, `AuthStore.ts#AuthStore.loginWithPasskey`, qui délègue au déroulé partagé).
|
|
649
649
|
|
|
650
650
|
## 📜 Normes appliquées
|
|
651
651
|
|
|
652
|
-
| Domaine | Norme | Ancrage
|
|
653
|
-
| ----------------------------------- | ---------------------------------- |
|
|
654
|
-
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts
|
|
655
|
-
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts
|
|
656
|
-
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts
|
|
657
|
-
| Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts
|
|
658
|
-
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts
|
|
659
|
-
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts
|
|
660
|
-
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts
|
|
661
|
-
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts
|
|
662
|
-
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts
|
|
652
|
+
| Domaine | Norme | Ancrage |
|
|
653
|
+
| ----------------------------------- | ---------------------------------- | -------------------------------------------------------------------------------------------------- |
|
|
654
|
+
| Cérémonie d'enregistrement | W3C WebAuthn L3 §7.1 | `WebAuthnService.verifyRegistration()` (`webAuthn.ts#WebAuthnService.verifyRegistration`) |
|
|
655
|
+
| Cérémonie d'authentification | W3C WebAuthn L3 §7.2 | `WebAuthnService.verifyAuthentication()` (`webAuthn.ts#WebAuthnService.verifyAuthentication`) |
|
|
656
|
+
| Compteur anti-clone | W3C WebAuthn §6.1.1 | `IWebAuthnCredential.signCount` (`IWebAuthnCredential.ts#IWebAuthnCredential.signCount`) |
|
|
657
|
+
| Flags de sauvegarde (BE/BS) | W3C WebAuthn §6.1.3 | `IWebAuthnCredential.backupEligible` (`IWebAuthnCredential.ts#IWebAuthnCredential.backupEligible`) |
|
|
658
|
+
| Liaison à l'origine (anti-phishing) | W3C WebAuthn §13.4.8 | `WebAuthnService.#expectedOrigin()` (`webAuthn.ts#WebAuthnService.#expectedOrigin`) |
|
|
659
|
+
| Clé publique COSE | RFC 8152 / RFC 9052 | `IWebAuthnCredential.publicKey` (`IWebAuthnCredential.ts#IWebAuthnCredential.publicKey`) |
|
|
660
|
+
| FIDO2 / CTAP2 | plafond `maxCredentialCountInList` | `passkeys.maxPerUser` (`config.ts#passkeysSchema`) |
|
|
661
|
+
| Assurance d'authentification | NIST SP 800-63B (AAL2) | `passkeys.userVerification` (`config.ts#passkeysSchema`) |
|
|
662
|
+
| Contrôle d'accès (IDOR) | OWASP A01 | `WebAuthnService.removeUserCredential()` (`webAuthn.ts#WebAuthnService.removeUserCredential`) |
|
|
663
663
|
|
|
664
664
|
La conformité cryptographique fine (parsing CBOR, formats d'attestation, vérification des signatures
|
|
665
665
|
ES256/RS256/EdDSA) est portée par `@simplewebauthn/server` — Nodefony fournit et prouve les
|
|
@@ -671,28 +671,28 @@ Les cérémonies sont un **chemin froid** : quelques appels par utilisateur et p
|
|
|
671
671
|
requête. Le code en tire trois conséquences.
|
|
672
672
|
|
|
673
673
|
- **Import paresseux de la bibliothèque** : `@simplewebauthn/server` n'est chargé qu'au premier usage
|
|
674
|
-
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts
|
|
674
|
+
réel (`WebAuthnService.#ensureLib()`, `webAuthn.ts#WebAuthnService.#ensureLib`) — une app qui n'enrôle personne ne paie
|
|
675
675
|
jamais son coût de parse.
|
|
676
676
|
- **Rien d'alloué quand c'est désactivé** : `passkeys.enabled: false` sort de `#build()` immédiatement
|
|
677
|
-
(`webAuthn.ts
|
|
677
|
+
(`webAuthn.ts#WebAuthnService.#build`) — pas de store, pas de `Map`.
|
|
678
678
|
- **Le listing admin ne matérialise jamais plus d'une page** : `listPage` applique les filtres au
|
|
679
|
-
store (`IWebAuthnCredentialStore.ts
|
|
680
|
-
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts
|
|
679
|
+
store (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.listPage`). Le seul appel non paginé, `findByUser`, est borné par
|
|
680
|
+
`maxPerUser` — par conception (`IWebAuthnCredentialStore.ts#IWebAuthnCredentialStore.findByUser`).
|
|
681
681
|
|
|
682
682
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
683
683
|
|
|
684
|
-
| Symptôme | Cause (dans le code)
|
|
685
|
-
| ---------------------------------------------------- |
|
|
686
|
-
| `409` à l'enrôlement | Plafond `passkeys.maxPerUser` atteint (`webAuthn.ts
|
|
687
|
-
| `400 No challenge` au `verify` | Défi absent : déjà consommé, ou pas de cookie renvoyé
|
|
688
|
-
| `401` systématique en production | Origine hors liste blanche, ou `rpId` ≠ domaine servi
|
|
689
|
-
| Passkey KO en dev sur `127.0.0.1` | Une IP n'est pas un `rpId` valide → repli `localhost`
|
|
690
|
-
| Passkeys d'un sous-domaine inutilisables sur l'autre | `rpId` unique, résolu au boot, pas de résolution par `Host`
|
|
691
|
-
| Login refusé après restauration d'une sauvegarde | `signCount` régressif → clone suspecté (§6.1.1)
|
|
692
|
-
| Tout le monde verrouillé dehors après un déploiement | `store` resté en `memory` : credentials volatils (`webAuthn.ts
|
|
693
|
-
| `503 WebAuthn unavailable` | `passkeys.enabled: false` ou boot du service échoué
|
|
694
|
-
| `total` absent du listing admin | Backend curseur (Redis) → `countCredentials()` rend `-1`
|
|
695
|
-
| 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#WebAuthnService.verifyRegistration`) | 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#WebAuthnService.#build`) | 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#IWebAuthnService.verifyRegistration`) | Ne pas renommer, ou ré-enrôler après renommage |
|
|
696
696
|
|
|
697
697
|
## 🧪 Tests & couverture
|
|
698
698
|
|