@nodefony/security 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { decryptSecret, encryptSecret } from "../crypto/secretCipher.js";
|
|
2
|
+
import "./totpCipher.js";
|
|
3
|
+
import { base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, matchRecoveryCode, verifyTotp } from "./totpCrypto.js";
|
|
4
|
+
//#region nodefony/src/totp/totpOperations.ts
|
|
5
|
+
/**
|
|
6
|
+
* Démarre l'enrôlement : génère un secret aléatoire, le **chiffre** au repos et
|
|
7
|
+
* l'enregistre en attente de confirmation (`confirmedAt: null`). Retourne le
|
|
8
|
+
* secret en clair (base32 + URI) — **seul moment** où il est exposé. Idempotent :
|
|
9
|
+
* un nouvel appel écrase un enrôlement non confirmé (re-scan du QR).
|
|
10
|
+
*/
|
|
11
|
+
async function beginTotpEnrollment(deps, userId, account) {
|
|
12
|
+
const secret = generateTotpSecret();
|
|
13
|
+
const secretBase32 = base32Encode(secret);
|
|
14
|
+
const now = deps.now();
|
|
15
|
+
const record = {
|
|
16
|
+
userId,
|
|
17
|
+
secretEnc: encryptSecret(secret, deps.key),
|
|
18
|
+
algorithm: deps.algorithm,
|
|
19
|
+
digits: deps.digits,
|
|
20
|
+
period: deps.period,
|
|
21
|
+
recoveryCodes: [],
|
|
22
|
+
confirmedAt: null,
|
|
23
|
+
lastUsedStep: null,
|
|
24
|
+
createdAt: now,
|
|
25
|
+
lastUsedAt: null
|
|
26
|
+
};
|
|
27
|
+
await deps.store.save(record);
|
|
28
|
+
return {
|
|
29
|
+
secretBase32,
|
|
30
|
+
otpauthUri: buildOtpauthUri({
|
|
31
|
+
issuer: deps.issuer,
|
|
32
|
+
account,
|
|
33
|
+
secretBase32,
|
|
34
|
+
algorithm: deps.algorithm,
|
|
35
|
+
digits: deps.digits,
|
|
36
|
+
period: deps.period
|
|
37
|
+
})
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Confirme l'enrôlement : vérifie un 1ᵉʳ code généré par l'app, **active** le 2FA
|
|
42
|
+
* et génère les codes de récupération (retournés clairs 1×, hachés au repos). Le
|
|
43
|
+
* step de confirmation est marqué consommé (anti-rejeu). Lève si aucun enrôlement
|
|
44
|
+
* n'est en cours, s'il est déjà confirmé, ou si le code est invalide (reste pending).
|
|
45
|
+
*/
|
|
46
|
+
async function confirmTotpEnrollment(deps, userId, code) {
|
|
47
|
+
const record = await deps.store.findByUser(userId);
|
|
48
|
+
if (!record) throw new Error("totp: aucun enrôlement en cours");
|
|
49
|
+
if (record.confirmedAt !== null) throw new Error("totp: 2FA déjà activé");
|
|
50
|
+
const secret = decryptSecret(record.secretEnc, deps.key);
|
|
51
|
+
const res = verifyTotp(code, secret, {
|
|
52
|
+
epochMs: deps.now(),
|
|
53
|
+
step: deps.period,
|
|
54
|
+
digits: deps.digits,
|
|
55
|
+
algorithm: deps.algorithm,
|
|
56
|
+
window: deps.window
|
|
57
|
+
});
|
|
58
|
+
if (!res.valid) throw new Error("totp: code de confirmation invalide");
|
|
59
|
+
const recoveryCodes = generateRecoveryCodes(deps.recoveryCodesCount);
|
|
60
|
+
const now = deps.now();
|
|
61
|
+
await deps.store.update(userId, {
|
|
62
|
+
confirmedAt: now,
|
|
63
|
+
recoveryCodes: recoveryCodes.map(hashRecoveryCode),
|
|
64
|
+
lastUsedStep: res.step,
|
|
65
|
+
lastUsedAt: now
|
|
66
|
+
});
|
|
67
|
+
return { recoveryCodes };
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Vérifie un second facteur au login : d'abord un code TOTP (fenêtre ±window,
|
|
71
|
+
* **anti-rejeu** via `lastUsedStep`), à défaut un code de récupération (consommé,
|
|
72
|
+
* usage unique). Retourne `ok: false` si le 2FA n'est pas activé ou si rien ne
|
|
73
|
+
* correspond — **jamais d'exception** (chemin d'authentification).
|
|
74
|
+
*/
|
|
75
|
+
async function verifyTotpLogin(deps, userId, code) {
|
|
76
|
+
const record = await deps.store.findByUser(userId);
|
|
77
|
+
if (!record || record.confirmedAt === null) return { ok: false };
|
|
78
|
+
const secret = decryptSecret(record.secretEnc, deps.key);
|
|
79
|
+
const res = verifyTotp(code, secret, {
|
|
80
|
+
epochMs: deps.now(),
|
|
81
|
+
step: deps.period,
|
|
82
|
+
digits: deps.digits,
|
|
83
|
+
algorithm: deps.algorithm,
|
|
84
|
+
window: deps.window
|
|
85
|
+
});
|
|
86
|
+
if (res.valid && res.step !== void 0) {
|
|
87
|
+
if (record.lastUsedStep !== null && res.step <= record.lastUsedStep) return { ok: false };
|
|
88
|
+
await deps.store.update(userId, {
|
|
89
|
+
lastUsedStep: res.step,
|
|
90
|
+
lastUsedAt: deps.now()
|
|
91
|
+
});
|
|
92
|
+
return {
|
|
93
|
+
ok: true,
|
|
94
|
+
method: "totp"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const idx = matchRecoveryCode(code, record.recoveryCodes);
|
|
98
|
+
if (idx >= 0) {
|
|
99
|
+
await deps.store.update(userId, {
|
|
100
|
+
recoveryCodes: record.recoveryCodes.filter((_, i) => i !== idx),
|
|
101
|
+
lastUsedAt: deps.now()
|
|
102
|
+
});
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
method: "recovery"
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { ok: false };
|
|
109
|
+
}
|
|
110
|
+
/** Désactive le 2FA (retire le secret et les codes de récupération). */
|
|
111
|
+
function disableTotp(deps, userId) {
|
|
112
|
+
return deps.store.delete(userId);
|
|
113
|
+
}
|
|
114
|
+
/** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
|
|
115
|
+
async function totpStatus(deps, userId) {
|
|
116
|
+
const record = await deps.store.findByUser(userId);
|
|
117
|
+
if (!record) return {
|
|
118
|
+
enabled: false,
|
|
119
|
+
pending: false,
|
|
120
|
+
recoveryCodesRemaining: 0
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
enabled: record.confirmedAt !== null,
|
|
124
|
+
pending: record.confirmedAt === null,
|
|
125
|
+
recoveryCodesRemaining: record.recoveryCodes.length
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
//#endregion
|
|
129
|
+
export { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MemoryTotpSecretStore } from "./MemoryTotpSecretStore.js";
|
|
2
|
+
//#region nodefony/src/totp/totpSecretStoreRegistry.ts
|
|
3
|
+
const factories = /* @__PURE__ */ new Map();
|
|
4
|
+
/** Enregistre (ou remplace) la fabrique d'un store de secrets TOTP. */
|
|
5
|
+
function registerTotpStore(name, factory) {
|
|
6
|
+
factories.set(name, factory);
|
|
7
|
+
}
|
|
8
|
+
/** Fabrique d'un store par nom, ou `undefined` si inconnu. */
|
|
9
|
+
function getTotpStoreFactory(name) {
|
|
10
|
+
return factories.get(name);
|
|
11
|
+
}
|
|
12
|
+
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
13
|
+
function listTotpStores() {
|
|
14
|
+
return [...factories.keys()];
|
|
15
|
+
}
|
|
16
|
+
registerTotpStore("memory", () => new MemoryTotpSecretStore());
|
|
17
|
+
//#endregion
|
|
18
|
+
export { getTotpStoreFactory, listTotpStores, registerTotpStore };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
|
|
2
|
+
import "../../contracts/IAccessVoter.js";
|
|
3
|
+
//#region nodefony/src/voter/RoleVoter.ts
|
|
4
|
+
/**
|
|
5
|
+
* Voter built-in **niveau A** — résout les attributs `ROLE_*` via la hiérarchie
|
|
6
|
+
* de rôles ({@link RoleHierarchyWalker}).
|
|
7
|
+
*
|
|
8
|
+
* Vote `GRANT` si l'utilisateur possède le rôle (hiérarchie résolue : `ROLE_ADMIN`
|
|
9
|
+
* hérite `ROLE_USER`), **`ABSTAIN` sinon** — jamais `DENY` : l'absence d'un rôle
|
|
10
|
+
* ne doit pas opposer son veto aux autres axes (scope, ownership). C'est le
|
|
11
|
+
* `default DENY` de l'`AuthorizationService` (tous ABSTAIN → refus) qui ferme la
|
|
12
|
+
* porte, pas ce voter.
|
|
13
|
+
*
|
|
14
|
+
* Lit la hiérarchie depuis le container (`roleHierarchy`, posée par le firewall
|
|
15
|
+
* au boot) en lazy — un walker vide gère quand même les rôles plats.
|
|
16
|
+
*/
|
|
17
|
+
var RoleVoter = class {
|
|
18
|
+
container;
|
|
19
|
+
#walker = null;
|
|
20
|
+
constructor(container) {
|
|
21
|
+
this.container = container;
|
|
22
|
+
}
|
|
23
|
+
supports(attribute) {
|
|
24
|
+
return attribute.startsWith("ROLE_");
|
|
25
|
+
}
|
|
26
|
+
vote(token, attribute) {
|
|
27
|
+
const vote = (this.#walker ??= this.container.get("roleHierarchy") ?? new RoleHierarchyWalker()).hasRole(token.getRoles(), attribute) ? "GRANT" : "ABSTAIN";
|
|
28
|
+
return Promise.resolve(vote);
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { RoleVoter, RoleVoter as default };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import "../../contracts/IAccessVoter.js";
|
|
2
|
+
//#region nodefony/src/voter/ScopeVoter.ts
|
|
3
|
+
/**
|
|
4
|
+
* Types de jetons **non scopables** — identité humaine interactive (session BFF
|
|
5
|
+
* web/Studio/WS, login mot de passe) ou visiteur anonyme. Un scope `api:action`
|
|
6
|
+
* ne bride JAMAIS un humain : il **downscope** un jeton MACHINE délégué (clé API,
|
|
7
|
+
* JWT d'agent, OAuth). Tout type ABSENT de cette liste — présent (`apikey`/`jwt`/
|
|
8
|
+
* `oauth2`) ou futur (`mtls`, `agent`…) — est considéré **scopable**, donc soumis
|
|
9
|
+
* au filtre : **fail-closed côté machine** (un nouveau type délégué est bridé par
|
|
10
|
+
* défaut, jamais ouvert par oubli).
|
|
11
|
+
*
|
|
12
|
+
* `type` est porté à l'identique par `IToken` (HTTP) ET `IRealtimeToken` (WS, pont
|
|
13
|
+
* `api.request`) → ce voter reste transport-agnostique sans rien ajouter au contrat.
|
|
14
|
+
*/
|
|
15
|
+
const NON_SCOPABLE_TOKEN_TYPES = /* @__PURE__ */ new Set([
|
|
16
|
+
"session",
|
|
17
|
+
"userpassword",
|
|
18
|
+
"anonymous"
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Voter built-in **axe SCOPE** (P6.8) — applique les scopes `api:action` déclarés
|
|
22
|
+
* par `@RequireScope`. Frère du {@link RoleVoter} sur l'autre axe : les rôles
|
|
23
|
+
* disent QUI tu es, les scopes disent ce qu'une CLÉ déléguée a le droit de faire.
|
|
24
|
+
*
|
|
25
|
+
* `supports()` ne capte QUE la forme conventionnée `api:action` (un `:`, jamais
|
|
26
|
+
* `ROLE_*`) → aucune collision avec le `RoleVoter` ni un voter métier (`doc.edit`).
|
|
27
|
+
*
|
|
28
|
+
* Vote :
|
|
29
|
+
* - **jeton non scopable** (humain/anonyme) → `GRANT` : le scope est un no-op,
|
|
30
|
+
* l'autorisation de l'humain est portée par ses rôles (`@IsGranted`), pas par
|
|
31
|
+
* un downscoping de clé ;
|
|
32
|
+
* - **jeton scopable** (clé API / JWT / OAuth) → `GRANT` si le scope exact est
|
|
33
|
+
* présent, sinon **`ABSTAIN`** (jamais `DENY` : l'absence d'un scope ne doit pas
|
|
34
|
+
* opposer un veto aux autres attributs OR d'une clause — c'est le default-DENY
|
|
35
|
+
* de l'`AuthorizationService`, tous ABSTAIN → refus, qui ferme la porte ;
|
|
36
|
+
* posture identique au `RoleVoter`).
|
|
37
|
+
*
|
|
38
|
+
* Pur : aucune dépendance (ni container, ni I/O) — il ne lit que le jeton déjà
|
|
39
|
+
* résolu au handshake/à l'authentification. Instancié UNE fois au boot.
|
|
40
|
+
*/
|
|
41
|
+
var ScopeVoter = class {
|
|
42
|
+
/** Capte les attributs scope `api:action` — ni `ROLE_*`, ni attribut métier sans `:`. */
|
|
43
|
+
supports(attribute) {
|
|
44
|
+
return attribute.includes(":") && !attribute.startsWith("ROLE_");
|
|
45
|
+
}
|
|
46
|
+
vote(token, attribute) {
|
|
47
|
+
if (NON_SCOPABLE_TOKEN_TYPES.has(token.type)) return Promise.resolve("GRANT");
|
|
48
|
+
return Promise.resolve(token.getScopes().includes(attribute) ? "GRANT" : "ABSTAIN");
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
//#endregion
|
|
52
|
+
export { ScopeVoter, ScopeVoter as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { RoleVoter } from "./RoleVoter.js";
|
|
2
|
+
import { ScopeVoter } from "./ScopeVoter.js";
|
|
3
|
+
//#region nodefony/src/voter/voterRegistry.ts
|
|
4
|
+
const factories = /* @__PURE__ */ new Map();
|
|
5
|
+
/**
|
|
6
|
+
* Enregistre (ou remplace) la fabrique d'un voter. Appelé par les builtins au
|
|
7
|
+
* chargement du module, et par les apps/plugins pour les leurs (`ProjectVoter`,
|
|
8
|
+
* `TenantVoter`…).
|
|
9
|
+
*/
|
|
10
|
+
function registerVoterFactory(name, factory) {
|
|
11
|
+
factories.set(name, factory);
|
|
12
|
+
}
|
|
13
|
+
/** Toutes les fabriques enregistrées (consommées par l'`AuthorizationService`). */
|
|
14
|
+
function listVoterFactories() {
|
|
15
|
+
return factories;
|
|
16
|
+
}
|
|
17
|
+
registerVoterFactory("role", ({ container }) => new RoleVoter(container));
|
|
18
|
+
registerVoterFactory("scope", () => new ScopeVoter());
|
|
19
|
+
//#endregion
|
|
20
|
+
export { listVoterFactories, registerVoterFactory };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { assertPageQuery } from "nodefony";
|
|
2
|
+
//#region nodefony/src/webauthn/MemoryWebAuthnCredentialStore.ts
|
|
3
|
+
/**
|
|
4
|
+
* Projection contractuelle : credential complet → vue admin **sans `publicKey`**.
|
|
5
|
+
* Partagée par les backends qui matérialisent des credentials en mémoire.
|
|
6
|
+
*/
|
|
7
|
+
function toWebAuthnSummary(c) {
|
|
8
|
+
return {
|
|
9
|
+
id: c.id,
|
|
10
|
+
userId: c.userId,
|
|
11
|
+
transports: c.transports,
|
|
12
|
+
backupEligible: c.backupEligible,
|
|
13
|
+
backupState: c.backupState,
|
|
14
|
+
uvInitialized: c.uvInitialized,
|
|
15
|
+
signCount: c.signCount,
|
|
16
|
+
createdAt: c.createdAt,
|
|
17
|
+
lastUsedAt: c.lastUsedAt,
|
|
18
|
+
...c.nickname !== void 0 ? { nickname: c.nickname } : {}
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
var MemoryWebAuthnCredentialStore = class {
|
|
22
|
+
/** id (base64url) → credential (source de vérité). */
|
|
23
|
+
#byId = /* @__PURE__ */ new Map();
|
|
24
|
+
/** userId → ids (allowCredentials + « mes appareils »). */
|
|
25
|
+
#idsByUser = /* @__PURE__ */ new Map();
|
|
26
|
+
findById(credentialId) {
|
|
27
|
+
return Promise.resolve(this.#byId.get(credentialId) ?? null);
|
|
28
|
+
}
|
|
29
|
+
findByUser(userId) {
|
|
30
|
+
const ids = this.#idsByUser.get(userId);
|
|
31
|
+
if (!ids) return Promise.resolve([]);
|
|
32
|
+
const out = [];
|
|
33
|
+
for (const id of ids) {
|
|
34
|
+
const cred = this.#byId.get(id);
|
|
35
|
+
if (cred) out.push(cred);
|
|
36
|
+
}
|
|
37
|
+
return Promise.resolve(out);
|
|
38
|
+
}
|
|
39
|
+
countByUser(userId) {
|
|
40
|
+
return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
|
|
41
|
+
}
|
|
42
|
+
save(credential) {
|
|
43
|
+
this.#byId.set(credential.id, credential);
|
|
44
|
+
let set = this.#idsByUser.get(credential.userId);
|
|
45
|
+
if (!set) {
|
|
46
|
+
set = /* @__PURE__ */ new Set();
|
|
47
|
+
this.#idsByUser.set(credential.userId, set);
|
|
48
|
+
}
|
|
49
|
+
set.add(credential.id);
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
update(credentialId, patch) {
|
|
53
|
+
const cred = this.#byId.get(credentialId);
|
|
54
|
+
if (cred) {
|
|
55
|
+
cred.signCount = patch.signCount;
|
|
56
|
+
cred.backupState = patch.backupState;
|
|
57
|
+
cred.uvInitialized = patch.uvInitialized;
|
|
58
|
+
cred.lastUsedAt = patch.lastUsedAt;
|
|
59
|
+
}
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
}
|
|
62
|
+
delete(credentialId) {
|
|
63
|
+
const cred = this.#byId.get(credentialId);
|
|
64
|
+
if (!cred) return Promise.resolve();
|
|
65
|
+
this.#byId.delete(credentialId);
|
|
66
|
+
const set = this.#idsByUser.get(cred.userId);
|
|
67
|
+
if (set) {
|
|
68
|
+
set.delete(credentialId);
|
|
69
|
+
if (set.size === 0) this.#idsByUser.delete(cred.userId);
|
|
70
|
+
}
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
/** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
|
|
74
|
+
#filtered(query) {
|
|
75
|
+
const out = [];
|
|
76
|
+
for (const cred of this.#byId.values()) {
|
|
77
|
+
if (query.userId !== void 0 && cred.userId !== query.userId) continue;
|
|
78
|
+
if (query.backedUp !== void 0 && cred.backupState !== query.backedUp) continue;
|
|
79
|
+
if (query.q !== void 0 && !cred.userId.startsWith(query.q)) continue;
|
|
80
|
+
out.push(cred);
|
|
81
|
+
}
|
|
82
|
+
return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
83
|
+
}
|
|
84
|
+
listPage(query) {
|
|
85
|
+
assertPageQuery(query, "offset");
|
|
86
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
87
|
+
const offset = query.offset !== void 0 && query.offset > 0 ? query.offset : 0;
|
|
88
|
+
const all = this.#filtered(query);
|
|
89
|
+
const items = all.slice(offset, offset + limit).map(toWebAuthnSummary);
|
|
90
|
+
return Promise.resolve({
|
|
91
|
+
items,
|
|
92
|
+
limit,
|
|
93
|
+
offset,
|
|
94
|
+
hasNext: offset + items.length < all.length,
|
|
95
|
+
...query.withTotal === false ? {} : { total: all.length }
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
countCredentials(query) {
|
|
99
|
+
return Promise.resolve(this.#filtered(query).length);
|
|
100
|
+
}
|
|
101
|
+
/** Instantané sérialisable de l'état courant (pour la persistance fichier). */
|
|
102
|
+
snapshot() {
|
|
103
|
+
return { credentials: [...this.#byId.values()] };
|
|
104
|
+
}
|
|
105
|
+
/** Remplace l'état par celui d'un instantané (reconstruit l'index par user). */
|
|
106
|
+
restore(snapshot) {
|
|
107
|
+
this.#byId.clear();
|
|
108
|
+
this.#idsByUser.clear();
|
|
109
|
+
for (const cred of snapshot.credentials) {
|
|
110
|
+
this.#byId.set(cred.id, cred);
|
|
111
|
+
let set = this.#idsByUser.get(cred.userId);
|
|
112
|
+
if (!set) {
|
|
113
|
+
set = /* @__PURE__ */ new Set();
|
|
114
|
+
this.#idsByUser.set(cred.userId, set);
|
|
115
|
+
}
|
|
116
|
+
set.add(cred.id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
export { MemoryWebAuthnCredentialStore, MemoryWebAuthnCredentialStore as default, toWebAuthnSummary };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { MemoryWebAuthnCredentialStore } from "./MemoryWebAuthnCredentialStore.js";
|
|
2
|
+
//#region nodefony/src/webauthn/webAuthnCredentialStoreRegistry.ts
|
|
3
|
+
const factories = /* @__PURE__ */ new Map();
|
|
4
|
+
/** Enregistre (ou remplace) la fabrique d'un store de credentials WebAuthn. */
|
|
5
|
+
function registerWebAuthnStore(name, factory) {
|
|
6
|
+
factories.set(name, factory);
|
|
7
|
+
}
|
|
8
|
+
/** Fabrique d'un store par nom, ou `undefined` si inconnu. */
|
|
9
|
+
function getWebAuthnStoreFactory(name) {
|
|
10
|
+
return factories.get(name);
|
|
11
|
+
}
|
|
12
|
+
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
13
|
+
function listWebAuthnStores() {
|
|
14
|
+
return [...factories.keys()];
|
|
15
|
+
}
|
|
16
|
+
registerWebAuthnStore("memory", () => new MemoryWebAuthnCredentialStore());
|
|
17
|
+
//#endregion
|
|
18
|
+
export { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "./webhookSort.js";
|
|
2
|
+
import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
|
|
3
|
+
//#region nodefony/src/webhook/MemoryWebhookStore.ts
|
|
4
|
+
/**
|
|
5
|
+
* Applique les filtres d'{@link IWebhookListQuery} à un endpoint — sémantique de
|
|
6
|
+
* RÉFÉRENCE du contrat, partagée par les backends qui filtrent en mémoire.
|
|
7
|
+
*
|
|
8
|
+
* @param e - endpoint candidat.
|
|
9
|
+
* @param query - filtres du listing (les champs omis ne filtrent pas).
|
|
10
|
+
* @returns `true` si l'endpoint appartient à la collection filtrée.
|
|
11
|
+
*/
|
|
12
|
+
function matchesWebhookQuery(e, query) {
|
|
13
|
+
if (query.enabled !== void 0 && e.enabled !== query.enabled) return false;
|
|
14
|
+
if (query.failing !== void 0 && e.failureCount > 0 !== query.failing) return false;
|
|
15
|
+
if (query.event !== void 0 && !e.events.includes(query.event)) return false;
|
|
16
|
+
if (query.q !== void 0 && query.q.length > 0) {
|
|
17
|
+
const needle = query.q.toLowerCase();
|
|
18
|
+
if (!`${e.url}\n${e.description ?? ""}`.toLowerCase().includes(needle)) return false;
|
|
19
|
+
}
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
|
|
24
|
+
* les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
|
|
25
|
+
* `mongoose` (config `webhooks.store`).
|
|
26
|
+
*
|
|
27
|
+
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
28
|
+
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
29
|
+
*/
|
|
30
|
+
var MemoryWebhookStore = class {
|
|
31
|
+
/**
|
|
32
|
+
* {@inheritDoc IWebhookStore.sortableFields}
|
|
33
|
+
*
|
|
34
|
+
* Le store porte l'endpoint complet : il sait trier tout le vocabulaire
|
|
35
|
+
* public, sans réduction de capacité.
|
|
36
|
+
*/
|
|
37
|
+
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
38
|
+
#byId = /* @__PURE__ */ new Map();
|
|
39
|
+
async save(endpoint) {
|
|
40
|
+
this.#byId.set(endpoint.id, { ...endpoint });
|
|
41
|
+
}
|
|
42
|
+
async findById(id) {
|
|
43
|
+
const found = this.#byId.get(id);
|
|
44
|
+
return found ? { ...found } : null;
|
|
45
|
+
}
|
|
46
|
+
async update(id, patch) {
|
|
47
|
+
const current = this.#byId.get(id);
|
|
48
|
+
if (!current) return;
|
|
49
|
+
this.#byId.set(id, {
|
|
50
|
+
...current,
|
|
51
|
+
...patch
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
async delete(id) {
|
|
55
|
+
this.#byId.delete(id);
|
|
56
|
+
}
|
|
57
|
+
async listAll() {
|
|
58
|
+
return [...this.#byId.values()].map((e) => ({ ...e }));
|
|
59
|
+
}
|
|
60
|
+
async listPage(query) {
|
|
61
|
+
assertPageQuery(query, "offset");
|
|
62
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
63
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
64
|
+
const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
|
|
65
|
+
const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
|
|
66
|
+
filtered.sort(compareByOrder(order, (e, field) => e[field]));
|
|
67
|
+
const items = filtered.slice(offset, offset + limit).map((e) => ({
|
|
68
|
+
...e,
|
|
69
|
+
events: [...e.events],
|
|
70
|
+
metadata: { ...e.metadata }
|
|
71
|
+
}));
|
|
72
|
+
return {
|
|
73
|
+
items,
|
|
74
|
+
total: query.withTotal === false ? void 0 : filtered.length,
|
|
75
|
+
limit,
|
|
76
|
+
offset,
|
|
77
|
+
hasNext: offset + items.length < filtered.length
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
async countEndpoints(query) {
|
|
81
|
+
let n = 0;
|
|
82
|
+
for (const e of this.#byId.values()) if (matchesWebhookQuery(e, query)) n += 1;
|
|
83
|
+
return n;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
export { MemoryWebhookStore, matchesWebhookQuery };
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { webhookSignatureHeaders } from "./webhookSignature.js";
|
|
2
|
+
//#region nodefony/src/webhook/WebhookDispatcher.ts
|
|
3
|
+
/**
|
|
4
|
+
* Dispatcher de webhooks sortants — abonné au journal d'audit, il filtre les
|
|
5
|
+
* événements par souscription, signe (Standard Webhooks v1) et livre, **sans
|
|
6
|
+
* jamais mettre le framework en danger** (RÈGLE PERF) :
|
|
7
|
+
*
|
|
8
|
+
* - **Hot-path protégé** : `onAuditEvent` (appelé dans le fire-and-forget de
|
|
9
|
+
* `AuditService.record`) court-circuite à **coût nul** quand aucun endpoint
|
|
10
|
+
* n'est configuré (cas dominant). Sinon il empile seulement ; le travail
|
|
11
|
+
* (JSON/signature/réseau) est **différé** hors de la pile via un pump microtask.
|
|
12
|
+
* - **Concurrence bornée** : au plus `maxConcurrent` livraisons en vol → un
|
|
13
|
+
* endpoint lent/mort ne peut pas saturer sockets/FD/mémoire.
|
|
14
|
+
* - **File bornée** : au-delà de `maxQueue`, les livraisons sont **abandonnées**
|
|
15
|
+
* (best-effort) — jamais de croissance mémoire illimitée sous un pic.
|
|
16
|
+
* - **Lazy alloc** : ni file ni Set de timers tant qu'aucune livraison.
|
|
17
|
+
*
|
|
18
|
+
* Toutes les E/S et le temps sont **injectés** (`deps`) → logique testable sans
|
|
19
|
+
* réseau ni timers réels.
|
|
20
|
+
*/
|
|
21
|
+
const MAX_BACKOFF_MS = 3e5;
|
|
22
|
+
const BASE_BACKOFF_MS = 5e3;
|
|
23
|
+
/** Une souscription matche-t-elle une action d'audit ? `*` = toutes, `x.*` = préfixe. */
|
|
24
|
+
function matchesSubscription(patterns, action) {
|
|
25
|
+
for (const p of patterns) {
|
|
26
|
+
if (p === "*" || p === action) return true;
|
|
27
|
+
if (p.endsWith(".*") && action.startsWith(p.slice(0, -1))) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Classe le résultat d'une livraison : 2xx = succès ; réseau/timeout/429/408/5xx =
|
|
33
|
+
* réessayable ; 3xx/4xx (config cliente erronée) = échec définitif (pas de retry).
|
|
34
|
+
*/
|
|
35
|
+
function classifyDelivery(r) {
|
|
36
|
+
if (r.ok) return "success";
|
|
37
|
+
if (r.status === null) return "retry";
|
|
38
|
+
if (r.status === 429 || r.status === 408 || r.status >= 500) return "retry";
|
|
39
|
+
return "fail";
|
|
40
|
+
}
|
|
41
|
+
/** Backoff exponentiel déterministe (jitter cross-pod = slice Redis cluster). */
|
|
42
|
+
function backoffMs(attempt) {
|
|
43
|
+
return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** attempt);
|
|
44
|
+
}
|
|
45
|
+
var WebhookDispatcher = class {
|
|
46
|
+
#deps;
|
|
47
|
+
/** File des livraisons en attente (lazy, bornée à `policy.maxQueue`). */
|
|
48
|
+
#queue = null;
|
|
49
|
+
/** Livraisons en vol (≤ `policy.maxConcurrent`). */
|
|
50
|
+
#inFlight = 0;
|
|
51
|
+
/** Annulations des retries planifiés (lazy) — vidées au shutdown. */
|
|
52
|
+
#timers = null;
|
|
53
|
+
/** Livraisons abandonnées (file pleine) — observabilité. */
|
|
54
|
+
#dropped = 0;
|
|
55
|
+
#pumpScheduled = false;
|
|
56
|
+
#stopped = false;
|
|
57
|
+
constructor(deps) {
|
|
58
|
+
this.#deps = deps;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Réagit à un événement d'audit. **Hot-path** : court-circuit à coût nul si
|
|
62
|
+
* aucun endpoint ; sinon filtre et empile (le travail lourd est différé).
|
|
63
|
+
*/
|
|
64
|
+
onAuditEvent(event) {
|
|
65
|
+
if (this.#stopped) return;
|
|
66
|
+
if (event.category === "webhook") return;
|
|
67
|
+
if (this.#deps.endpointCount() === 0) return;
|
|
68
|
+
for (const ep of this.#deps.getSnapshot()) {
|
|
69
|
+
if (!ep.enabled) continue;
|
|
70
|
+
if (!matchesSubscription(ep.events, event.action)) continue;
|
|
71
|
+
this.#enqueue({
|
|
72
|
+
ep,
|
|
73
|
+
event,
|
|
74
|
+
attempt: 0
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** Empile une livraison ; **abandonne** (best-effort) si la file est pleine. */
|
|
79
|
+
#enqueue(job) {
|
|
80
|
+
if (this.#stopped) return;
|
|
81
|
+
if (this.#queue === null) this.#queue = [];
|
|
82
|
+
if (this.#queue.length >= this.#deps.policy.maxQueue) {
|
|
83
|
+
this.#dropped++;
|
|
84
|
+
if (this.#dropped === 1 || this.#dropped % 100 === 0) this.#deps.log(`webhooks: file pleine (${this.#deps.policy.maxQueue}) — ${this.#dropped} livraison(s) abandonnée(s) (best-effort)`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this.#queue.push(job);
|
|
88
|
+
this.#schedulePump();
|
|
89
|
+
}
|
|
90
|
+
/** Déclenche le pump hors de la pile courante (1 microtask coalescée). */
|
|
91
|
+
#schedulePump() {
|
|
92
|
+
if (this.#pumpScheduled || this.#stopped) return;
|
|
93
|
+
this.#pumpScheduled = true;
|
|
94
|
+
queueMicrotask(() => {
|
|
95
|
+
this.#pumpScheduled = false;
|
|
96
|
+
this.#pump();
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Lance des livraisons jusqu'à `maxConcurrent`. */
|
|
100
|
+
#pump() {
|
|
101
|
+
if (this.#queue === null || this.#stopped) return;
|
|
102
|
+
const max = this.#deps.policy.maxConcurrent;
|
|
103
|
+
while (this.#inFlight < max && this.#queue.length > 0) {
|
|
104
|
+
const job = this.#queue.shift();
|
|
105
|
+
this.#inFlight++;
|
|
106
|
+
this.#process(job).finally(() => {
|
|
107
|
+
this.#inFlight--;
|
|
108
|
+
if (this.#queue !== null && this.#queue.length > 0) this.#schedulePump();
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
/** Signe + livre une tentative ; gère succès / retry / échec définitif. */
|
|
113
|
+
async #process(job) {
|
|
114
|
+
const { ep, event, attempt } = job;
|
|
115
|
+
try {
|
|
116
|
+
const id = this.#deps.newMessageId();
|
|
117
|
+
const nowMs = this.#deps.now();
|
|
118
|
+
const tsS = Math.floor(nowMs / 1e3);
|
|
119
|
+
const body = JSON.stringify({
|
|
120
|
+
id,
|
|
121
|
+
timestamp: new Date(nowMs).toISOString(),
|
|
122
|
+
type: event.action,
|
|
123
|
+
data: event
|
|
124
|
+
});
|
|
125
|
+
const headers = {
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
"user-agent": "Nodefony-Webhooks/1.0",
|
|
128
|
+
...webhookSignatureHeaders(this.#deps.secretOf(ep), id, tsS, body)
|
|
129
|
+
};
|
|
130
|
+
let addresses;
|
|
131
|
+
try {
|
|
132
|
+
addresses = await this.#deps.resolveTarget(ep.url);
|
|
133
|
+
} catch (e) {
|
|
134
|
+
const error = `ssrf: ${e.message}`;
|
|
135
|
+
await this.#deps.markDelivery(ep.id, {
|
|
136
|
+
ok: false,
|
|
137
|
+
status: null,
|
|
138
|
+
error
|
|
139
|
+
});
|
|
140
|
+
this.#deps.recordDelivery(ep.id, {
|
|
141
|
+
messageId: id,
|
|
142
|
+
type: event.action,
|
|
143
|
+
attempt,
|
|
144
|
+
ok: false,
|
|
145
|
+
status: null,
|
|
146
|
+
error,
|
|
147
|
+
durationMs: this.#deps.now() - nowMs,
|
|
148
|
+
requestBody: body,
|
|
149
|
+
responseBody: null
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const result = await this.#deps.deliver(ep.url, body, headers, {
|
|
154
|
+
timeoutMs: this.#deps.policy.deliveryTimeoutMs,
|
|
155
|
+
addresses,
|
|
156
|
+
allowHttp: this.#deps.policy.allowHttp
|
|
157
|
+
});
|
|
158
|
+
if (classifyDelivery(result) === "retry" && attempt < this.#deps.policy.maxRetries) {
|
|
159
|
+
this.#scheduleRetry(job);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
await this.#deps.markDelivery(ep.id, result);
|
|
163
|
+
this.#deps.recordDelivery(ep.id, {
|
|
164
|
+
messageId: id,
|
|
165
|
+
type: event.action,
|
|
166
|
+
attempt,
|
|
167
|
+
ok: result.ok,
|
|
168
|
+
status: result.status,
|
|
169
|
+
error: result.error,
|
|
170
|
+
durationMs: this.#deps.now() - nowMs,
|
|
171
|
+
requestBody: body,
|
|
172
|
+
responseBody: result.responseBody ?? null
|
|
173
|
+
});
|
|
174
|
+
} catch (e) {
|
|
175
|
+
this.#deps.log(`webhook dispatch ${ep.id}: ${e.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/** Replanifie la livraison après backoff (repasse par la file bornée). */
|
|
179
|
+
#scheduleRetry(job) {
|
|
180
|
+
if (this.#stopped) return;
|
|
181
|
+
if (this.#timers === null) this.#timers = /* @__PURE__ */ new Set();
|
|
182
|
+
const cancel = this.#deps.schedule(() => {
|
|
183
|
+
this.#timers?.delete(cancel);
|
|
184
|
+
this.#enqueue({
|
|
185
|
+
ep: job.ep,
|
|
186
|
+
event: job.event,
|
|
187
|
+
attempt: job.attempt + 1
|
|
188
|
+
});
|
|
189
|
+
}, backoffMs(job.attempt));
|
|
190
|
+
this.#timers.add(cancel);
|
|
191
|
+
}
|
|
192
|
+
/** Livraisons abandonnées pour cause de file pleine (observabilité). */
|
|
193
|
+
droppedCount() {
|
|
194
|
+
return this.#dropped;
|
|
195
|
+
}
|
|
196
|
+
/** Arrêt propre : stoppe l'admission, annule les retries, vide la file. */
|
|
197
|
+
shutdown() {
|
|
198
|
+
this.#stopped = true;
|
|
199
|
+
if (this.#timers !== null) {
|
|
200
|
+
for (const cancel of this.#timers) cancel();
|
|
201
|
+
this.#timers.clear();
|
|
202
|
+
this.#timers = null;
|
|
203
|
+
}
|
|
204
|
+
this.#queue = null;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
208
|
+
export { WebhookDispatcher, backoffMs, classifyDelivery, matchesSubscription };
|