@nodefony/security 10.0.0-alpha.3 → 10.0.0-alpha.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/LICENSE +201 -543
- package/README.md +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.148.0 → _@oxc-project_runtime@0.149.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-secrets.js +7 -3
- package/dist/nodefony/command/security-token.js +5 -5
- package/dist/nodefony/command/security-user-add.js +22 -6
- package/dist/nodefony/command/security-user-password.js +111 -0
- package/dist/nodefony/config/config.js +3 -1
- package/dist/nodefony/service/auditService.js +3 -3
- package/dist/nodefony/service/oauth2.js +79 -2
- package/dist/nodefony/service/tokenService.js +3 -3
- package/dist/nodefony/service/totp.js +3 -3
- package/dist/nodefony/service/webAuthn.js +3 -3
- package/dist/nodefony/service/webhooks.js +3 -3
- package/dist/nodefony/src/token/JwtKeystore.js +37 -1
- package/dist/types/nodefony/command/security-user-add.d.ts +16 -0
- package/dist/types/nodefony/command/security-user-password.d.ts +31 -0
- package/dist/types/nodefony/config/config.d.ts +2 -0
- package/dist/types/nodefony/service/oauth2.d.ts +41 -1
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +20 -0
- package/docs/audit.md +5 -5
- package/docs/authenticators.md +10 -10
- package/docs/authorization.md +2 -2
- package/docs/cors.md +4 -4
- package/docs/csrf.md +5 -5
- package/docs/firewall.md +7 -7
- package/docs/headers.md +7 -7
- package/docs/index.md +24 -0
- package/docs/oauth2.md +54 -32
- package/docs/tokens.md +10 -9
- package/docs/totp.md +1 -1
- package/docs/webauthn.md +3 -3
- package/docs/webhooks.md +1 -1
- package/package.json +12 -12
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.149.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.149.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
|
@@ -73,11 +73,12 @@ import SecuritySecrets from "./nodefony/command/security-secrets.js";
|
|
|
73
73
|
import SecurityUserAdd from "./nodefony/command/security-user-add.js";
|
|
74
74
|
import SecurityUserList from "./nodefony/command/security-user-list.js";
|
|
75
75
|
import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
|
|
76
|
+
import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
76
77
|
import SecurityToken from "./nodefony/command/security-token.js";
|
|
77
78
|
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
78
79
|
import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
79
|
-
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.
|
|
80
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
80
|
+
import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorateMetadata.js";
|
|
81
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.149.0/helpers/esm/decorate.js";
|
|
81
82
|
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
82
83
|
import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
|
|
83
84
|
import "./nodefony/errors/index.js";
|
|
@@ -108,6 +109,7 @@ let Security = class Security extends Module {
|
|
|
108
109
|
this.addCommand(SecurityUserAdd);
|
|
109
110
|
this.addCommand(SecurityUserList);
|
|
110
111
|
this.addCommand(SecurityUserDelete);
|
|
112
|
+
this.addCommand(SecurityUserPassword);
|
|
111
113
|
this.addCommand(SecurityToken);
|
|
112
114
|
}
|
|
113
115
|
/**
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readIfPresentSync } from "../src/token/secretFile.js";
|
|
2
|
-
import { Command } from "nodefony";
|
|
2
|
+
import { Command, diskManifestReader, manifestFileWith, readManifestCode } from "nodefony";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { appendFileSync } from "node:fs";
|
|
@@ -114,7 +114,10 @@ var SecuritySecrets = class extends Command {
|
|
|
114
114
|
const dotenvLocal = this.#read(".env.local");
|
|
115
115
|
const dotenv = this.#read(".env") + "\n" + dotenvLocal;
|
|
116
116
|
const envTs = this.#read("env.ts");
|
|
117
|
-
const cfgTs = this.#
|
|
117
|
+
const cfgTs = readManifestCode(this.#root(), diskManifestReader);
|
|
118
|
+
const cfgFile = manifestFileWith(this.#root(), diskManifestReader, /satisfies\s+ISecurityConfigInput/);
|
|
119
|
+
const cfgRel = path.relative(this.#root(), cfgFile).split(path.sep).join("/");
|
|
120
|
+
const cfgIsFragment = cfgRel !== "nodefony.config.ts";
|
|
118
121
|
const missingInDotenv = KEYS.filter((k) => !new RegExp(`^\\s*${k}\\s*=`, "m").test(dotenv));
|
|
119
122
|
const missingInEnvTs = KEYS.filter((k) => !envTs.includes(k));
|
|
120
123
|
const WIRING = {
|
|
@@ -143,8 +146,9 @@ var SecuritySecrets = class extends Command {
|
|
|
143
146
|
w(`${BOLD}2. Fichier ${CYAN}env.ts${RESET}${BOLD} — la déclaration typée${RESET} ${DIM}(env.ts est le seul lecteur de process.env)${RESET}\n`);
|
|
144
147
|
if (missingInEnvTs.length === 0) w(` ${GREEN}✓ déjà déclarées${RESET}\n\n`);
|
|
145
148
|
else w(` ajoute dans le defineEnv({ … }) :\n\n` + missingInEnvTs.map((k) => ` ${k}: envString({ optional: true }),`).join("\n") + `\n\n`);
|
|
146
|
-
w(`${BOLD}3. Fichier ${CYAN}
|
|
149
|
+
w(`${BOLD}3. Fichier ${CYAN}${cfgRel}${RESET}${BOLD} — le câblage vers le module security${RESET}\n`);
|
|
147
150
|
if (missingInCfg.length === 0) w(` ${GREEN}✓ déjà câblées${RESET}\n\n`);
|
|
151
|
+
else if (cfgIsFragment) w(` complète le descripteur ${CYAN}securityConfig${RESET} :\n\n` + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n\n`);
|
|
148
152
|
else w(" complète l'entrée security du manifeste modules :\n\n use(\"@nodefony/security\", {\n" + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n }),\n\n`);
|
|
149
153
|
const jwtCable = /keystore\s*:/u.test(cfgTs);
|
|
150
154
|
w(`${BOLD}4. Fichier ${CYAN}nodefony.config.ts${RESET}${BOLD} — les clés de SIGNATURE des jetons${RESET} ${DIM}(jwt.keystore)${RESET}\n`);
|
|
@@ -306,10 +306,10 @@ var SecurityToken = class extends Command {
|
|
|
306
306
|
let targets = requested ?? [];
|
|
307
307
|
if (requested === void 0) {
|
|
308
308
|
const presents = this.#agentsPresents();
|
|
309
|
-
const
|
|
310
|
-
const added = presents.filter((c) => !
|
|
311
|
-
targets =
|
|
312
|
-
if (
|
|
309
|
+
const carriers = presents.filter((c) => alreadyHasKey(c.forme, this.#contentOf(c), MCP_TOKEN_ENV));
|
|
310
|
+
const added = presents.filter((c) => !carriers.includes(c));
|
|
311
|
+
targets = carriers;
|
|
312
|
+
if (carriers.length === 0 && added.length > 0 && process.stdin.isTTY) {
|
|
313
313
|
const { checkbox } = await chargePrompts();
|
|
314
314
|
const chosen = await checkbox({
|
|
315
315
|
message: "Poser le jeton chez quels agents ?",
|
|
@@ -320,7 +320,7 @@ var SecurityToken = class extends Command {
|
|
|
320
320
|
}))
|
|
321
321
|
});
|
|
322
322
|
targets = added.filter((c) => chosen.includes(c.key));
|
|
323
|
-
} else if (
|
|
323
|
+
} else if (carriers.length === 0) targets = added;
|
|
324
324
|
else if (added.length > 0) w(`${DIM} ${added.map((c) => c.name).join(", ")} ${added.length > 1 ? "sont présents" : "est présent"} mais ne porte${added.length > 1 ? "nt" : ""} pas encore le jeton — ajoute --agent ${added.map((c) => c.key).join(",")}.${RESET}\n\n`);
|
|
325
325
|
}
|
|
326
326
|
if (this.#writeForAgents(token, w, targets) === 0) w(`${YELLOW}⚠ aucun agent reconnu dans ce projet — rien n'est écrit.${RESET}\n${DIM} Les agents connus rangent leur configuration ici :${RESET}\n` + AGENT_TARGETS.map((c) => `${DIM} ${c.name} : ${c.scope === "projet" ? c.file : `$${c.home ?? "HOME"}/${c.file}`}${RESET}\n`).join("") + `\n Le geste qui vaut pour TOUS — dans le shell d'où tu lances l'agent :\n\n ${BOLD}export ${MCP_TOKEN_ENV}=${token}${RESET}\n\n`);
|
|
@@ -16,6 +16,22 @@ const ADMIN_ROLES = ["ROLE_ADMIN", "ROLE_NODEFONY_ADMIN"];
|
|
|
16
16
|
/** Rôle de base, toujours proposé même si l'application n'en déclare aucun. */
|
|
17
17
|
const ROLE_BASE = "ROLE_USER";
|
|
18
18
|
/**
|
|
19
|
+
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
20
|
+
*
|
|
21
|
+
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
22
|
+
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
23
|
+
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
24
|
+
* suivante au moment où elle a un sens.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
27
|
+
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
28
|
+
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
29
|
+
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
30
|
+
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
31
|
+
* qu'on refuse.
|
|
32
|
+
*/
|
|
33
|
+
const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
34
|
+
/**
|
|
19
35
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
20
36
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
21
37
|
* jamais de mot de passe stocké en clair).
|
|
@@ -55,9 +71,9 @@ var SecurityUserAdd = class extends Command {
|
|
|
55
71
|
#knownRoles() {
|
|
56
72
|
const hierarchy = ((this.kernel?.modules)?.security?.options)?.roleHierarchy;
|
|
57
73
|
const all = /* @__PURE__ */ new Set([ROLE_BASE]);
|
|
58
|
-
for (const [
|
|
59
|
-
all.add(
|
|
60
|
-
for (const c of
|
|
74
|
+
for (const [role, covered] of Object.entries(hierarchy ?? {})) {
|
|
75
|
+
all.add(role);
|
|
76
|
+
for (const c of covered) all.add(c);
|
|
61
77
|
}
|
|
62
78
|
return [ROLE_BASE, ...[...all].filter((r) => r !== ROLE_BASE).sort()];
|
|
63
79
|
}
|
|
@@ -80,7 +96,7 @@ var SecurityUserAdd = class extends Command {
|
|
|
80
96
|
return this;
|
|
81
97
|
}
|
|
82
98
|
if (await users.findByIdentifier(identifier)) {
|
|
83
|
-
this.log(`le compte « ${identifier} » existe déjà.\n · le voir : nodefony security:user:list -q ${identifier}\n · le supprimer : nodefony security:user:delete ${identifier}\n · mot de passe oublié :
|
|
99
|
+
this.log(`le compte « ${identifier} » existe déjà.\n · le voir : nodefony security:user:list -q ${identifier}\n · le supprimer : nodefony security:user:delete ${identifier}\n · mot de passe oublié : nodefony security:user:password ${identifier}`, "ERROR");
|
|
84
100
|
process.exitCode = 1;
|
|
85
101
|
return this;
|
|
86
102
|
}
|
|
@@ -124,9 +140,9 @@ var SecurityUserAdd = class extends Command {
|
|
|
124
140
|
plainPassword: password,
|
|
125
141
|
roles
|
|
126
142
|
});
|
|
127
|
-
process.stdout.write(`\n${GREEN}✓ compte créé${RESET} — ${BOLD}${user.identifier}${RESET} ${DIM}(id ${user.id})${RESET}\n rôles : ${roles.join(" · ")}\n` + (opts.admin ? ` ${DIM}accès console Studio : /nodefony${RESET}\n` : "") + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : ""));
|
|
143
|
+
process.stdout.write(`\n${GREEN}✓ compte créé${RESET} — ${BOLD}${user.identifier}${RESET} ${DIM}(id ${user.id})${RESET}\n rôles : ${roles.join(" · ")}\n` + (opts.admin ? ` ${DIM}accès console Studio : /nodefony${RESET}\n` : "") + ` ${DIM}pour t'authentifier : POST ${AUTH_LOGIN_PATH}${RESET}\n ${DIM} body JSON {"username","password"} → cookie de session ; l'identité courante se relit sur GET /nodefony/security/api/auth/me${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : ""));
|
|
128
144
|
return this;
|
|
129
145
|
}
|
|
130
146
|
};
|
|
131
147
|
//#endregion
|
|
132
|
-
export { SecurityUserAdd as default };
|
|
148
|
+
export { AUTH_LOGIN_PATH, SecurityUserAdd as default };
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
|
|
2
|
+
import { USER_REVOKED_EVENT } from "@nodefony/user";
|
|
3
|
+
//#region nodefony/command/security-user-password.ts
|
|
4
|
+
const options = {
|
|
5
|
+
runProfile: CONSOLE_DATA_RUN_PROFILE,
|
|
6
|
+
helpGroup: "COMPTES ET SECRETS",
|
|
7
|
+
showBanner: false,
|
|
8
|
+
kernelEvent: "onPostReady",
|
|
9
|
+
quietBoot: true
|
|
10
|
+
};
|
|
11
|
+
const GREEN = "\x1B[32m";
|
|
12
|
+
const YELLOW = "\x1B[33m";
|
|
13
|
+
const DIM = "\x1B[2m";
|
|
14
|
+
const BOLD = "\x1B[1m";
|
|
15
|
+
const RESET = "\x1B[0m";
|
|
16
|
+
/**
|
|
17
|
+
* `nodefony security:user:password [identifiant]` — change le mot de passe d'un
|
|
18
|
+
* compte.
|
|
19
|
+
*
|
|
20
|
+
* Le geste d'exploitation qui manquait : `user:add`, `user:list` et
|
|
21
|
+
* `user:delete` existaient, mais un mot de passe perdu n'avait aucun recours en
|
|
22
|
+
* ligne de commande — seule la console d'administration savait le faire, ce qui
|
|
23
|
+
* suppose d'y être déjà entré. Sur un serveur, c'est précisément le cas où l'on
|
|
24
|
+
* ne peut pas.
|
|
25
|
+
*
|
|
26
|
+
* Mot de passe : `--password` (visible dans l'historique shell — accepté pour
|
|
27
|
+
* les scripts) ou PROMPT MASQUÉ en TTY, demandé deux fois. Le hachage est celui
|
|
28
|
+
* du service (`UserService.changePassword` → Argon2id) : jamais de clair
|
|
29
|
+
* persisté, et la liste de mots de passe interdits s'applique comme à la
|
|
30
|
+
* création.
|
|
31
|
+
*
|
|
32
|
+
* 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
|
|
33
|
+
* option : on change un mot de passe parce qu'il est compromis ou perdu, et
|
|
34
|
+
* laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
|
|
35
|
+
* révocation passe par l'événement que la suppression emploie déjà — une
|
|
36
|
+
* cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
|
|
37
|
+
* rien changer ici.
|
|
38
|
+
*/
|
|
39
|
+
var SecurityUserPassword = class extends Command {
|
|
40
|
+
constructor(cli) {
|
|
41
|
+
super("security:user:password", "change le mot de passe d'un compte", cli, options);
|
|
42
|
+
this.addArgument("[identifier]", "identifiant (login) du compte");
|
|
43
|
+
this.addOption("-p, --password <password>", "nouveau mot de passe (sinon : prompt masqué en TTY)");
|
|
44
|
+
}
|
|
45
|
+
async generate(identifierArg, opts) {
|
|
46
|
+
const users = this.kernel?.container?.get("users");
|
|
47
|
+
if (!users) {
|
|
48
|
+
this.log("service « users » absent — l'application ne provisionne pas son annuaire utilisateurs (cf nodefony/security/provisionUsers.ts d'une app générée).", "ERROR");
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return this;
|
|
51
|
+
}
|
|
52
|
+
let identifier;
|
|
53
|
+
try {
|
|
54
|
+
identifier = await this.askArgument(identifierArg, {
|
|
55
|
+
name: "identifier",
|
|
56
|
+
message: "Compte dont changer le mot de passe :"
|
|
57
|
+
});
|
|
58
|
+
} catch (e) {
|
|
59
|
+
this.log(e.message, "ERROR");
|
|
60
|
+
process.exitCode = 1;
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
const user = await users.findByIdentifier(identifier);
|
|
64
|
+
if (!user) {
|
|
65
|
+
this.log(`aucun compte « ${identifier} » — nodefony security:user:list`, "ERROR");
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return this;
|
|
68
|
+
}
|
|
69
|
+
let password = opts.password;
|
|
70
|
+
if (!password) {
|
|
71
|
+
if (!process.stdin.isTTY) {
|
|
72
|
+
this.log("mot de passe requis : --password <pwd> (pas de prompt hors terminal).", "ERROR");
|
|
73
|
+
process.exitCode = 1;
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
password = await askPasswordMasked(`${BOLD}Nouveau mot de passe de « ${identifier} »${RESET} ${DIM}(frappe masquée)${RESET} : `);
|
|
77
|
+
const confirmed = await askPasswordMasked(`${BOLD}Confirme le mot de passe${RESET} : `);
|
|
78
|
+
if (password !== confirmed) {
|
|
79
|
+
this.log("les deux saisies diffèrent — rien n'a changé.", "ERROR");
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!password) {
|
|
85
|
+
this.log("mot de passe vide — rien n'a changé.", "ERROR");
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
if (await users.changePassword(user.id, password) === null) {
|
|
91
|
+
this.log(`aucune ligne modifiée pour « ${identifier} » — le compte a-t-il disparu entre-temps ?`, "ERROR");
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
this.log(e.message, "ERROR");
|
|
97
|
+
process.exitCode = 1;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
this.kernel?.fire(USER_REVOKED_EVENT, {
|
|
101
|
+
id: user.id,
|
|
102
|
+
identifier: user.identifier,
|
|
103
|
+
tenantId: null,
|
|
104
|
+
reason: "password_changed"
|
|
105
|
+
});
|
|
106
|
+
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 /nodefony/security/api/auth/login${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
|
|
107
|
+
return this;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
export { SecurityUserPassword as default };
|
|
@@ -309,7 +309,9 @@ const oauthProviderSchema = z.strictObject({
|
|
|
309
309
|
scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
|
|
310
310
|
successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
|
|
311
311
|
failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
|
|
312
|
-
defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur.")
|
|
312
|
+
defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur."),
|
|
313
|
+
label: z.string().min(1).optional().describe("Libellé du bouton sur l'écran de connexion. OMIS = dérivé du nom du fournisseur (`keycloak` → « Keycloak », `oidc` → « OIDC », `mon-idp` → « Mon Idp »). À poser quand la marque ne se devine pas du nom de la clé (« Connexion agent », « Annuaire interne »)."),
|
|
314
|
+
hidden: z.boolean().default(false).describe("Retire le bouton de l'écran de connexion SANS désactiver le fournisseur : le flux `/authorize` reste ouvert et fonctionnel. C'est la seule différence avec le fait de ne pas le configurer. Deux usages : une FIXTURE de développement qui pointe vers un serveur fictif (bouton mort), et un fournisseur réservé à un point d'entrée particulier (lien direct, sous-domaine) plutôt qu'offert à tout visiteur.")
|
|
313
315
|
}).describe("Fournisseur OAuth/OIDC (secrets via env).");
|
|
314
316
|
const oauth2Schema = z.strictObject({
|
|
315
317
|
enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
|
|
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
3
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
5
|
//#region nodefony/service/auditService.ts
|
|
6
6
|
const serviceName = "auditService";
|
|
@@ -50,7 +50,7 @@ var AuditService = class extends Service {
|
|
|
50
50
|
let storeName = config.audit.store;
|
|
51
51
|
let reason = `store explicitement configuré ("${storeName}")`;
|
|
52
52
|
if (storeName === AUTO_STORE) {
|
|
53
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listAuditStores());
|
|
53
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listAuditStores(), "memory", runNeedsExternalServices(this.kernel));
|
|
54
54
|
storeName = auto.store;
|
|
55
55
|
reason = auto.reason;
|
|
56
56
|
this.log(`audit.store "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
@@ -62,7 +62,7 @@ var AuditService = class extends Service {
|
|
|
62
62
|
this.log(`${msg} — audit désactivé (journal de sécurité non collecté)`, "CRITIC");
|
|
63
63
|
return;
|
|
64
64
|
}
|
|
65
|
-
if (storeName === "memory" && this.kernel?.environment === "production") this.log("audit.store \"memory\" en PRODUCTION — journal de sécurité volatil et per-pod : perdu au redémarrage, invisible des autres pods, rétention de conformité impossible.
|
|
65
|
+
if (storeName === "memory" && this.kernel?.environment === "production") this.log("audit.store \"memory\" en PRODUCTION — journal de sécurité volatil et per-pod : perdu au redémarrage, invisible des autres pods, rétention de conformité impossible. " + durableStoreRemedy(runNeedsExternalServices(this.kernel)), "WARNING");
|
|
66
66
|
this.#enabled = true;
|
|
67
67
|
this.#idPrefix = randomBytes(4).toString("hex");
|
|
68
68
|
this.#store = factory({
|
|
@@ -6,6 +6,58 @@ import { Service } from "nodefony";
|
|
|
6
6
|
//#region nodefony/service/oauth2.ts
|
|
7
7
|
const serviceName = "oauth2";
|
|
8
8
|
/**
|
|
9
|
+
* Sigles qui se lisent en capitales — les capitaliser mot à mot rendrait
|
|
10
|
+
* « Oidc », « Sso », qu'aucun utilisateur ne reconnaît comme la technologie.
|
|
11
|
+
*/
|
|
12
|
+
const ACRONYMS = /* @__PURE__ */ new Set([
|
|
13
|
+
"oidc",
|
|
14
|
+
"sso",
|
|
15
|
+
"saml",
|
|
16
|
+
"ldap",
|
|
17
|
+
"cas",
|
|
18
|
+
"adfs",
|
|
19
|
+
"iam"
|
|
20
|
+
]);
|
|
21
|
+
/**
|
|
22
|
+
* Marques dont la casse INTERNE ne se devine pas d'un nom en minuscules.
|
|
23
|
+
*
|
|
24
|
+
* Capitaliser la première lettre rendrait « Github », que la marque n'écrit
|
|
25
|
+
* jamais ainsi — et c'est précisément le nom que l'utilisateur cherche des yeux
|
|
26
|
+
* sur un bouton. Vu à l'écran, pas déduit : la première version de cette
|
|
27
|
+
* fonction affichait « Github » là où la console montrait « GitHub » avant.
|
|
28
|
+
*/
|
|
29
|
+
const CANONICAL_LABELS = {
|
|
30
|
+
github: "GitHub",
|
|
31
|
+
gitlab: "GitLab",
|
|
32
|
+
google: "Google",
|
|
33
|
+
keycloak: "Keycloak",
|
|
34
|
+
microsoft: "Microsoft",
|
|
35
|
+
auth0: "Auth0",
|
|
36
|
+
okta: "Okta",
|
|
37
|
+
linkedin: "LinkedIn",
|
|
38
|
+
paypal: "PayPal",
|
|
39
|
+
youtube: "YouTube"
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Libellé affichable d'un fournisseur, quand sa configuration n'en donne pas.
|
|
43
|
+
*
|
|
44
|
+
* Un écran de connexion ne doit JAMAIS montrer un identifiant technique brut :
|
|
45
|
+
* `mon-idp-interne` sur un bouton ne dit rien à qui doit cliquer. À défaut de
|
|
46
|
+
* marque connue, le nom de la clé de configuration est ce qui s'en rapproche le
|
|
47
|
+
* plus — mais rendu lisible : séparateurs en espaces, initiales en capitales,
|
|
48
|
+
* sigles préservés.
|
|
49
|
+
*
|
|
50
|
+
* Fonction PURE, donc éprouvable sans boot ni réseau.
|
|
51
|
+
*
|
|
52
|
+
* @param name - nom du fournisseur, tel qu'il est écrit dans la configuration
|
|
53
|
+
* @returns le libellé à afficher sur le bouton
|
|
54
|
+
*/
|
|
55
|
+
function oauthDisplayLabel(name) {
|
|
56
|
+
const canonical = CANONICAL_LABELS[name.toLowerCase()];
|
|
57
|
+
if (canonical !== void 0) return canonical;
|
|
58
|
+
return name.split(/[-_.\s]+/).filter((word) => word.length > 0).map((word) => ACRONYMS.has(word.toLowerCase()) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
9
61
|
* **Social login OAuth 2.0** (P6 J9) — orchestrateur du flux *Authorization Code*.
|
|
10
62
|
*
|
|
11
63
|
* Posture OAuth 2.1 (RFC 9700) : Authorization Code uniquement (jamais implicit /
|
|
@@ -57,13 +109,38 @@ var OAuth2Service = class extends Service {
|
|
|
57
109
|
isEnabled() {
|
|
58
110
|
return this.#ready;
|
|
59
111
|
}
|
|
60
|
-
/**
|
|
112
|
+
/**
|
|
113
|
+
* Noms des fournisseurs OPÉRATIONNELS — configurés ET connus du registre.
|
|
114
|
+
*
|
|
115
|
+
* 🔴 C'est la **garde d'autorisation** : `/authorize` refuse en 404 tout nom
|
|
116
|
+
* absent de cette liste. Elle répond donc à « ce flux peut-il s'ouvrir ? »,
|
|
117
|
+
* jamais à « ce bouton doit-il s'afficher ? » — pour l'écran, voir
|
|
118
|
+
* {@link listDisplayProviders}. Confondre les deux ferait d'un masquage une
|
|
119
|
+
* désactivation, et couperait les bancs qui exercent une fixture masquée.
|
|
120
|
+
*/
|
|
61
121
|
listProviders() {
|
|
62
122
|
if (!this.#ready || this.#config === null) return [];
|
|
63
123
|
const known = new Set(listOAuthProviders());
|
|
64
124
|
return Object.keys(this.#config.oauth2.providers).filter((n) => known.has(n));
|
|
65
125
|
}
|
|
66
126
|
/**
|
|
127
|
+
* Fournisseurs à MONTRER sur l'écran de connexion, libellés compris.
|
|
128
|
+
*
|
|
129
|
+
* Rend TOUT fournisseur opérationnel — y compris ceux dont le framework ne
|
|
130
|
+
* connaît pas la marque, qui sont précisément ceux qu'une application
|
|
131
|
+
* enregistre elle-même. Le seul retrait possible est explicite et se lit dans
|
|
132
|
+
* la configuration du fournisseur (`hidden: true`), à côté de la raison qui
|
|
133
|
+
* l'a motivé ; il ne désactive rien.
|
|
134
|
+
*/
|
|
135
|
+
listDisplayProviders() {
|
|
136
|
+
if (this.#config === null) return [];
|
|
137
|
+
const configured = this.#config.oauth2.providers;
|
|
138
|
+
return this.listProviders().filter((name) => configured[name]?.hidden !== true).map((name) => ({
|
|
139
|
+
name,
|
|
140
|
+
label: configured[name]?.label ?? oauthDisplayLabel(name)
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
67
144
|
* Redirections post-login (succès / échec) — lues par le controller.
|
|
68
145
|
* Surcharge PAR FOURNISSEUR si fournie, sinon valeur globale, sinon défaut.
|
|
69
146
|
*/
|
|
@@ -159,4 +236,4 @@ var OAuth2Service = class extends Service {
|
|
|
159
236
|
}
|
|
160
237
|
};
|
|
161
238
|
//#endregion
|
|
162
|
-
export { OAuth2Service, OAuth2Service as default };
|
|
239
|
+
export { OAuth2Service, OAuth2Service as default, oauthDisplayLabel };
|
|
@@ -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, readStoreLocation, refusedAdminScopes, resolveAutoStore } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, 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";
|
|
@@ -59,7 +59,7 @@ var TokenService = class extends Service {
|
|
|
59
59
|
let storeName = config.tokenStore.store;
|
|
60
60
|
let reason = `store explicitement configuré ("${storeName}")`;
|
|
61
61
|
if (storeName === AUTO_STORE) {
|
|
62
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores());
|
|
62
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", runNeedsExternalServices(this.kernel));
|
|
63
63
|
storeName = auto.store;
|
|
64
64
|
reason = auto.reason;
|
|
65
65
|
this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
@@ -71,7 +71,7 @@ var TokenService = class extends Service {
|
|
|
71
71
|
this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
|
|
72
72
|
return;
|
|
73
73
|
}
|
|
74
|
-
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.
|
|
74
|
+
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(runNeedsExternalServices(this.kernel)), "WARNING");
|
|
75
75
|
this.#store = factory({
|
|
76
76
|
container: this.container,
|
|
77
77
|
config
|
|
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
6
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
7
7
|
//#region nodefony/service/totp.ts
|
|
8
8
|
const serviceName = "totp";
|
|
9
9
|
function isFlushable(s) {
|
|
@@ -90,7 +90,7 @@ var TotpService = class extends Service {
|
|
|
90
90
|
let driver = config.totp.store;
|
|
91
91
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
92
92
|
if (driver === AUTO_STORE) {
|
|
93
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores());
|
|
93
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", runNeedsExternalServices(this.kernel));
|
|
94
94
|
driver = auto.store;
|
|
95
95
|
reason = auto.reason;
|
|
96
96
|
this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -102,7 +102,7 @@ var TotpService = class extends Service {
|
|
|
102
102
|
this.log(`${msg} — 2FA indisponible`, "CRITIC");
|
|
103
103
|
return null;
|
|
104
104
|
}
|
|
105
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés).
|
|
105
|
+
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(runNeedsExternalServices(this.kernel)), "WARNING");
|
|
106
106
|
const store = factory({
|
|
107
107
|
container: this.container,
|
|
108
108
|
config
|
|
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
//#region nodefony/service/webAuthn.ts
|
|
8
8
|
const serviceName = "webauthn";
|
|
@@ -74,7 +74,7 @@ var WebAuthnService = class extends Service {
|
|
|
74
74
|
let driver = config.passkeys.store;
|
|
75
75
|
reason = `store explicitement configuré ("${driver}")`;
|
|
76
76
|
if (driver === AUTO_STORE) {
|
|
77
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores());
|
|
77
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", runNeedsExternalServices(this.kernel));
|
|
78
78
|
driver = auto.store;
|
|
79
79
|
reason = auto.reason;
|
|
80
80
|
this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -86,7 +86,7 @@ var WebAuthnService = class extends Service {
|
|
|
86
86
|
this.log(`${msg} — passkeys indisponibles`, "CRITIC");
|
|
87
87
|
return;
|
|
88
88
|
}
|
|
89
|
-
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).
|
|
89
|
+
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(runNeedsExternalServices(this.kernel)), "WARNING");
|
|
90
90
|
this.#store = factory({
|
|
91
91
|
container: this.container,
|
|
92
92
|
config
|
|
@@ -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, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, 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";
|
|
@@ -153,7 +153,7 @@ var WebhookService = class extends Service {
|
|
|
153
153
|
let driver = config.webhooks.store;
|
|
154
154
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
155
155
|
if (driver === AUTO_STORE) {
|
|
156
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores());
|
|
156
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", runNeedsExternalServices(this.kernel));
|
|
157
157
|
driver = auto.store;
|
|
158
158
|
reason = auto.reason;
|
|
159
159
|
this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -165,7 +165,7 @@ var WebhookService = class extends Service {
|
|
|
165
165
|
this.log(`${msg} — webhooks indisponibles`, "CRITIC");
|
|
166
166
|
return null;
|
|
167
167
|
}
|
|
168
|
-
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.
|
|
168
|
+
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(runNeedsExternalServices(this.kernel)), "WARNING");
|
|
169
169
|
const store = factory({
|
|
170
170
|
container: this.container,
|
|
171
171
|
config
|
|
@@ -2,6 +2,40 @@ import { messageNonRestreint, modeNonRestreintAsync, readIfPresent, writeSecret
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
//#region nodefony/src/token/JwtKeystore.ts
|
|
4
4
|
/**
|
|
5
|
+
* Dossiers que le `Dockerfile` généré EFFACE à l'étage de construction.
|
|
6
|
+
*
|
|
7
|
+
* La liste est ici parce que c'est ici qu'on s'en sert ; elle est tenue honnête
|
|
8
|
+
* par le test qui la confronte au gabarit — deux copies d'une même règle
|
|
9
|
+
* divergent en silence, et c'est une clé privée qui paierait la dérive.
|
|
10
|
+
*/
|
|
11
|
+
const CLEANED_DIRS = ["var", "tmp"];
|
|
12
|
+
/**
|
|
13
|
+
* Le trousseau va-t-il partir dans l'image de conteneur ?
|
|
14
|
+
*
|
|
15
|
+
* ⭐ **Pourquoi cet avertissement existe.** Le trousseau est une clé privée
|
|
16
|
+
* Ed25519. Elle ne sort pas de l'image aujourd'hui pour UNE seule raison : le
|
|
17
|
+
* gabarit a choisi `var/keys`, et le `Dockerfile` généré efface `var/`. Rien
|
|
18
|
+
* n'attache cette sécurité à la configuration — un utilisateur qui écrit
|
|
19
|
+
* `keystore: { dir: "nodefony/config/keys" }`, chemin parfaitement raisonnable,
|
|
20
|
+
* publie sa clé privée sans qu'aucun signal n'existe. C'est exactement le
|
|
21
|
+
* chemin par lequel une clé TLS est déjà partie dans une image publiée.
|
|
22
|
+
*
|
|
23
|
+
* ⚠️ **Les chemins ABSOLUS ne sont pas jugés**, et c'est délibéré : `/etc/…` ou
|
|
24
|
+
* un point de montage sont des choix d'exploitation qui sortent du contexte de
|
|
25
|
+
* construction, et prétendre les évaluer ferait crier ce contrôle sur la
|
|
26
|
+
* pratique la plus saine. Un contrôle qui crie faux apprend à passer outre.
|
|
27
|
+
*
|
|
28
|
+
* @param dir - la valeur de `jwt.keystore.dir`, telle que configurée.
|
|
29
|
+
* @returns le message à journaliser, ou `null` quand le dossier est nettoyé.
|
|
30
|
+
*/
|
|
31
|
+
function keystoreLeaksIntoImage(dir) {
|
|
32
|
+
const normalized = dir.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
33
|
+
if (normalized.startsWith("/") || /^[A-Za-z]:/u.test(normalized)) return null;
|
|
34
|
+
const first = normalized.split("/")[0];
|
|
35
|
+
if (CLEANED_DIRS.includes(first)) return null;
|
|
36
|
+
return `JWT keystore: « ${dir} » n'est PAS sous ${CLEANED_DIRS.map((d) => `\`${d}/\``).join(" ni ")}, les seuls dossiers que le Dockerfile généré efface avant de fabriquer l'image. La clé privée de signature partira donc dans ton image de conteneur, où elle reste lisible par quiconque la télécharge — même effacée par une couche suivante. Deux issues : déplacer le dossier sous \`var/\` (ex. \`var/keys\`), ou injecter la clé par jwt.keystore.keySetJson depuis l'environnement, ce qui est la voie de production.`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
5
39
|
* Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
|
|
6
40
|
*
|
|
7
41
|
* Résout la clé de signature selon une **priorité** (jamais d'auto-génération en
|
|
@@ -61,6 +95,8 @@ var JwtKeystore = class {
|
|
|
61
95
|
return;
|
|
62
96
|
}
|
|
63
97
|
if (this.#source.dir) {
|
|
98
|
+
const risk = keystoreLeaksIntoImage(this.#source.dir);
|
|
99
|
+
if (risk) this.#log(risk, "WARNING");
|
|
64
100
|
const file = join(this.#source.dir, "keyset.json");
|
|
65
101
|
const existing = await this.#readFile(file);
|
|
66
102
|
if (existing) {
|
|
@@ -157,4 +193,4 @@ var JwtKeystore = class {
|
|
|
157
193
|
}
|
|
158
194
|
};
|
|
159
195
|
//#endregion
|
|
160
|
-
export { JwtKeystore, JwtKeystore as default };
|
|
196
|
+
export { JwtKeystore, JwtKeystore as default, keystoreLeaksIntoImage };
|
|
@@ -1,4 +1,20 @@
|
|
|
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";
|
|
2
18
|
/**
|
|
3
19
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
4
20
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CliKernel, Command } from "nodefony";
|
|
2
|
+
/**
|
|
3
|
+
* `nodefony security:user:password [identifiant]` — change le mot de passe d'un
|
|
4
|
+
* compte.
|
|
5
|
+
*
|
|
6
|
+
* Le geste d'exploitation qui manquait : `user:add`, `user:list` et
|
|
7
|
+
* `user:delete` existaient, mais un mot de passe perdu n'avait aucun recours en
|
|
8
|
+
* ligne de commande — seule la console d'administration savait le faire, ce qui
|
|
9
|
+
* suppose d'y être déjà entré. Sur un serveur, c'est précisément le cas où l'on
|
|
10
|
+
* ne peut pas.
|
|
11
|
+
*
|
|
12
|
+
* Mot de passe : `--password` (visible dans l'historique shell — accepté pour
|
|
13
|
+
* les scripts) ou PROMPT MASQUÉ en TTY, demandé deux fois. Le hachage est celui
|
|
14
|
+
* du service (`UserService.changePassword` → Argon2id) : jamais de clair
|
|
15
|
+
* persisté, et la liste de mots de passe interdits s'applique comme à la
|
|
16
|
+
* création.
|
|
17
|
+
*
|
|
18
|
+
* 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
|
|
19
|
+
* option : on change un mot de passe parce qu'il est compromis ou perdu, et
|
|
20
|
+
* laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
|
|
21
|
+
* révocation passe par l'événement que la suppression emploie déjà — une
|
|
22
|
+
* cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
|
|
23
|
+
* rien changer ici.
|
|
24
|
+
*/
|
|
25
|
+
declare class SecurityUserPassword extends Command {
|
|
26
|
+
constructor(cli: CliKernel);
|
|
27
|
+
generate(identifierArg: string | undefined, opts: {
|
|
28
|
+
password?: string;
|
|
29
|
+
}): Promise<this>;
|
|
30
|
+
}
|
|
31
|
+
export default SecurityUserPassword;
|