@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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-token.js +4 -4
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +6 -5
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +30 -20
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +41 -33
- package/dist/nodefony/service/webhooks.js +55 -44
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- 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 +67 -65
- package/docs/audit.md +87 -82
- package/docs/authenticators.md +61 -49
- package/docs/authorization.md +21 -21
- package/docs/cors.md +30 -30
- package/docs/csrf.md +28 -28
- package/docs/external-jwt.md +21 -13
- package/docs/firewall.md +32 -32
- package/docs/headers.md +29 -29
- package/docs/index.md +3 -1
- package/docs/oauth2.md +61 -61
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +56 -56
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +85 -85
- package/docs/webhooks.md +50 -47
- package/package.json +19 -16
|
@@ -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";
|
|
@@ -223,7 +223,7 @@ var SecurityToken = class extends Command {
|
|
|
223
223
|
identifier = await this.prompts.select({
|
|
224
224
|
message: "Compte porteur du jeton :",
|
|
225
225
|
choices: page.items.map((u) => ({
|
|
226
|
-
name: `${u.identifier}${
|
|
226
|
+
name: `${u.identifier}${u.roles.length ? ` ${DIM}(${u.roles.join(", ")})${RESET}` : ""}`,
|
|
227
227
|
value: u.identifier
|
|
228
228
|
}))
|
|
229
229
|
});
|
|
@@ -269,7 +269,7 @@ var SecurityToken = class extends Command {
|
|
|
269
269
|
throw e;
|
|
270
270
|
}
|
|
271
271
|
const token = issued.access_token;
|
|
272
|
-
const accordes =
|
|
272
|
+
const accordes = issued.scope.split(/\s+/u).filter(Boolean);
|
|
273
273
|
const nonAccordes = scopes.filter((s) => !accordes.includes(s));
|
|
274
274
|
if (opts.json) {
|
|
275
275
|
process.stdout.write(`${JSON.stringify({
|
|
@@ -285,7 +285,7 @@ var SecurityToken = class extends Command {
|
|
|
285
285
|
process.stdout.write(s);
|
|
286
286
|
};
|
|
287
287
|
let write = opts.write === true;
|
|
288
|
-
if (!write && process.stdin.isTTY
|
|
288
|
+
if (!write && process.stdin.isTTY) {
|
|
289
289
|
await this.loadPrompts();
|
|
290
290
|
write = await this.prompts.confirm({
|
|
291
291
|
message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
|
|
@@ -293,7 +293,7 @@ var SecurityToken = class extends Command {
|
|
|
293
293
|
});
|
|
294
294
|
}
|
|
295
295
|
if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement.${RESET}\n`);
|
|
296
|
-
const minutes = Math.round(
|
|
296
|
+
const minutes = Math.round(issued.expires_in / 60);
|
|
297
297
|
w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${minutes} min${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
|
|
298
298
|
if (nonAccordes.length > 0) w(`${DIM} ⚠️ non accordé(s) : ${nonAccordes.join(" ")} — réservé(s) au rôle d'administration, que le compte « ${identifier} » ne porte pas.${RESET}\n\n`);
|
|
299
299
|
if (write) {
|
|
@@ -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 };
|
|
@@ -64,7 +64,7 @@ var SecurityUserDelete = class extends Command {
|
|
|
64
64
|
process.exitCode = 1;
|
|
65
65
|
return this;
|
|
66
66
|
}
|
|
67
|
-
if (
|
|
67
|
+
if (user.roles.includes(ADMIN_ROLE)) {
|
|
68
68
|
if (await users.countActiveAdmins(ADMIN_ROLE) <= 1) {
|
|
69
69
|
this.log(`« ${identifier} » est le DERNIER administrateur actif — refus.\n Sans lui, plus personne n'administre cette application.\n Crée d'abord un autre admin : nodefony security:user:add <id> --admin`, "ERROR");
|
|
70
70
|
process.exitCode = 1;
|
|
@@ -74,7 +74,7 @@ var SecurityUserDelete = class extends Command {
|
|
|
74
74
|
const w = (t) => {
|
|
75
75
|
process.stdout.write(t);
|
|
76
76
|
};
|
|
77
|
-
w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${
|
|
77
|
+
w(`\n${BOLD}Compte à supprimer${RESET}\n identifiant : ${BOLD}${user.identifier}${RESET}\n rôles : ${user.roles.join(", ") || "—"}\n id interne : ${DIM}${user.id}${RESET}\n\n`);
|
|
78
78
|
if (!opts.yes) {
|
|
79
79
|
if (!process.stdin.isTTY) {
|
|
80
80
|
this.log("confirmation impossible sans terminal — relance avec --yes si c'est voulu.", "ERROR");
|
|
@@ -52,7 +52,7 @@ var SecurityUserList = class extends Command {
|
|
|
52
52
|
});
|
|
53
53
|
const rows = page.items.map((u) => ({
|
|
54
54
|
identifiant: u.identifier,
|
|
55
|
-
rôles:
|
|
55
|
+
rôles: u.roles.join(", ") || "—",
|
|
56
56
|
actif: u.isActive() ? "oui" : "non",
|
|
57
57
|
verrouillé: u.isLocked() ? "OUI" : "—",
|
|
58
58
|
id: u.id
|
|
@@ -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."
|
|
@@ -199,7 +199,7 @@ var ApiKeyService = class extends Service {
|
|
|
199
199
|
async revokeAnyPat(id, actorId) {
|
|
200
200
|
const store = this.#resolveStore();
|
|
201
201
|
const record = await store.findById(id);
|
|
202
|
-
if (
|
|
202
|
+
if (record?.kind !== "pat") return null;
|
|
203
203
|
await store.revoke(id, "manual");
|
|
204
204
|
this.log(`api key revoked by admin — id=${id} actor=${actorId} subject=${record.subjectId}`, "INFO");
|
|
205
205
|
recordAudit(this.container, {
|
|
@@ -240,7 +240,7 @@ var ApiKeyService = class extends Service {
|
|
|
240
240
|
async revokeForSubject(subjectId, id) {
|
|
241
241
|
const store = this.#resolveStore();
|
|
242
242
|
const record = await store.findById(id);
|
|
243
|
-
if (
|
|
243
|
+
if (record?.kind !== "pat" || record.subjectId !== subjectId) return false;
|
|
244
244
|
await store.revoke(id, "manual");
|
|
245
245
|
this.log(`api key revoked — id=${id} subject=${subjectId}`, "INFO");
|
|
246
246
|
recordAudit(this.container, {
|
|
@@ -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,
|
|
@@ -107,14 +108,14 @@ var AuditService = class extends Service {
|
|
|
107
108
|
ts: Date.now()
|
|
108
109
|
};
|
|
109
110
|
this.#store.append(event).catch((error) => this.log(error, "ERROR"));
|
|
110
|
-
if (this.#listeners !== null) for (
|
|
111
|
-
|
|
111
|
+
if (this.#listeners !== null) for (const listener of this.#listeners) try {
|
|
112
|
+
listener(event);
|
|
112
113
|
} catch (error) {
|
|
113
114
|
this.log(error, "ERROR");
|
|
114
115
|
}
|
|
115
116
|
}
|
|
116
117
|
subscribe(listener) {
|
|
117
|
-
|
|
118
|
+
this.#listeners ??= [];
|
|
118
119
|
this.#listeners.push(listener);
|
|
119
120
|
let active = true;
|
|
120
121
|
return () => {
|
|
@@ -238,7 +238,7 @@ var AuthFlow = class extends Service {
|
|
|
238
238
|
*/
|
|
239
239
|
async logout(context) {
|
|
240
240
|
const session = context.session;
|
|
241
|
-
if (
|
|
241
|
+
if (session?.status !== "active") return false;
|
|
242
242
|
const actor = typeof session.user === "string" ? session.user : null;
|
|
243
243
|
await session.destroy(true);
|
|
244
244
|
context.session = null;
|
|
@@ -315,7 +315,7 @@ var AuthFlow = class extends Service {
|
|
|
315
315
|
#resolveTotp() {
|
|
316
316
|
if (!this.#totpResolved) {
|
|
317
317
|
const svc = this.get("totp") ?? null;
|
|
318
|
-
this.#totp = svc
|
|
318
|
+
this.#totp = svc?.isEnabled() ? svc : null;
|
|
319
319
|
this.#totpResolved = true;
|
|
320
320
|
}
|
|
321
321
|
return this.#totp;
|
|
@@ -45,8 +45,7 @@ var Authorization = class extends Service {
|
|
|
45
45
|
const voters = this.#voters ?? this.#build();
|
|
46
46
|
let granted = false;
|
|
47
47
|
let considered = 0;
|
|
48
|
-
for (
|
|
49
|
-
const voter = voters[i];
|
|
48
|
+
for (const voter of voters) {
|
|
50
49
|
if (!voter.supports(attribute, subject)) continue;
|
|
51
50
|
considered++;
|
|
52
51
|
let vote;
|
|
@@ -88,8 +87,8 @@ var Authorization = class extends Service {
|
|
|
88
87
|
/** Descripteur léger d'un sujet pour l'audit (jamais de JSON.stringify aveugle). */
|
|
89
88
|
function describeSubject(subject) {
|
|
90
89
|
if (typeof subject === "string") return subject;
|
|
91
|
-
if (subject === null) return
|
|
92
|
-
return subject
|
|
90
|
+
if (subject === null || subject === void 0) return String(subject);
|
|
91
|
+
return subject.constructor?.name ?? typeof subject;
|
|
93
92
|
}
|
|
94
93
|
//#endregion
|
|
95
94
|
export { Authorization, Authorization as default };
|
|
@@ -47,6 +47,11 @@ function cookieValue(cookieHeader, name) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
+
* Défi posé sur un 401 quand aucun authenticator de la zone n'en déclare
|
|
51
|
+
* (RFC 9110 §15.5.2). Schéma inconnu des navigateurs : aucune fenêtre de login.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
|
|
54
|
+
/**
|
|
50
55
|
* Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
|
|
51
56
|
*
|
|
52
57
|
* `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
|
|
@@ -63,8 +68,9 @@ function cookieValue(cookieHeader, name) {
|
|
|
63
68
|
* d'identité down, câblage manquant) → log ERROR serveur + 401 générique
|
|
64
69
|
* (aucun détail ne fuite au client).
|
|
65
70
|
*
|
|
66
|
-
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC
|
|
67
|
-
*
|
|
71
|
+
* Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
|
|
72
|
+
* §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
|
|
73
|
+
* le défi de repli {@link FALLBACK_CHALLENGE}.
|
|
68
74
|
*
|
|
69
75
|
* CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
|
|
70
76
|
* Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
|
|
@@ -109,10 +115,9 @@ var Firewall = class extends Service {
|
|
|
109
115
|
if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
|
|
110
116
|
if (this.#config.headers.enabled) this.#securityHeaders = new SecurityHeaders(this.#config.headers);
|
|
111
117
|
const areas = this.#config.areas;
|
|
112
|
-
|
|
113
|
-
if (names.length) {
|
|
118
|
+
if (Object.keys(areas).length) {
|
|
114
119
|
const list = [];
|
|
115
|
-
for (const name of
|
|
120
|
+
for (const [name, area] of Object.entries(areas)) list.push(new SecuredArea(name, area));
|
|
116
121
|
list.sort((a, b) => b.pattern.source.length - a.pattern.source.length);
|
|
117
122
|
this.#areas = list;
|
|
118
123
|
this.#instantiateAuthenticators(list);
|
|
@@ -333,7 +338,7 @@ var Firewall = class extends Service {
|
|
|
333
338
|
describeRoleHierarchy() {
|
|
334
339
|
const raw = this.#config?.roleHierarchy ?? {};
|
|
335
340
|
const hierarchy = {};
|
|
336
|
-
for (const role of Object.
|
|
341
|
+
for (const [role, inherited] of Object.entries(raw)) hierarchy[role] = [...inherited];
|
|
337
342
|
const walker = this.roleHierarchy;
|
|
338
343
|
return {
|
|
339
344
|
hierarchy,
|
|
@@ -565,7 +570,10 @@ var Firewall = class extends Service {
|
|
|
565
570
|
if (typeof response?.setHeader !== "function") return void 0;
|
|
566
571
|
const isPreflight = context.method?.toUpperCase() === "OPTIONS" && headerValue(headers, "access-control-request-method") !== void 0;
|
|
567
572
|
const corsHeaders = isPreflight ? this.#cors.preflightHeaders(origin) : this.#cors.actualHeaders(origin);
|
|
568
|
-
if (corsHeaders) for (const name in corsHeaders)
|
|
573
|
+
if (corsHeaders) for (const name in corsHeaders) {
|
|
574
|
+
const value = corsHeaders[name];
|
|
575
|
+
if (value !== void 0) response.setHeader(name, value);
|
|
576
|
+
}
|
|
569
577
|
return isPreflight ? 204 : void 0;
|
|
570
578
|
}
|
|
571
579
|
/**
|
|
@@ -584,7 +592,10 @@ var Firewall = class extends Service {
|
|
|
584
592
|
const response = context.response;
|
|
585
593
|
if (typeof response?.setHeader !== "function") return;
|
|
586
594
|
const headers = sh.headers;
|
|
587
|
-
for (const name in headers)
|
|
595
|
+
for (const name in headers) {
|
|
596
|
+
const value = headers[name];
|
|
597
|
+
if (value !== void 0) response.setHeader(name, value);
|
|
598
|
+
}
|
|
588
599
|
const extra = context.cspDirectives;
|
|
589
600
|
if (sh.hasNonce) {
|
|
590
601
|
const nonce = context.cspNonce;
|
|
@@ -665,7 +676,7 @@ var Firewall = class extends Service {
|
|
|
665
676
|
throw error;
|
|
666
677
|
}
|
|
667
678
|
if (error instanceof UnverifiableTokenError) {
|
|
668
|
-
this.log(`token unverifiable (area "${area.name}", authenticator "${name}") —
|
|
679
|
+
this.log(`token unverifiable (area "${area.name}", authenticator "${name}") — ` + (error.detail ?? "cause non renseignée"), "ERROR");
|
|
669
680
|
this.log(error, "ERROR");
|
|
670
681
|
throw error;
|
|
671
682
|
}
|
|
@@ -689,6 +700,7 @@ var Firewall = class extends Service {
|
|
|
689
700
|
return;
|
|
690
701
|
}
|
|
691
702
|
}
|
|
703
|
+
response.setHeader("WWW-Authenticate", FALLBACK_CHALLENGE);
|
|
692
704
|
}
|
|
693
705
|
log(pci, severity, msgid, msg) {
|
|
694
706
|
if (!msgid) msgid = logColor.cyan("FIREWALL");
|
|
@@ -135,7 +135,7 @@ var OAuth2Service = class extends Service {
|
|
|
135
135
|
listDisplayProviders() {
|
|
136
136
|
if (this.#config === null) return [];
|
|
137
137
|
const configured = this.#config.oauth2.providers;
|
|
138
|
-
return this.listProviders().filter((name) => configured[name]?.hidden
|
|
138
|
+
return this.listProviders().filter((name) => !configured[name]?.hidden).map((name) => ({
|
|
139
139
|
name,
|
|
140
140
|
label: configured[name]?.label ?? oauthDisplayLabel(name)
|
|
141
141
|
}));
|
|
@@ -192,7 +192,7 @@ var OAuth2Service = class extends Service {
|
|
|
192
192
|
codeVerifier
|
|
193
193
|
});
|
|
194
194
|
const profile = await p.fetchProfile(tokens);
|
|
195
|
-
const cfg = this.#
|
|
195
|
+
const cfg = this.#ensureReady().oauth2;
|
|
196
196
|
const defaultRoles = cfg.providers[provider]?.defaultRoles ?? cfg.defaultRoles;
|
|
197
197
|
return { identifier: (await this.#resolveProvisioner().provisionOAuthUser(profile, {
|
|
198
198
|
defaultRoles: [...defaultRoles],
|
|
@@ -210,7 +210,8 @@ var OAuth2Service = class extends Service {
|
|
|
210
210
|
return pending;
|
|
211
211
|
}
|
|
212
212
|
async #buildProvider(name) {
|
|
213
|
-
const
|
|
213
|
+
const providers = this.#ensureReady().oauth2.providers;
|
|
214
|
+
const cfg = Object.hasOwn(providers, name) ? providers[name] : void 0;
|
|
214
215
|
if (!cfg) throw new AuthenticationError(`OAuth provider "${name}" non configuré`);
|
|
215
216
|
const factory = getOAuthProviderFactory(name);
|
|
216
217
|
if (!factory) throw new AuthenticationError(`OAuth provider "${name}" inconnu du registre`);
|
|
@@ -233,6 +234,7 @@ var OAuth2Service = class extends Service {
|
|
|
233
234
|
}
|
|
234
235
|
#ensureReady() {
|
|
235
236
|
if (!this.#ready || this.#config === null) throw new Error("OAuth2Service: non initialisé (social login désactivé ou boot échoué)");
|
|
237
|
+
return this.#config;
|
|
236
238
|
}
|
|
237
239
|
};
|
|
238
240
|
//#endregion
|
|
@@ -39,7 +39,7 @@ var SecurityHeaders = class {
|
|
|
39
39
|
if (o.permissionsPolicy) h["Permissions-Policy"] = o.permissionsPolicy;
|
|
40
40
|
this.#headers = Object.freeze(h);
|
|
41
41
|
this.#cspParts = cspParts ? Object.freeze(cspParts) : null;
|
|
42
|
-
this.#cspRaw = o.csp
|
|
42
|
+
this.#cspRaw = o.csp;
|
|
43
43
|
}
|
|
44
44
|
/** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
|
|
45
45
|
get headers() {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
2
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
3
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
|
-
import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
4
|
+
import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
5
5
|
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) {
|
|
@@ -249,8 +250,8 @@ var TokenService = class extends Service {
|
|
|
249
250
|
* @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
|
|
250
251
|
* non déclarée
|
|
251
252
|
*/
|
|
252
|
-
#resolveAudience(requested) {
|
|
253
|
-
const fallback =
|
|
253
|
+
#resolveAudience(runtime, requested) {
|
|
254
|
+
const fallback = defaultAudience(runtime);
|
|
254
255
|
if (requested === void 0 || requested === null) return fallback;
|
|
255
256
|
if (Array.isArray(requested)) throw new InvalidTargetError("A single `resource` is accepted — a token valid at several resources lets each of them act at the others (RFC 8707 §3).");
|
|
256
257
|
if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
|
|
@@ -261,7 +262,7 @@ var TokenService = class extends Service {
|
|
|
261
262
|
throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
|
|
262
263
|
}
|
|
263
264
|
if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
|
|
264
|
-
if (!
|
|
265
|
+
if (!runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
|
|
265
266
|
return requested;
|
|
266
267
|
}
|
|
267
268
|
/**
|
|
@@ -303,12 +304,12 @@ var TokenService = class extends Service {
|
|
|
303
304
|
return requested.filter((scope) => !refuses.includes(scope));
|
|
304
305
|
}
|
|
305
306
|
async issueTokens(user, requestedScopes, resource, accessTtlS) {
|
|
306
|
-
this.#ensureReady();
|
|
307
|
+
const { store, runtime } = this.#ensureReady();
|
|
307
308
|
const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
|
|
308
|
-
const audience = this.#resolveAudience(resource);
|
|
309
|
+
const audience = this.#resolveAudience(runtime, resource);
|
|
309
310
|
const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
|
|
310
311
|
const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
|
|
311
|
-
await
|
|
312
|
+
await store.put(record);
|
|
312
313
|
recordAudit(this.container, {
|
|
313
314
|
category: "token",
|
|
314
315
|
action: "token.issued",
|
|
@@ -323,7 +324,7 @@ var TokenService = class extends Service {
|
|
|
323
324
|
access_token: access,
|
|
324
325
|
refresh_token: raw,
|
|
325
326
|
token_type: "Bearer",
|
|
326
|
-
expires_in: accessTtlS ??
|
|
327
|
+
expires_in: accessTtlS ?? runtime.accessTtlS,
|
|
327
328
|
scope: scopes.join(" ")
|
|
328
329
|
};
|
|
329
330
|
}
|
|
@@ -344,11 +345,10 @@ var TokenService = class extends Service {
|
|
|
344
345
|
* @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
|
|
345
346
|
*/
|
|
346
347
|
async refresh(rawRefresh, resource) {
|
|
347
|
-
this.#ensureReady();
|
|
348
|
+
const { store, runtime } = this.#ensureReady();
|
|
348
349
|
if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
|
|
349
|
-
const store = this.#store;
|
|
350
350
|
const record = await store.findByHash(this.#hash(rawRefresh));
|
|
351
|
-
if (
|
|
351
|
+
if (record?.kind !== "refresh") throw new AuthenticationError("Invalid token");
|
|
352
352
|
if (record.revokedAt !== null) {
|
|
353
353
|
if (record.family) await store.revokeFamily(record.family, "reuse_detected");
|
|
354
354
|
recordAudit(this.container, {
|
|
@@ -368,14 +368,14 @@ var TokenService = class extends Service {
|
|
|
368
368
|
if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
|
|
369
369
|
const user = await this.#resolveUserForRefresh(record.subjectId);
|
|
370
370
|
const scopes = [...record.scopes];
|
|
371
|
-
const audience = record.audience?.
|
|
371
|
+
const audience = record.audience?.at(0) ?? defaultAudience(runtime);
|
|
372
372
|
if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
|
|
373
373
|
const access = await this.#signAccess(user.identifier, scopes, audience);
|
|
374
|
-
if (!
|
|
374
|
+
if (!runtime.rotateRefresh) return {
|
|
375
375
|
access_token: access,
|
|
376
376
|
refresh_token: rawRefresh,
|
|
377
377
|
token_type: "Bearer",
|
|
378
|
-
expires_in:
|
|
378
|
+
expires_in: runtime.accessTtlS,
|
|
379
379
|
scope: scopes.join(" ")
|
|
380
380
|
};
|
|
381
381
|
const family = record.family ?? this.#randomId();
|
|
@@ -389,14 +389,14 @@ var TokenService = class extends Service {
|
|
|
389
389
|
access_token: access,
|
|
390
390
|
refresh_token: next.raw,
|
|
391
391
|
token_type: "Bearer",
|
|
392
|
-
expires_in:
|
|
392
|
+
expires_in: runtime.accessTtlS,
|
|
393
393
|
scope: scopes.join(" ")
|
|
394
394
|
};
|
|
395
395
|
}
|
|
396
396
|
async #signAccess(subject, scopes, audience, ttlS) {
|
|
397
|
+
const { keystore, runtime: rt } = this.#ensureReady();
|
|
397
398
|
const jose = await this.#ensureJose();
|
|
398
|
-
const { key, kid } = await
|
|
399
|
-
const rt = this.#runtime;
|
|
399
|
+
const { key, kid } = await keystore.getSigningKey();
|
|
400
400
|
return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
|
|
401
401
|
alg: "EdDSA",
|
|
402
402
|
kid,
|
|
@@ -433,7 +433,7 @@ var TokenService = class extends Service {
|
|
|
433
433
|
family,
|
|
434
434
|
replacedBy: null,
|
|
435
435
|
createdAt: now,
|
|
436
|
-
expiresAt: now + this.#runtime.refreshTtlS * 1e3,
|
|
436
|
+
expiresAt: now + this.#ensureReady().runtime.refreshTtlS * 1e3,
|
|
437
437
|
lastUsedAt: null,
|
|
438
438
|
lastUsedIp: null,
|
|
439
439
|
lastUsedUserAgent: null,
|
|
@@ -444,8 +444,18 @@ var TokenService = class extends Service {
|
|
|
444
444
|
raw
|
|
445
445
|
};
|
|
446
446
|
}
|
|
447
|
+
/**
|
|
448
|
+
* Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
|
|
449
|
+
* store, le keystore et le runtime typés non nuls. Aucun des trois n'est
|
|
450
|
+
* réassigné après le boot — les capturer équivaut à les relire plus tard.
|
|
451
|
+
*/
|
|
447
452
|
#ensureReady() {
|
|
448
453
|
if (!this.#store || !this.#keystore || !this.#runtime) throw new Error("TokenService: non initialisé (JWT désactivé ou store indisponible)");
|
|
454
|
+
return {
|
|
455
|
+
store: this.#store,
|
|
456
|
+
keystore: this.#keystore,
|
|
457
|
+
runtime: this.#runtime
|
|
458
|
+
};
|
|
449
459
|
}
|
|
450
460
|
async #ensureJose() {
|
|
451
461
|
return this.#jose ??= await import("jose");
|
|
@@ -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
|
}
|