@nodefony/security 10.0.0-alpha.4 → 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/index.js +2 -0
- package/dist/nodefony/command/security-user-add.js +19 -3
- package/dist/nodefony/command/security-user-password.js +111 -0
- 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/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 +28 -28
- 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
package/dist/index.js
CHANGED
|
@@ -73,6 +73,7 @@ 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";
|
|
@@ -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
|
/**
|
|
@@ -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).
|
|
@@ -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 };
|
|
@@ -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;
|
|
@@ -9,6 +9,26 @@ interface KeystoreSource {
|
|
|
9
9
|
/** Dossier de persistance `keyset.json` — source `fichier` (opt-in dev/VPS). */
|
|
10
10
|
readonly dir?: string;
|
|
11
11
|
}
|
|
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
|
+
export declare function keystoreLeaksIntoImage(dir: string): string | null;
|
|
12
32
|
/**
|
|
13
33
|
* Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
|
|
14
34
|
*
|
package/docs/audit.md
CHANGED
|
@@ -383,7 +383,7 @@ Quatre sorties d'échec du firewall passent par le même helper `Firewall.#recor
|
|
|
383
383
|
- `auth.failure` — un credential a été **présenté** et rejeté (`firewall.ts:794`) ;
|
|
384
384
|
- `auth.denied` / `no_credentials` — Zero Trust : rien n'a été présenté sur une zone protégée
|
|
385
385
|
(`firewall.ts:811`) ;
|
|
386
|
-
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:
|
|
386
|
+
- `auth.denied` / `unauthenticated` — un jeton non promu hors `anonymous` (`firewall.ts:850`).
|
|
387
387
|
|
|
388
388
|
Le parcours de login BFF émet en parallèle son propre vocabulaire depuis `AuthFlow` :
|
|
389
389
|
`login.failure` sur identité inconnue (`authFlow.ts:125`) ou mot de passe faux (`authFlow.ts:153`),
|
|
@@ -458,7 +458,7 @@ Quatre mécanismes, tous prouvés par les tests.
|
|
|
458
458
|
|
|
459
459
|
**1. Le chemin nominal n'émet rien.** Ce n'est pas une optimisation, c'est le modèle : le firewall
|
|
460
460
|
n'appelle `#recordAuth()` que depuis ses quatre sorties d'échec, jamais depuis le succès
|
|
461
|
-
(`firewall.ts:
|
|
461
|
+
(`firewall.ts:900`). Le verrou WS ne tire sa closure `onDeny` que sur refus (`firewall.ts:341`).
|
|
462
462
|
Prouvé : « frame AUTORISÉE → onDeny JAMAIS appelé » (`auditEmissionHotPath.test.ts:324`).
|
|
463
463
|
|
|
464
464
|
**2. Audit désactivé = coût nul, pas juste coût faible.** `record()` sort avant toute allocation et
|
|
@@ -484,7 +484,7 @@ jamais faire tomber ce qu'on supervise.
|
|
|
484
484
|
## ⚙️ Configuration
|
|
485
485
|
|
|
486
486
|
Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la racine sous la clé `audit`
|
|
487
|
-
(`config.ts:
|
|
487
|
+
(`config.ts:1152`).
|
|
488
488
|
|
|
489
489
|
| Option | Type | Défaut | Effet |
|
|
490
490
|
| --------------- | --------- | -------- | ------------------------------------------------------------------------------------ |
|
|
@@ -503,7 +503,7 @@ Table dérivée du schéma Zod `auditSchema` (`config.ts:877`), rattaché à la
|
|
|
503
503
|
### Comment `store: "auto"` décide
|
|
504
504
|
|
|
505
505
|
Le défaut ne suppose rien : il **suit l'infrastructure déclarée**, borné aux backends réellement
|
|
506
|
-
enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:
|
|
506
|
+
enregistrés (`auditService.ts:92`, logique `resolveAutoStore()` dans `infra.ts:289`).
|
|
507
507
|
|
|
508
508
|
1. `NF_STORE` posée et le backend est enregistré pour l'audit → il gagne (levier de banc de charge) ;
|
|
509
509
|
2. sinon, une base est déclarée (`NF_DATABASE_URL`) → `drizzle`, ou `mongoose` selon la famille ;
|
|
@@ -618,7 +618,7 @@ pour les migrations de production ; en dev et en test, le DDL dérivé les ignor
|
|
|
618
618
|
filtrage, jamais de sémantique.
|
|
619
619
|
|
|
620
620
|
L'entité et la fabrique sont enregistrées automatiquement par l'adapter au démarrage
|
|
621
|
-
(`registerStores.ts:
|
|
621
|
+
(`registerStores.ts:276`, entité via `registerAuditEntities()`, `auditEventEntity.ts:141`). Côté
|
|
622
622
|
implémentation, `DrizzleAuditStore` (`DrizzleAuditStore.ts:64`) résout son handle de base **à chaque
|
|
623
623
|
appel**, pas à la construction : l'ordre de démarrage n'est pas garanti, et l'ORM se déconnecte au
|
|
624
624
|
`onTerminate` avant le drain des serveurs.
|
package/docs/authenticators.md
CHANGED
|
@@ -49,7 +49,7 @@ flowchart TD
|
|
|
49
49
|
S --> CTRL["→ autorisation → contrôleur"]
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
C'est `Firewall.#authenticate()` (`firewall.ts:
|
|
52
|
+
C'est `Firewall.#authenticate()` (`firewall.ts:1128`) qui déroule ce cycle pour chaque maillon de la
|
|
53
53
|
zone, dans l'ordre déclaré. Le succès pose l'identité dans l'ALS ; l'échec remonte au firewall qui
|
|
54
54
|
pose le 401 et son challenge — l'authenticator, lui, ne touche jamais à la réponse.
|
|
55
55
|
|
|
@@ -105,7 +105,7 @@ totalement agnostique de la stratégie :
|
|
|
105
105
|
### Le registre pluggable
|
|
106
106
|
|
|
107
107
|
Les authenticators sont résolus par **nom** : `Firewall.#instantiateAuthenticators()`
|
|
108
|
-
(`firewall.ts:
|
|
108
|
+
(`firewall.ts:429`) interroge `getAuthenticatorFactory()` (`authenticatorRegistry.ts:59`) — jamais
|
|
109
109
|
un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggable ».
|
|
110
110
|
|
|
111
111
|
- Les **cinq builtins HTTP** (`anonymous`, `userpassword`, `session`, `jwt`, `apikey`)
|
|
@@ -116,7 +116,7 @@ un `if (name === "jwt")` dans le firewall, qui trahirait la promesse « pluggabl
|
|
|
116
116
|
- La fabrique ne fait que **construire** ; les résolutions de services coûteuses (`users`,
|
|
117
117
|
`tokenStore`, keystore) restent **lazy** dans l'instance (cold path).
|
|
118
118
|
- Un nom inconnu en config = boot **fail-closed** — `#configError` posé + log CRITIC
|
|
119
|
-
(`firewall.ts:
|
|
119
|
+
(`firewall.ts:582`) : jamais de zone « protégée » silencieusement ouverte à cause d'une
|
|
120
120
|
faute de frappe.
|
|
121
121
|
|
|
122
122
|
## 🚀 Démarrage rapide
|
|
@@ -261,10 +261,10 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
|
|
|
261
261
|
d'un utilisateur (`SessionAuthenticator.ts:43-46`) — le pipeline http démarre la session _avant_
|
|
262
262
|
le firewall ; c'est `AuthFlow.login()` qui ouvre et régénère l'ID (anti-fixation).
|
|
263
263
|
- **L'identité est re-résolue à CHAQUE requête** via `resolveSessionIdentity`
|
|
264
|
-
(`SessionAuthenticator.ts:
|
|
264
|
+
(`SessionAuthenticator.ts:91`) → rôles frais, révocation immédiate. Les contrôles d'état sont
|
|
265
265
|
partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts:40`).
|
|
266
266
|
- `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
|
|
267
|
-
principal courant (`SessionAuthenticator.ts:
|
|
267
|
+
principal courant (`SessionAuthenticator.ts:110-116`).
|
|
268
268
|
- **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login,
|
|
269
269
|
jamais une popup Basic (`SessionAuthenticator.ts:25-27`).
|
|
270
270
|
|
|
@@ -353,12 +353,12 @@ Deux preuves différentes, mêmes routes — c'est la config du Démarrage rapid
|
|
|
353
353
|
de lecture :
|
|
354
354
|
|
|
355
355
|
- un maillon dont `supports()` est faux est simplement **sauté** en mode `first`
|
|
356
|
-
(`firewall.ts:
|
|
356
|
+
(`firewall.ts:1114`) ;
|
|
357
357
|
- un credential **présenté mais invalide échoue immédiatement** — l'échec d'`authenticate()`
|
|
358
|
-
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:
|
|
358
|
+
remonte, jamais de fallback silencieux vers le maillon suivant (`firewall.ts:1128`). Une clé
|
|
359
359
|
API révoquée donne un 401 direct, même si un autre maillon aurait pu réussir.
|
|
360
360
|
- aucune preuve présentée sur toute la chaîne → `handleSecurity()` lève l'`AuthenticationError`
|
|
361
|
-
Zero Trust (`firewall.ts:
|
|
361
|
+
Zero Trust (`firewall.ts:754`).
|
|
362
362
|
|
|
363
363
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
364
364
|
|
|
@@ -397,7 +397,7 @@ paresse : c'est une **défense anti-énumération / anti-oracle**.
|
|
|
397
397
|
Distinguer « compte inconnu » de « mot de passe faux », ou « token expiré » de « signature
|
|
398
398
|
invalide », donnerait à un attaquant une sonde. La cause fine part **toujours** en log d'audit ; le
|
|
399
399
|
client n'obtient qu'un 401 + son challenge — posé par le firewall, premier maillon de la zone qui
|
|
400
|
-
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:
|
|
400
|
+
en déclare un (`Firewall.#setChallenge()`, `firewall.ts:1207`).
|
|
401
401
|
|
|
402
402
|
## 🧩 Ajouter un authenticator maison
|
|
403
403
|
|
|
@@ -423,7 +423,7 @@ registerAuthenticatorFactory("ldap", ({ container, config }) => {
|
|
|
423
423
|
<!-- prettier-ignore -->
|
|
424
424
|
| Domaine | Norme | Ancrage |
|
|
425
425
|
| --- | --- | --- |
|
|
426
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
426
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
|
|
427
427
|
| Bearer | RFC 6750 | `readBearerHeader()` (`runtime/bearer.ts:68`, cœur) — une porte UNIQUE au cœur, plus une constante par authenticator |
|
|
428
428
|
| JWT (BCP) | RFC 7519, 8725 | `jwtVerify` durci : allowlist + claims (`JwtAuthenticator.ts:103-107`) |
|
|
429
429
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator` (`UserPasswordAuthenticator.ts:25-27`) |
|
package/docs/authorization.md
CHANGED
|
@@ -302,7 +302,7 @@ Dès le `DENY`, le jury **s'arrête** — court-circuit, inutile de finir (`auth
|
|
|
302
302
|
|
|
303
303
|
**Contre-exemple piégeux** : le veto ne traverse **pas** une clause OR. Dans
|
|
304
304
|
`@IsGranted(["ROLE_ADMIN", "doc.edit"])`, chaque attribut est un **jury séparé**
|
|
305
|
-
(`Resolver.ts:
|
|
305
|
+
(`Resolver.ts:587-607`) : si `ROLE_ADMIN` est accordé, `doc.edit` — et son veto — n'est même pas
|
|
306
306
|
consulté. Un interdit absolu se porte en clause **AND** : empiler `@IsGranted("ROLE_ADMIN")` puis
|
|
307
307
|
`@IsGranted("doc.edit", { subject: "id" })`.
|
|
308
308
|
|
|
@@ -428,7 +428,7 @@ compilation** — rien à scanner au runtime ; le registre **est** le marqueur e
|
|
|
428
428
|
et `IRealtimeToken` (WS) (`authorization.ts:119-122`).
|
|
429
429
|
- **Le verrou de frame** (canaux realtime) applique son RBAC par canal avec la **même
|
|
430
430
|
hiérarchie** : `satisfies()` (`frameAuthorizer.ts:276`) délègue à `Firewall.hasRole()`
|
|
431
|
-
(`firewall.ts:
|
|
431
|
+
(`firewall.ts:482`) — les rôles exigés par un canal héritent comme partout ailleurs.
|
|
432
432
|
|
|
433
433
|
## 📜 Normes appliquées
|
|
434
434
|
|
package/docs/cors.md
CHANGED
|
@@ -323,7 +323,7 @@ sequenceDiagram
|
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
`Firewall.handleCors()` (`firewall.ts:991`) est appelé **en tête de** `HttpKernel.handleHttp()`
|
|
326
|
-
(`http-kernel.ts:
|
|
326
|
+
(`http-kernel.ts:1301`), à la ligne `http-kernel.ts:1301` — **avant le routing**. La raison est
|
|
327
327
|
concrète : un preflight `OPTIONS /api/articles` n'a **pas de route déclarée** ; s'il traversait le
|
|
328
328
|
router, il repartirait en 405. Et selon le Fetch Standard, un preflight ne transporte jamais de
|
|
329
329
|
credentials — il ne doit donc ni s'authentifier, ni exécuter le moindre code applicatif.
|
|
@@ -338,7 +338,7 @@ Quatre sorties en no-op, dans cet ordre (`firewall.ts:797`) :
|
|
|
338
338
|
court-circuité en 204 (`firewall.ts:822`).
|
|
339
339
|
|
|
340
340
|
**La détection du preflight est stricte** : méthode `OPTIONS` **et** présence de
|
|
341
|
-
`Access-Control-Request-Method` (`firewall.ts:
|
|
341
|
+
`Access-Control-Request-Method` (`firewall.ts:999`). Un `OPTIONS` nu — celui d'un client qui interroge
|
|
342
342
|
les méthodes supportées d'une route — est donc traité comme une requête réelle et continue le pipeline.
|
|
343
343
|
|
|
344
344
|
### Ce que chaque moment pose
|
|
@@ -384,7 +384,7 @@ origine (`config.ts:180`). Ajouter une origine à `cors.origins` est **plus** pe
|
|
|
384
384
|
**Les navigateurs n'appliquent pas CORS aux WebSockets.** Une page tierce peut ouvrir un
|
|
385
385
|
`new WebSocket("wss://api.example.com/…")` et le handshake partira **avec le cookie de session de la
|
|
386
386
|
victime** : c'est le CSWSH. C'est pourquoi `handleCors` s'arrête net sur un contexte WS
|
|
387
|
-
(`firewall.ts:
|
|
387
|
+
(`firewall.ts:1007`) — il n'y aurait rien à protéger avec des en-têtes que personne ne lit.
|
|
388
388
|
|
|
389
389
|
La garde équivalente vit dans le transport : `HttpKernel.checkWebsocketOrigin()`
|
|
390
390
|
(`http-kernel.ts:599`) valide l'`Origin` **au handshake**, avant l'accept, et ferme en code WS `1008`
|
|
@@ -428,7 +428,7 @@ Le coût par requête est donc :
|
|
|
428
428
|
## 📡 Observabilité — Studio
|
|
429
429
|
|
|
430
430
|
La configuration CORS **résolue** (celle qui tourne réellement, pas le fichier source) est exposée par
|
|
431
|
-
`Firewall.describe()` (`firewall.ts:
|
|
431
|
+
`Firewall.describe()` (`firewall.ts:549`), qui délègue à `Firewall.#describeDefenses()`
|
|
432
432
|
(`firewall.ts:575`). La projection CORS y expose `origins`, `credentials`, `methods`,
|
|
433
433
|
`allowedHeaders`, `exposedHeaders` et `maxAgeS` (`firewall.ts:594`) — aucun secret ne transite par
|
|
434
434
|
cette surface.
|
package/docs/csrf.md
CHANGED
|
@@ -93,7 +93,7 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
93
93
|
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
94
|
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:151-156`).
|
|
95
95
|
- **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
|
|
96
|
-
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1470`) arrive **après** le
|
|
97
97
|
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
98
|
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
99
|
- **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
|
|
@@ -153,7 +153,7 @@ le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les ac
|
|
|
153
153
|
### Comment le front obtient — puis rejoue — le token
|
|
154
154
|
|
|
155
155
|
1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
|
|
156
|
-
(`firewall.ts:
|
|
156
|
+
(`firewall.ts:958-964`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
|
|
157
157
|
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
|
|
158
158
|
2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
|
|
159
159
|
`x-csrf-token` sur chaque mutation.
|
|
@@ -298,10 +298,10 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
298
298
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
299
|
(`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
300
|
(`Context.ts:181-183`).
|
|
301
|
-
3. `Firewall.enforceCsrf()` (`firewall.ts:
|
|
301
|
+
3. `Firewall.enforceCsrf()` (`firewall.ts:948`) fait les trois rôles : **émission** du token sur
|
|
302
302
|
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
303
303
|
`@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
|
|
304
|
-
(`firewall.ts:
|
|
304
|
+
(`firewall.ts:950-953`), les `@CsrfExempt` sortent après la barrière méthode sûre
|
|
305
305
|
(`firewall.ts:951`).
|
|
306
306
|
4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
|
|
307
307
|
avec le cookie de session (`HttpContext.ts:419-432`).
|
|
@@ -346,7 +346,7 @@ de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
|
346
346
|
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
347
347
|
`Firewall.tsx:313-314`). La projection est **sans secret par construction** :
|
|
348
348
|
`Firewall.#describeDefenses()` (`firewall.ts:575`) publie la config résolue, et `synchronizerToken`
|
|
349
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:
|
|
349
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:601`).
|
|
350
350
|
|
|
351
351
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
352
352
|
|
package/docs/firewall.md
CHANGED
|
@@ -342,7 +342,7 @@ Ce qui se passe, requête par requête :
|
|
|
342
342
|
| --- | --- | --- |
|
|
343
343
|
| le cookie de session | `session` | identifié, `apikey` jamais consulté |
|
|
344
344
|
| `Authorization: Bearer nf_…` | `apikey` | identifié (session ne matche pas, on passe) |
|
|
345
|
-
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:
|
|
345
|
+
| une clé **révoquée** `nf_…` | `apikey` | **401 direct** — l'échec d'`authenticate()` remonte, pas de fallback (`firewall.ts:1128`) |
|
|
346
346
|
| rien | aucun | **401** (Zero Trust) |
|
|
347
347
|
|
|
348
348
|
### Situation 2 — le piège de l'ordre (`anonymous` toujours EN DERNIER)
|
|
@@ -377,7 +377,7 @@ Basic …`). Une seule manque → 401. Le **dernier** token de la chaîne porte
|
|
|
377
377
|
|
|
378
378
|
> [!TIP]
|
|
379
379
|
> Un nom d'authenticator inconnu en config **fait échouer le boot** —
|
|
380
|
-
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:
|
|
380
|
+
> `Firewall.#instantiateAuthenticators()` est fail-closed (`firewall.ts:429`) : jamais de zone
|
|
381
381
|
> « protégée » silencieusement ouverte à cause d'une faute de frappe.
|
|
382
382
|
|
|
383
383
|
## 🧑⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)
|
|
@@ -470,10 +470,10 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
470
470
|
|
|
471
471
|
## 🛡️ En-têtes de sécurité, CSRF, CORS
|
|
472
472
|
|
|
473
|
-
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:
|
|
474
|
-
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:
|
|
473
|
+
- **`Firewall.applySecurityHeaders()`** (`firewall.ts:1045`) : CSP, Referrer-Policy, COOP/COEP/CORP
|
|
474
|
+
au-dessus du socle transport de `@nodefony/http`. **Nonce CSP paresseux** (`hasNonce`, `firewall.ts:1045`) :
|
|
475
475
|
alloué seulement si une directive en a besoin.
|
|
476
|
-
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:
|
|
476
|
+
- **`Firewall.enforceCsrf()`** (défense en profondeur, `firewall.ts:948`) : Fetch Metadata
|
|
477
477
|
(`Sec-Fetch-Site`) + garde `Origin` (`firewall.ts:764`), puis double-submit `x-csrf-token` ≡
|
|
478
478
|
cookie + HMAC (`firewall.ts:778`).
|
|
479
479
|
- **`Firewall.handleCors()`** : preflight `OPTIONS` → 204 (`firewall.ts:991`).
|
|
@@ -482,13 +482,13 @@ Sur une socket, un refus n'a pas d'en-tête `WWW-Authenticate` (`Firewall.#setCh
|
|
|
482
482
|
|
|
483
483
|
| Domaine | Norme | Ancrage |
|
|
484
484
|
| ---------------------- | --------------- | ------------------------------------------------------ |
|
|
485
|
-
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:
|
|
485
|
+
| Challenge d'auth (401) | RFC 7235 | `Firewall.#setChallenge()` (`firewall.ts:1207`) |
|
|
486
486
|
| Bearer | RFC 6750 | `JwtAuthenticator.ts:13` · `ApiKeyAuthenticator.ts:11` |
|
|
487
487
|
| JWT (BCP) | RFC 7519, 8725 | `JwtAuthenticator.ts:33-44,104-108` |
|
|
488
488
|
| HTTP Basic | RFC 7617 | `UserPasswordAuthenticator.ts:10-28` |
|
|
489
489
|
| Rate limit (429) | RFC 6585 | 429 + `Retry-After` (`firewall.ts:764`) |
|
|
490
490
|
| Backoff de login | NIST SP 800-63B | `UserPasswordAuthenticator.ts:43-46,101-104` |
|
|
491
|
-
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:
|
|
491
|
+
| CSRF | Fetch Metadata | `Firewall.enforceCsrf()` (`firewall.ts:948`) |
|
|
492
492
|
| Modèle | Zero Trust | `firewall.ts:611` (aucune preuve → 401) |
|
|
493
493
|
|
|
494
494
|
## ⚡ Performance & mémoire
|