@nodefony/security 10.0.0-alpha.8 → 10.0.0-alpha.9
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.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -2
- package/dist/nodefony/service/tokenService.js +3 -2
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +3 -2
- package/dist/nodefony/service/webhooks.js +5 -3
- package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +62 -60
- package/docs/audit.md +77 -77
- package/docs/authenticators.md +55 -43
- package/docs/authorization.md +13 -13
- package/docs/cors.md +29 -29
- package/docs/csrf.md +26 -26
- package/docs/external-jwt.md +4 -4
- package/docs/firewall.md +29 -29
- package/docs/headers.md +26 -26
- package/docs/oauth2.md +60 -60
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +55 -55
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +81 -81
- package/docs/webhooks.md +43 -37
- package/package.json +15 -12
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function __decorateMetadata(k, v) {
|
|
3
3
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
4
4
|
}
|
package/dist/index.js
CHANGED
|
@@ -77,8 +77,8 @@ import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
|
77
77
|
import SecurityToken from "./nodefony/command/security-token.js";
|
|
78
78
|
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
79
79
|
import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
80
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
81
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
80
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
|
|
81
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
|
|
82
82
|
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
83
83
|
import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
|
|
84
84
|
import "./nodefony/errors/index.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
1
|
+
import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
2
|
import { WeakPasswordError } from "@nodefony/user";
|
|
3
3
|
//#region nodefony/command/security-user-add.ts
|
|
4
4
|
const options = {
|
|
@@ -17,22 +17,6 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
|
|
|
17
17
|
/** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
|
|
18
18
|
const ROLE_BASE = "ROLE_USER";
|
|
19
19
|
/**
|
|
20
|
-
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
21
|
-
*
|
|
22
|
-
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
23
|
-
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
24
|
-
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
25
|
-
* suivante au moment où elle a un sens.
|
|
26
|
-
*
|
|
27
|
-
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
28
|
-
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
29
|
-
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
30
|
-
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
31
|
-
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
32
|
-
* qu'on refuse.
|
|
33
|
-
*/
|
|
34
|
-
const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
35
|
-
/**
|
|
36
20
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
37
21
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
38
22
|
* jamais de mot de passe stocké en clair).
|
|
@@ -163,4 +147,4 @@ var SecurityUserAdd = class extends Command {
|
|
|
163
147
|
}
|
|
164
148
|
};
|
|
165
149
|
//#endregion
|
|
166
|
-
export {
|
|
150
|
+
export { SecurityUserAdd as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
1
|
+
import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
2
|
import { USER_REVOKED_EVENT } from "@nodefony/user";
|
|
3
3
|
//#region nodefony/command/security-user-password.ts
|
|
4
4
|
const options = {
|
|
@@ -106,7 +106,7 @@ var SecurityUserPassword = class extends Command {
|
|
|
106
106
|
tenantId: null,
|
|
107
107
|
reason: "password_changed"
|
|
108
108
|
});
|
|
109
|
-
process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST
|
|
109
|
+
process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST ${AUTH_LOGIN_PATH}${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
|
|
110
110
|
return this;
|
|
111
111
|
}
|
|
112
112
|
};
|
|
@@ -208,7 +208,7 @@ const totpSchema = z.strictObject({
|
|
|
208
208
|
window: z.number().int().nonnegative().default(1).describe("Tolérance de dérive d'horloge (± N pas). RFC 6238 §5.2 : « at most one time step » → 1 [défaut]. Plus = surface d'attaque accrue."),
|
|
209
209
|
recoveryCodes: z.number().int().positive().default(10).describe("Nombre de codes de récupération générés à l'activation (NIST SP 800-63B « look-up secrets », usage unique, affichés 1×)."),
|
|
210
210
|
encryptionKey: z.string().optional().describe("Clé de chiffrement AES-256-GCM du secret TOTP au repos (fournie par l'app depuis env, ≥ 32 octets après décodage). Omise = clé éphémère dérivée + WARNING (dev mono-process) ; en prod multi-pod, REQUISE (un secret 2FA déchiffrable doit l'être par tous les pods, et jamais en clair)."),
|
|
211
|
-
store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte).
|
|
211
|
+
store: z.string().default("auto").describe("Backend de stockage du secret : auto [défaut, forçable par `NF_STORE`] | memory | drizzle | mongoose — plus tout backend ajouté par `registerTotpStore` (la liste qui fait foi est celle de l'écran Stores de Studio, pas ce texte). Les deux backends DURABLES le portent : sur une infra SQL comme sur une infra Mongo, `auto` trouve un store persistant. ⚠️ @nodefony/redis n'en enregistre pas : avec Redis pour seule infra, `auto` se replie sur `memory` (repli tracé au boot, et WARNING en production) — les secrets 2FA seraient perdus au redémarrage, verrouillant les utilisateurs hors de leur second facteur.")
|
|
212
212
|
}).describe("2FA TOTP (RFC 6238) — second facteur step-up au login. Secret chiffré au repos, codes de récupération hachés.");
|
|
213
213
|
const remoteJwtAlgSchema = z.enum([
|
|
214
214
|
"RS256",
|
|
@@ -276,7 +276,7 @@ const webhooksSchema = z.strictObject({
|
|
|
276
276
|
}).describe("Webhooks sortants signés (Standard Webhooks).");
|
|
277
277
|
const auditSchema = z.strictObject({
|
|
278
278
|
enabled: z.boolean().default(true),
|
|
279
|
-
store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` =
|
|
279
|
+
store: z.string().default("auto").describe("Store du journal (résolu via `auditStoreRegistry`). `auto` = suit l'infra database déclarée, repli memory (défaut) ; `memory` = per-pod, volatile, borné ; `drizzle` et `mongoose` = persistants + partagés multi-pod (auto-register par l'adapter chargé). @nodefony/redis n'en fournit pas : un journal croît sans borne et se consulte, ce n'est pas le motif d'accès d'un cache. Vocabulaire unifié : données = `store`."),
|
|
280
280
|
immutable: z.boolean().default(true).meta({
|
|
281
281
|
reserved: true,
|
|
282
282
|
description: "INERTE — l'immuabilité du journal vient du contrat `IAuditStore` (aucune méthode `update`/`delete`), pas de ce drapeau. Le passer à `false` n'ouvre aucune mutation."
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
2
|
import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
|
|
3
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
3
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
//#region nodefony/service/auditService.ts
|
|
6
6
|
const serviceName = "auditService";
|
|
@@ -78,7 +78,8 @@ var AuditService = class extends Service {
|
|
|
78
78
|
available: listAuditStores(),
|
|
79
79
|
reason,
|
|
80
80
|
configPath: "security.audit.store",
|
|
81
|
-
location: readStoreLocation(this.#store)
|
|
81
|
+
location: readStoreLocation(this.#store),
|
|
82
|
+
connector: readStoreConnector(this.#store)
|
|
82
83
|
});
|
|
83
84
|
this.#gc = new GcScheduler({
|
|
84
85
|
intervalS: GC_INTERVAL_MS / 1e3,
|
|
@@ -6,7 +6,7 @@ import { recordAudit } from "../src/audit/recordAudit.js";
|
|
|
6
6
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
7
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
8
8
|
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
//#region nodefony/service/tokenService.ts
|
|
12
12
|
const serviceName = "tokenService";
|
|
@@ -84,7 +84,8 @@ var TokenService = class extends Service {
|
|
|
84
84
|
available: listTokenStores(),
|
|
85
85
|
reason,
|
|
86
86
|
configPath: "security.tokenStore.store",
|
|
87
|
-
location: readStoreLocation(this.#store)
|
|
87
|
+
location: readStoreLocation(this.#store),
|
|
88
|
+
connector: readStoreConnector(this.#store)
|
|
88
89
|
});
|
|
89
90
|
this.container?.set("tokenStore", this.#store);
|
|
90
91
|
if (jwtEnabled) {
|
|
@@ -3,7 +3,7 @@ import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStore
|
|
|
3
3
|
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
4
4
|
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
5
5
|
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
|
|
6
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
7
7
|
//#region nodefony/service/totp.ts
|
|
8
8
|
const serviceName = "totp";
|
|
9
9
|
function isFlushable(s) {
|
|
@@ -83,7 +83,8 @@ var TotpService = class extends Service {
|
|
|
83
83
|
available: listTotpStores(),
|
|
84
84
|
reason: "adapter posé au container (infra database déclarée)",
|
|
85
85
|
configPath: "security.totp.store",
|
|
86
|
-
location: readStoreLocation(existing)
|
|
86
|
+
location: readStoreLocation(existing),
|
|
87
|
+
connector: readStoreConnector(existing)
|
|
87
88
|
});
|
|
88
89
|
return existing;
|
|
89
90
|
}
|
|
@@ -116,7 +117,8 @@ var TotpService = class extends Service {
|
|
|
116
117
|
available: listTotpStores(),
|
|
117
118
|
reason,
|
|
118
119
|
configPath: "security.totp.store",
|
|
119
|
-
location: readStoreLocation(store)
|
|
120
|
+
location: readStoreLocation(store),
|
|
121
|
+
connector: readStoreConnector(store)
|
|
120
122
|
});
|
|
121
123
|
return store;
|
|
122
124
|
}
|
|
@@ -2,7 +2,7 @@ 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, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
//#region nodefony/service/webAuthn.ts
|
|
8
8
|
const serviceName = "webauthn";
|
|
@@ -102,7 +102,8 @@ var WebAuthnService = class extends Service {
|
|
|
102
102
|
available: listWebAuthnStores(),
|
|
103
103
|
reason,
|
|
104
104
|
configPath: "security.passkeys.store",
|
|
105
|
-
location: readStoreLocation(this.#store)
|
|
105
|
+
location: readStoreLocation(this.#store),
|
|
106
|
+
connector: readStoreConnector(this.#store)
|
|
106
107
|
});
|
|
107
108
|
if (config.passkeys.attestation !== "none") this.log(`passkeys.attestation "${config.passkeys.attestation}" — l'attestation est demandée au navigateur mais N'EST PAS VÉRIFIÉE : ni métadonnées FIDO (MDS), ni certificats racines fabricant, et l'AAGUID n'est pas conservé. Ce réglage ne tient donc pas un AAL3 régulé. Repasser à "none" si la garantie n'est pas requise.`, "WARNING");
|
|
108
109
|
if (config.passkeys.residentKey === "discouraged") this.log("passkeys.residentKey \"discouraged\" — les credentials enrôlés ne sont pas découvrables, or le login par passkey est usernameless (aucun ciblage depuis la requête, anti-énumération). Ces porteurs ne pourront pas se connecter : repasser à \"preferred\" ou \"required\".", "WARNING");
|
|
@@ -6,7 +6,7 @@ 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, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { schemaMismatchOf } from "@nodefony/http";
|
|
@@ -146,7 +146,8 @@ var WebhookService = class extends Service {
|
|
|
146
146
|
available: listWebhookStores(),
|
|
147
147
|
reason: "adapter posé au container (infra database déclarée)",
|
|
148
148
|
configPath: "security.webhooks.store",
|
|
149
|
-
location: readStoreLocation(existing)
|
|
149
|
+
location: readStoreLocation(existing),
|
|
150
|
+
connector: readStoreConnector(existing)
|
|
150
151
|
});
|
|
151
152
|
return existing;
|
|
152
153
|
}
|
|
@@ -179,7 +180,8 @@ var WebhookService = class extends Service {
|
|
|
179
180
|
available: listWebhookStores(),
|
|
180
181
|
reason,
|
|
181
182
|
configPath: "security.webhooks.store",
|
|
182
|
-
location: readStoreLocation(store)
|
|
183
|
+
location: readStoreLocation(store),
|
|
184
|
+
connector: readStoreConnector(store)
|
|
183
185
|
});
|
|
184
186
|
return store;
|
|
185
187
|
}
|
|
@@ -17,6 +17,14 @@ function matchesTokenQuery(record, query, now) {
|
|
|
17
17
|
return true;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Copie PROFONDE d'un record lu : `scopes`, `audience`, `resources`, `cnf` et
|
|
21
|
+
* `metadata` sont mutables — rendre la référence laisserait l'appelant muter le
|
|
22
|
+
* store en place, ce que les backends persistants rendent impossible.
|
|
23
|
+
*/
|
|
24
|
+
function cloneOrNull(record) {
|
|
25
|
+
return record ? structuredClone(record) : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
20
28
|
* Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
|
|
21
29
|
*
|
|
22
30
|
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
@@ -59,6 +67,11 @@ var MemoryTokenStore = class {
|
|
|
59
67
|
this.#retentionRevokedMs = retentionRevokedMs;
|
|
60
68
|
}
|
|
61
69
|
put(record) {
|
|
70
|
+
const owner = this.#idByHash.get(record.secretHash);
|
|
71
|
+
if (owner !== void 0 && owner !== record.id) return Promise.reject(/* @__PURE__ */ new Error(`secretHash déjà porté par le jeton ${owner} : un secret ne désigne qu'un jeton`));
|
|
72
|
+
const previous = this.#byId.get(record.id);
|
|
73
|
+
if (previous) this.#removeRecord(record.id, previous);
|
|
74
|
+
record = structuredClone(record);
|
|
62
75
|
this.#byId.set(record.id, record);
|
|
63
76
|
this.#idByHash.set(record.secretHash, record.id);
|
|
64
77
|
this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
|
|
@@ -66,11 +79,11 @@ var MemoryTokenStore = class {
|
|
|
66
79
|
return Promise.resolve();
|
|
67
80
|
}
|
|
68
81
|
findById(id) {
|
|
69
|
-
return Promise.resolve(this.#byId.get(id)
|
|
82
|
+
return Promise.resolve(cloneOrNull(this.#byId.get(id)));
|
|
70
83
|
}
|
|
71
84
|
findByHash(secretHash) {
|
|
72
85
|
const id = this.#idByHash.get(secretHash);
|
|
73
|
-
return Promise.resolve(id !== void 0 ? this.#byId.get(id)
|
|
86
|
+
return Promise.resolve(id !== void 0 ? cloneOrNull(this.#byId.get(id)) : null);
|
|
74
87
|
}
|
|
75
88
|
findBySubject(subjectId) {
|
|
76
89
|
const ids = this.#idsBySubject.get(subjectId);
|
|
@@ -78,12 +91,12 @@ var MemoryTokenStore = class {
|
|
|
78
91
|
const out = [];
|
|
79
92
|
for (const id of ids) {
|
|
80
93
|
const record = this.#byId.get(id);
|
|
81
|
-
if (record) out.push(record);
|
|
94
|
+
if (record) out.push(structuredClone(record));
|
|
82
95
|
}
|
|
83
96
|
return Promise.resolve(out);
|
|
84
97
|
}
|
|
85
98
|
listAll() {
|
|
86
|
-
return Promise.resolve([...this.#byId.values()]);
|
|
99
|
+
return Promise.resolve([...this.#byId.values()].map((r) => structuredClone(r)));
|
|
87
100
|
}
|
|
88
101
|
listPage(query) {
|
|
89
102
|
assertPageQuery(query, "offset");
|
|
@@ -93,7 +106,7 @@ var MemoryTokenStore = class {
|
|
|
93
106
|
const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
|
|
94
107
|
const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
|
|
95
108
|
filtered.sort(compareByOrder(order, (r, field) => r[field]));
|
|
96
|
-
const items = filtered.slice(offset, offset + limit);
|
|
109
|
+
const items = filtered.slice(offset, offset + limit).map((r) => structuredClone(r));
|
|
97
110
|
const total = query.withTotal === false ? void 0 : filtered.length;
|
|
98
111
|
return Promise.resolve({
|
|
99
112
|
items,
|
|
@@ -37,21 +37,33 @@ function matchesTotpQuery(secret, query) {
|
|
|
37
37
|
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
38
38
|
* (2FA activé), jamais sur le hot path par requête.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Copie d'un secret : `recoveryCodes` est un tableau mutable — rendre ou garder
|
|
42
|
+
* la référence laisserait l'appelant rendre un code de secours réutilisable, ou
|
|
43
|
+
* en effacer un, sans passer par le store.
|
|
44
|
+
*/
|
|
45
|
+
function cloneSecret(s) {
|
|
46
|
+
return {
|
|
47
|
+
...s,
|
|
48
|
+
recoveryCodes: [...s.recoveryCodes]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
40
51
|
var MemoryTotpSecretStore = class {
|
|
41
52
|
/** userId → secret (source de vérité). */
|
|
42
53
|
#byUser = /* @__PURE__ */ new Map();
|
|
43
54
|
findByUser(userId) {
|
|
44
|
-
|
|
55
|
+
const secret = this.#byUser.get(userId);
|
|
56
|
+
return Promise.resolve(secret ? cloneSecret(secret) : null);
|
|
45
57
|
}
|
|
46
58
|
save(secret) {
|
|
47
|
-
this.#byUser.set(secret.userId, secret);
|
|
59
|
+
this.#byUser.set(secret.userId, cloneSecret(secret));
|
|
48
60
|
return Promise.resolve();
|
|
49
61
|
}
|
|
50
62
|
update(userId, patch) {
|
|
51
63
|
const secret = this.#byUser.get(userId);
|
|
52
64
|
if (secret) {
|
|
53
65
|
if (patch.confirmedAt !== void 0) secret.confirmedAt = patch.confirmedAt;
|
|
54
|
-
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = patch.recoveryCodes;
|
|
66
|
+
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = [...patch.recoveryCodes];
|
|
55
67
|
if (patch.lastUsedStep !== void 0) secret.lastUsedStep = patch.lastUsedStep;
|
|
56
68
|
if (patch.lastUsedAt !== void 0) secret.lastUsedAt = patch.lastUsedAt;
|
|
57
69
|
}
|
|
@@ -18,13 +18,25 @@ function toWebAuthnSummary(c) {
|
|
|
18
18
|
...c.nickname !== void 0 ? { nickname: c.nickname } : {}
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Copie d'une passkey : `transports` est un tableau mutable — rendre ou garder
|
|
23
|
+
* la référence laisserait l'appelant muter le store en place, ce que les
|
|
24
|
+
* backends persistants rendent impossible par sérialisation.
|
|
25
|
+
*/
|
|
26
|
+
function cloneCredential(c) {
|
|
27
|
+
return {
|
|
28
|
+
...c,
|
|
29
|
+
transports: [...c.transports]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
21
32
|
var MemoryWebAuthnCredentialStore = class {
|
|
22
33
|
/** id (base64url) → credential (source de vérité). */
|
|
23
34
|
#byId = /* @__PURE__ */ new Map();
|
|
24
35
|
/** userId → ids (allowCredentials + « mes appareils »). */
|
|
25
36
|
#idsByUser = /* @__PURE__ */ new Map();
|
|
26
37
|
findById(credentialId) {
|
|
27
|
-
|
|
38
|
+
const cred = this.#byId.get(credentialId);
|
|
39
|
+
return Promise.resolve(cred ? cloneCredential(cred) : null);
|
|
28
40
|
}
|
|
29
41
|
findByUser(userId) {
|
|
30
42
|
const ids = this.#idsByUser.get(userId);
|
|
@@ -32,7 +44,7 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
32
44
|
const out = [];
|
|
33
45
|
for (const id of ids) {
|
|
34
46
|
const cred = this.#byId.get(id);
|
|
35
|
-
if (cred) out.push(cred);
|
|
47
|
+
if (cred) out.push(cloneCredential(cred));
|
|
36
48
|
}
|
|
37
49
|
return Promise.resolve(out);
|
|
38
50
|
}
|
|
@@ -40,7 +52,9 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
40
52
|
return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
|
|
41
53
|
}
|
|
42
54
|
save(credential) {
|
|
43
|
-
this.#byId.
|
|
55
|
+
const previous = this.#byId.get(credential.id);
|
|
56
|
+
if (previous && previous.userId !== credential.userId) this.#unindex(previous.userId, credential.id);
|
|
57
|
+
this.#byId.set(credential.id, cloneCredential(credential));
|
|
44
58
|
let set = this.#idsByUser.get(credential.userId);
|
|
45
59
|
if (!set) {
|
|
46
60
|
set = /* @__PURE__ */ new Set();
|
|
@@ -63,12 +77,16 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
63
77
|
const cred = this.#byId.get(credentialId);
|
|
64
78
|
if (!cred) return Promise.resolve();
|
|
65
79
|
this.#byId.delete(credentialId);
|
|
66
|
-
|
|
80
|
+
this.#unindex(cred.userId, credentialId);
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
/** Retire un id de l'index d'un porteur ; l'entrée vide disparaît. */
|
|
84
|
+
#unindex(userId, credentialId) {
|
|
85
|
+
const set = this.#idsByUser.get(userId);
|
|
67
86
|
if (set) {
|
|
68
87
|
set.delete(credentialId);
|
|
69
|
-
if (set.size === 0) this.#idsByUser.delete(
|
|
88
|
+
if (set.size === 0) this.#idsByUser.delete(userId);
|
|
70
89
|
}
|
|
71
|
-
return Promise.resolve();
|
|
72
90
|
}
|
|
73
91
|
/** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
|
|
74
92
|
#filtered(query) {
|
|
@@ -27,6 +27,18 @@ function matchesWebhookQuery(e, query) {
|
|
|
27
27
|
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
28
28
|
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* Copie PROFONDE d'un endpoint : `events` et `metadata` sont des structures
|
|
32
|
+
* mutables — une copie de surface laisserait l'appelant muter le store en place,
|
|
33
|
+
* ce que les backends persistants rendent impossible par sérialisation.
|
|
34
|
+
*/
|
|
35
|
+
function cloneEndpoint(e) {
|
|
36
|
+
return {
|
|
37
|
+
...e,
|
|
38
|
+
events: [...e.events],
|
|
39
|
+
metadata: structuredClone(e.metadata)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
30
42
|
var MemoryWebhookStore = class {
|
|
31
43
|
/**
|
|
32
44
|
* {@inheritDoc IWebhookStore.sortableFields}
|
|
@@ -37,25 +49,25 @@ var MemoryWebhookStore = class {
|
|
|
37
49
|
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
38
50
|
#byId = /* @__PURE__ */ new Map();
|
|
39
51
|
async save(endpoint) {
|
|
40
|
-
this.#byId.set(endpoint.id,
|
|
52
|
+
this.#byId.set(endpoint.id, cloneEndpoint(endpoint));
|
|
41
53
|
}
|
|
42
54
|
async findById(id) {
|
|
43
55
|
const found = this.#byId.get(id);
|
|
44
|
-
return found ?
|
|
56
|
+
return found ? cloneEndpoint(found) : null;
|
|
45
57
|
}
|
|
46
58
|
async update(id, patch) {
|
|
47
59
|
const current = this.#byId.get(id);
|
|
48
60
|
if (!current) return;
|
|
49
|
-
this.#byId.set(id, {
|
|
61
|
+
this.#byId.set(id, cloneEndpoint({
|
|
50
62
|
...current,
|
|
51
63
|
...patch
|
|
52
|
-
});
|
|
64
|
+
}));
|
|
53
65
|
}
|
|
54
66
|
async delete(id) {
|
|
55
67
|
this.#byId.delete(id);
|
|
56
68
|
}
|
|
57
69
|
async listAll() {
|
|
58
|
-
return [...this.#byId.values()].map(
|
|
70
|
+
return [...this.#byId.values()].map(cloneEndpoint);
|
|
59
71
|
}
|
|
60
72
|
async listPage(query) {
|
|
61
73
|
assertPageQuery(query, "offset");
|
|
@@ -64,11 +76,7 @@ var MemoryWebhookStore = class {
|
|
|
64
76
|
const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
|
|
65
77
|
const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
|
|
66
78
|
filtered.sort(compareByOrder(order, (e, field) => e[field]));
|
|
67
|
-
const items = filtered.slice(offset, offset + limit).map(
|
|
68
|
-
...e,
|
|
69
|
-
events: [...e.events],
|
|
70
|
-
metadata: { ...e.metadata }
|
|
71
|
-
}));
|
|
79
|
+
const items = filtered.slice(offset, offset + limit).map(cloneEndpoint);
|
|
72
80
|
return {
|
|
73
81
|
items,
|
|
74
82
|
total: query.withTotal === false ? void 0 : filtered.length,
|
|
@@ -1,20 +1,4 @@
|
|
|
1
1
|
import { CliKernel, Command } from "nodefony";
|
|
2
|
-
/**
|
|
3
|
-
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
4
|
-
*
|
|
5
|
-
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
6
|
-
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
7
|
-
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
8
|
-
* suivante au moment où elle a un sens.
|
|
9
|
-
*
|
|
10
|
-
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
11
|
-
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
12
|
-
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
13
|
-
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
14
|
-
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
15
|
-
* qu'on refuse.
|
|
16
|
-
*/
|
|
17
|
-
export declare const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
18
2
|
/**
|
|
19
3
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
20
4
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
* Sous-système de sécurité concerné — axe de filtrage principal de la console
|
|
13
13
|
* d'audit (Studio P6.15). Fermé : la liste des sous-systèmes est stable.
|
|
14
14
|
*
|
|
15
|
-
* ⚠️
|
|
15
|
+
* ⚠️ Trois catégories sont RÉSERVÉES — déclarées au contrat mais **sans émetteur à
|
|
16
16
|
* ce jour** (aucun `record({ category: … })` ne les produit) : `oauth`, `csrf`,
|
|
17
|
-
* `cors
|
|
17
|
+
* `cors`. La console Studio ne propose donc PAS de filtre pour elles (un
|
|
18
18
|
* filtre qui ne rend jamais rien induit en erreur — cf `studio/.../auditModel.ts`).
|
|
19
19
|
* En câbler une = ajouter son émetteur ICI (l'émetteur), puis la métadonnée
|
|
20
20
|
* d'affichage dans `AUDIT_CATEGORIES`. Émettent réellement : auth, authz, token,
|
|
21
|
-
* session, webauthn, ws, webhook.
|
|
21
|
+
* session, webauthn, ws, webhook, config (`config.update`).
|
|
22
22
|
*/
|
|
23
23
|
export type AuditCategory = "auth" | "authz" | "token" | "session" | "oauth" | "webauthn" | "csrf" | "cors" | "ws" | "webhook" | "config";
|
|
24
24
|
/**
|
|
@@ -16,15 +16,6 @@ export declare function matchesTotpQuery(secret: ITotpSecret, query: ITotpListQu
|
|
|
16
16
|
export interface TotpStoreSnapshot {
|
|
17
17
|
secrets: ITotpSecret[];
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
* Store de secrets TOTP **en mémoire** — implémentation de référence
|
|
21
|
-
* d'{@link ITotpSecretStore}. Clé = `userId` (un secret par utilisateur).
|
|
22
|
-
*
|
|
23
|
-
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
24
|
-
* partagée entre process et **volatile** → en production multi-process, utiliser
|
|
25
|
-
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
26
|
-
* (2FA activé), jamais sur le hot path par requête.
|
|
27
|
-
*/
|
|
28
19
|
export declare class MemoryTotpSecretStore implements ITotpSecretStore {
|
|
29
20
|
#private;
|
|
30
21
|
findByUser(userId: string): Promise<ITotpSecret | null>;
|
|
@@ -10,14 +10,6 @@ import type { IWebhookEndpoint, WebhookEndpointUpdate } from "../../contracts/IW
|
|
|
10
10
|
* @returns `true` si l'endpoint appartient à la collection filtrée.
|
|
11
11
|
*/
|
|
12
12
|
export declare function matchesWebhookQuery(e: IWebhookEndpoint, query: IWebhookListQuery): boolean;
|
|
13
|
-
/**
|
|
14
|
-
* Store d'endpoints webhook **en mémoire** — défaut dev/test (non persistant :
|
|
15
|
-
* les endpoints sont perdus au redémarrage). En prod, utiliser `drizzle`/
|
|
16
|
-
* `mongoose` (config `webhooks.store`).
|
|
17
|
-
*
|
|
18
|
-
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
19
|
-
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
20
|
-
*/
|
|
21
13
|
export declare class MemoryWebhookStore implements IWebhookStore {
|
|
22
14
|
#private;
|
|
23
15
|
/**
|