@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,378 @@
|
|
|
1
|
+
import { WEBHOOK_FACETS, WEBHOOK_FILTERS, WEBHOOK_STATS_FILTERS } from "../webhook/webhookFilters.js";
|
|
2
|
+
import { adminActor, auditAdmin } from "./adminAudit.js";
|
|
3
|
+
import { parseFilters, parsePageQuery } from "nodefony";
|
|
4
|
+
//#region nodefony/src/admin/WebhookAdminApi.ts
|
|
5
|
+
/**
|
|
6
|
+
* Déduit le driver logique du store webhook depuis son nom de classe (miroir de
|
|
7
|
+
* `tokenStoreDriver`). Fermé sur les implémentations connues ; `null` pour un
|
|
8
|
+
* store tiers inconnu (honnête — on n'invente pas un driver). **Pas de variante
|
|
9
|
+
* Redis** : un endpoint webhook est une donnée DURABLE (registre), sa place est
|
|
10
|
+
* en SGBD, pas dans un cache volatil (Redis sert la file d'envoi cluster, pas le
|
|
11
|
+
* registre — chantier séparé).
|
|
12
|
+
*
|
|
13
|
+
* @param className - `store.constructor.name` (ex. `DrizzleWebhookStore`).
|
|
14
|
+
*/
|
|
15
|
+
function webhookStoreDriver(className) {
|
|
16
|
+
switch (className) {
|
|
17
|
+
case "MemoryWebhookStore": return "memory";
|
|
18
|
+
case "DrizzleWebhookStore":
|
|
19
|
+
case "MongooseWebhookStore": return "orm";
|
|
20
|
+
default: return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** String non vide d'un corps de requête, ou `undefined`. */
|
|
24
|
+
function bodyString(v) {
|
|
25
|
+
return typeof v === "string" && v.length > 0 ? v : void 0;
|
|
26
|
+
}
|
|
27
|
+
/** Array de strings non vides (≥ 1 élément), ou `undefined` si invalide. */
|
|
28
|
+
function bodyStringArray(v) {
|
|
29
|
+
if (!Array.isArray(v) || v.length === 0) return void 0;
|
|
30
|
+
return v.every((x) => typeof x === "string" && x.length > 0) ? v : void 0;
|
|
31
|
+
}
|
|
32
|
+
/** Page par défaut du listing d'endpoints (registre de config, volume modeste). */
|
|
33
|
+
const ENDPOINTS_DEFAULT_LIMIT = 50;
|
|
34
|
+
/** Plafond dur : un client ne peut pas demander « tout » via `?limit=`. */
|
|
35
|
+
const ENDPOINTS_MAX_LIMIT = 200;
|
|
36
|
+
/**
|
|
37
|
+
* Traduit la query string admin en {@link IWebhookListQuery} **bornée**
|
|
38
|
+
* (`limit` défaut 50, cap 200 ; pagination, tri, et les filtres de
|
|
39
|
+
* {@link WEBHOOK_FILTERS}). Pagination **offset uniquement** : les trois stores
|
|
40
|
+
* webhook (memory/drizzle/mongoose) sont offset-pur — aucun curseur ici.
|
|
41
|
+
*
|
|
42
|
+
* **UN SEUL traducteur par dimension, jamais deux** : `parsePageQuery` pour la
|
|
43
|
+
* page et le tri, `parseFilters` pour les filtres. En appeler un second sans son
|
|
44
|
+
* allowlist ferait refuser en 400 ce que le premier venait d'accepter, et aucun
|
|
45
|
+
* test unitaire ne le verrait.
|
|
46
|
+
*
|
|
47
|
+
* Un filtre inconnu ou mal formé est **refusé** (400) et non plus ignoré :
|
|
48
|
+
* `?enabled=oui` rendait la liste entière, lue comme « aucun endpoint désactivé ».
|
|
49
|
+
*
|
|
50
|
+
* @param query - `request.query` du broker admin.
|
|
51
|
+
* @param sortable - champs que le backend branché sait trier ; un `?order=`
|
|
52
|
+
* portant autre chose est refusé en 400 par le traducteur.
|
|
53
|
+
*/
|
|
54
|
+
function parseWebhookListQuery(query, sortable) {
|
|
55
|
+
const page = parsePageQuery(query, {
|
|
56
|
+
defaultLimit: ENDPOINTS_DEFAULT_LIMIT,
|
|
57
|
+
maxLimit: ENDPOINTS_MAX_LIMIT,
|
|
58
|
+
sortable,
|
|
59
|
+
searchable: true
|
|
60
|
+
});
|
|
61
|
+
const out = {
|
|
62
|
+
limit: page.limit,
|
|
63
|
+
...parseFilters(query, WEBHOOK_FILTERS)
|
|
64
|
+
};
|
|
65
|
+
if (page.offset !== void 0) out.offset = page.offset;
|
|
66
|
+
if (page.q !== void 0) out.q = page.q;
|
|
67
|
+
if (page.order !== void 0) out.order = page.order;
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Mappe une erreur du service webhook en réponse admin. Seule l'erreur SSRF
|
|
72
|
+
* (`code === 422`, **duck-typée** — pas d'import de la classe `SsrfError`) est
|
|
73
|
+
* « attendue » et traduite en 422 ; tout le reste remonte (le broker rend un 500
|
|
74
|
+
* fail-closed, et logge).
|
|
75
|
+
*/
|
|
76
|
+
function mapWebhookError(e) {
|
|
77
|
+
if (e.code === 422) {
|
|
78
|
+
const message = e.message;
|
|
79
|
+
return {
|
|
80
|
+
status: 422,
|
|
81
|
+
body: { error: typeof message === "string" ? message : "invalid url" }
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
throw e;
|
|
85
|
+
}
|
|
86
|
+
const NOT_FOUND = {
|
|
87
|
+
status: 404,
|
|
88
|
+
body: { error: "not found" }
|
|
89
|
+
};
|
|
90
|
+
const UNAVAILABLE = {
|
|
91
|
+
status: 503,
|
|
92
|
+
body: { error: "webhooks unavailable" }
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* Construit les endpoints admin webhook, à **spreader** dans les
|
|
96
|
+
* `adminEndpoints()` du producteur `security`. Les handlers résolvent le service
|
|
97
|
+
* `webhooks` **lazy** (à la requête) → un service désactivé/absent rend 503 (ou
|
|
98
|
+
* un état honnête en lecture), jamais une erreur au montage.
|
|
99
|
+
*
|
|
100
|
+
* @param container - container du kernel (capturé par les handlers lazy).
|
|
101
|
+
*/
|
|
102
|
+
function webhookAdminEndpoints(container) {
|
|
103
|
+
const svc = () => container.get("webhooks");
|
|
104
|
+
const ready = (s) => !!s && s.isReady();
|
|
105
|
+
const pathId = (request) => {
|
|
106
|
+
const id = request.params.id;
|
|
107
|
+
return typeof id === "string" && id.length > 0 ? id : null;
|
|
108
|
+
};
|
|
109
|
+
return [
|
|
110
|
+
{
|
|
111
|
+
path: "webhooks",
|
|
112
|
+
method: "GET",
|
|
113
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
114
|
+
summary: "Endpoints webhook sortants (registre) + backend du store (« où on écrit » : memory/orm). Secrets EXCLUS (chiffrés au repos, jamais ici).",
|
|
115
|
+
page: {
|
|
116
|
+
sortable: () => {
|
|
117
|
+
const s = svc();
|
|
118
|
+
return ready(s) ? s.sortableFields() : [];
|
|
119
|
+
},
|
|
120
|
+
filters: WEBHOOK_FILTERS,
|
|
121
|
+
search: () => ready(svc())
|
|
122
|
+
},
|
|
123
|
+
handler: async (request) => {
|
|
124
|
+
const s = svc();
|
|
125
|
+
const className = container.get("webhookStore")?.constructor?.name;
|
|
126
|
+
const query = parseWebhookListQuery(request.query, ready(s) ? s.sortableFields() : []);
|
|
127
|
+
const page = ready(s) ? await s.listPage(query) : null;
|
|
128
|
+
return {
|
|
129
|
+
enabled: ready(s),
|
|
130
|
+
driver: webhookStoreDriver(className),
|
|
131
|
+
store: className ?? "none",
|
|
132
|
+
endpoints: page?.items ?? [],
|
|
133
|
+
total: page?.total,
|
|
134
|
+
limit: query.limit,
|
|
135
|
+
offset: page?.offset
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
path: "webhooks/stats",
|
|
141
|
+
method: "GET",
|
|
142
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
143
|
+
summary: "Compteurs des endpoints sur la collection ENTIÈRE (total, actifs, désactivés, en échec) — mêmes filtres que la liste. `null` = le backend ne sait pas compter. Webhooks coupés → tous les compteurs null.",
|
|
144
|
+
page: {
|
|
145
|
+
filters: WEBHOOK_STATS_FILTERS,
|
|
146
|
+
facets: WEBHOOK_FACETS,
|
|
147
|
+
search: () => ready(svc())
|
|
148
|
+
},
|
|
149
|
+
handler: async (request) => {
|
|
150
|
+
const s = svc();
|
|
151
|
+
const page = parsePageQuery(request.query, { searchable: ready(s) });
|
|
152
|
+
if (!ready(s)) return {
|
|
153
|
+
total: null,
|
|
154
|
+
active: null,
|
|
155
|
+
disabled: null,
|
|
156
|
+
failing: null
|
|
157
|
+
};
|
|
158
|
+
return s.countWebhookFacets({
|
|
159
|
+
...parseFilters(request.query, WEBHOOK_STATS_FILTERS),
|
|
160
|
+
...page.q !== void 0 ? { q: page.q } : {}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
path: "webhooks",
|
|
166
|
+
method: "POST",
|
|
167
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
168
|
+
summary: "Crée un endpoint (URL validée anti-SSRF). Le secret de signature n'est renvoyé QU'ICI, une seule fois. Audité (webhook.created).",
|
|
169
|
+
handler: async (request) => {
|
|
170
|
+
const s = svc();
|
|
171
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
172
|
+
const body = request.body ?? {};
|
|
173
|
+
const url = bodyString(body.url);
|
|
174
|
+
if (!url) return {
|
|
175
|
+
status: 400,
|
|
176
|
+
body: { error: "url required" }
|
|
177
|
+
};
|
|
178
|
+
const events = bodyStringArray(body.events);
|
|
179
|
+
if (!events) return {
|
|
180
|
+
status: 400,
|
|
181
|
+
body: { error: "events must be a non-empty string array" }
|
|
182
|
+
};
|
|
183
|
+
if (body.description !== void 0 && body.description !== null && typeof body.description !== "string") return {
|
|
184
|
+
status: 400,
|
|
185
|
+
body: { error: "description must be a string" }
|
|
186
|
+
};
|
|
187
|
+
if (body.enabled !== void 0 && typeof body.enabled !== "boolean") return {
|
|
188
|
+
status: 400,
|
|
189
|
+
body: { error: "enabled must be a boolean" }
|
|
190
|
+
};
|
|
191
|
+
const actor = adminActor(request.user);
|
|
192
|
+
try {
|
|
193
|
+
const created = await s.register({
|
|
194
|
+
url,
|
|
195
|
+
events,
|
|
196
|
+
description: body.description ?? null,
|
|
197
|
+
enabled: body.enabled,
|
|
198
|
+
createdBy: actor
|
|
199
|
+
});
|
|
200
|
+
auditAdmin(container, {
|
|
201
|
+
category: "webhook",
|
|
202
|
+
action: "webhook.created",
|
|
203
|
+
outcome: "success",
|
|
204
|
+
actor,
|
|
205
|
+
resource: created.endpoint.id,
|
|
206
|
+
metadata: { url }
|
|
207
|
+
});
|
|
208
|
+
return {
|
|
209
|
+
status: 201,
|
|
210
|
+
body: created
|
|
211
|
+
};
|
|
212
|
+
} catch (e) {
|
|
213
|
+
return mapWebhookError(e);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
{
|
|
218
|
+
path: "webhooks/{id}",
|
|
219
|
+
method: "GET",
|
|
220
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
221
|
+
summary: "Un endpoint webhook par id (vue publique, sans secret). 404 sinon.",
|
|
222
|
+
handler: async (request) => {
|
|
223
|
+
const s = svc();
|
|
224
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
225
|
+
const id = pathId(request);
|
|
226
|
+
if (!id) return NOT_FOUND;
|
|
227
|
+
return await s.getEndpoint(id) ?? NOT_FOUND;
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
path: "webhooks/{id}/deliveries",
|
|
232
|
+
method: "GET",
|
|
233
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
234
|
+
summary: "Historique des dernières livraisons d'un endpoint (ce qui a été ENVOYÉ + la réponse) — RAM, borné, par pod. Aucun secret. 404 si endpoint absent.",
|
|
235
|
+
handler: async (request) => {
|
|
236
|
+
const s = svc();
|
|
237
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
238
|
+
const id = pathId(request);
|
|
239
|
+
if (!id) return NOT_FOUND;
|
|
240
|
+
if (!await s.getEndpoint(id)) return NOT_FOUND;
|
|
241
|
+
return { deliveries: s.listDeliveries(id) };
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
{
|
|
245
|
+
path: "webhooks/{id}",
|
|
246
|
+
method: "PATCH",
|
|
247
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
248
|
+
summary: "Met à jour un endpoint (url/events/enabled/description). Une nouvelle url est re-validée anti-SSRF. Audité (webhook.updated). 404 si absent.",
|
|
249
|
+
handler: async (request) => {
|
|
250
|
+
const s = svc();
|
|
251
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
252
|
+
const id = pathId(request);
|
|
253
|
+
if (!id) return NOT_FOUND;
|
|
254
|
+
const body = request.body ?? {};
|
|
255
|
+
const patch = {};
|
|
256
|
+
const fields = [];
|
|
257
|
+
if ("url" in body) {
|
|
258
|
+
const u = bodyString(body.url);
|
|
259
|
+
if (!u) return {
|
|
260
|
+
status: 400,
|
|
261
|
+
body: { error: "url must be a non-empty string" }
|
|
262
|
+
};
|
|
263
|
+
patch.url = u;
|
|
264
|
+
fields.push("url");
|
|
265
|
+
}
|
|
266
|
+
if ("events" in body) {
|
|
267
|
+
const ev = bodyStringArray(body.events);
|
|
268
|
+
if (!ev) return {
|
|
269
|
+
status: 400,
|
|
270
|
+
body: { error: "events must be a non-empty string array" }
|
|
271
|
+
};
|
|
272
|
+
patch.events = ev;
|
|
273
|
+
fields.push("events");
|
|
274
|
+
}
|
|
275
|
+
if ("enabled" in body) {
|
|
276
|
+
if (typeof body.enabled !== "boolean") return {
|
|
277
|
+
status: 400,
|
|
278
|
+
body: { error: "enabled must be a boolean" }
|
|
279
|
+
};
|
|
280
|
+
patch.enabled = body.enabled;
|
|
281
|
+
fields.push("enabled");
|
|
282
|
+
}
|
|
283
|
+
if ("description" in body) {
|
|
284
|
+
const d = body.description;
|
|
285
|
+
if (d !== null && typeof d !== "string") return {
|
|
286
|
+
status: 400,
|
|
287
|
+
body: { error: "description must be a string or null" }
|
|
288
|
+
};
|
|
289
|
+
patch.description = d;
|
|
290
|
+
fields.push("description");
|
|
291
|
+
}
|
|
292
|
+
const actor = adminActor(request.user);
|
|
293
|
+
try {
|
|
294
|
+
const updated = await s.update(id, patch);
|
|
295
|
+
if (!updated) return NOT_FOUND;
|
|
296
|
+
auditAdmin(container, {
|
|
297
|
+
category: "webhook",
|
|
298
|
+
action: "webhook.updated",
|
|
299
|
+
outcome: "success",
|
|
300
|
+
actor,
|
|
301
|
+
resource: id,
|
|
302
|
+
metadata: { fields }
|
|
303
|
+
});
|
|
304
|
+
return updated;
|
|
305
|
+
} catch (e) {
|
|
306
|
+
return mapWebhookError(e);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
},
|
|
310
|
+
{
|
|
311
|
+
path: "webhooks/{id}",
|
|
312
|
+
method: "DELETE",
|
|
313
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
314
|
+
summary: "Supprime un endpoint. Audité (webhook.deleted). 404 si absent.",
|
|
315
|
+
handler: async (request) => {
|
|
316
|
+
const s = svc();
|
|
317
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
318
|
+
const id = pathId(request);
|
|
319
|
+
if (!id) return NOT_FOUND;
|
|
320
|
+
if (!await s.delete(id)) return NOT_FOUND;
|
|
321
|
+
auditAdmin(container, {
|
|
322
|
+
category: "webhook",
|
|
323
|
+
action: "webhook.deleted",
|
|
324
|
+
outcome: "success",
|
|
325
|
+
actor: adminActor(request.user),
|
|
326
|
+
resource: id
|
|
327
|
+
});
|
|
328
|
+
return { ok: true };
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
path: "webhooks/{id}/rotate",
|
|
333
|
+
method: "POST",
|
|
334
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
335
|
+
summary: "Régénère le secret de signature (rotation) — nouveau secret renvoyé QU'ICI, une fois. L'ancien cesse d'être valide. Audité. 404 si absent.",
|
|
336
|
+
handler: async (request) => {
|
|
337
|
+
const s = svc();
|
|
338
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
339
|
+
const id = pathId(request);
|
|
340
|
+
if (!id) return NOT_FOUND;
|
|
341
|
+
const rotated = await s.rotateSecret(id);
|
|
342
|
+
if (!rotated) return NOT_FOUND;
|
|
343
|
+
auditAdmin(container, {
|
|
344
|
+
category: "webhook",
|
|
345
|
+
action: "webhook.rotated",
|
|
346
|
+
outcome: "success",
|
|
347
|
+
actor: adminActor(request.user),
|
|
348
|
+
resource: id
|
|
349
|
+
});
|
|
350
|
+
return rotated;
|
|
351
|
+
}
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
path: "webhooks/{id}/reveal",
|
|
355
|
+
method: "POST",
|
|
356
|
+
role: "ROLE_NODEFONY_ADMIN",
|
|
357
|
+
summary: "Révèle le secret de signature en clair (réversible — copie console). Action SENSIBLE systématiquement audité (webhook.revealed). 404 sinon. POST (pas GET) : jamais de secret dans une URL/log d'accès, CSRF requise.",
|
|
358
|
+
handler: async (request) => {
|
|
359
|
+
const s = svc();
|
|
360
|
+
if (!ready(s)) return UNAVAILABLE;
|
|
361
|
+
const id = pathId(request);
|
|
362
|
+
if (!id) return NOT_FOUND;
|
|
363
|
+
const secret = await s.revealSecret(id);
|
|
364
|
+
if (secret === null) return NOT_FOUND;
|
|
365
|
+
auditAdmin(container, {
|
|
366
|
+
category: "webhook",
|
|
367
|
+
action: "webhook.revealed",
|
|
368
|
+
outcome: "success",
|
|
369
|
+
actor: adminActor(request.user),
|
|
370
|
+
resource: id
|
|
371
|
+
});
|
|
372
|
+
return { secret };
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
];
|
|
376
|
+
}
|
|
377
|
+
//#endregion
|
|
378
|
+
export { parseWebhookListQuery, webhookAdminEndpoints };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region nodefony/src/admin/adminAudit.ts
|
|
2
|
+
/**
|
|
3
|
+
* Helpers d'audit PARTAGÉS par les producteurs admin du module sécurité
|
|
4
|
+
* (`SecurityAdminApi`, `WebhookAdminApi`…). Extraits dans leur propre fichier
|
|
5
|
+
* pour être consommés par plusieurs producteurs SANS créer de cycle d'import
|
|
6
|
+
* (un producteur composé ne ré-importe pas le producteur qui le compose).
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Identité de l'admin appelant (label d'audit) — duck-typing prudent sur
|
|
10
|
+
* l'`IUser` projeté dans `IAdminRequest.user` (ALS du firewall). Repli
|
|
11
|
+
* `"admin"` (libellé d'audit, jamais une décision d'autorisation).
|
|
12
|
+
*
|
|
13
|
+
* @param user - `request.user` du broker admin.
|
|
14
|
+
* @returns un libellé d'identité stable, jamais un secret.
|
|
15
|
+
*/
|
|
16
|
+
function adminActor(user) {
|
|
17
|
+
if (user && typeof user === "object") {
|
|
18
|
+
const u = user;
|
|
19
|
+
if (typeof u.username === "string" && u.username) return u.username;
|
|
20
|
+
if (typeof u.identifier === "string" && u.identifier) return u.identifier;
|
|
21
|
+
}
|
|
22
|
+
return "admin";
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Émet un événement d'audit pour une mutation admin (best-effort,
|
|
26
|
+
* fire-and-forget) — l'audit ne doit jamais bloquer ni faire échouer l'action.
|
|
27
|
+
* No-op si le service `auditService` est absent. Couplage structurel : `record`
|
|
28
|
+
* lu défensivement (jamais d'import de la classe concrète).
|
|
29
|
+
*
|
|
30
|
+
* @param container - container du kernel.
|
|
31
|
+
* @param draft - événement (sans `id`/`ts`, posés par le service).
|
|
32
|
+
*/
|
|
33
|
+
function auditAdmin(container, draft) {
|
|
34
|
+
container.get("auditService")?.record?.(draft);
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { adminActor, auditAdmin };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { USER_REVOKED_EVENT } from "@nodefony/user";
|
|
2
|
+
//#region nodefony/src/admin/userRevocationCascade.ts
|
|
3
|
+
/**
|
|
4
|
+
* Cascade de révocation déclenchée par {@link USER_REVOKED_EVENT} : éjecte
|
|
5
|
+
* **immédiatement** les artefacts d'accès du porteur — ses **sessions** (http,
|
|
6
|
+
* `destroyByUser`) et ses **jetons/PAT** (`tokenStore.revokeAllForSubject`,
|
|
7
|
+
* seuil `invalidBefore`). Best-effort par brique (une indispo n'empêche pas
|
|
8
|
+
* l'autre) — l'accès était DÉJÀ neutralisé par le re-fetch des authenticators,
|
|
9
|
+
* cette cascade est de la **propreté + défense en profondeur**, jamais l'unique
|
|
10
|
+
* rempart. `tenantId` du payload est réservé (scoping non câblé en mono-tenant).
|
|
11
|
+
*
|
|
12
|
+
* @param container - container du kernel (résolution lazy de `sessions`/`tokenStore`).
|
|
13
|
+
* @param event - charge utile de l'événement (porteur + raison).
|
|
14
|
+
* @param now - horloge (epoch ms) injectable pour les tests.
|
|
15
|
+
*/
|
|
16
|
+
async function cascadeUserRevocation(container, event, now = Date.now()) {
|
|
17
|
+
const identifier = event.identifier;
|
|
18
|
+
try {
|
|
19
|
+
await container.get("sessions")?.destroyByUser?.(identifier);
|
|
20
|
+
} catch {}
|
|
21
|
+
try {
|
|
22
|
+
await container.get("tokenStore")?.revokeAllForSubject?.(identifier, now);
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Abonne la cascade au bus kernel. À appeler au `onKernelBoot` d'un module
|
|
27
|
+
* bootable (ici `@nodefony/security`). **Extensible** : tout autre module
|
|
28
|
+
* (webhooks…) peut s'abonner au MÊME `USER_REVOKED_EVENT` pour ses propres
|
|
29
|
+
* artefacts, sans toucher à ce fichier.
|
|
30
|
+
*
|
|
31
|
+
* @param kernel - bus d'événements (kernel) exposant `on`.
|
|
32
|
+
* @param container - container capturé par le handler.
|
|
33
|
+
*/
|
|
34
|
+
function registerUserRevocationCascade(kernel, container) {
|
|
35
|
+
kernel.on(USER_REVOKED_EVENT, (event) => {
|
|
36
|
+
cascadeUserRevocation(container, event);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { cascadeUserRevocation, registerUserRevocationCascade };
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
//#region nodefony/src/apikey/apiKeyFormat.ts
|
|
3
|
+
/**
|
|
4
|
+
* Encodage / décodage / hachage des clés API (PAT) — logique **pure** (aucun I/O,
|
|
5
|
+
* aucun container) → testable sans serveur et partagée par l'émetteur
|
|
6
|
+
* (`ApiKeyService`) et le vérificateur (`ApiKeyAuthenticator`).
|
|
7
|
+
*
|
|
8
|
+
* **Format émis** : `<prefix>_<pubid><secret><crc>` (un seul séparateur `_`, le
|
|
9
|
+
* reste est **positionnel** — pas de `split` fragile, car le charset base64url
|
|
10
|
+
* contient lui-même `-` et `_`). Exemple : `nf_a1b2c3d4XXXX…XXXXz9z9z9`.
|
|
11
|
+
*
|
|
12
|
+
* - `prefix` : marque applicative (`apiKeys.prefix`, ex. `nf`) — discrimine un
|
|
13
|
+
* PAT d'un JWT (qui, lui, a la structure compacte `a.b.c`) ;
|
|
14
|
+
* - `pubid` : 6 octets aléatoires → 8 car. — identifiant **public** affichable
|
|
15
|
+
* (`record.prefix` = `nf_a1b2c3d4`), jamais secret, sert l'UI/console ;
|
|
16
|
+
* - `secret` : 32 octets aléatoires → 43 car. = **256 bits d'entropie** ;
|
|
17
|
+
* - `crc` : CRC32 (4 octets → 6 car.) du `prefix_pubid_secret`. Checksum
|
|
18
|
+
* **public** (jamais un secret) : permet (1) de **rejeter une clé malformée en
|
|
19
|
+
* O(1) sans toucher la base** (anti-DoS du store), (2) le **secret-scanning**
|
|
20
|
+
* (GitGuardian/GitHub détectent un `nf_…` au checksum valide qui aurait fuité).
|
|
21
|
+
*
|
|
22
|
+
* **Au repos** : seul `sha256(token entier)` est stocké (`secretHash`). `sha256`
|
|
23
|
+
* suffit ici — contrairement à un mot de passe humain (faible → argon2), un secret
|
|
24
|
+
* de 256 bits aléatoires n'est ni brute-forçable ni sujet aux rainbow tables ; un
|
|
25
|
+
* pepper ne protègerait que des secrets faibles.
|
|
26
|
+
*/
|
|
27
|
+
const PUBID_BYTES = 6;
|
|
28
|
+
const SECRET_BYTES = 32;
|
|
29
|
+
const PUBID_LEN = 8;
|
|
30
|
+
const BODY_LEN = 57;
|
|
31
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
32
|
+
const CRC_TABLE = (() => {
|
|
33
|
+
const table = /* @__PURE__ */ new Uint32Array(256);
|
|
34
|
+
for (let n = 0; n < 256; n++) {
|
|
35
|
+
let c = n;
|
|
36
|
+
for (let k = 0; k < 8; k++) c = c & 1 ? 3988292384 ^ c >>> 1 : c >>> 1;
|
|
37
|
+
table[n] = c >>> 0;
|
|
38
|
+
}
|
|
39
|
+
return table;
|
|
40
|
+
})();
|
|
41
|
+
/** CRC32 (IEEE) d'une chaîne ASCII — entier non signé 32 bits. */
|
|
42
|
+
function crc32(input) {
|
|
43
|
+
let crc = 4294967295;
|
|
44
|
+
for (let i = 0; i < input.length; i++) crc = CRC_TABLE[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
|
|
45
|
+
return (crc ^ 4294967295) >>> 0;
|
|
46
|
+
}
|
|
47
|
+
/** Chunk de checksum (6 car. base64url) du payload `prefix_pubid_secret`. */
|
|
48
|
+
function crcChunk(payload) {
|
|
49
|
+
const buf = Buffer.allocUnsafe(4);
|
|
50
|
+
buf.writeUInt32BE(crc32(payload));
|
|
51
|
+
return buf.toString("base64url");
|
|
52
|
+
}
|
|
53
|
+
/** Hash au repos d'une clé présentée (token entier) — `sha256` hex. */
|
|
54
|
+
function hashApiKey(token) {
|
|
55
|
+
return createHash("sha256").update(token).digest("hex");
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Génère une nouvelle clé API cryptographiquement aléatoire.
|
|
59
|
+
*
|
|
60
|
+
* @param prefix - marque applicative (`apiKeys.prefix`).
|
|
61
|
+
* @returns le token clair + ses dérivés publics/persistants.
|
|
62
|
+
*/
|
|
63
|
+
function generateApiKey(prefix) {
|
|
64
|
+
const pubid = randomBytes(PUBID_BYTES).toString("base64url");
|
|
65
|
+
const payload = `${prefix}_${pubid}${randomBytes(SECRET_BYTES).toString("base64url")}`;
|
|
66
|
+
const token = `${payload}${crcChunk(payload)}`;
|
|
67
|
+
return {
|
|
68
|
+
token,
|
|
69
|
+
pubid,
|
|
70
|
+
publicPrefix: `${prefix}_${pubid}`,
|
|
71
|
+
secretHash: hashApiKey(token)
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Test **bon marché** (préfixe seul) — discrimine un PAT d'un JWT à l'`supports()`
|
|
76
|
+
* de l'authenticator, sans calculer le checksum.
|
|
77
|
+
*/
|
|
78
|
+
function looksLikeApiKey(token, prefix) {
|
|
79
|
+
return token.startsWith(`${prefix}_`);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Valide la **forme** d'une clé présentée et en dérive le hash de lookup —
|
|
83
|
+
* **sans aucun accès au store**. Rejette (→ `null`) un préfixe absent, une
|
|
84
|
+
* longueur incorrecte, un charset non base64url ou un **CRC invalide** : autant
|
|
85
|
+
* de requêtes qui n'atteignent jamais la base (anti-DoS).
|
|
86
|
+
*
|
|
87
|
+
* @param token - valeur brute présentée (after `Bearer `).
|
|
88
|
+
* @param prefix - marque applicative attendue.
|
|
89
|
+
* @returns la décomposition + le `secretHash` de lookup, ou `null` si malformée.
|
|
90
|
+
*/
|
|
91
|
+
function parseApiKey(token, prefix) {
|
|
92
|
+
const head = `${prefix}_`;
|
|
93
|
+
if (!token.startsWith(head)) return null;
|
|
94
|
+
const body = token.slice(head.length);
|
|
95
|
+
if (body.length !== BODY_LEN || !BASE64URL.test(body)) return null;
|
|
96
|
+
const pubid = body.slice(0, PUBID_LEN);
|
|
97
|
+
const secret = body.slice(PUBID_LEN, 51);
|
|
98
|
+
const crc = body.slice(51);
|
|
99
|
+
if (crcChunk(`${prefix}_${pubid}${secret}`) !== crc) return null;
|
|
100
|
+
return {
|
|
101
|
+
pubid,
|
|
102
|
+
publicPrefix: `${prefix}_${pubid}`,
|
|
103
|
+
secretHash: hashApiKey(token)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
//#endregion
|
|
107
|
+
export { generateApiKey, hashApiKey, looksLikeApiKey, parseApiKey };
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { assertPageQuery } from "nodefony";
|
|
2
|
+
//#region nodefony/src/audit/MemoryAuditStore.ts
|
|
3
|
+
const DEFAULT_MAX_ENTRIES = 1e4;
|
|
4
|
+
const DEFAULT_LIMIT = 100;
|
|
5
|
+
const MAX_LIMIT = 500;
|
|
6
|
+
/**
|
|
7
|
+
* Sépare les deux composantes du curseur. Le curseur est **composite**
|
|
8
|
+
* (`<ts>:<id>`) et non un id nu : il se suffit à lui-même, donc une page reste
|
|
9
|
+
* exacte même si l'événement qui l'a produite a été purgé entre-temps par
|
|
10
|
+
* {@link MemoryAuditStore.gc} (un id nu, lui, n'aurait plus rien à résoudre →
|
|
11
|
+
* curseur ignoré → retour silencieux à la première page).
|
|
12
|
+
*
|
|
13
|
+
* Format **privé au store** : l'appelant repasse le jeton tel quel.
|
|
14
|
+
*/
|
|
15
|
+
const CURSOR_SEPARATOR = ":";
|
|
16
|
+
/**
|
|
17
|
+
* Journal d'audit **en mémoire** — implémentation de référence d'{@link IAuditStore}.
|
|
18
|
+
*
|
|
19
|
+
* 0 dépendance, idéal pour le dev mono-process et les **tests**. **Volatile**
|
|
20
|
+
* (perdu au redémarrage) et **non partagé** (per-pod) → en prod multi-process,
|
|
21
|
+
* brancher un backend ORM/Redis. Le volume est **borné** (`maxEntries`, FIFO : au
|
|
22
|
+
* delà, le plus ancien tombe) pour ne JAMAIS fuir, doublé d'une purge par âge
|
|
23
|
+
* ({@link MemoryAuditStore.gc}, rétention). Append-only : aucune mutation d'un
|
|
24
|
+
* événement déjà journalisé.
|
|
25
|
+
*
|
|
26
|
+
* Horloge injectable (`now`) pour des tests déterministes (pattern `MemoryTokenStore`).
|
|
27
|
+
*/
|
|
28
|
+
var MemoryAuditStore = class {
|
|
29
|
+
/** Événements en ordre d'insertion (ancien → récent). FIFO borné. */
|
|
30
|
+
#events = [];
|
|
31
|
+
#now;
|
|
32
|
+
#maxEntries;
|
|
33
|
+
/** Fenêtre de rétention (ms) — au-delà, `gc` purge. */
|
|
34
|
+
#retentionMs;
|
|
35
|
+
constructor(now = Date.now, retentionMs = 31536e6, maxEntries = DEFAULT_MAX_ENTRIES) {
|
|
36
|
+
this.#now = now;
|
|
37
|
+
this.#retentionMs = retentionMs;
|
|
38
|
+
this.#maxEntries = maxEntries;
|
|
39
|
+
}
|
|
40
|
+
append(event) {
|
|
41
|
+
this.#events.push(event);
|
|
42
|
+
if (this.#events.length > this.#maxEntries) this.#events.shift();
|
|
43
|
+
return Promise.resolve();
|
|
44
|
+
}
|
|
45
|
+
listPage(query) {
|
|
46
|
+
assertPageQuery(query, "cursor");
|
|
47
|
+
const limit = Math.min(Math.max(1, query.limit ?? DEFAULT_LIMIT), MAX_LIMIT);
|
|
48
|
+
const matched = [];
|
|
49
|
+
for (let i = 0; i < this.#events.length; i++) {
|
|
50
|
+
const event = this.#events[i];
|
|
51
|
+
if (this.#matches(event, query)) matched.push(event);
|
|
52
|
+
}
|
|
53
|
+
const total = matched.length;
|
|
54
|
+
matched.sort((a, b) => a.ts !== b.ts ? b.ts - a.ts : a.id < b.id ? 1 : -1);
|
|
55
|
+
let page = matched;
|
|
56
|
+
const cursor = this.#parseCursor(query.cursor);
|
|
57
|
+
if (cursor) {
|
|
58
|
+
const from = matched.findIndex((event) => event.ts < cursor.ts || event.ts === cursor.ts && event.id < cursor.id);
|
|
59
|
+
page = from >= 0 ? matched.slice(from) : [];
|
|
60
|
+
}
|
|
61
|
+
const hasNext = page.length > limit;
|
|
62
|
+
const items = hasNext ? page.slice(0, limit) : page;
|
|
63
|
+
const last = items[items.length - 1];
|
|
64
|
+
return Promise.resolve({
|
|
65
|
+
items,
|
|
66
|
+
limit,
|
|
67
|
+
hasNext,
|
|
68
|
+
nextCursor: hasNext && last ? `${last.ts}${CURSOR_SEPARATOR}${last.id}` : null,
|
|
69
|
+
...query.withTotal === false ? {} : { total }
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Décode le curseur composite. Un jeton absent ou malformé (forgé — le nôtre
|
|
74
|
+
* ne l'est jamais) rend `null` : la lecture repart de la page la plus récente,
|
|
75
|
+
* jamais d'erreur sur un chemin de consultation.
|
|
76
|
+
*/
|
|
77
|
+
#parseCursor(cursor) {
|
|
78
|
+
if (cursor === void 0) return null;
|
|
79
|
+
const sep = cursor.indexOf(CURSOR_SEPARATOR);
|
|
80
|
+
if (sep <= 0) return null;
|
|
81
|
+
const ts = Number(cursor.slice(0, sep));
|
|
82
|
+
return Number.isFinite(ts) ? {
|
|
83
|
+
ts,
|
|
84
|
+
id: cursor.slice(sep + 1)
|
|
85
|
+
} : null;
|
|
86
|
+
}
|
|
87
|
+
gc(now = this.#now()) {
|
|
88
|
+
const threshold = now - this.#retentionMs;
|
|
89
|
+
let purged = 0;
|
|
90
|
+
while (this.#events.length > 0 && this.#events[0].ts < threshold) {
|
|
91
|
+
this.#events.shift();
|
|
92
|
+
purged++;
|
|
93
|
+
}
|
|
94
|
+
return Promise.resolve(purged);
|
|
95
|
+
}
|
|
96
|
+
#matches(event, filter) {
|
|
97
|
+
if (filter.category !== void 0 && event.category !== filter.category) return false;
|
|
98
|
+
if (filter.outcome !== void 0 && event.outcome !== filter.outcome) return false;
|
|
99
|
+
if (filter.actor !== void 0 && event.actor !== filter.actor) return false;
|
|
100
|
+
if (filter.action !== void 0 && event.action !== filter.action) return false;
|
|
101
|
+
if (filter.requestId !== void 0 && event.requestId !== filter.requestId) return false;
|
|
102
|
+
if (filter.since !== void 0 && event.ts < filter.since) return false;
|
|
103
|
+
if (filter.until !== void 0 && event.ts > filter.until) return false;
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/** Nombre d'événements actuellement retenus (introspection / tests). */
|
|
107
|
+
get size() {
|
|
108
|
+
return this.#events.length;
|
|
109
|
+
}
|
|
110
|
+
/** Instantané sérialisable de l'état courant. */
|
|
111
|
+
snapshot() {
|
|
112
|
+
return { events: [...this.#events] };
|
|
113
|
+
}
|
|
114
|
+
/** Remplace l'état par celui d'un instantané. */
|
|
115
|
+
restore(snapshot) {
|
|
116
|
+
this.#events.length = 0;
|
|
117
|
+
for (const event of snapshot.events) this.#events.push(event);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
//#endregion
|
|
121
|
+
export { MemoryAuditStore };
|