@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
|
@@ -13,9 +13,11 @@ import { UserToken } from "../token/UserToken.js";
|
|
|
13
13
|
* démarre jamais rien lui-même. L'identité est re-résolue à CHAQUE requête
|
|
14
14
|
* via {@link resolveSessionIdentity} (rôles frais, révocation immédiate).
|
|
15
15
|
*
|
|
16
|
-
* Pas de `challenge()` : une
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* Pas de `challenge()` : sur une zone à session seule, le firewall pose son
|
|
17
|
+
* défi de repli `Session realm="nodefony"` (RFC 9110 §15.5.2 : tout 401 en
|
|
18
|
+
* porte un) — schéma inconnu des navigateurs, donc jamais de popup Basic ; le
|
|
19
|
+
* client web redirige vers son écran de login. Si la zone liste aussi
|
|
20
|
+
* `userpassword`, le firewall pose SON challenge.
|
|
19
21
|
*/
|
|
20
22
|
var SessionAuthenticator = class {
|
|
21
23
|
name = "session";
|
|
@@ -35,12 +35,12 @@ var UserPasswordAuthenticator = class {
|
|
|
35
35
|
}
|
|
36
36
|
/** La requête porte-t-elle un en-tête `Authorization: Basic ...` ? */
|
|
37
37
|
supports(context) {
|
|
38
|
-
const auth = context.request?.headers
|
|
38
|
+
const auth = context.request?.headers.authorization;
|
|
39
39
|
return typeof auth === "string" && BASIC_SCHEME.test(auth);
|
|
40
40
|
}
|
|
41
41
|
/** Décode l'enveloppe Basic — un contenu malformé donne un credential vide (échec uniforme). */
|
|
42
42
|
createToken(context) {
|
|
43
|
-
const auth = context.request?.headers
|
|
43
|
+
const auth = context.request?.headers.authorization;
|
|
44
44
|
const decoded = Buffer.from(auth.replace(BASIC_SCHEME, ""), "base64").toString("utf8");
|
|
45
45
|
const sep = decoded.indexOf(":");
|
|
46
46
|
const credentials = sep > 0 ? {
|
package/dist/nodefony/src/csp.js
CHANGED
|
@@ -7,10 +7,9 @@
|
|
|
7
7
|
function parseCsp(csp) {
|
|
8
8
|
const out = [];
|
|
9
9
|
for (const part of csp.split(";")) {
|
|
10
|
-
const
|
|
11
|
-
if (
|
|
12
|
-
|
|
13
|
-
out.push([name, tokens.slice(1)]);
|
|
10
|
+
const [name, ...sources] = part.trim().split(/\s+/).filter(Boolean);
|
|
11
|
+
if (name === void 0) continue;
|
|
12
|
+
out.push([name, sources]);
|
|
14
13
|
}
|
|
15
14
|
return out;
|
|
16
15
|
}
|
|
@@ -39,7 +38,7 @@ function mergeCspFragments(base, fragments) {
|
|
|
39
38
|
for (const [name, src] of directives) index.set(name, src);
|
|
40
39
|
for (const fragment of fragments) for (const name in fragment) {
|
|
41
40
|
const additions = fragment[name];
|
|
42
|
-
if (additions.length === 0) continue;
|
|
41
|
+
if (additions === void 0 || additions.length === 0) continue;
|
|
43
42
|
let sources = index.get(name);
|
|
44
43
|
if (!sources) {
|
|
45
44
|
sources = [];
|
|
@@ -69,7 +69,7 @@ function createGithubProvider(ctx) {
|
|
|
69
69
|
let emailVerified = email !== null;
|
|
70
70
|
if (email === null) {
|
|
71
71
|
const list = await ghGet(`${API}/user/emails`, accessToken);
|
|
72
|
-
const primary = list.find((e) => e.primary === true) ?? list
|
|
72
|
+
const primary = list.find((e) => e.primary === true) ?? list.at(0) ?? null;
|
|
73
73
|
if (primary && typeof primary.email === "string") {
|
|
74
74
|
email = primary.email;
|
|
75
75
|
emailVerified = primary.verified === true;
|
|
@@ -161,7 +161,7 @@ function containsCI(s, needle) {
|
|
|
161
161
|
* pas en altérant la casse du namespace.
|
|
162
162
|
*/
|
|
163
163
|
function matchSystemPolicy(channel, rules) {
|
|
164
|
-
for (
|
|
164
|
+
for (const rule of rules) if (startsWithCI(channel, rule.prefix)) return floorReserved(channel, rule.policy);
|
|
165
165
|
if (containsCI(channel, ":health") || containsCI(channel, ":stats")) return SYSTEM_CHANNEL_POLICY;
|
|
166
166
|
return null;
|
|
167
167
|
}
|
|
@@ -178,7 +178,7 @@ function matchSystemPolicy(channel, rules) {
|
|
|
178
178
|
*/
|
|
179
179
|
function floorReserved(channel, policy) {
|
|
180
180
|
if (policy.authenticated) return policy;
|
|
181
|
-
for (
|
|
181
|
+
for (const prefix of RESERVED_FLOOR_PREFIXES) if (startsWithCI(channel, prefix)) return {
|
|
182
182
|
...policy,
|
|
183
183
|
authenticated: true
|
|
184
184
|
};
|
|
@@ -197,7 +197,7 @@ function satisfies(policy, token, firewall) {
|
|
|
197
197
|
if (policy.roles && policy.roles.length > 0) {
|
|
198
198
|
const userRoles = token.getRoles();
|
|
199
199
|
let granted = false;
|
|
200
|
-
for (
|
|
200
|
+
for (const role of policy.roles) if (firewall.hasRole(userRoles, role)) {
|
|
201
201
|
granted = true;
|
|
202
202
|
break;
|
|
203
203
|
}
|
|
@@ -206,7 +206,7 @@ function satisfies(policy, token, firewall) {
|
|
|
206
206
|
if (policy.scopes && policy.scopes.length > 0) {
|
|
207
207
|
const userScopes = token.getScopes();
|
|
208
208
|
let granted = false;
|
|
209
|
-
for (
|
|
209
|
+
for (const scope of policy.scopes) if (userScopes.includes(scope)) {
|
|
210
210
|
granted = true;
|
|
211
211
|
break;
|
|
212
212
|
}
|
|
@@ -279,9 +279,9 @@ function buildFrameAuthorizer(firewall, options) {
|
|
|
279
279
|
return (frame, token) => {
|
|
280
280
|
const f = frame;
|
|
281
281
|
const method = f?.method;
|
|
282
|
-
if (method === "api.request") return authorizeApiRequest(firewall, f
|
|
282
|
+
if (method === "api.request") return authorizeApiRequest(firewall, f?.params, token, onDeny);
|
|
283
283
|
if (method === "subscribe") {
|
|
284
|
-
const channel = f
|
|
284
|
+
const channel = (f?.params)?.channel;
|
|
285
285
|
if (typeof channel !== "string") return true;
|
|
286
286
|
return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
|
|
287
287
|
}
|
|
@@ -31,7 +31,7 @@ const CLEANED_DIRS = ["var", "tmp"];
|
|
|
31
31
|
function keystoreLeaksIntoImage(dir) {
|
|
32
32
|
const normalized = dir.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
33
33
|
if (normalized.startsWith("/") || /^[A-Za-z]:/u.test(normalized)) return null;
|
|
34
|
-
const first = normalized.split("/")
|
|
34
|
+
const [first = ""] = normalized.split("/");
|
|
35
35
|
if (CLEANED_DIRS.includes(first)) return null;
|
|
36
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
37
|
}
|
|
@@ -138,9 +138,9 @@ var JwtKeystore = class {
|
|
|
138
138
|
kid: stored.kid,
|
|
139
139
|
privateKey: imported,
|
|
140
140
|
publicJwk: {
|
|
141
|
-
kty: stored.kty,
|
|
142
|
-
crv: stored.crv,
|
|
143
|
-
x: stored.x,
|
|
141
|
+
...stored.kty !== void 0 && { kty: stored.kty },
|
|
142
|
+
...stored.crv !== void 0 && { crv: stored.crv },
|
|
143
|
+
...stored.x !== void 0 && { x: stored.x },
|
|
144
144
|
kid: stored.kid,
|
|
145
145
|
use: "sig",
|
|
146
146
|
alg: "EdDSA"
|
|
@@ -148,9 +148,10 @@ var JwtKeystore = class {
|
|
|
148
148
|
createdAt: stored.createdAt ?? Date.now()
|
|
149
149
|
});
|
|
150
150
|
}
|
|
151
|
-
|
|
151
|
+
const [firstKey] = loaded;
|
|
152
|
+
if (firstKey === void 0) throw new Error("JwtKeystore: keyset vide");
|
|
152
153
|
this.#keys = loaded;
|
|
153
|
-
this.#activeKid = keyset.active && loaded.some((k) => k.kid === keyset.active) ? keyset.active :
|
|
154
|
+
this.#activeKid = keyset.active && loaded.some((k) => k.kid === keyset.active) ? keyset.active : firstKey.kid;
|
|
154
155
|
}
|
|
155
156
|
#parseKeyset(json) {
|
|
156
157
|
let parsed;
|
|
@@ -17,6 +17,14 @@ function matchesTokenQuery(record, query, now) {
|
|
|
17
17
|
return true;
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
|
+
* Copie PROFONDE d'un record lu : `scopes`, `audience`, `resources`, `cnf` et
|
|
21
|
+
* `metadata` sont mutables — rendre la référence laisserait l'appelant muter le
|
|
22
|
+
* store en place, ce que les backends persistants rendent impossible.
|
|
23
|
+
*/
|
|
24
|
+
function cloneOrNull(record) {
|
|
25
|
+
return record ? structuredClone(record) : null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
20
28
|
* Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
|
|
21
29
|
*
|
|
22
30
|
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
@@ -59,6 +67,11 @@ var MemoryTokenStore = class {
|
|
|
59
67
|
this.#retentionRevokedMs = retentionRevokedMs;
|
|
60
68
|
}
|
|
61
69
|
put(record) {
|
|
70
|
+
const owner = this.#idByHash.get(record.secretHash);
|
|
71
|
+
if (owner !== void 0 && owner !== record.id) return Promise.reject(/* @__PURE__ */ new Error(`secretHash déjà porté par le jeton ${owner} : un secret ne désigne qu'un jeton`));
|
|
72
|
+
const previous = this.#byId.get(record.id);
|
|
73
|
+
if (previous) this.#removeRecord(record.id, previous);
|
|
74
|
+
record = structuredClone(record);
|
|
62
75
|
this.#byId.set(record.id, record);
|
|
63
76
|
this.#idByHash.set(record.secretHash, record.id);
|
|
64
77
|
this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
|
|
@@ -66,11 +79,11 @@ var MemoryTokenStore = class {
|
|
|
66
79
|
return Promise.resolve();
|
|
67
80
|
}
|
|
68
81
|
findById(id) {
|
|
69
|
-
return Promise.resolve(this.#byId.get(id)
|
|
82
|
+
return Promise.resolve(cloneOrNull(this.#byId.get(id)));
|
|
70
83
|
}
|
|
71
84
|
findByHash(secretHash) {
|
|
72
85
|
const id = this.#idByHash.get(secretHash);
|
|
73
|
-
return Promise.resolve(id !== void 0 ? this.#byId.get(id)
|
|
86
|
+
return Promise.resolve(id !== void 0 ? cloneOrNull(this.#byId.get(id)) : null);
|
|
74
87
|
}
|
|
75
88
|
findBySubject(subjectId) {
|
|
76
89
|
const ids = this.#idsBySubject.get(subjectId);
|
|
@@ -78,12 +91,12 @@ var MemoryTokenStore = class {
|
|
|
78
91
|
const out = [];
|
|
79
92
|
for (const id of ids) {
|
|
80
93
|
const record = this.#byId.get(id);
|
|
81
|
-
if (record) out.push(record);
|
|
94
|
+
if (record) out.push(structuredClone(record));
|
|
82
95
|
}
|
|
83
96
|
return Promise.resolve(out);
|
|
84
97
|
}
|
|
85
98
|
listAll() {
|
|
86
|
-
return Promise.resolve([...this.#byId.values()]);
|
|
99
|
+
return Promise.resolve([...this.#byId.values()].map((r) => structuredClone(r)));
|
|
87
100
|
}
|
|
88
101
|
listPage(query) {
|
|
89
102
|
assertPageQuery(query, "offset");
|
|
@@ -93,7 +106,7 @@ var MemoryTokenStore = class {
|
|
|
93
106
|
const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
|
|
94
107
|
const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
|
|
95
108
|
filtered.sort(compareByOrder(order, (r, field) => r[field]));
|
|
96
|
-
const items = filtered.slice(offset, offset + limit);
|
|
109
|
+
const items = filtered.slice(offset, offset + limit).map((r) => structuredClone(r));
|
|
97
110
|
const total = query.withTotal === false ? void 0 : filtered.length;
|
|
98
111
|
return Promise.resolve({
|
|
99
112
|
items,
|
|
@@ -206,7 +219,7 @@ var MemoryTokenStore = class {
|
|
|
206
219
|
set.add(id);
|
|
207
220
|
}
|
|
208
221
|
#revokeRecord(record, reason) {
|
|
209
|
-
if (record
|
|
222
|
+
if (record?.revokedAt === null) {
|
|
210
223
|
record.revokedAt = this.#now();
|
|
211
224
|
record.revokedReason = reason;
|
|
212
225
|
}
|
|
@@ -15,5 +15,17 @@ function resolveJwtRuntime(jwt) {
|
|
|
15
15
|
alg: "EdDSA"
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* Audience inscrite dans un jeton quand aucune ressource n'est demandée : la
|
|
20
|
+
* première de la liste blanche. La liste n'est jamais vide après
|
|
21
|
+
* {@link resolveJwtRuntime} ; un runtime construit à la main qui la laisserait
|
|
22
|
+
* vide retombe sur la même règle — l'application est sa propre audience.
|
|
23
|
+
*
|
|
24
|
+
* @param runtime - paramètres JWT résolus
|
|
25
|
+
* @returns l'audience par défaut
|
|
26
|
+
*/
|
|
27
|
+
function defaultAudience(runtime) {
|
|
28
|
+
return runtime.audiences[0] ?? runtime.issuer;
|
|
29
|
+
}
|
|
18
30
|
//#endregion
|
|
19
|
-
export { resolveJwtRuntime };
|
|
31
|
+
export { defaultAudience, resolveJwtRuntime };
|
|
@@ -37,21 +37,33 @@ function matchesTotpQuery(secret, query) {
|
|
|
37
37
|
* un adapter ORM ou Redis. Perf : la `Map` n'existe que si le store est instancié
|
|
38
38
|
* (2FA activé), jamais sur le hot path par requête.
|
|
39
39
|
*/
|
|
40
|
+
/**
|
|
41
|
+
* Copie d'un secret : `recoveryCodes` est un tableau mutable — rendre ou garder
|
|
42
|
+
* la référence laisserait l'appelant rendre un code de secours réutilisable, ou
|
|
43
|
+
* en effacer un, sans passer par le store.
|
|
44
|
+
*/
|
|
45
|
+
function cloneSecret(s) {
|
|
46
|
+
return {
|
|
47
|
+
...s,
|
|
48
|
+
recoveryCodes: [...s.recoveryCodes]
|
|
49
|
+
};
|
|
50
|
+
}
|
|
40
51
|
var MemoryTotpSecretStore = class {
|
|
41
52
|
/** userId → secret (source de vérité). */
|
|
42
53
|
#byUser = /* @__PURE__ */ new Map();
|
|
43
54
|
findByUser(userId) {
|
|
44
|
-
|
|
55
|
+
const secret = this.#byUser.get(userId);
|
|
56
|
+
return Promise.resolve(secret ? cloneSecret(secret) : null);
|
|
45
57
|
}
|
|
46
58
|
save(secret) {
|
|
47
|
-
this.#byUser.set(secret.userId, secret);
|
|
59
|
+
this.#byUser.set(secret.userId, cloneSecret(secret));
|
|
48
60
|
return Promise.resolve();
|
|
49
61
|
}
|
|
50
62
|
update(userId, patch) {
|
|
51
63
|
const secret = this.#byUser.get(userId);
|
|
52
64
|
if (secret) {
|
|
53
65
|
if (patch.confirmedAt !== void 0) secret.confirmedAt = patch.confirmedAt;
|
|
54
|
-
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = patch.recoveryCodes;
|
|
66
|
+
if (patch.recoveryCodes !== void 0) secret.recoveryCodes = [...patch.recoveryCodes];
|
|
55
67
|
if (patch.lastUsedStep !== void 0) secret.lastUsedStep = patch.lastUsedStep;
|
|
56
68
|
if (patch.lastUsedAt !== void 0) secret.lastUsedAt = patch.lastUsedAt;
|
|
57
69
|
}
|
|
@@ -27,8 +27,8 @@ function base32Encode(buf) {
|
|
|
27
27
|
let bits = 0;
|
|
28
28
|
let value = 0;
|
|
29
29
|
let out = "";
|
|
30
|
-
for (
|
|
31
|
-
value = value << 8 |
|
|
30
|
+
for (const byte of buf) {
|
|
31
|
+
value = value << 8 | byte;
|
|
32
32
|
bits += 8;
|
|
33
33
|
while (bits >= 5) {
|
|
34
34
|
out += BASE32_ALPHABET[value >>> bits - 5 & 31];
|
|
@@ -48,9 +48,9 @@ function base32Decode(input) {
|
|
|
48
48
|
let bits = 0;
|
|
49
49
|
let value = 0;
|
|
50
50
|
const out = [];
|
|
51
|
-
for (
|
|
52
|
-
const idx = BASE32_ALPHABET.indexOf(
|
|
53
|
-
if (idx === -1) throw new Error(`base32: caractère invalide « ${
|
|
51
|
+
for (const char of clean) {
|
|
52
|
+
const idx = BASE32_ALPHABET.indexOf(char);
|
|
53
|
+
if (idx === -1) throw new Error(`base32: caractère invalide « ${char} »`);
|
|
54
54
|
value = value << 5 | idx;
|
|
55
55
|
bits += 5;
|
|
56
56
|
if (bits >= 8) {
|
|
@@ -85,8 +85,8 @@ function counterBuffer(counter) {
|
|
|
85
85
|
*/
|
|
86
86
|
function hotp(secret, counter, digits = TOTP_DEFAULTS.digits, algorithm = TOTP_DEFAULTS.algorithm) {
|
|
87
87
|
const hs = createHmac(hmacName(algorithm), secret).update(counterBuffer(counter)).digest();
|
|
88
|
-
const offset = hs
|
|
89
|
-
return ((
|
|
88
|
+
const offset = hs.readUInt8(hs.length - 1) & 15;
|
|
89
|
+
return ((hs.readUInt32BE(offset) & 2147483647) % 10 ** digits).toString().padStart(digits, "0");
|
|
90
90
|
}
|
|
91
91
|
/** Numéro de tranche temporelle `T` (RFC 6238 §4.2) pour un instant donné. */
|
|
92
92
|
function totpCounter(epochSec, step = TOTP_DEFAULTS.step, t0 = TOTP_DEFAULTS.t0) {
|
|
@@ -216,8 +216,8 @@ function hashRecoveryCode(code) {
|
|
|
216
216
|
function matchRecoveryCode(presented, hashes) {
|
|
217
217
|
const target = Buffer.from(hashRecoveryCode(presented));
|
|
218
218
|
let found = -1;
|
|
219
|
-
for (
|
|
220
|
-
const candidate = Buffer.from(
|
|
219
|
+
for (const [i, hash] of hashes.entries()) {
|
|
220
|
+
const candidate = Buffer.from(hash);
|
|
221
221
|
if (candidate.length === target.length && timingSafeEqual(candidate, target)) found = i;
|
|
222
222
|
}
|
|
223
223
|
return found;
|
|
@@ -18,13 +18,25 @@ function toWebAuthnSummary(c) {
|
|
|
18
18
|
...c.nickname !== void 0 ? { nickname: c.nickname } : {}
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Copie d'une passkey : `transports` est un tableau mutable — rendre ou garder
|
|
23
|
+
* la référence laisserait l'appelant muter le store en place, ce que les
|
|
24
|
+
* backends persistants rendent impossible par sérialisation.
|
|
25
|
+
*/
|
|
26
|
+
function cloneCredential(c) {
|
|
27
|
+
return {
|
|
28
|
+
...c,
|
|
29
|
+
transports: [...c.transports]
|
|
30
|
+
};
|
|
31
|
+
}
|
|
21
32
|
var MemoryWebAuthnCredentialStore = class {
|
|
22
33
|
/** id (base64url) → credential (source de vérité). */
|
|
23
34
|
#byId = /* @__PURE__ */ new Map();
|
|
24
35
|
/** userId → ids (allowCredentials + « mes appareils »). */
|
|
25
36
|
#idsByUser = /* @__PURE__ */ new Map();
|
|
26
37
|
findById(credentialId) {
|
|
27
|
-
|
|
38
|
+
const cred = this.#byId.get(credentialId);
|
|
39
|
+
return Promise.resolve(cred ? cloneCredential(cred) : null);
|
|
28
40
|
}
|
|
29
41
|
findByUser(userId) {
|
|
30
42
|
const ids = this.#idsByUser.get(userId);
|
|
@@ -32,7 +44,7 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
32
44
|
const out = [];
|
|
33
45
|
for (const id of ids) {
|
|
34
46
|
const cred = this.#byId.get(id);
|
|
35
|
-
if (cred) out.push(cred);
|
|
47
|
+
if (cred) out.push(cloneCredential(cred));
|
|
36
48
|
}
|
|
37
49
|
return Promise.resolve(out);
|
|
38
50
|
}
|
|
@@ -40,7 +52,9 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
40
52
|
return Promise.resolve(this.#idsByUser.get(userId)?.size ?? 0);
|
|
41
53
|
}
|
|
42
54
|
save(credential) {
|
|
43
|
-
this.#byId.
|
|
55
|
+
const previous = this.#byId.get(credential.id);
|
|
56
|
+
if (previous && previous.userId !== credential.userId) this.#unindex(previous.userId, credential.id);
|
|
57
|
+
this.#byId.set(credential.id, cloneCredential(credential));
|
|
44
58
|
let set = this.#idsByUser.get(credential.userId);
|
|
45
59
|
if (!set) {
|
|
46
60
|
set = /* @__PURE__ */ new Set();
|
|
@@ -63,12 +77,16 @@ var MemoryWebAuthnCredentialStore = class {
|
|
|
63
77
|
const cred = this.#byId.get(credentialId);
|
|
64
78
|
if (!cred) return Promise.resolve();
|
|
65
79
|
this.#byId.delete(credentialId);
|
|
66
|
-
|
|
80
|
+
this.#unindex(cred.userId, credentialId);
|
|
81
|
+
return Promise.resolve();
|
|
82
|
+
}
|
|
83
|
+
/** Retire un id de l'index d'un porteur ; l'entrée vide disparaît. */
|
|
84
|
+
#unindex(userId, credentialId) {
|
|
85
|
+
const set = this.#idsByUser.get(userId);
|
|
67
86
|
if (set) {
|
|
68
87
|
set.delete(credentialId);
|
|
69
|
-
if (set.size === 0) this.#idsByUser.delete(
|
|
88
|
+
if (set.size === 0) this.#idsByUser.delete(userId);
|
|
70
89
|
}
|
|
71
|
-
return Promise.resolve();
|
|
72
90
|
}
|
|
73
91
|
/** Credentials filtrés, dans l'ordre contractuel (createdAt DESC, id ASC). */
|
|
74
92
|
#filtered(query) {
|
|
@@ -27,6 +27,18 @@ function matchesWebhookQuery(e, query) {
|
|
|
27
27
|
* Map indexée par id (O(1)). Les lectures renvoient une **copie défensive** : le
|
|
28
28
|
* store détient la vérité, un consommateur ne peut pas muter un record en place.
|
|
29
29
|
*/
|
|
30
|
+
/**
|
|
31
|
+
* Copie PROFONDE d'un endpoint : `events` et `metadata` sont des structures
|
|
32
|
+
* mutables — une copie de surface laisserait l'appelant muter le store en place,
|
|
33
|
+
* ce que les backends persistants rendent impossible par sérialisation.
|
|
34
|
+
*/
|
|
35
|
+
function cloneEndpoint(e) {
|
|
36
|
+
return {
|
|
37
|
+
...e,
|
|
38
|
+
events: [...e.events],
|
|
39
|
+
metadata: structuredClone(e.metadata)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
30
42
|
var MemoryWebhookStore = class {
|
|
31
43
|
/**
|
|
32
44
|
* {@inheritDoc IWebhookStore.sortableFields}
|
|
@@ -37,25 +49,25 @@ var MemoryWebhookStore = class {
|
|
|
37
49
|
sortableFields = WEBHOOK_SORTABLE_FIELDS;
|
|
38
50
|
#byId = /* @__PURE__ */ new Map();
|
|
39
51
|
async save(endpoint) {
|
|
40
|
-
this.#byId.set(endpoint.id,
|
|
52
|
+
this.#byId.set(endpoint.id, cloneEndpoint(endpoint));
|
|
41
53
|
}
|
|
42
54
|
async findById(id) {
|
|
43
55
|
const found = this.#byId.get(id);
|
|
44
|
-
return found ?
|
|
56
|
+
return found ? cloneEndpoint(found) : null;
|
|
45
57
|
}
|
|
46
58
|
async update(id, patch) {
|
|
47
59
|
const current = this.#byId.get(id);
|
|
48
60
|
if (!current) return;
|
|
49
|
-
this.#byId.set(id, {
|
|
61
|
+
this.#byId.set(id, cloneEndpoint({
|
|
50
62
|
...current,
|
|
51
63
|
...patch
|
|
52
|
-
});
|
|
64
|
+
}));
|
|
53
65
|
}
|
|
54
66
|
async delete(id) {
|
|
55
67
|
this.#byId.delete(id);
|
|
56
68
|
}
|
|
57
69
|
async listAll() {
|
|
58
|
-
return [...this.#byId.values()].map(
|
|
70
|
+
return [...this.#byId.values()].map(cloneEndpoint);
|
|
59
71
|
}
|
|
60
72
|
async listPage(query) {
|
|
61
73
|
assertPageQuery(query, "offset");
|
|
@@ -64,11 +76,7 @@ var MemoryWebhookStore = class {
|
|
|
64
76
|
const filtered = [...this.#byId.values()].filter((e) => matchesWebhookQuery(e, query));
|
|
65
77
|
const order = pickOrder(query.order, this.sortableFields, WEBHOOK_DEFAULT_ORDER);
|
|
66
78
|
filtered.sort(compareByOrder(order, (e, field) => e[field]));
|
|
67
|
-
const items = filtered.slice(offset, offset + limit).map(
|
|
68
|
-
...e,
|
|
69
|
-
events: [...e.events],
|
|
70
|
-
metadata: { ...e.metadata }
|
|
71
|
-
}));
|
|
79
|
+
const items = filtered.slice(offset, offset + limit).map(cloneEndpoint);
|
|
72
80
|
return {
|
|
73
81
|
items,
|
|
74
82
|
total: query.withTotal === false ? void 0 : filtered.length,
|
|
@@ -78,7 +78,7 @@ var WebhookDispatcher = class {
|
|
|
78
78
|
/** Empile une livraison ; **abandonne** (best-effort) si la file est pleine. */
|
|
79
79
|
#enqueue(job) {
|
|
80
80
|
if (this.#stopped) return;
|
|
81
|
-
|
|
81
|
+
this.#queue ??= [];
|
|
82
82
|
if (this.#queue.length >= this.#deps.policy.maxQueue) {
|
|
83
83
|
this.#dropped++;
|
|
84
84
|
if (this.#dropped === 1 || this.#dropped % 100 === 0) this.#deps.log(`webhooks: file pleine (${this.#deps.policy.maxQueue}) — ${this.#dropped} livraison(s) abandonnée(s) (best-effort)`);
|
|
@@ -100,8 +100,9 @@ var WebhookDispatcher = class {
|
|
|
100
100
|
#pump() {
|
|
101
101
|
if (this.#queue === null || this.#stopped) return;
|
|
102
102
|
const max = this.#deps.policy.maxConcurrent;
|
|
103
|
-
while (this.#inFlight < max
|
|
103
|
+
while (this.#inFlight < max) {
|
|
104
104
|
const job = this.#queue.shift();
|
|
105
|
+
if (job === void 0) break;
|
|
105
106
|
this.#inFlight++;
|
|
106
107
|
this.#process(job).finally(() => {
|
|
107
108
|
this.#inFlight--;
|
|
@@ -178,7 +179,7 @@ var WebhookDispatcher = class {
|
|
|
178
179
|
/** Replanifie la livraison après backoff (repasse par la file bornée). */
|
|
179
180
|
#scheduleRetry(job) {
|
|
180
181
|
if (this.#stopped) return;
|
|
181
|
-
|
|
182
|
+
this.#timers ??= /* @__PURE__ */ new Set();
|
|
182
183
|
const cancel = this.#deps.schedule(() => {
|
|
183
184
|
this.#timers?.delete(cancel);
|
|
184
185
|
this.#enqueue({
|
|
@@ -1,20 +1,4 @@
|
|
|
1
1
|
import { CliKernel, Command } from "nodefony";
|
|
2
|
-
/**
|
|
3
|
-
* La route qui échange un compte contre une SESSION — le geste qui manquait.
|
|
4
|
-
*
|
|
5
|
-
* Créer un compte ne sert à rien tant qu'on ne sait pas s'en servir : mesuré,
|
|
6
|
-
* 33 minutes d'un essai réel se sont passées entre un premier refus et
|
|
7
|
-
* l'abandon, faute de savoir quoi appeler. La commande dit donc l'étape
|
|
8
|
-
* suivante au moment où elle a un sens.
|
|
9
|
-
*
|
|
10
|
-
* ⚠️ La route est MONTÉE ailleurs — `mountSessionAuthRoutes` du module
|
|
11
|
-
* framework — et ce paquet ne peut pas l'importer pour la lire (le montage est
|
|
12
|
-
* conditionné au service `authFlow`, et la valeur ne vit dans aucune constante
|
|
13
|
-
* exportée). La copie est donc assumée, et un test la CONFRONTE à la table du
|
|
14
|
-
* controller : deux copies qui divergent en silence, c'est précisément ce
|
|
15
|
-
* qu'on refuse.
|
|
16
|
-
*/
|
|
17
|
-
export declare const AUTH_LOGIN_PATH = "/nodefony/security/api/auth/login";
|
|
18
2
|
/**
|
|
19
3
|
* `nodefony security:user:add [identifier]` — crée un compte utilisateur via le
|
|
20
4
|
* service applicatif `users` (hash Argon2id fait par `UserService.createUser`,
|
|
@@ -12,13 +12,17 @@
|
|
|
12
12
|
* Sous-système de sécurité concerné — axe de filtrage principal de la console
|
|
13
13
|
* d'audit (Studio P6.15). Fermé : la liste des sous-systèmes est stable.
|
|
14
14
|
*
|
|
15
|
-
* ⚠️
|
|
15
|
+
* ⚠️ Trois catégories sont RÉSERVÉES — déclarées au contrat mais **sans émetteur à
|
|
16
16
|
* ce jour** (aucun `record({ category: … })` ne les produit) : `oauth`, `csrf`,
|
|
17
|
-
* `cors
|
|
17
|
+
* `cors`. La console Studio ne propose donc PAS de filtre pour elles (un
|
|
18
18
|
* filtre qui ne rend jamais rien induit en erreur — cf `studio/.../auditModel.ts`).
|
|
19
19
|
* En câbler une = ajouter son émetteur ICI (l'émetteur), puis la métadonnée
|
|
20
20
|
* d'affichage dans `AUDIT_CATEGORIES`. Émettent réellement : auth, authz, token,
|
|
21
|
-
* session, webauthn, ws, webhook.
|
|
21
|
+
* session, webauthn, ws, webhook, config (`config.update`, `log.debug.*`).
|
|
22
|
+
*
|
|
23
|
+
* Un émetteur hors de `@nodefony/security` ne peut pas importer ce type et passe
|
|
24
|
+
* la catégorie en `string` : `tests/unit/auditCategoryEmitters.test.ts` relit
|
|
25
|
+
* les sources de tous les paquets pour refuser une catégorie hors contrat.
|
|
22
26
|
*/
|
|
23
27
|
export type AuditCategory = "auth" | "authz" | "token" | "session" | "oauth" | "webauthn" | "csrf" | "cors" | "ws" | "webhook" | "config";
|
|
24
28
|
/**
|
|
@@ -85,7 +89,7 @@ export interface IAuditEvent {
|
|
|
85
89
|
*/
|
|
86
90
|
flags?: IAuditEventFlags;
|
|
87
91
|
/** Extras applicatifs libres (anti-migration) ; absents par défaut. */
|
|
88
|
-
metadata?: Record<string, unknown
|
|
92
|
+
metadata?: Record<string, unknown> | undefined;
|
|
89
93
|
}
|
|
90
94
|
/**
|
|
91
95
|
* Champs fournis par l'**émetteur** d'un événement — l'`AuditService` y ajoute
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextType } from "@nodefony/http";
|
|
1
|
+
import type { ContextType, IFirewallGate } from "@nodefony/http";
|
|
2
2
|
import type { IAuthenticator } from "./IAuthenticator.js";
|
|
3
3
|
import type { ISecuredArea } from "./ISecuredArea.js";
|
|
4
4
|
import type { CspFragment } from "../src/csp.js";
|
|
@@ -11,7 +11,7 @@ import type { IProtectedResourceInput } from "nodefony";
|
|
|
11
11
|
* `context.security`. `handleSecurity()` (lazy, seulement si la requête est dans
|
|
12
12
|
* une zone) exécute CORS → headers → authenticators → Zero Trust → CSRF.
|
|
13
13
|
*/
|
|
14
|
-
export interface IFirewall {
|
|
14
|
+
export interface IFirewall extends IFirewallGate {
|
|
15
15
|
/** Match rapide de zone (pose `context.security`). `true` si zone capturée. */
|
|
16
16
|
isSecure(context: ContextType): boolean;
|
|
17
17
|
/** Pipeline complet d'authentification de la zone. Rejette (401/403) ou résout. */
|
|
@@ -78,11 +78,11 @@ export interface IFirewallDefensesDescription {
|
|
|
78
78
|
frameguard: "deny" | "sameorigin";
|
|
79
79
|
noSniff: boolean;
|
|
80
80
|
referrerPolicy: string;
|
|
81
|
-
coop?: string;
|
|
82
|
-
coep?: string;
|
|
83
|
-
corp?: string;
|
|
84
|
-
originAgentCluster?: boolean;
|
|
85
|
-
permissionsPolicy?: string;
|
|
81
|
+
coop?: string | undefined;
|
|
82
|
+
coep?: string | undefined;
|
|
83
|
+
corp?: string | undefined;
|
|
84
|
+
originAgentCluster?: boolean | undefined;
|
|
85
|
+
permissionsPolicy?: string | undefined;
|
|
86
86
|
};
|
|
87
87
|
rateLimit: {
|
|
88
88
|
enabled: boolean;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextType } from "@nodefony/http";
|
|
1
|
+
import type { ContextType, ISecurityZone } from "@nodefony/http";
|
|
2
2
|
/**
|
|
3
3
|
* Zone sécurisée — un pattern d'URL + la liste des authenticators à exécuter.
|
|
4
4
|
*
|
|
@@ -7,7 +7,7 @@ import type { ContextType } from "@nodefony/http";
|
|
|
7
7
|
* `defineSecurityConfig({ areas })` — patterns compilés et triés au boot, conflits
|
|
8
8
|
* détectés au boot (pas au premier match runtime).
|
|
9
9
|
*/
|
|
10
|
-
export interface ISecuredArea {
|
|
10
|
+
export interface ISecuredArea extends ISecurityZone {
|
|
11
11
|
/** Nom de la zone (`"main_api"`, `"admin"`…). */
|
|
12
12
|
readonly name: string;
|
|
13
13
|
/** Pattern d'URL compilé. */
|
|
@@ -29,7 +29,7 @@ export interface ISecuredArea {
|
|
|
29
29
|
/** Noms des authenticators à exécuter (sémantique selon {@link mode}). */
|
|
30
30
|
readonly authenticators: readonly string[];
|
|
31
31
|
/** Domaine/vhost de la zone (ex. `admin.exemple.com`). Omis = tous domaines. */
|
|
32
|
-
readonly host?: string;
|
|
32
|
+
readonly host?: string | undefined;
|
|
33
33
|
/**
|
|
34
34
|
* URI canonique de la ressource protégée par cette zone — l'**audience**
|
|
35
35
|
* qu'un jeton doit porter pour y être accepté (RFC 8707 §2).
|
|
@@ -44,7 +44,7 @@ export interface ISecuredArea {
|
|
|
44
44
|
* Omise, une zone reste parfaitement utilisable par les authenticators qui
|
|
45
45
|
* n'en ont pas besoin (session, mot de passe, jetons maison).
|
|
46
46
|
*/
|
|
47
|
-
readonly resource?: string;
|
|
47
|
+
readonly resource?: string | undefined;
|
|
48
48
|
/**
|
|
49
49
|
* Zone valable AUSSI pour le WebSocket (frames `api.request` + `subscribe`),
|
|
50
50
|
* pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS
|
|
@@ -27,7 +27,7 @@ export interface IResourcePermission {
|
|
|
27
27
|
/** Type de ressource (ex. `"repo"`, `"project"`). */
|
|
28
28
|
type: string;
|
|
29
29
|
/** Identifiants ciblés (omis = toutes les ressources de ce type). */
|
|
30
|
-
ids?: string[];
|
|
30
|
+
ids?: string[] | undefined;
|
|
31
31
|
/** Permissions accordées sur ces ressources. */
|
|
32
32
|
perms: Array<"read" | "write">;
|
|
33
33
|
}
|
|
@@ -36,9 +36,9 @@ export interface ITokenUsage {
|
|
|
36
36
|
/** Instant d'usage (epoch ms). */
|
|
37
37
|
at: number;
|
|
38
38
|
/** IP source, si disponible. */
|
|
39
|
-
ip?: string | null;
|
|
39
|
+
ip?: string | null | undefined;
|
|
40
40
|
/** User-Agent source, si disponible. */
|
|
41
|
-
userAgent?: string | null;
|
|
41
|
+
userAgent?: string | null | undefined;
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Enregistrement d'un jeton persistant — un PAT (clé API style GitHub) ou un
|
|
@@ -46,13 +46,13 @@ export interface ITotpListQuery extends IPageQuery {
|
|
|
46
46
|
/** Champs mutables d'un secret TOTP (patch partiel). */
|
|
47
47
|
export interface TotpSecretUpdate {
|
|
48
48
|
/** Confirmation de l'enrôlement (epoch ms) — passe le secret en « actif ». */
|
|
49
|
-
confirmedAt?: number | null;
|
|
49
|
+
confirmedAt?: number | null | undefined;
|
|
50
50
|
/** Stock résiduel de codes de récupération hachés (après consommation). */
|
|
51
|
-
recoveryCodes?: string[];
|
|
51
|
+
recoveryCodes?: string[] | undefined;
|
|
52
52
|
/** Dernière tranche `T` validée (anti-rejeu RFC 6238 §5.2). */
|
|
53
|
-
lastUsedStep?: number;
|
|
53
|
+
lastUsedStep?: number | undefined;
|
|
54
54
|
/** Horodatage du dernier usage réussi (epoch ms). */
|
|
55
|
-
lastUsedAt?: number;
|
|
55
|
+
lastUsedAt?: number | undefined;
|
|
56
56
|
}
|
|
57
57
|
/**
|
|
58
58
|
* Store **pluggable** du secret TOTP par utilisateur — découple le cœur du backend
|