@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/nodefony/testing/index.js +108 -0
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/testing/index.d.ts +51 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +136 -124
- package/package.json +19 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
2
|
+
import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
|
|
2
3
|
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
3
4
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
4
5
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
5
6
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
7
|
+
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
|
|
6
8
|
import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
7
9
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
8
10
|
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
9
11
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
10
12
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
11
|
-
import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation,
|
|
13
|
+
import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
12
14
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
13
|
-
|
|
15
|
+
// nodefony/service/tokenService.ts
|
|
14
16
|
const serviceName = "tokenService";
|
|
15
17
|
/**
|
|
16
18
|
* Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
|
|
@@ -39,6 +41,8 @@ var TokenService = class extends Service {
|
|
|
39
41
|
#throttler = null;
|
|
40
42
|
#throttlerResolved = false;
|
|
41
43
|
#transport = null;
|
|
44
|
+
/** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
|
|
45
|
+
#scopeGrants = null;
|
|
42
46
|
#gc = null;
|
|
43
47
|
constructor(module) {
|
|
44
48
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
@@ -87,6 +91,7 @@ var TokenService = class extends Service {
|
|
|
87
91
|
}
|
|
88
92
|
const jwtEnabled = config.jwt.enabled;
|
|
89
93
|
const apiKeysEnabled = config.apiKeys.enabled;
|
|
94
|
+
this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
|
|
90
95
|
if (!jwtEnabled && !apiKeysEnabled) {
|
|
91
96
|
this.log("token service idle — JWT et clés API désactivés", "DEBUG");
|
|
92
97
|
return;
|
|
@@ -94,7 +99,7 @@ var TokenService = class extends Service {
|
|
|
94
99
|
let storeName = config.tokenStore.store;
|
|
95
100
|
let reason = `store explicitement configuré ("${storeName}")`;
|
|
96
101
|
if (storeName === AUTO_STORE) {
|
|
97
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory",
|
|
102
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", storeConnectivity(this.kernel));
|
|
98
103
|
storeName = auto.store;
|
|
99
104
|
reason = auto.reason;
|
|
100
105
|
this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
@@ -106,7 +111,7 @@ var TokenService = class extends Service {
|
|
|
106
111
|
this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
|
|
107
112
|
return;
|
|
108
113
|
}
|
|
109
|
-
if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. " + durableStoreRemedy(
|
|
114
|
+
if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
110
115
|
this.#store = factory({
|
|
111
116
|
container: this.container,
|
|
112
117
|
config
|
|
@@ -340,26 +345,30 @@ var TokenService = class extends Service {
|
|
|
340
345
|
* d'informer le client — c'est ce que fait le champ `scope` de la réponse,
|
|
341
346
|
* déjà rempli avec les scopes réellement accordés.
|
|
342
347
|
*
|
|
343
|
-
* Les scopes d'une APPLICATION
|
|
344
|
-
* qui peut obtenir `shop:
|
|
348
|
+
* Les scopes d'une APPLICATION se bornent par `grantableScopes` : c'est elle
|
|
349
|
+
* qui dit qui peut obtenir `shop:write`. Un scope qu'elle ne déclare pas
|
|
350
|
+
* reste accordé à tout porteur. Les deux familles passent par le MÊME juge
|
|
351
|
+
* ({@link refusedScopes}), que les clés API appellent aussi.
|
|
345
352
|
*
|
|
346
353
|
* @param user - le porteur, déjà authentifié.
|
|
347
354
|
* @param requested - ce qu'il demande.
|
|
348
355
|
* @returns ce qui lui est accordé.
|
|
349
356
|
*/
|
|
350
357
|
#grantableScopes(user, requested) {
|
|
358
|
+
if (requested.length === 0) return requested;
|
|
351
359
|
const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
|
|
352
|
-
const
|
|
353
|
-
if (
|
|
354
|
-
|
|
355
|
-
|
|
360
|
+
const refusals = refusedScopes(requested, roles, this.#scopeGrants ?? new ScopeGrantPolicy(), this.container?.get("roleHierarchy") ?? null);
|
|
361
|
+
if (refusals.length === 0) return requested;
|
|
362
|
+
const named = refusals.map((r) => `${r.scope} (${r.rule === "admin" ? "réservé au rôle d'administration" : "réservé par grantableScopes"})`).join(", ");
|
|
363
|
+
this.log(`grant : scope(s) NON accordé(s) à « ${user.identifier} » — ${named}. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
|
|
364
|
+
return requested.filter((scope) => !refusals.some((r) => r.scope === scope));
|
|
356
365
|
}
|
|
357
366
|
async issueTokens(user, requestedScopes, resource, accessTtlS) {
|
|
358
367
|
const { store, runtime } = this.#ensureReady();
|
|
359
368
|
const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
|
|
360
369
|
const audience = this.#resolveAudience(runtime, resource);
|
|
361
|
-
const access = await this.#signAccess(user
|
|
362
|
-
const { record, raw } = this.#buildRefresh(user
|
|
370
|
+
const access = await this.#signAccess(user, scopes, audience, accessTtlS);
|
|
371
|
+
const { record, raw } = this.#buildRefresh(user, scopes, this.#randomId(), audience);
|
|
363
372
|
await store.put(record);
|
|
364
373
|
recordAudit(this.container, {
|
|
365
374
|
category: "token",
|
|
@@ -418,10 +427,11 @@ var TokenService = class extends Service {
|
|
|
418
427
|
const now = Date.now();
|
|
419
428
|
if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
|
|
420
429
|
const user = await this.#resolveUserForRefresh(record.subjectId);
|
|
421
|
-
|
|
430
|
+
if (!isBoundToAccount(user, record.metadata["accountId"], record.metadata["credentialsVersion"])) throw new AuthenticationError("Invalid token");
|
|
431
|
+
const scopes = this.#grantableScopes(user, [...record.scopes]);
|
|
422
432
|
const audience = record.audience?.at(0) ?? defaultAudience(runtime);
|
|
423
433
|
if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
|
|
424
|
-
const access = await this.#signAccess(user
|
|
434
|
+
const access = await this.#signAccess(user, scopes, audience);
|
|
425
435
|
if (!runtime.rotateRefresh) return {
|
|
426
436
|
access_token: access,
|
|
427
437
|
refresh_token: rawRefresh,
|
|
@@ -430,7 +440,7 @@ var TokenService = class extends Service {
|
|
|
430
440
|
scope: scopes.join(" ")
|
|
431
441
|
};
|
|
432
442
|
const family = record.family ?? this.#randomId();
|
|
433
|
-
const next = this.#buildRefresh(user
|
|
443
|
+
const next = this.#buildRefresh(user, scopes, family, audience);
|
|
434
444
|
await store.put(next.record);
|
|
435
445
|
record.replacedBy = next.record.id;
|
|
436
446
|
record.revokedAt = now;
|
|
@@ -444,15 +454,19 @@ var TokenService = class extends Service {
|
|
|
444
454
|
scope: scopes.join(" ")
|
|
445
455
|
};
|
|
446
456
|
}
|
|
447
|
-
async #signAccess(
|
|
457
|
+
async #signAccess(user, scopes, audience, ttlS) {
|
|
448
458
|
const { keystore, runtime: rt } = this.#ensureReady();
|
|
449
459
|
const jose = await this.#ensureJose();
|
|
450
460
|
const { key, kid } = await keystore.getSigningKey();
|
|
451
|
-
return new jose.SignJWT({
|
|
461
|
+
return new jose.SignJWT({
|
|
462
|
+
scope: scopes.join(" "),
|
|
463
|
+
["uid"]: user.id,
|
|
464
|
+
["cv"]: credentialsVersionOf(user)
|
|
465
|
+
}).setProtectedHeader({
|
|
452
466
|
alg: "EdDSA",
|
|
453
467
|
kid,
|
|
454
468
|
typ: "at+jwt"
|
|
455
|
-
}).setIssuedAt().setIssuer(rt.issuer).setSubject(
|
|
469
|
+
}).setIssuedAt().setIssuer(rt.issuer).setSubject(user.identifier).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
|
|
456
470
|
}
|
|
457
471
|
/**
|
|
458
472
|
* Construit (sans persister) un record refresh + son secret opaque en clair.
|
|
@@ -462,7 +476,7 @@ var TokenService = class extends Service {
|
|
|
462
476
|
* silencieusement l'accès à l'audience par défaut — un downscoping
|
|
463
477
|
* qui s'annule au bout de quelques minutes n'en est pas un.
|
|
464
478
|
*/
|
|
465
|
-
#buildRefresh(
|
|
479
|
+
#buildRefresh(user, scopes, family, audience) {
|
|
466
480
|
const raw = `nfr_${randomBytes(32).toString("base64url")}`;
|
|
467
481
|
const now = Date.now();
|
|
468
482
|
return {
|
|
@@ -471,7 +485,7 @@ var TokenService = class extends Service {
|
|
|
471
485
|
kind: "refresh",
|
|
472
486
|
name: "refresh token",
|
|
473
487
|
prefix: null,
|
|
474
|
-
subjectId:
|
|
488
|
+
subjectId: user.identifier,
|
|
475
489
|
subjectType: "user",
|
|
476
490
|
tenantId: null,
|
|
477
491
|
scopes: [...scopes],
|
|
@@ -490,7 +504,10 @@ var TokenService = class extends Service {
|
|
|
490
504
|
lastUsedUserAgent: null,
|
|
491
505
|
revokedAt: null,
|
|
492
506
|
revokedReason: null,
|
|
493
|
-
metadata: {
|
|
507
|
+
metadata: {
|
|
508
|
+
[ACCOUNT_ID_KEY]: user.id,
|
|
509
|
+
[CREDENTIALS_VERSION_KEY]: credentialsVersionOf(user)
|
|
510
|
+
}
|
|
494
511
|
},
|
|
495
512
|
raw
|
|
496
513
|
};
|
|
@@ -543,5 +560,4 @@ var TokenService = class extends Service {
|
|
|
543
560
|
return this.#throttler;
|
|
544
561
|
}
|
|
545
562
|
};
|
|
546
|
-
//#endregion
|
|
547
563
|
export { TokenService, TokenService as default };
|
|
@@ -5,8 +5,8 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
|
|
|
5
5
|
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
6
6
|
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
7
7
|
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
|
|
8
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
9
|
-
|
|
8
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
9
|
+
// nodefony/service/totp.ts
|
|
10
10
|
const serviceName = "totp";
|
|
11
11
|
function isFlushable(s) {
|
|
12
12
|
return s !== null && typeof s.flushNow === "function";
|
|
@@ -93,7 +93,7 @@ var TotpService = class extends Service {
|
|
|
93
93
|
let driver = config.totp.store;
|
|
94
94
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
95
95
|
if (driver === AUTO_STORE) {
|
|
96
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory",
|
|
96
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", storeConnectivity(this.kernel));
|
|
97
97
|
driver = auto.store;
|
|
98
98
|
reason = auto.reason;
|
|
99
99
|
this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -105,7 +105,7 @@ var TotpService = class extends Service {
|
|
|
105
105
|
this.log(`${msg} — 2FA indisponible`, "CRITIC");
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(
|
|
108
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
109
109
|
const store = factory({
|
|
110
110
|
container: this.container,
|
|
111
111
|
config
|
|
@@ -252,5 +252,4 @@ var TotpService = class extends Service {
|
|
|
252
252
|
return this.#deps;
|
|
253
253
|
}
|
|
254
254
|
};
|
|
255
|
-
//#endregion
|
|
256
255
|
export { TotpService, TotpService as default };
|
|
@@ -2,10 +2,10 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
|
2
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
6
6
|
import { createHash } from "node:crypto";
|
|
7
7
|
import { Buffer } from "node:buffer";
|
|
8
|
-
|
|
8
|
+
// nodefony/service/webAuthn.ts
|
|
9
9
|
const serviceName = "webauthn";
|
|
10
10
|
function isFlushable(s) {
|
|
11
11
|
return s !== null && typeof s.flushNow === "function";
|
|
@@ -92,7 +92,7 @@ var WebAuthnService = class extends Service {
|
|
|
92
92
|
let driver = config.passkeys.store;
|
|
93
93
|
reason = `store explicitement configuré ("${driver}")`;
|
|
94
94
|
if (driver === AUTO_STORE) {
|
|
95
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory",
|
|
95
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", storeConnectivity(this.kernel));
|
|
96
96
|
driver = auto.store;
|
|
97
97
|
reason = auto.reason;
|
|
98
98
|
this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -104,7 +104,7 @@ var WebAuthnService = class extends Service {
|
|
|
104
104
|
this.log(`${msg} — passkeys indisponibles`, "CRITIC");
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(
|
|
107
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
108
108
|
this.#store = factory({
|
|
109
109
|
container: this.container,
|
|
110
110
|
config
|
|
@@ -365,5 +365,4 @@ var WebAuthnService = class extends Service {
|
|
|
365
365
|
};
|
|
366
366
|
}
|
|
367
367
|
};
|
|
368
|
-
//#endregion
|
|
369
368
|
export { WebAuthnService, WebAuthnService as default, userHandleBytes };
|
|
@@ -6,11 +6,11 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
|
|
|
6
6
|
import { assertPublicUrl } from "../src/net/ssrfGuard.js";
|
|
7
7
|
import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
|
|
8
8
|
import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { schemaMismatchOf } from "@nodefony/http";
|
|
13
|
-
|
|
13
|
+
// nodefony/service/webhooks.ts
|
|
14
14
|
const serviceName = "webhooks";
|
|
15
15
|
/** Historique de livraisons gardé PAR endpoint (ring borné, RAM, par pod). */
|
|
16
16
|
const MAX_DELIVERIES_PER_ENDPOINT = 20;
|
|
@@ -155,7 +155,7 @@ var WebhookService = class extends Service {
|
|
|
155
155
|
let driver = config.webhooks.store;
|
|
156
156
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
157
157
|
if (driver === AUTO_STORE) {
|
|
158
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory",
|
|
158
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", storeConnectivity(this.kernel));
|
|
159
159
|
driver = auto.store;
|
|
160
160
|
reason = auto.reason;
|
|
161
161
|
this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -167,7 +167,7 @@ var WebhookService = class extends Service {
|
|
|
167
167
|
this.log(`${msg} — webhooks indisponibles`, "CRITIC");
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(
|
|
170
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
171
171
|
const store = factory({
|
|
172
172
|
container: this.container,
|
|
173
173
|
config
|
|
@@ -546,5 +546,4 @@ var WebhookService = class extends Service {
|
|
|
546
546
|
return addresses;
|
|
547
547
|
}
|
|
548
548
|
};
|
|
549
|
-
//#endregion
|
|
550
549
|
export { WebhookService, WebhookService as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/RoleHierarchyWalker.ts
|
|
2
2
|
/**
|
|
3
3
|
* Résout la hiérarchie de rôles — `ROLE_ADMIN` hérite `ROLE_USER`, etc.
|
|
4
4
|
*
|
|
@@ -73,5 +73,4 @@ var RoleHierarchyWalker = class {
|
|
|
73
73
|
for (const node of Object.keys(hierarchy)) if ((color.get(node) ?? WHITE) === WHITE) visit(node);
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
|
-
//#endregion
|
|
77
76
|
export { RoleHierarchyWalker, RoleHierarchyWalker as default };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { stripTrailingSlashes } from "nodefony";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/SecuredArea.ts
|
|
3
3
|
/**
|
|
4
4
|
* Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
|
|
5
5
|
*
|
|
@@ -65,5 +65,4 @@ var SecuredArea = class {
|
|
|
65
65
|
return this.matchPath(pathname, context.domain);
|
|
66
66
|
}
|
|
67
67
|
};
|
|
68
|
-
//#endregion
|
|
69
68
|
export { SecuredArea, SecuredArea as default };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// nodefony/src/accountBinding.ts
|
|
2
|
+
/**
|
|
3
|
+
* Claim d'un jeton d'accès émis par l'application qui porte l'identifiant
|
|
4
|
+
* IMMUABLE du compte (`IUser.id`) — `sub` porte l'identifiant fonctionnel.
|
|
5
|
+
*/
|
|
6
|
+
const ACCOUNT_ID_CLAIM = "uid";
|
|
7
|
+
/**
|
|
8
|
+
* Clé, dans `metadata` d'un enregistrement de jeton (refresh, clé d'API) comme
|
|
9
|
+
* dans le `metaBag` d'une session, qui porte l'identifiant immuable du compte.
|
|
10
|
+
*/
|
|
11
|
+
const ACCOUNT_ID_KEY = "accountId";
|
|
12
|
+
/**
|
|
13
|
+
* Claim d'un jeton d'accès émis par l'application qui porte la version des
|
|
14
|
+
* identifiants du compte à l'émission (`IUser.credentialsVersion`).
|
|
15
|
+
*/
|
|
16
|
+
const CREDENTIALS_VERSION_CLAIM = "cv";
|
|
17
|
+
/**
|
|
18
|
+
* Clé, dans `metadata` d'un enregistrement de jeton comme dans le `metaBag`
|
|
19
|
+
* d'une session, qui porte la version des identifiants lue à l'émission.
|
|
20
|
+
*/
|
|
21
|
+
const CREDENTIALS_VERSION_KEY = "credentialsVersion";
|
|
22
|
+
/**
|
|
23
|
+
* La version des identifiants à inscrire dans un artefact qu'on émet pour ce
|
|
24
|
+
* compte — 0 quand le compte n'en porte pas encore.
|
|
25
|
+
*
|
|
26
|
+
* @param user - compte pour lequel l'artefact est émis.
|
|
27
|
+
* @returns la version courante, jamais `null`.
|
|
28
|
+
*/
|
|
29
|
+
function credentialsVersionOf(user) {
|
|
30
|
+
return user.credentialsVersion ?? 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Dit si un artefact d'accès (jeton, session, clé) appartient bien au compte
|
|
34
|
+
* qu'on vient de recharger par son identifiant fonctionnel, ET s'il a été émis
|
|
35
|
+
* depuis le dernier changement de mot de passe.
|
|
36
|
+
*
|
|
37
|
+
* Les artefacts sont rangés sous l'`identifier`, qu'un compte RECRÉÉ peut
|
|
38
|
+
* reprendre : supprimé hors du framework (`DELETE` SQL, restauration, import)
|
|
39
|
+
* puis revenu sous le même nom, l'identité se rechargerait et l'artefact de
|
|
40
|
+
* l'ancien titulaire ouvrirait le compte du nouveau. L'identifiant immuable
|
|
41
|
+
* lève l'ambiguïté sans dépendre d'aucun événement ni d'aucun stockage : deux
|
|
42
|
+
* comptes successifs portent deux `id` différents. SEULE règle de
|
|
43
|
+
* comparaison — chaque authentificateur l'appelle, aucun ne la recopie.
|
|
44
|
+
*
|
|
45
|
+
* Un artefact SANS liaison est refusé : il a été émis avant que la règle
|
|
46
|
+
* existe, et rien ne dit à qui il appartient.
|
|
47
|
+
*
|
|
48
|
+
* La version des identifiants fait de la coupure au changement de mot de passe
|
|
49
|
+
* un ÉTAT du compte, et non une suppression : si la cascade de révocation
|
|
50
|
+
* échoue, ou si une requête en vol sur un autre processus réécrit la session
|
|
51
|
+
* qu'on vient d'effacer, l'artefact porte toujours l'ancienne version, et il
|
|
52
|
+
* tombe ici. Un artefact émis avant cette règle n'en porte pas : il vaut 0,
|
|
53
|
+
* comme un compte qui n'a jamais changé de mot de passe — le déploiement ne
|
|
54
|
+
* déconnecte personne. Une valeur d'un autre type est refusée.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ Chemin chaud — appelée sur CHAQUE requête authentifiée : deux comparaisons
|
|
57
|
+
* de chaîne et d'entier, aucune allocation.
|
|
58
|
+
*
|
|
59
|
+
* @param user - compte rechargé par l'identifiant que porte l'artefact.
|
|
60
|
+
* @param boundAccountId - identifiant immuable inscrit dans l'artefact.
|
|
61
|
+
* @param boundCredentialsVersion - version des identifiants inscrite dans
|
|
62
|
+
* l'artefact ({@link CREDENTIALS_VERSION_KEY}), `undefined` si absente.
|
|
63
|
+
* Obligatoire dans la signature pour qu'aucune porte ne l'oublie.
|
|
64
|
+
* @returns `true` si l'artefact a été émis pour CE compte, à sa version courante.
|
|
65
|
+
*/
|
|
66
|
+
function isBoundToAccount(user, boundAccountId, boundCredentialsVersion) {
|
|
67
|
+
return typeof boundAccountId === "string" && boundAccountId.length > 0 && boundAccountId === user.id && (boundCredentialsVersion ?? 0) === (user.credentialsVersion ?? 0);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Recharge le compte qu'un jeton d'accès de CETTE application désigne, ou
|
|
71
|
+
* `null` : sujet inconnu, compte inactif ou verrouillé, jeton émis pour un
|
|
72
|
+
* autre compte du même nom ou avant un changement de mot de passe.
|
|
73
|
+
*
|
|
74
|
+
* SEULE implémentation de la règle « ce jeton ouvre-t-il encore ce compte ? »
|
|
75
|
+
* pour un jeton maison : la zone `jwt` (`JwtAuthenticator`) et le résolveur de
|
|
76
|
+
* compte d'une ressource protégée l'appellent, aucun ne la recopie. Un refus
|
|
77
|
+
* est `null`, jamais une exception : chaque appelant le traduit dans son
|
|
78
|
+
* propre contrat (401 uniforme, outil en `isError`).
|
|
79
|
+
*
|
|
80
|
+
* @param provider - annuaire des comptes de l'application.
|
|
81
|
+
* @param identifier - identifiant fonctionnel porté par le jeton (`sub`).
|
|
82
|
+
* @param boundAccountId - identifiant immuable inscrit dans le jeton ({@link ACCOUNT_ID_CLAIM}).
|
|
83
|
+
* @param boundCredentialsVersion - version des identifiants inscrite ({@link CREDENTIALS_VERSION_CLAIM}).
|
|
84
|
+
* @returns le compte, ou `null` s'il n'est pas (ou plus) celui du jeton.
|
|
85
|
+
*/
|
|
86
|
+
async function loadBoundAccount(provider, identifier, boundAccountId, boundCredentialsVersion) {
|
|
87
|
+
let user;
|
|
88
|
+
try {
|
|
89
|
+
user = await provider.loadUserByIdentifier(identifier);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!user.isActive() || user.isLocked()) return null;
|
|
94
|
+
return isBoundToAccount(user, boundAccountId, boundCredentialsVersion) ? user : null;
|
|
95
|
+
}
|
|
96
|
+
export { ACCOUNT_ID_CLAIM, ACCOUNT_ID_KEY, CREDENTIALS_VERSION_CLAIM, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount, loadBoundAccount };
|
|
@@ -3,7 +3,7 @@ import { AUDIT_FILTERS } from "../audit/auditFilters.js";
|
|
|
3
3
|
import { adminActor, auditAdmin } from "./adminAudit.js";
|
|
4
4
|
import { webhookAdminEndpoints } from "./WebhookAdminApi.js";
|
|
5
5
|
import { parseFilters, parsePageQuery } from "nodefony";
|
|
6
|
-
|
|
6
|
+
// nodefony/src/admin/SecurityAdminApi.ts
|
|
7
7
|
/**
|
|
8
8
|
* Déduit le **driver** logique d'un store à partir de son nom de classe — le
|
|
9
9
|
* `tokenService` connaît le driver config (`tokenStore.store`) mais ne l'expose
|
|
@@ -522,5 +522,4 @@ function registerSecurityAdminApi(registry, container) {
|
|
|
522
522
|
if (registry.has("security")) return;
|
|
523
523
|
registry.register(createSecurityAdminApi(container));
|
|
524
524
|
}
|
|
525
|
-
//#endregion
|
|
526
525
|
export { createSecurityAdminApi, parseAuditQuery, parseTokenListQuery, registerSecurityAdminApi };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WEBHOOK_FACETS, WEBHOOK_FILTERS, WEBHOOK_STATS_FILTERS } from "../webhook/webhookFilters.js";
|
|
2
2
|
import { adminActor, auditAdmin } from "./adminAudit.js";
|
|
3
|
-
import { parseFilters, parsePageQuery } from "nodefony";
|
|
4
|
-
|
|
3
|
+
import { isPlainObject, parseFilters, parsePageQuery } from "nodefony";
|
|
4
|
+
// nodefony/src/admin/WebhookAdminApi.ts
|
|
5
5
|
/**
|
|
6
6
|
* Déduit le driver logique du store webhook depuis son nom de classe (miroir de
|
|
7
7
|
* `tokenStoreDriver`). Fermé sur les implémentations connues ; `null` pour un
|
|
@@ -30,6 +30,21 @@ function bodyStringArray(v) {
|
|
|
30
30
|
if (!Array.isArray(v) || v.length === 0) return void 0;
|
|
31
31
|
return v.every((x) => typeof x === "string" && x.length > 0) ? v : void 0;
|
|
32
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Le corps d'une requête d'écriture : un objet JSON, ou rien (lu comme `{}`).
|
|
35
|
+
* Un tableau ou un scalaire rend `null` — l'appelant répond 400 au lieu de
|
|
36
|
+
* laisser un `in` lever sur une chaîne.
|
|
37
|
+
*/
|
|
38
|
+
function bodyObject(request) {
|
|
39
|
+
const body = request.body;
|
|
40
|
+
if (body === void 0 || body === null) return {};
|
|
41
|
+
return isPlainObject(body) ? body : null;
|
|
42
|
+
}
|
|
43
|
+
/** Réponse au corps qui n'est pas un objet JSON. */
|
|
44
|
+
const BODY_NOT_OBJECT = {
|
|
45
|
+
status: 400,
|
|
46
|
+
body: { error: "JSON object body required" }
|
|
47
|
+
};
|
|
33
48
|
/** Page par défaut du listing d'endpoints (registre de config, volume modeste). */
|
|
34
49
|
const ENDPOINTS_DEFAULT_LIMIT = 50;
|
|
35
50
|
/** Plafond dur : un client ne peut pas demander « tout » via `?limit=`. */
|
|
@@ -75,8 +90,8 @@ function parseWebhookListQuery(query, sortable) {
|
|
|
75
90
|
* fail-closed, et logge).
|
|
76
91
|
*/
|
|
77
92
|
function mapWebhookError(e) {
|
|
78
|
-
if (e.code === 422) {
|
|
79
|
-
const message = e.message;
|
|
93
|
+
if (typeof e === "object" && e !== null && "code" in e && e.code === 422) {
|
|
94
|
+
const message = "message" in e ? e.message : void 0;
|
|
80
95
|
return {
|
|
81
96
|
status: 422,
|
|
82
97
|
body: { error: typeof message === "string" ? message : "invalid url" }
|
|
@@ -170,7 +185,8 @@ function webhookAdminEndpoints(container) {
|
|
|
170
185
|
handler: async (request) => {
|
|
171
186
|
const s = svc();
|
|
172
187
|
if (!ready(s)) return UNAVAILABLE;
|
|
173
|
-
const body = request
|
|
188
|
+
const body = bodyObject(request);
|
|
189
|
+
if (body === null) return BODY_NOT_OBJECT;
|
|
174
190
|
const url = bodyString(body.url);
|
|
175
191
|
if (!url) return {
|
|
176
192
|
status: 400,
|
|
@@ -252,7 +268,8 @@ function webhookAdminEndpoints(container) {
|
|
|
252
268
|
if (!ready(s)) return UNAVAILABLE;
|
|
253
269
|
const id = pathId(request);
|
|
254
270
|
if (!id) return NOT_FOUND;
|
|
255
|
-
const body = request
|
|
271
|
+
const body = bodyObject(request);
|
|
272
|
+
if (body === null) return BODY_NOT_OBJECT;
|
|
256
273
|
const patch = {};
|
|
257
274
|
const fields = [];
|
|
258
275
|
if ("url" in body) {
|
|
@@ -375,5 +392,4 @@ function webhookAdminEndpoints(container) {
|
|
|
375
392
|
}
|
|
376
393
|
];
|
|
377
394
|
}
|
|
378
|
-
//#endregion
|
|
379
395
|
export { parseWebhookListQuery, webhookAdminEndpoints };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/admin/adminAudit.ts
|
|
2
2
|
/**
|
|
3
3
|
* Helpers d'audit PARTAGÉS par les producteurs admin du module sécurité
|
|
4
4
|
* (`SecurityAdminApi`, `WebhookAdminApi`…). Extraits dans leur propre fichier
|
|
@@ -32,5 +32,4 @@ function adminActor(user) {
|
|
|
32
32
|
function auditAdmin(container, draft) {
|
|
33
33
|
container.get("auditService")?.record?.(draft);
|
|
34
34
|
}
|
|
35
|
-
//#endregion
|
|
36
35
|
export { adminActor, auditAdmin };
|
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/admin/userRevocationCascade.ts
|
|
3
|
+
/** Garde : la valeur sait-elle journaliser (`log` est une fonction) ? */
|
|
4
|
+
function isKernelLogger(value) {
|
|
5
|
+
return typeof value === "object" && value !== null && "log" in value && typeof value.log === "function";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Journalise l'échec d'une brique de la cascade. Le geste reste best-effort —
|
|
9
|
+
* une brique en panne n'empêche pas les autres — mais il ne peut plus échouer
|
|
10
|
+
* EN SILENCE : un accès qui survit à sa révocation (session encore ouverte
|
|
11
|
+
* après un changement de mot de passe) doit laisser une trace qui dit laquelle.
|
|
12
|
+
*
|
|
13
|
+
* @param container - container du kernel (service `kernel`).
|
|
14
|
+
* @param brick - brique en échec (`sessions`, `tokenStore`, `totp`, `webauthn`).
|
|
15
|
+
* @param identifier - compte visé par la cascade.
|
|
16
|
+
* @param error - erreur levée par la brique.
|
|
17
|
+
*/
|
|
18
|
+
function reportCascadeFailure(container, brick, identifier, error) {
|
|
19
|
+
try {
|
|
20
|
+
const kernel = container.get("kernel");
|
|
21
|
+
if (!isKernelLogger(kernel)) return;
|
|
22
|
+
kernel.log(`révocation de "${identifier}" incomplète : la brique ${brick} a échoué — ` + (error instanceof Error ? error.message : String(error)), "ERROR", "SECURITY");
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
3
25
|
/**
|
|
4
26
|
* Cascade de révocation déclenchée par {@link USER_REVOKED_EVENT} : éjecte
|
|
5
27
|
* **immédiatement** les artefacts d'accès du porteur — ses **sessions** (http,
|
|
@@ -22,13 +44,51 @@ import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
|
|
|
22
44
|
*/
|
|
23
45
|
async function cascadeUserRevocation(container, event, now = Date.now()) {
|
|
24
46
|
const identifier = event.identifier;
|
|
47
|
+
await revokeAccessArtifacts(container, identifier, now);
|
|
48
|
+
if (event.reason === "deleted") await removeAccountFactors(container, identifier);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Éjecte les artefacts d'accès rangés sous un identifiant — ses sessions (http,
|
|
52
|
+
* `destroyByUser`) et ses jetons/PAT/clés d'API (`tokenStore.revokeAllForSubject`,
|
|
53
|
+
* seuil `invalidBefore`). SEULE implémentation du geste, appelée à la révocation
|
|
54
|
+
* d'un compte ET à sa création. Best-effort par brique : l'indisponibilité de
|
|
55
|
+
* l'une n'empêche pas l'autre.
|
|
56
|
+
*
|
|
57
|
+
* @param container - container du kernel (services `sessions`, `tokenStore`).
|
|
58
|
+
* @param identifier - `IUser.identifier` du compte.
|
|
59
|
+
* @param invalidBefore - seuil (epoch ms) : tout jeton émis avant est rejeté.
|
|
60
|
+
*/
|
|
61
|
+
async function revokeAccessArtifacts(container, identifier, invalidBefore) {
|
|
25
62
|
try {
|
|
26
63
|
await container.get("sessions")?.destroyByUser?.(identifier);
|
|
27
|
-
} catch {
|
|
64
|
+
} catch (error) {
|
|
65
|
+
reportCascadeFailure(container, "sessions", identifier, error);
|
|
66
|
+
}
|
|
28
67
|
try {
|
|
29
|
-
await container.get("tokenStore")?.revokeAllForSubject(identifier,
|
|
30
|
-
} catch {
|
|
31
|
-
|
|
68
|
+
await container.get("tokenStore")?.revokeAllForSubject(identifier, invalidBefore);
|
|
69
|
+
} catch (error) {
|
|
70
|
+
reportCascadeFailure(container, "tokenStore", identifier, error);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Purge, à la NAISSANCE d'un compte, tout ce qu'un ancien titulaire du même
|
|
75
|
+
* identifiant a laissé derrière lui : sessions, jetons et facteurs. Un compte
|
|
76
|
+
* qui vient de naître n'a encore rien émis — tout artefact déjà rangé sous son
|
|
77
|
+
* identifiant appartient donc à quelqu'un d'autre. C'est ce qui ferme le cas
|
|
78
|
+
* d'un compte supprimé HORS du framework (`DELETE` SQL, aucun événement), dont
|
|
79
|
+
* l'accès redeviendrait valide dès qu'un compte renaît sous son nom.
|
|
80
|
+
*
|
|
81
|
+
* Le seuil est tronqué à la SECONDE : l'`iat` d'un JWT l'est aussi (RFC 7519
|
|
82
|
+
* §2, NumericDate), et un seuil à la milliseconde rejetterait le jeton du
|
|
83
|
+
* nouveau titulaire émis dans la même seconde que sa création.
|
|
84
|
+
*
|
|
85
|
+
* @param container - container du kernel.
|
|
86
|
+
* @param identifier - `IUser.identifier` du compte créé.
|
|
87
|
+
* @param now - horloge (epoch ms) injectable pour les tests.
|
|
88
|
+
*/
|
|
89
|
+
async function purgeInheritedAccess(container, identifier, now = Date.now()) {
|
|
90
|
+
await revokeAccessArtifacts(container, identifier, Math.floor(now / 1e3) * 1e3);
|
|
91
|
+
await removeAccountFactors(container, identifier);
|
|
32
92
|
}
|
|
33
93
|
/**
|
|
34
94
|
* Retire les facteurs rangés sous un identifiant — secret TOTP (codes de
|
|
@@ -44,14 +104,18 @@ async function removeAccountFactors(container, identifier) {
|
|
|
44
104
|
try {
|
|
45
105
|
const totp = container.get("totp");
|
|
46
106
|
if (totp?.isEnabled?.() !== false) await totp?.disable?.(identifier);
|
|
47
|
-
} catch {
|
|
107
|
+
} catch (error) {
|
|
108
|
+
reportCascadeFailure(container, "totp", identifier, error);
|
|
109
|
+
}
|
|
48
110
|
try {
|
|
49
111
|
const webauthn = container.get("webauthn");
|
|
50
112
|
if (webauthn?.isEnabled?.() !== false) {
|
|
51
113
|
const creds = await webauthn?.listUserCredentials?.(identifier) ?? [];
|
|
52
114
|
for (const cred of creds) await webauthn?.removeUserCredential?.(identifier, cred.id);
|
|
53
115
|
}
|
|
54
|
-
} catch {
|
|
116
|
+
} catch (error) {
|
|
117
|
+
reportCascadeFailure(container, "webauthn", identifier, error);
|
|
118
|
+
}
|
|
55
119
|
}
|
|
56
120
|
/** Garde : charge utile de {@link USER_CREATED_EVENT} (identifiant non vide). */
|
|
57
121
|
function isUserCreatedEvent(value) {
|
|
@@ -62,11 +126,12 @@ function isUserCreatedEvent(value) {
|
|
|
62
126
|
* `@nodefony/security` — avant qu'aucun compte ne puisse être créé ou révoqué
|
|
63
127
|
* (les comptes semés au démarrage compris). Deux abonnements :
|
|
64
128
|
* - {@link USER_REVOKED_EVENT} → {@link cascadeUserRevocation} ;
|
|
65
|
-
* - {@link USER_CREATED_EVENT} →
|
|
66
|
-
* vient de naître n'a encore rien
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
129
|
+
* - {@link USER_CREATED_EVENT} → {@link purgeInheritedAccess} : sessions,
|
|
130
|
+
* jetons et facteurs HÉRITÉS. Un compte qui vient de naître n'a encore rien
|
|
131
|
+
* émis ni enrôlé, donc tout artefact déjà rangé sous son identifiant
|
|
132
|
+
* appartient à un ancien titulaire. Règle exacte, aucune date à comparer.
|
|
133
|
+
* ⚠️ Un import qui chargerait les facteurs AVANT les comptes les verrait
|
|
134
|
+
* retirés : importer les comptes d'abord.
|
|
70
135
|
*
|
|
71
136
|
* Les deux sont émis par `UserService` seul, et attendus : chaque handler rend
|
|
72
137
|
* sa promesse. **Extensible** : tout autre module (webhooks…) s'abonne aux
|
|
@@ -77,7 +142,7 @@ function isUserCreatedEvent(value) {
|
|
|
77
142
|
*/
|
|
78
143
|
function registerUserRevocationCascade(kernel, container) {
|
|
79
144
|
kernel.on(USER_REVOKED_EVENT, (event) => cascadeUserRevocation(container, event));
|
|
80
|
-
kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ?
|
|
145
|
+
kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ? purgeInheritedAccess(container, event.identifier) : void 0);
|
|
81
146
|
}
|
|
82
147
|
/**
|
|
83
148
|
* Avis de démarrage quand le service `users` n'émet pas sur le bus du kernel —
|
|
@@ -100,5 +165,4 @@ function usersEventBusNotice(container, kernel) {
|
|
|
100
165
|
fix: "new UserService(repository, encoder, module.container)"
|
|
101
166
|
};
|
|
102
167
|
}
|
|
103
|
-
|
|
104
|
-
export { cascadeUserRevocation, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };
|
|
168
|
+
export { cascadeUserRevocation, purgeInheritedAccess, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };
|