@nodefony/security 10.0.0-beta.6 → 10.0.0-beta.7
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/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +17 -6
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +3 -1
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/service/accessTokenVerifier.js +1 -1
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +6 -3
- package/dist/nodefony/service/firewall.js +48 -3
- package/dist/nodefony/service/tokenService.js +8 -3
- package/dist/nodefony/service/totp.js +7 -2
- package/dist/nodefony/service/webAuthn.js +45 -6
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/types/index.d.ts +4 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +6 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/docs/audit.md +112 -28
- package/docs/keycloak.md +4 -4
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/webhooks.md +15 -7
- package/package.json +13 -13
|
@@ -11,6 +11,7 @@ import { Csrf } from "./csrf.js";
|
|
|
11
11
|
import { Cors } from "./cors.js";
|
|
12
12
|
import { SecurityHeaders, responseKindOf } from "./securityHeaders.js";
|
|
13
13
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
14
|
+
import { StaleSessionError } from "../errors/StaleSessionError.js";
|
|
14
15
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
15
16
|
import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
|
|
16
17
|
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
@@ -26,6 +27,7 @@ import { findZonesOpenToSignup } from "../src/openToSignup.js";
|
|
|
26
27
|
import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
|
|
27
28
|
import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
|
|
28
29
|
import { randomBytes } from "node:crypto";
|
|
30
|
+
import { rpcCorsLists } from "@nodefony/http";
|
|
29
31
|
import { encoderFromConfig } from "@nodefony/user";
|
|
30
32
|
// nodefony/service/firewall.ts
|
|
31
33
|
const serviceName = "firewall";
|
|
@@ -102,6 +104,29 @@ var Firewall = class extends Service {
|
|
|
102
104
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
103
105
|
this.module = module;
|
|
104
106
|
this.kernel?.once("onBoot", () => this.#build());
|
|
107
|
+
this.kernel?.once("onReady", () => this.#widenCorsForRpc());
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Ajoute à la politique CORS les méthodes et en-têtes que demandent les
|
|
111
|
+
* protocoles RPC — dès qu'un service gRPC est déclaré et que le CORS est
|
|
112
|
+
* actif (ADR-0026 D9 point 2). Le préflight part AVANT le routage et ne
|
|
113
|
+
* connaît pas la route : l'union est la seule voie. Une seule politique,
|
|
114
|
+
* celle de l'application ; calculée une fois, annoncée au démarrage.
|
|
115
|
+
*/
|
|
116
|
+
#widenCorsForRpc() {
|
|
117
|
+
const config = this.#config;
|
|
118
|
+
const rpc = rpcCorsLists();
|
|
119
|
+
if (this.#cors === null || config === null || rpc === null) return;
|
|
120
|
+
const methods = withMissing(config.cors.methods, rpc.allowedMethods);
|
|
121
|
+
const allowedHeaders = withMissing(config.cors.allowedHeaders, rpc.allowedHeaders);
|
|
122
|
+
const added = [...methods.added, ...allowedHeaders.added];
|
|
123
|
+
if (added.length === 0) return;
|
|
124
|
+
this.#cors = new Cors({
|
|
125
|
+
...config.cors,
|
|
126
|
+
methods: methods.list,
|
|
127
|
+
allowedHeaders: allowedHeaders.list
|
|
128
|
+
});
|
|
129
|
+
this.log(`CORS : service gRPC déclaré — le préflight autorise aussi ${added.join(", ")} (ADR-0026 D9)`, "INFO");
|
|
105
130
|
}
|
|
106
131
|
#build() {
|
|
107
132
|
try {
|
|
@@ -552,8 +577,7 @@ var Firewall = class extends Service {
|
|
|
552
577
|
* @returns la trace fraîche (posée sur le context), ou `null` hors profiling.
|
|
553
578
|
*/
|
|
554
579
|
#startTrace(context) {
|
|
555
|
-
|
|
556
|
-
if (!ctx.profiling) return null;
|
|
580
|
+
if (!context.profiling) return null;
|
|
557
581
|
const trace = {
|
|
558
582
|
authenticator: null,
|
|
559
583
|
outcome: "denied",
|
|
@@ -561,7 +585,7 @@ var Firewall = class extends Service {
|
|
|
561
585
|
user: null,
|
|
562
586
|
roles: null
|
|
563
587
|
};
|
|
564
|
-
|
|
588
|
+
context.securityTrace = trace;
|
|
565
589
|
return trace;
|
|
566
590
|
}
|
|
567
591
|
#recordAuth(context, area, action, outcome, reason, actor) {
|
|
@@ -923,6 +947,10 @@ var Firewall = class extends Service {
|
|
|
923
947
|
authenticated = await authenticator.authenticate(created);
|
|
924
948
|
} catch (error) {
|
|
925
949
|
await authenticator.onFailure(context, error);
|
|
950
|
+
if (error instanceof StaleSessionError && area.mode === "first") {
|
|
951
|
+
this.log(`session périmée purgée (area "${area.name}", authenticator "${name}") — la requête continue sans elle`, "WARNING");
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
926
954
|
if (error instanceof AuthenticationError) {
|
|
927
955
|
this.log(`authentication failed (area "${area.name}", authenticator "${name}")`, "WARNING");
|
|
928
956
|
throw error;
|
|
@@ -991,4 +1019,21 @@ var Firewall = class extends Service {
|
|
|
991
1019
|
return super.log(pci, severity, msgid, msg);
|
|
992
1020
|
}
|
|
993
1021
|
};
|
|
1022
|
+
/**
|
|
1023
|
+
* La liste `base` complétée des entrées de `extra` qu'elle ne porte pas encore
|
|
1024
|
+
* — comparaison sans casse (les noms d'en-têtes et de méthodes HTTP ne
|
|
1025
|
+
* distinguent pas la casse) ; l'ordre et l'écriture de `base` sont gardés.
|
|
1026
|
+
*
|
|
1027
|
+
* @param base - la liste de la configuration.
|
|
1028
|
+
* @param extra - les entrées à garantir.
|
|
1029
|
+
* @returns la liste complétée et les entrées ajoutées.
|
|
1030
|
+
*/
|
|
1031
|
+
function withMissing(base, extra) {
|
|
1032
|
+
const known = new Set(base.map((entry) => entry.toLowerCase()));
|
|
1033
|
+
const added = extra.filter((entry) => !known.has(entry.toLowerCase()));
|
|
1034
|
+
return {
|
|
1035
|
+
list: [...base, ...added],
|
|
1036
|
+
added
|
|
1037
|
+
};
|
|
1038
|
+
}
|
|
994
1039
|
export { Firewall, Firewall as default, NONCED_DOCUMENT_CACHE_CONTROL };
|
|
@@ -47,9 +47,14 @@ var TokenService = class extends Service {
|
|
|
47
47
|
constructor(module) {
|
|
48
48
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
49
49
|
this.module = module;
|
|
50
|
-
const tokenServiceBoot = () =>
|
|
50
|
+
const tokenServiceBoot = () => {
|
|
51
|
+
try {
|
|
52
|
+
this.#build();
|
|
53
|
+
} finally {
|
|
54
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
55
|
+
}
|
|
56
|
+
};
|
|
51
57
|
this.kernel?.once("onBoot", tokenServiceBoot);
|
|
52
|
-
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
53
58
|
}
|
|
54
59
|
/**
|
|
55
60
|
* Refuse de SERVIR en production sans clé de signature partagée.
|
|
@@ -130,7 +135,7 @@ var TokenService = class extends Service {
|
|
|
130
135
|
this.container?.set("tokenStore", this.#store);
|
|
131
136
|
if (jwtEnabled) {
|
|
132
137
|
this.#requireSharedSigningKey(config.jwt.keystore);
|
|
133
|
-
this.#runtime = resolveJwtRuntime(config.jwt);
|
|
138
|
+
this.#runtime = resolveJwtRuntime(config.jwt, config.areas);
|
|
134
139
|
this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
|
|
135
140
|
this.container?.set("jwtKeystore", this.#keystore);
|
|
136
141
|
this.#resolvePublication(config.jwt.jwks, this.#runtime.issuer);
|
|
@@ -36,8 +36,13 @@ var TotpService = class extends Service {
|
|
|
36
36
|
constructor(module) {
|
|
37
37
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
38
38
|
this.module = module;
|
|
39
|
-
this.kernel?.once("onBoot", () =>
|
|
40
|
-
|
|
39
|
+
this.kernel?.once("onBoot", () => {
|
|
40
|
+
try {
|
|
41
|
+
this.#build();
|
|
42
|
+
} finally {
|
|
43
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
44
|
+
}
|
|
45
|
+
});
|
|
41
46
|
}
|
|
42
47
|
#build() {
|
|
43
48
|
let config;
|
|
@@ -3,6 +3,7 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
5
|
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
6
|
+
import { domainToASCII } from "node:url";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
8
|
import { Buffer } from "node:buffer";
|
|
8
9
|
// nodefony/service/webAuthn.ts
|
|
@@ -59,12 +60,19 @@ var WebAuthnService = class extends Service {
|
|
|
59
60
|
#store = null;
|
|
60
61
|
#rpID = "localhost";
|
|
61
62
|
#rpName = "Nodefony";
|
|
63
|
+
/** Origines de la config, en ASCII (punycode) — résolues au boot. */
|
|
64
|
+
#origins = [];
|
|
62
65
|
#ready = false;
|
|
63
66
|
constructor(module) {
|
|
64
67
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
65
68
|
this.module = module;
|
|
66
|
-
this.kernel?.once("onBoot", () =>
|
|
67
|
-
|
|
69
|
+
this.kernel?.once("onBoot", () => {
|
|
70
|
+
try {
|
|
71
|
+
this.#build();
|
|
72
|
+
} finally {
|
|
73
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
74
|
+
}
|
|
75
|
+
});
|
|
68
76
|
}
|
|
69
77
|
#build() {
|
|
70
78
|
let config;
|
|
@@ -79,7 +87,11 @@ var WebAuthnService = class extends Service {
|
|
|
79
87
|
}
|
|
80
88
|
this.#config = config;
|
|
81
89
|
const rawDomain = config.passkeys.rpId ?? (this.kernel?.domain || "localhost");
|
|
82
|
-
|
|
90
|
+
const isIp = /^(\d{1,3}\.){3}\d{1,3}$|:/.test(rawDomain);
|
|
91
|
+
const asciiDomain = isIp ? "" : domainToASCII(rawDomain);
|
|
92
|
+
if (!isIp && asciiDomain === "") this.log(`passkeys : domaine « ${rawDomain} » invalide comme RP ID — repli sur « localhost »`, "ERROR");
|
|
93
|
+
this.#rpID = asciiDomain === "" ? "localhost" : asciiDomain;
|
|
94
|
+
this.#origins = asciiOrigins(config.passkeys.origins, (bad) => this.log(`passkeys.origins : « ${bad} » n'est pas une origine (scheme://hôte[:port]) — écartée`, "ERROR"));
|
|
83
95
|
this.#rpName = config.passkeys.rpName ?? "Nodefony";
|
|
84
96
|
const existing = this.get("webAuthnCredentialStore");
|
|
85
97
|
let resolved;
|
|
@@ -195,7 +207,7 @@ var WebAuthnService = class extends Service {
|
|
|
195
207
|
verification = await lib.verifyRegistrationResponse({
|
|
196
208
|
response,
|
|
197
209
|
expectedChallenge,
|
|
198
|
-
expectedOrigin: this.#expectedOrigin(
|
|
210
|
+
expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
|
|
199
211
|
expectedRPID: this.#rpID,
|
|
200
212
|
requireUserVerification: pk.userVerification === "required"
|
|
201
213
|
});
|
|
@@ -268,7 +280,7 @@ var WebAuthnService = class extends Service {
|
|
|
268
280
|
verification = await lib.verifyAuthenticationResponse({
|
|
269
281
|
response,
|
|
270
282
|
expectedChallenge,
|
|
271
|
-
expectedOrigin: this.#expectedOrigin(
|
|
283
|
+
expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
|
|
272
284
|
expectedRPID: this.#rpID,
|
|
273
285
|
credential: {
|
|
274
286
|
id: stored.id,
|
|
@@ -365,4 +377,31 @@ var WebAuthnService = class extends Service {
|
|
|
365
377
|
};
|
|
366
378
|
}
|
|
367
379
|
};
|
|
368
|
-
|
|
380
|
+
/**
|
|
381
|
+
* Origines WebAuthn rendues en ASCII (punycode), forme sous laquelle le
|
|
382
|
+
* navigateur les présente — `https://café.example` → `https://xn--caf-dma.example`.
|
|
383
|
+
*
|
|
384
|
+
* Une entrée qui n'est pas une origine (`scheme://hôte[:port]`) est écartée et
|
|
385
|
+
* signalée : la garder ferait échouer CHAQUE cérémonie, sans que personne ne
|
|
386
|
+
* sache pourquoi.
|
|
387
|
+
*
|
|
388
|
+
* @param origins - origines telles qu'écrites dans la config.
|
|
389
|
+
* @param onInvalid - appelé pour chaque entrée écartée.
|
|
390
|
+
* @returns les origines valides, sérialisées en ASCII, dans l'ordre reçu.
|
|
391
|
+
*/
|
|
392
|
+
function asciiOrigins(origins, onInvalid) {
|
|
393
|
+
const out = [];
|
|
394
|
+
for (const origin of origins) {
|
|
395
|
+
let ascii;
|
|
396
|
+
try {
|
|
397
|
+
const serialized = new URL(origin).origin;
|
|
398
|
+
ascii = serialized === "null" ? null : serialized;
|
|
399
|
+
} catch {
|
|
400
|
+
ascii = null;
|
|
401
|
+
}
|
|
402
|
+
if (ascii === null) onInvalid(origin);
|
|
403
|
+
else out.push(ascii);
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
export { WebAuthnService, WebAuthnService as default, asciiOrigins, userHandleBytes };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// nodefony/src/audit/AuditRefusalAggregator.ts
|
|
2
|
+
/** IP de la clé de repli, quand `maxKeys` est atteint. */
|
|
3
|
+
const OVERFLOW_IP = "*";
|
|
4
|
+
const SEP = "";
|
|
5
|
+
/**
|
|
6
|
+
* Une clé suivie : le gabarit de la ligne de synthèse et les compteurs de la
|
|
7
|
+
* fenêtre en cours. Champs déclarés au constructeur (forme stable).
|
|
8
|
+
*/
|
|
9
|
+
var RefusalBucket = class {
|
|
10
|
+
start;
|
|
11
|
+
count = 0;
|
|
12
|
+
suppressed = 0;
|
|
13
|
+
firstSuppressed = 0;
|
|
14
|
+
lastSuppressed = 0;
|
|
15
|
+
category;
|
|
16
|
+
action;
|
|
17
|
+
outcome;
|
|
18
|
+
resource;
|
|
19
|
+
reason;
|
|
20
|
+
ip;
|
|
21
|
+
constructor(draft, ip, now) {
|
|
22
|
+
this.start = now;
|
|
23
|
+
this.category = draft.category;
|
|
24
|
+
this.action = draft.action;
|
|
25
|
+
this.outcome = draft.outcome;
|
|
26
|
+
this.resource = draft.resource ?? null;
|
|
27
|
+
this.reason = draft.reason ?? null;
|
|
28
|
+
this.ip = ip;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Agrège les REFUS du journal d'audit par source, pour qu'un flot ne se paie pas
|
|
33
|
+
* d'une ligne par requête (OWASP Logging Cheat Sheet : la journalisation ne doit
|
|
34
|
+
* pas épuiser les ressources ni le journal de la base).
|
|
35
|
+
*
|
|
36
|
+
* Clé : catégorie, action, motif, ressource et IP — l'IP résolue par le contexte
|
|
37
|
+
* (`trustProxy`), donc non forgeable par le client. L'acteur n'entre PAS dans la
|
|
38
|
+
* clé : un bourrage d'identifiants change d'acteur à chaque essai, c'est sa
|
|
39
|
+
* source qui le trahit.
|
|
40
|
+
*
|
|
41
|
+
* Par clé et par fenêtre (ouverte au premier refus) : les `burst` premiers refus
|
|
42
|
+
* sont écrits tels quels — l'enquêteur voit les premiers essais, acteur compris ;
|
|
43
|
+
* les suivants sont seulement comptés, et la fenêtre close émet UNE ligne de
|
|
44
|
+
* synthèse (`metadata.aggregated`). Mémoire bornée : au-delà de `maxKeys` clés,
|
|
45
|
+
* les refus se regroupent sous l'IP `*`.
|
|
46
|
+
*
|
|
47
|
+
* Pur : l'horloge est passée à chaque appel, la synthèse part par `emit`. Les
|
|
48
|
+
* succès n'y passent jamais (l'appelant les écarte).
|
|
49
|
+
*/
|
|
50
|
+
var AuditRefusalAggregator = class {
|
|
51
|
+
#buckets = /* @__PURE__ */ new Map();
|
|
52
|
+
#options;
|
|
53
|
+
#emit;
|
|
54
|
+
/**
|
|
55
|
+
* @param options - fenêtre, rafale écrite telle quelle, borne de clés.
|
|
56
|
+
* @param emit - écrit une ligne de synthèse dans le journal.
|
|
57
|
+
*/
|
|
58
|
+
constructor(options, emit) {
|
|
59
|
+
this.#options = options;
|
|
60
|
+
this.#emit = emit;
|
|
61
|
+
}
|
|
62
|
+
/** Nombre de clés suivies (fenêtres ouvertes). */
|
|
63
|
+
get size() {
|
|
64
|
+
return this.#buckets.size;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Compte un refus.
|
|
68
|
+
*
|
|
69
|
+
* @param draft - l'événement de refus.
|
|
70
|
+
* @param now - horloge, en ms.
|
|
71
|
+
* @returns `true` : l'écrire tel quel ; `false` : il est compté dans la synthèse.
|
|
72
|
+
*/
|
|
73
|
+
admit(draft, now) {
|
|
74
|
+
const prefix = draft.category + SEP + draft.action + SEP + draft.outcome + SEP + (draft.reason ?? "") + SEP + (draft.resource ?? "") + SEP;
|
|
75
|
+
const ip = draft.ip ?? null;
|
|
76
|
+
let key = prefix + (ip ?? "");
|
|
77
|
+
let bucket = this.#buckets.get(key);
|
|
78
|
+
if (bucket === void 0) {
|
|
79
|
+
let bucketIp = ip;
|
|
80
|
+
if (this.#buckets.size >= this.#options.maxKeys) {
|
|
81
|
+
key = prefix + OVERFLOW_IP;
|
|
82
|
+
bucketIp = OVERFLOW_IP;
|
|
83
|
+
bucket = this.#buckets.get(key);
|
|
84
|
+
}
|
|
85
|
+
if (bucket === void 0) {
|
|
86
|
+
bucket = new RefusalBucket(draft, bucketIp, now);
|
|
87
|
+
this.#buckets.set(key, bucket);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (now - bucket.start >= this.#options.windowMs) {
|
|
91
|
+
this.#close(bucket);
|
|
92
|
+
bucket.start = now;
|
|
93
|
+
bucket.count = 0;
|
|
94
|
+
bucket.suppressed = 0;
|
|
95
|
+
}
|
|
96
|
+
bucket.count += 1;
|
|
97
|
+
if (bucket.count <= this.#options.burst) return true;
|
|
98
|
+
if (bucket.suppressed === 0) bucket.firstSuppressed = now;
|
|
99
|
+
bucket.suppressed += 1;
|
|
100
|
+
bucket.lastSuppressed = now;
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Ferme les fenêtres échues : émet leur synthèse et libère leurs clés.
|
|
105
|
+
*
|
|
106
|
+
* @param now - horloge, en ms.
|
|
107
|
+
*/
|
|
108
|
+
sweep(now) {
|
|
109
|
+
for (const [key, bucket] of this.#buckets) {
|
|
110
|
+
if (now - bucket.start < this.#options.windowMs) continue;
|
|
111
|
+
this.#close(bucket);
|
|
112
|
+
this.#buckets.delete(key);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/** Émet la synthèse de toutes les fenêtres ouvertes (arrêt) et vide la table. */
|
|
116
|
+
flush() {
|
|
117
|
+
for (const bucket of this.#buckets.values()) this.#close(bucket);
|
|
118
|
+
this.#buckets.clear();
|
|
119
|
+
}
|
|
120
|
+
#close(bucket) {
|
|
121
|
+
if (bucket.suppressed === 0) return;
|
|
122
|
+
this.#emit({
|
|
123
|
+
category: bucket.category,
|
|
124
|
+
action: bucket.action,
|
|
125
|
+
outcome: bucket.outcome,
|
|
126
|
+
actor: null,
|
|
127
|
+
resource: bucket.resource,
|
|
128
|
+
reason: bucket.reason,
|
|
129
|
+
ip: bucket.ip,
|
|
130
|
+
metadata: { aggregated: {
|
|
131
|
+
count: bucket.suppressed,
|
|
132
|
+
firstTs: bucket.firstSuppressed,
|
|
133
|
+
lastTs: bucket.lastSuppressed,
|
|
134
|
+
windowMs: this.#options.windowMs
|
|
135
|
+
} }
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
export { AuditRefusalAggregator };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
2
|
import { UserToken } from "../token/UserToken.js";
|
|
3
|
+
import { defaultAudience } from "../token/jwtRuntime.js";
|
|
3
4
|
import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
|
|
4
5
|
import { bearerToken } from "./bearer.js";
|
|
5
6
|
import peekIssuer from "./peekIssuer.js";
|
|
@@ -8,6 +9,7 @@ import { canonicalIssuer } from "nodefony";
|
|
|
8
9
|
// nodefony/src/authenticator/JwtAuthenticator.ts
|
|
9
10
|
const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
10
11
|
const INVALID_TOKEN = "Invalid token";
|
|
12
|
+
const AUDIENCE = "audience";
|
|
11
13
|
/**
|
|
12
14
|
* Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
|
|
13
15
|
* agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
|
|
@@ -19,7 +21,8 @@ const INVALID_TOKEN = "Invalid token";
|
|
|
19
21
|
* - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
|
|
20
22
|
* `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
|
|
21
23
|
* - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
|
|
22
|
-
* access et refresh) + `exp`/`nbf` (jose).
|
|
24
|
+
* access et refresh) + `exp`/`nbf` (jose). L'audience est celle de la ZONE
|
|
25
|
+
* (`resource`, ou l'audience par défaut), jamais toute la liste `jwt.audiences`.
|
|
23
26
|
* - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
|
|
24
27
|
* est auto-porté et non révocable sans état serveur).
|
|
25
28
|
* - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
|
|
@@ -68,15 +71,31 @@ var JwtAuthenticator = class {
|
|
|
68
71
|
const issuer = peekIssuer(token);
|
|
69
72
|
return issuer === null || issuer === this.#runtime.issuer;
|
|
70
73
|
}
|
|
71
|
-
/**
|
|
74
|
+
/**
|
|
75
|
+
* Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`,
|
|
76
|
+
* avec la ressource de la zone quand elle en déclare une.
|
|
77
|
+
*
|
|
78
|
+
* L'audience transite par le token parce que `authenticate()` ne reçoit pas
|
|
79
|
+
* le contexte (même patron que `ExternalJwtAuthenticator`). Rien n'est posé
|
|
80
|
+
* pour une zone sans ressource : l'audience par défaut s'y applique.
|
|
81
|
+
*/
|
|
72
82
|
createToken(context) {
|
|
73
83
|
const auth = context.request?.headers.authorization;
|
|
74
|
-
|
|
84
|
+
const token = new UserToken("jwt", bearerToken(auth) ?? "");
|
|
85
|
+
const zone = context.security;
|
|
86
|
+
if (zone && "resource" in zone && typeof zone.resource === "string" && zone.resource.length > 0) token.setAttribute(AUDIENCE, zone.resource);
|
|
87
|
+
return Promise.resolve(token);
|
|
75
88
|
}
|
|
76
89
|
/**
|
|
77
90
|
* Vérifie la signature + les claims du JWT, applique la révocation et résout le
|
|
78
91
|
* sujet — ou lève un 401 au message uniforme.
|
|
79
92
|
*
|
|
93
|
+
* L'audience exigée est celle de la ZONE, jamais la liste blanche d'émission
|
|
94
|
+
* entière (RFC 8707, RFC 9068 §4) : la `resource` de la zone si elle en
|
|
95
|
+
* déclare une, sinon l'audience par défaut — l'application elle-même. Un
|
|
96
|
+
* jeton émis pour la porte MCP, ou pour la ressource d'une autre zone, figure
|
|
97
|
+
* dans `jwt.audiences` sans pour autant ouvrir celle-ci.
|
|
98
|
+
*
|
|
80
99
|
* @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
|
|
81
100
|
* sujet disparu/banni.
|
|
82
101
|
* @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
|
|
@@ -85,7 +104,8 @@ var JwtAuthenticator = class {
|
|
|
85
104
|
async authenticate(token) {
|
|
86
105
|
const raw = token.getCredentials();
|
|
87
106
|
if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
|
|
88
|
-
const
|
|
107
|
+
const audience = token.getAttribute(AUDIENCE) ?? defaultAudience(this.#runtime);
|
|
108
|
+
const { user, payload } = await this.#verify(raw, audience);
|
|
89
109
|
return this.#promote(token, user, payload);
|
|
90
110
|
}
|
|
91
111
|
/**
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { StaleSessionError } from "../../errors/StaleSessionError.js";
|
|
2
3
|
import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
|
|
3
4
|
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
|
|
4
5
|
import { UserToken } from "../token/UserToken.js";
|
|
@@ -72,15 +73,22 @@ var SessionAuthenticator = class {
|
|
|
72
73
|
* token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
|
|
73
74
|
* {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
|
|
74
75
|
*
|
|
75
|
-
* @throws
|
|
76
|
-
* compte verrouillé ou désactiv
|
|
76
|
+
* @throws StaleSessionError (401, message uniforme) — session orpheline,
|
|
77
|
+
* compte verrouillé ou désactivé, liaison au compte rompue : un état à
|
|
78
|
+
* purger ({@link onFailure}). Une PANNE de la source d'identité remonte
|
|
79
|
+
* telle quelle, et ne purge rien.
|
|
77
80
|
*/
|
|
78
81
|
async authenticate(token) {
|
|
79
82
|
const credentials = token.getCredentials();
|
|
80
|
-
if (!credentials?.identifier) throw new
|
|
83
|
+
if (!credentials?.identifier) throw new StaleSessionError();
|
|
81
84
|
const provider = this.#provider ??= this.#resolveProvider();
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
try {
|
|
86
|
+
const user = await resolveBoundSessionIdentity(provider, credentials.identifier, credentials.accountId, credentials.credentialsVersion);
|
|
87
|
+
return token.promote(user);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error instanceof AuthenticationError) throw new StaleSessionError(error.message);
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
84
92
|
}
|
|
85
93
|
/**
|
|
86
94
|
* Pose l'identifiant sur le contexte : la persistance de session du pipeline
|
|
@@ -90,9 +98,14 @@ var SessionAuthenticator = class {
|
|
|
90
98
|
context.user = token.getUser().identifier;
|
|
91
99
|
return Promise.resolve();
|
|
92
100
|
}
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
101
|
+
/**
|
|
102
|
+
* Une session périmée est DÉTRUITE, côté serveur (entrée du stockage) et
|
|
103
|
+
* côté navigateur (cookie expiré, RFC 6265 §5.3) — sans quoi le navigateur la
|
|
104
|
+
* renvoie à chaque requête et chaque page lui répond 401, sans issue pour
|
|
105
|
+
* l'utilisateur. Le firewall poursuit ensuite la chaîne de la zone.
|
|
106
|
+
*/
|
|
107
|
+
async onFailure(context, error) {
|
|
108
|
+
if (error instanceof StaleSessionError) await context.session?.destroy(true);
|
|
96
109
|
}
|
|
97
110
|
};
|
|
98
111
|
export { SessionAuthenticator, SessionAuthenticator as default };
|
|
@@ -42,7 +42,7 @@ registerAuthenticatorFactory("session", ({ container }) => {
|
|
|
42
42
|
});
|
|
43
43
|
});
|
|
44
44
|
registerAuthenticatorFactory("jwt", ({ container, config }) => {
|
|
45
|
-
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
|
|
45
|
+
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt, config.areas));
|
|
46
46
|
});
|
|
47
47
|
registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
|
|
48
48
|
return new ExternalJwtAuthenticator(container, externalSubjectOptions(config));
|