@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
|
@@ -2,7 +2,7 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
|
2
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
6
6
|
import { Buffer } from "node:buffer";
|
|
7
7
|
//#region nodefony/service/webAuthn.ts
|
|
8
8
|
const serviceName = "webauthn";
|
|
@@ -102,7 +102,8 @@ var WebAuthnService = class extends Service {
|
|
|
102
102
|
available: listWebAuthnStores(),
|
|
103
103
|
reason,
|
|
104
104
|
configPath: "security.passkeys.store",
|
|
105
|
-
location: readStoreLocation(this.#store)
|
|
105
|
+
location: readStoreLocation(this.#store),
|
|
106
|
+
connector: readStoreConnector(this.#store)
|
|
106
107
|
});
|
|
107
108
|
if (config.passkeys.attestation !== "none") this.log(`passkeys.attestation "${config.passkeys.attestation}" — l'attestation est demandée au navigateur mais N'EST PAS VÉRIFIÉE : ni métadonnées FIDO (MDS), ni certificats racines fabricant, et l'AAGUID n'est pas conservé. Ce réglage ne tient donc pas un AAL3 régulé. Repasser à "none" si la garantie n'est pas requise.`, "WARNING");
|
|
108
109
|
if (config.passkeys.residentKey === "discouraged") this.log("passkeys.residentKey \"discouraged\" — les credentials enrôlés ne sont pas découvrables, or le login par passkey est usernameless (aucun ciblage depuis la requête, anti-énumération). Ces porteurs ne pourront pas se connecter : repasser à \"preferred\" ou \"required\".", "WARNING");
|
|
@@ -134,10 +135,10 @@ var WebAuthnService = class extends Service {
|
|
|
134
135
|
* pour empêcher un double enregistrement sur le même authenticator (§7.1).
|
|
135
136
|
*/
|
|
136
137
|
async generateRegistrationOptions(user) {
|
|
137
|
-
this.#ensureReady();
|
|
138
|
+
const { store, config } = this.#ensureReady();
|
|
138
139
|
const lib = await this.#ensureLib();
|
|
139
|
-
const pk =
|
|
140
|
-
const existing = await
|
|
140
|
+
const pk = config.passkeys;
|
|
141
|
+
const existing = await store.findByUser(user.id);
|
|
141
142
|
return lib.generateRegistrationOptions({
|
|
142
143
|
rpName: this.#rpName,
|
|
143
144
|
rpID: this.#rpID,
|
|
@@ -168,23 +169,23 @@ var WebAuthnService = class extends Service {
|
|
|
168
169
|
* @throws WebAuthnError (409) — plafond `passkeys.maxPerUser` atteint.
|
|
169
170
|
*/
|
|
170
171
|
async verifyRegistration(response, expectedChallenge, userId, requestOrigin) {
|
|
171
|
-
this.#ensureReady();
|
|
172
|
+
const { store, config } = this.#ensureReady();
|
|
172
173
|
const lib = await this.#ensureLib();
|
|
173
|
-
const pk =
|
|
174
|
+
const pk = config.passkeys;
|
|
174
175
|
let verification;
|
|
175
176
|
try {
|
|
176
177
|
verification = await lib.verifyRegistrationResponse({
|
|
177
178
|
response,
|
|
178
179
|
expectedChallenge,
|
|
179
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
180
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
180
181
|
expectedRPID: this.#rpID,
|
|
181
182
|
requireUserVerification: pk.userVerification === "required"
|
|
182
183
|
});
|
|
183
184
|
} catch {
|
|
184
185
|
throw new AuthenticationError("WebAuthn registration failed");
|
|
185
186
|
}
|
|
186
|
-
if (!verification.verified
|
|
187
|
-
if (await
|
|
187
|
+
if (!verification.verified) throw new AuthenticationError("WebAuthn registration failed");
|
|
188
|
+
if (await store.countByUser(userId) >= pk.maxPerUser) throw new WebAuthnError(`passkey limit reached (${pk.maxPerUser})`, 409);
|
|
188
189
|
const info = verification.registrationInfo;
|
|
189
190
|
const credential = {
|
|
190
191
|
id: info.credential.id,
|
|
@@ -198,7 +199,7 @@ var WebAuthnService = class extends Service {
|
|
|
198
199
|
createdAt: Date.now(),
|
|
199
200
|
lastUsedAt: null
|
|
200
201
|
};
|
|
201
|
-
await
|
|
202
|
+
await store.save(credential);
|
|
202
203
|
return credential;
|
|
203
204
|
}
|
|
204
205
|
/**
|
|
@@ -215,17 +216,17 @@ var WebAuthnService = class extends Service {
|
|
|
215
216
|
* `WebAuthnController.loginOptions()` ne cible que depuis la session.
|
|
216
217
|
*/
|
|
217
218
|
async generateAuthenticationOptions(userId) {
|
|
218
|
-
this.#ensureReady();
|
|
219
|
+
const { store, config } = this.#ensureReady();
|
|
219
220
|
const lib = await this.#ensureLib();
|
|
220
|
-
const pk =
|
|
221
|
+
const pk = config.passkeys;
|
|
221
222
|
let allowCredentials;
|
|
222
|
-
if (userId) allowCredentials = (await
|
|
223
|
+
if (userId) allowCredentials = (await store.findByUser(userId)).map((c) => ({
|
|
223
224
|
id: c.id,
|
|
224
225
|
transports: [...c.transports]
|
|
225
226
|
}));
|
|
226
227
|
return lib.generateAuthenticationOptions({
|
|
227
228
|
rpID: this.#rpID,
|
|
228
|
-
allowCredentials,
|
|
229
|
+
...allowCredentials !== void 0 && { allowCredentials },
|
|
229
230
|
userVerification: pk.userVerification,
|
|
230
231
|
timeout: pk.timeoutMs
|
|
231
232
|
});
|
|
@@ -239,17 +240,17 @@ var WebAuthnService = class extends Service {
|
|
|
239
240
|
* @throws AuthenticationError (401) — credential inconnu ou vérification échouée.
|
|
240
241
|
*/
|
|
241
242
|
async verifyAuthentication(response, expectedChallenge, requestOrigin) {
|
|
242
|
-
this.#ensureReady();
|
|
243
|
+
const { store, config } = this.#ensureReady();
|
|
243
244
|
const lib = await this.#ensureLib();
|
|
244
|
-
const pk =
|
|
245
|
-
const stored = await
|
|
245
|
+
const pk = config.passkeys;
|
|
246
|
+
const stored = await store.findById(response.id);
|
|
246
247
|
if (!stored) throw new AuthenticationError("WebAuthn authentication failed");
|
|
247
248
|
let verification;
|
|
248
249
|
try {
|
|
249
250
|
verification = await lib.verifyAuthenticationResponse({
|
|
250
251
|
response,
|
|
251
252
|
expectedChallenge,
|
|
252
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
253
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
253
254
|
expectedRPID: this.#rpID,
|
|
254
255
|
credential: {
|
|
255
256
|
id: stored.id,
|
|
@@ -264,7 +265,7 @@ var WebAuthnService = class extends Service {
|
|
|
264
265
|
}
|
|
265
266
|
if (!verification.verified) throw new AuthenticationError("WebAuthn authentication failed");
|
|
266
267
|
const info = verification.authenticationInfo;
|
|
267
|
-
await
|
|
268
|
+
await store.update(stored.id, {
|
|
268
269
|
signCount: info.newCounter,
|
|
269
270
|
backupState: info.credentialBackedUp,
|
|
270
271
|
uvInitialized: stored.uvInitialized || info.userVerified,
|
|
@@ -277,8 +278,7 @@ var WebAuthnService = class extends Service {
|
|
|
277
278
|
}
|
|
278
279
|
/** Liste les credentials d'un utilisateur (UX « mes appareils »). */
|
|
279
280
|
listUserCredentials(userId) {
|
|
280
|
-
this.#ensureReady();
|
|
281
|
-
return this.#store.findByUser(userId);
|
|
281
|
+
return this.#ensureReady().store.findByUser(userId);
|
|
282
282
|
}
|
|
283
283
|
/**
|
|
284
284
|
* Page de passkeys pour le data plane admin (vue TRANSVERSE : « quels appareils
|
|
@@ -289,21 +289,18 @@ var WebAuthnService = class extends Service {
|
|
|
289
289
|
* du store exclut la clé publique.
|
|
290
290
|
*/
|
|
291
291
|
listCredentialsPage(query) {
|
|
292
|
-
this.#ensureReady();
|
|
293
|
-
return this.#store.listPage(query);
|
|
292
|
+
return this.#ensureReady().store.listPage(query);
|
|
294
293
|
}
|
|
295
294
|
/**
|
|
296
295
|
* Nombre de passkeys correspondant aux filtres, ou `-1` si le backend ne sait
|
|
297
296
|
* pas compter à coût raisonnable (Redis).
|
|
298
297
|
*/
|
|
299
298
|
countCredentials(query) {
|
|
300
|
-
this.#ensureReady();
|
|
301
|
-
return this.#store.countCredentials(query);
|
|
299
|
+
return this.#ensureReady().store.countCredentials(query);
|
|
302
300
|
}
|
|
303
301
|
/** Révoque un credential (retrait d'un appareil). */
|
|
304
302
|
removeCredential(credentialId) {
|
|
305
|
-
this.#ensureReady();
|
|
306
|
-
return this.#store.delete(credentialId);
|
|
303
|
+
return this.#ensureReady().store.delete(credentialId);
|
|
307
304
|
}
|
|
308
305
|
/**
|
|
309
306
|
* Supprime un credential **du propriétaire** (self-service, anti-IDOR) : la
|
|
@@ -312,10 +309,10 @@ var WebAuthnService = class extends Service {
|
|
|
312
309
|
* credential d'autrui).
|
|
313
310
|
*/
|
|
314
311
|
async removeUserCredential(userId, credentialId) {
|
|
315
|
-
this.#ensureReady();
|
|
316
|
-
const cred = await
|
|
312
|
+
const { store } = this.#ensureReady();
|
|
313
|
+
const cred = await store.findById(credentialId);
|
|
317
314
|
if (!cred || cred.userId !== userId) return false;
|
|
318
|
-
await
|
|
315
|
+
await store.delete(credentialId);
|
|
319
316
|
return true;
|
|
320
317
|
}
|
|
321
318
|
/**
|
|
@@ -323,9 +320,11 @@ var WebAuthnService = class extends Service {
|
|
|
323
320
|
* priorité (prod). À défaut : l'origine de la requête est acceptée **seulement
|
|
324
321
|
* si son hostname == rpID** (dev : `localhost:port` quel que soit le port, sans
|
|
325
322
|
* jamais ouvrir à un domaine tiers). Dernier recours : `https://{rpID}`.
|
|
323
|
+
*
|
|
324
|
+
* @param origins - liste blanche `passkeys.origins` de la config prête.
|
|
325
|
+
* @param requestOrigin - origine HTTP de la requête, si connue.
|
|
326
326
|
*/
|
|
327
|
-
#expectedOrigin(requestOrigin) {
|
|
328
|
-
const origins = this.#config.passkeys.origins;
|
|
327
|
+
#expectedOrigin(origins, requestOrigin) {
|
|
329
328
|
if (origins.length > 0) return [...origins];
|
|
330
329
|
if (requestOrigin) try {
|
|
331
330
|
if (new URL(requestOrigin).hostname === this.#rpID) return requestOrigin;
|
|
@@ -335,8 +334,17 @@ var WebAuthnService = class extends Service {
|
|
|
335
334
|
async #ensureLib() {
|
|
336
335
|
return this.#lib ??= await import("@simplewebauthn/server");
|
|
337
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
|
|
339
|
+
* store et la config typés non nuls (ni l'un ni l'autre ne sont réassignés
|
|
340
|
+
* après le boot — les capturer ici équivaut à les relire plus tard).
|
|
341
|
+
*/
|
|
338
342
|
#ensureReady() {
|
|
339
343
|
if (!this.#ready || !this.#store || !this.#config) throw new Error("WebAuthnService: non initialisé (passkeys désactivés ou boot échoué)");
|
|
344
|
+
return {
|
|
345
|
+
store: this.#store,
|
|
346
|
+
config: this.#config
|
|
347
|
+
};
|
|
340
348
|
}
|
|
341
349
|
};
|
|
342
350
|
//#endregion
|
|
@@ -6,7 +6,7 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
|
|
|
6
6
|
import { assertPublicUrl } from "../src/net/ssrfGuard.js";
|
|
7
7
|
import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
|
|
8
8
|
import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { schemaMismatchOf } from "@nodefony/http";
|
|
@@ -104,7 +104,7 @@ var WebhookService = class extends Service {
|
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
this.#audit = audit;
|
|
107
|
-
|
|
107
|
+
const dispatcher = new WebhookDispatcher({
|
|
108
108
|
endpointCount: () => this.endpointCount(),
|
|
109
109
|
getSnapshot: () => this.getSnapshot(),
|
|
110
110
|
secretOf: (ep) => this.decryptEndpointSecret(ep).toString("utf8"),
|
|
@@ -122,7 +122,8 @@ var WebhookService = class extends Service {
|
|
|
122
122
|
},
|
|
123
123
|
log: (m) => this.log(m, "ERROR")
|
|
124
124
|
});
|
|
125
|
-
this.#
|
|
125
|
+
this.#dispatcher = dispatcher;
|
|
126
|
+
this.#unsubscribe = audit.subscribe((e) => dispatcher.onAuditEvent(e));
|
|
126
127
|
this.log("webhooks dispatcher abonné à l'audit", "DEBUG");
|
|
127
128
|
}
|
|
128
129
|
/** Arrêt propre : désabonnement audit + annulation des retries en vol. */
|
|
@@ -146,7 +147,8 @@ var WebhookService = class extends Service {
|
|
|
146
147
|
available: listWebhookStores(),
|
|
147
148
|
reason: "adapter posé au container (infra database déclarée)",
|
|
148
149
|
configPath: "security.webhooks.store",
|
|
149
|
-
location: readStoreLocation(existing)
|
|
150
|
+
location: readStoreLocation(existing),
|
|
151
|
+
connector: readStoreConnector(existing)
|
|
150
152
|
});
|
|
151
153
|
return existing;
|
|
152
154
|
}
|
|
@@ -179,7 +181,8 @@ var WebhookService = class extends Service {
|
|
|
179
181
|
available: listWebhookStores(),
|
|
180
182
|
reason,
|
|
181
183
|
configPath: "security.webhooks.store",
|
|
182
|
-
location: readStoreLocation(store)
|
|
184
|
+
location: readStoreLocation(store),
|
|
185
|
+
connector: readStoreConnector(store)
|
|
183
186
|
});
|
|
184
187
|
return store;
|
|
185
188
|
}
|
|
@@ -260,14 +263,14 @@ var WebhookService = class extends Service {
|
|
|
260
263
|
* @throws SsrfError si l'URL est invalide / cible non publique.
|
|
261
264
|
*/
|
|
262
265
|
async register(input) {
|
|
263
|
-
this.#assertReady();
|
|
264
|
-
await this.#assertSafeUrl(input.url);
|
|
266
|
+
const { store, key, config } = this.#assertReady();
|
|
267
|
+
await this.#assertSafeUrl(config, input.url);
|
|
265
268
|
const secret = generateSecret();
|
|
266
269
|
const now = Date.now();
|
|
267
270
|
const endpoint = {
|
|
268
271
|
id: generateId(),
|
|
269
272
|
url: input.url,
|
|
270
|
-
secretEnc: encryptSecret(Buffer.from(secret, "utf8"),
|
|
273
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), key),
|
|
271
274
|
events: [...input.events],
|
|
272
275
|
enabled: input.enabled ?? true,
|
|
273
276
|
description: input.description ?? null,
|
|
@@ -281,7 +284,7 @@ var WebhookService = class extends Service {
|
|
|
281
284
|
failureCount: 0,
|
|
282
285
|
metadata: input.metadata ?? {}
|
|
283
286
|
};
|
|
284
|
-
await
|
|
287
|
+
await store.save(endpoint);
|
|
285
288
|
this.#endpoints?.set(endpoint.id, endpoint);
|
|
286
289
|
return {
|
|
287
290
|
endpoint: toSummary(endpoint),
|
|
@@ -296,8 +299,7 @@ var WebhookService = class extends Service {
|
|
|
296
299
|
* @returns la page, chaque endpoint réduit à sa vue publique.
|
|
297
300
|
*/
|
|
298
301
|
async listPage(query) {
|
|
299
|
-
this.#assertReady();
|
|
300
|
-
const page = await this.#store.listPage(query);
|
|
302
|
+
const page = await this.#assertReady().store.listPage(query);
|
|
301
303
|
return {
|
|
302
304
|
...page,
|
|
303
305
|
items: page.items.map(toSummary)
|
|
@@ -310,8 +312,7 @@ var WebhookService = class extends Service {
|
|
|
310
312
|
* @returns le compte exact, ou `-1` si le backend ne sait pas compter.
|
|
311
313
|
*/
|
|
312
314
|
async countEndpoints(query) {
|
|
313
|
-
this.#assertReady();
|
|
314
|
-
return this.#store.countEndpoints(query);
|
|
315
|
+
return this.#assertReady().store.countEndpoints(query);
|
|
315
316
|
}
|
|
316
317
|
/**
|
|
317
318
|
* Les compteurs de tête de la console — posés sur la collection ENTIÈRE, pas
|
|
@@ -324,16 +325,15 @@ var WebhookService = class extends Service {
|
|
|
324
325
|
* @param query - filtres à appliquer avant comptage (sans fenêtre).
|
|
325
326
|
*/
|
|
326
327
|
async countWebhookFacets(query) {
|
|
327
|
-
this.#assertReady();
|
|
328
|
-
return countFacets(WEBHOOK_FACETS, (facet) =>
|
|
328
|
+
const { store } = this.#assertReady();
|
|
329
|
+
return countFacets(WEBHOOK_FACETS, (facet) => store.countEndpoints({
|
|
329
330
|
...query,
|
|
330
331
|
...facet
|
|
331
332
|
}));
|
|
332
333
|
}
|
|
333
334
|
/** Un endpoint par id (vue publique), ou `null`. */
|
|
334
335
|
async getEndpoint(id) {
|
|
335
|
-
this.#assertReady();
|
|
336
|
-
const found = await this.#store.findById(id);
|
|
336
|
+
const found = await this.#assertReady().store.findById(id);
|
|
337
337
|
return found ? toSummary(found) : null;
|
|
338
338
|
}
|
|
339
339
|
/**
|
|
@@ -342,15 +342,15 @@ var WebhookService = class extends Service {
|
|
|
342
342
|
* ou `null` si absent.
|
|
343
343
|
*/
|
|
344
344
|
async update(id, patch) {
|
|
345
|
-
this.#assertReady();
|
|
346
|
-
const current = await
|
|
345
|
+
const { store, config } = this.#assertReady();
|
|
346
|
+
const current = await store.findById(id);
|
|
347
347
|
if (!current) return null;
|
|
348
|
-
if (patch.url !== void 0 && patch.url !== current.url) await this.#assertSafeUrl(patch.url);
|
|
348
|
+
if (patch.url !== void 0 && patch.url !== current.url) await this.#assertSafeUrl(config, patch.url);
|
|
349
349
|
const applied = {
|
|
350
350
|
...patch,
|
|
351
351
|
updatedAt: Date.now()
|
|
352
352
|
};
|
|
353
|
-
await
|
|
353
|
+
await store.update(id, applied);
|
|
354
354
|
const next = {
|
|
355
355
|
...current,
|
|
356
356
|
...applied
|
|
@@ -367,15 +367,15 @@ var WebhookService = class extends Service {
|
|
|
367
367
|
* L'ancien cesse immédiatement d'être valide. `null` si l'endpoint est absent.
|
|
368
368
|
*/
|
|
369
369
|
async rotateSecret(id) {
|
|
370
|
-
this.#assertReady();
|
|
371
|
-
const current = await
|
|
370
|
+
const { store, key } = this.#assertReady();
|
|
371
|
+
const current = await store.findById(id);
|
|
372
372
|
if (!current) return null;
|
|
373
373
|
const secret = generateSecret();
|
|
374
374
|
const patch = {
|
|
375
|
-
secretEnc: encryptSecret(Buffer.from(secret, "utf8"),
|
|
375
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), key),
|
|
376
376
|
updatedAt: Date.now()
|
|
377
377
|
};
|
|
378
|
-
await
|
|
378
|
+
await store.update(id, patch);
|
|
379
379
|
const next = {
|
|
380
380
|
...current,
|
|
381
381
|
...patch
|
|
@@ -391,16 +391,16 @@ var WebhookService = class extends Service {
|
|
|
391
391
|
* par l'appelant). `null` si absent.
|
|
392
392
|
*/
|
|
393
393
|
async revealSecret(id) {
|
|
394
|
-
this.#assertReady();
|
|
395
|
-
const current = await
|
|
394
|
+
const { store, key } = this.#assertReady();
|
|
395
|
+
const current = await store.findById(id);
|
|
396
396
|
if (!current) return null;
|
|
397
|
-
return decryptSecret(current.secretEnc,
|
|
397
|
+
return decryptSecret(current.secretEnc, key).toString("utf8");
|
|
398
398
|
}
|
|
399
399
|
/** Supprime un endpoint. Retourne `false` si absent. */
|
|
400
400
|
async delete(id) {
|
|
401
|
-
this.#assertReady();
|
|
402
|
-
if (!await
|
|
403
|
-
await
|
|
401
|
+
const { store } = this.#assertReady();
|
|
402
|
+
if (!await store.findById(id)) return false;
|
|
403
|
+
await store.delete(id);
|
|
404
404
|
this.#endpoints?.delete(id);
|
|
405
405
|
this.#deliveries?.delete(id);
|
|
406
406
|
return true;
|
|
@@ -420,7 +420,7 @@ var WebhookService = class extends Service {
|
|
|
420
420
|
* sur l'issue FINALE d'une livraison.
|
|
421
421
|
*/
|
|
422
422
|
#recordDelivery(id, rec) {
|
|
423
|
-
|
|
423
|
+
this.#deliveries ??= /* @__PURE__ */ new Map();
|
|
424
424
|
let ring = this.#deliveries.get(id);
|
|
425
425
|
if (ring === void 0) {
|
|
426
426
|
ring = [];
|
|
@@ -459,13 +459,11 @@ var WebhookService = class extends Service {
|
|
|
459
459
|
}
|
|
460
460
|
/** Déchiffre le secret de signature d'un endpoint (pour signer une livraison). */
|
|
461
461
|
decryptEndpointSecret(endpoint) {
|
|
462
|
-
this.#assertReady();
|
|
463
|
-
return decryptSecret(endpoint.secretEnc, this.#key);
|
|
462
|
+
return decryptSecret(endpoint.secretEnc, this.#assertReady().key);
|
|
464
463
|
}
|
|
465
464
|
/** Politique de livraison (tolérance/retries/timeout…) issue de la config. */
|
|
466
465
|
getDeliveryPolicy() {
|
|
467
|
-
this.#assertReady();
|
|
468
|
-
const w = this.#config.webhooks;
|
|
466
|
+
const w = this.#assertReady().config.webhooks;
|
|
469
467
|
return {
|
|
470
468
|
timestampToleranceS: w.timestampToleranceS,
|
|
471
469
|
maxRetries: w.maxRetries,
|
|
@@ -483,12 +481,14 @@ var WebhookService = class extends Service {
|
|
|
483
481
|
* l'endpoint au-delà du seuil (façon GitHub). Le succès remet le compteur à 0.
|
|
484
482
|
*/
|
|
485
483
|
async markDelivery(id, result) {
|
|
486
|
-
|
|
487
|
-
const
|
|
484
|
+
const config = this.#config;
|
|
485
|
+
const store = this.#store;
|
|
486
|
+
if (!this.#ready || !store || !config) return;
|
|
487
|
+
const current = await store.findById(id);
|
|
488
488
|
if (!current) return;
|
|
489
489
|
const failureCount = result.ok ? 0 : current.failureCount + 1;
|
|
490
490
|
const now = Date.now();
|
|
491
|
-
const threshold =
|
|
491
|
+
const threshold = config.webhooks.autoDisableThreshold;
|
|
492
492
|
const disable = !result.ok && threshold > 0 && failureCount >= threshold;
|
|
493
493
|
const patch = {
|
|
494
494
|
lastDeliveryAt: now,
|
|
@@ -509,17 +509,28 @@ var WebhookService = class extends Service {
|
|
|
509
509
|
reason: "max_failures"
|
|
510
510
|
});
|
|
511
511
|
}
|
|
512
|
-
await
|
|
512
|
+
await store.update(id, patch);
|
|
513
513
|
this.#endpoints?.set(id, {
|
|
514
514
|
...current,
|
|
515
515
|
...patch
|
|
516
516
|
});
|
|
517
517
|
}
|
|
518
|
+
/**
|
|
519
|
+
* Garde d'initialisation : lève si le service n'est pas prêt, sinon rend le
|
|
520
|
+
* store, la clé de chiffrement et la config typés non nuls. Les quatre champs
|
|
521
|
+
* sont posés ensemble au boot et jamais réassignés — tester aussi `#config`
|
|
522
|
+
* ne change donc rien au verdict.
|
|
523
|
+
*/
|
|
518
524
|
#assertReady() {
|
|
519
|
-
if (!this.#ready || !this.#store || !this.#key) throw new Error("webhooks indisponibles (désactivés ou mal configurés)");
|
|
525
|
+
if (!this.#ready || !this.#store || !this.#key || !this.#config) throw new Error("webhooks indisponibles (désactivés ou mal configurés)");
|
|
526
|
+
return {
|
|
527
|
+
store: this.#store,
|
|
528
|
+
key: this.#key,
|
|
529
|
+
config: this.#config
|
|
530
|
+
};
|
|
520
531
|
}
|
|
521
|
-
async #assertSafeUrl(url) {
|
|
522
|
-
const w =
|
|
532
|
+
async #assertSafeUrl(config, url) {
|
|
533
|
+
const w = config.webhooks;
|
|
523
534
|
await assertPublicUrl(url, {
|
|
524
535
|
allowPrivate: !w.denyPrivateIps,
|
|
525
536
|
allowHttp: w.allowHttp
|
|
@@ -527,7 +538,7 @@ var WebhookService = class extends Service {
|
|
|
527
538
|
}
|
|
528
539
|
/** Re-contrôle SSRF avant livraison → IP validées à pinner (anti-rebinding). */
|
|
529
540
|
async #resolveTarget(url) {
|
|
530
|
-
const w = this.#config.webhooks;
|
|
541
|
+
const w = this.#assertReady().config.webhooks;
|
|
531
542
|
const { addresses } = await assertPublicUrl(url, {
|
|
532
543
|
allowPrivate: !w.denyPrivateIps,
|
|
533
544
|
allowHttp: w.allowHttp
|
|
@@ -21,6 +21,7 @@ function tokenStoreDriver(className) {
|
|
|
21
21
|
case "DrizzleTokenStore":
|
|
22
22
|
case "MongooseTokenStore": return "orm";
|
|
23
23
|
case "RedisTokenStore": return "redis";
|
|
24
|
+
case void 0:
|
|
24
25
|
default: return null;
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -65,7 +66,7 @@ function parseTokenListQuery(query, sortable) {
|
|
|
65
66
|
function one(query, key) {
|
|
66
67
|
const value = query[key];
|
|
67
68
|
if (value === void 0) return void 0;
|
|
68
|
-
return Array.isArray(value) ? value
|
|
69
|
+
return Array.isArray(value) ? value.at(0) : value;
|
|
69
70
|
}
|
|
70
71
|
/** Taille de page du journal d'audit quand l'appelant n'en demande pas. */
|
|
71
72
|
const AUDIT_DEFAULT_LIMIT = 100;
|
|
@@ -19,7 +19,7 @@ async function cascadeUserRevocation(container, event, now = Date.now()) {
|
|
|
19
19
|
await container.get("sessions")?.destroyByUser?.(identifier);
|
|
20
20
|
} catch {}
|
|
21
21
|
try {
|
|
22
|
-
await container.get("tokenStore")?.revokeAllForSubject
|
|
22
|
+
await container.get("tokenStore")?.revokeAllForSubject(identifier, now);
|
|
23
23
|
} catch {}
|
|
24
24
|
}
|
|
25
25
|
/**
|
|
@@ -41,7 +41,7 @@ const CRC_TABLE = (() => {
|
|
|
41
41
|
/** CRC32 (IEEE) d'une chaîne ASCII — entier non signé 32 bits. */
|
|
42
42
|
function crc32(input) {
|
|
43
43
|
let crc = 4294967295;
|
|
44
|
-
for (let i = 0; i < input.length; i++) crc = CRC_TABLE[(crc ^ input.charCodeAt(i)) & 255] ^ crc >>> 8;
|
|
44
|
+
for (let i = 0; i < input.length; i++) crc = (CRC_TABLE[(crc ^ input.charCodeAt(i)) & 255] ?? 0) ^ crc >>> 8;
|
|
45
45
|
return (crc ^ 4294967295) >>> 0;
|
|
46
46
|
}
|
|
47
47
|
/** Chunk de checksum (6 car. base64url) du payload `prefix_pubid_secret`. */
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { assertPageQuery } from "nodefony";
|
|
2
2
|
//#region nodefony/src/audit/MemoryAuditStore.ts
|
|
3
3
|
const DEFAULT_MAX_ENTRIES = 1e4;
|
|
4
|
-
const DEFAULT_LIMIT = 100;
|
|
5
4
|
const MAX_LIMIT = 500;
|
|
6
5
|
/**
|
|
7
6
|
* Sépare les deux composantes du curseur. Le curseur est **composite**
|
|
@@ -44,12 +43,9 @@ var MemoryAuditStore = class {
|
|
|
44
43
|
}
|
|
45
44
|
listPage(query) {
|
|
46
45
|
assertPageQuery(query, "cursor");
|
|
47
|
-
const limit = Math.min(Math.max(1, query.limit
|
|
46
|
+
const limit = Math.min(Math.max(1, query.limit), MAX_LIMIT);
|
|
48
47
|
const matched = [];
|
|
49
|
-
for (
|
|
50
|
-
const event = this.#events[i];
|
|
51
|
-
if (this.#matches(event, query)) matched.push(event);
|
|
52
|
-
}
|
|
48
|
+
for (const event of this.#events) if (this.#matches(event, query)) matched.push(event);
|
|
53
49
|
const total = matched.length;
|
|
54
50
|
matched.sort((a, b) => a.ts !== b.ts ? b.ts - a.ts : a.id < b.id ? 1 : -1);
|
|
55
51
|
let page = matched;
|
|
@@ -60,7 +56,7 @@ var MemoryAuditStore = class {
|
|
|
60
56
|
}
|
|
61
57
|
const hasNext = page.length > limit;
|
|
62
58
|
const items = hasNext ? page.slice(0, limit) : page;
|
|
63
|
-
const last = items
|
|
59
|
+
const last = items.at(-1);
|
|
64
60
|
return Promise.resolve({
|
|
65
61
|
items,
|
|
66
62
|
limit,
|
|
@@ -87,7 +83,7 @@ var MemoryAuditStore = class {
|
|
|
87
83
|
gc(now = this.#now()) {
|
|
88
84
|
const threshold = now - this.#retentionMs;
|
|
89
85
|
let purged = 0;
|
|
90
|
-
while (this.#events
|
|
86
|
+
while ((this.#events[0]?.ts ?? threshold) < threshold) {
|
|
91
87
|
this.#events.shift();
|
|
92
88
|
purged++;
|
|
93
89
|
}
|
|
@@ -39,11 +39,15 @@ function createAuditBridge(source, publish, channel, opts = {}) {
|
|
|
39
39
|
let timer = null;
|
|
40
40
|
const flush = () => {
|
|
41
41
|
timer = null;
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
const buf = ring;
|
|
43
|
+
if (buf === null || count === 0 && dropped === 0) return;
|
|
44
|
+
const events = [];
|
|
45
|
+
for (let i = 0; i < count; i++) {
|
|
46
|
+
const event = buf[(head + i) % maxBatch];
|
|
47
|
+
if (event !== void 0) events.push(event);
|
|
48
|
+
}
|
|
45
49
|
const d = dropped;
|
|
46
|
-
for (let i = 0; i < maxBatch; i++)
|
|
50
|
+
for (let i = 0; i < maxBatch; i++) buf[i] = void 0;
|
|
47
51
|
head = 0;
|
|
48
52
|
count = 0;
|
|
49
53
|
dropped = 0;
|
|
@@ -53,7 +57,7 @@ function createAuditBridge(source, publish, channel, opts = {}) {
|
|
|
53
57
|
});
|
|
54
58
|
};
|
|
55
59
|
const onEvent = (event) => {
|
|
56
|
-
|
|
60
|
+
ring ??= new Array(maxBatch);
|
|
57
61
|
if (count === maxBatch) {
|
|
58
62
|
ring[head] = event;
|
|
59
63
|
head = (head + 1) % maxBatch;
|
|
@@ -50,14 +50,14 @@ var ApiKeyAuthenticator = class {
|
|
|
50
50
|
}
|
|
51
51
|
/** La requête porte-t-elle un `Authorization: Bearer <prefix>_…` ? (test bon marché) */
|
|
52
52
|
supports(context) {
|
|
53
|
-
const auth = context.request?.headers
|
|
53
|
+
const auth = context.request?.headers.authorization;
|
|
54
54
|
if (typeof auth !== "string") return false;
|
|
55
55
|
const token = bearerToken(auth);
|
|
56
56
|
return token !== null && looksLikeApiKey(token, this.#prefix);
|
|
57
57
|
}
|
|
58
58
|
/** Extrait la valeur brute (non vérifiée) → portée par un `UserToken` type `"apikey"`. */
|
|
59
59
|
createToken(context) {
|
|
60
|
-
const auth = context.request?.headers
|
|
60
|
+
const auth = context.request?.headers.authorization;
|
|
61
61
|
return Promise.resolve(new UserToken("apikey", bearerToken(auth) ?? ""));
|
|
62
62
|
}
|
|
63
63
|
/**
|
|
@@ -77,7 +77,7 @@ var ApiKeyAuthenticator = class {
|
|
|
77
77
|
const store = this.#resolveStore();
|
|
78
78
|
const record = await store.findByHash(parsed.secretHash);
|
|
79
79
|
const now = Date.now();
|
|
80
|
-
if (
|
|
80
|
+
if (record?.kind !== "pat" || record.revokedAt !== null || record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError(INVALID_TOKEN);
|
|
81
81
|
const invalidBefore = await store.getInvalidBefore(record.subjectId);
|
|
82
82
|
if (invalidBefore !== null && record.createdAt < invalidBefore) throw new AuthenticationError(INVALID_TOKEN);
|
|
83
83
|
const user = await this.#resolveUserOrReject(record.subjectId);
|
|
@@ -83,7 +83,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
83
83
|
* un jeton d'un émetteur inconnu n'a pas à provoquer le moindre travail.
|
|
84
84
|
*/
|
|
85
85
|
supports(context) {
|
|
86
|
-
const auth = context.request?.headers
|
|
86
|
+
const auth = context.request?.headers.authorization;
|
|
87
87
|
if (typeof auth !== "string") return false;
|
|
88
88
|
const raw = bearerToken(auth);
|
|
89
89
|
if (raw === null) return false;
|
|
@@ -104,7 +104,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
104
104
|
* visée.
|
|
105
105
|
*/
|
|
106
106
|
createToken(context) {
|
|
107
|
-
const auth = context.request?.headers
|
|
107
|
+
const auth = context.request?.headers.authorization;
|
|
108
108
|
const token = new UserToken("external-jwt", bearerToken(auth) ?? "");
|
|
109
109
|
const area = context.security;
|
|
110
110
|
if (area?.resource) token.setAttribute(AUDIENCE, area.resource);
|
|
@@ -100,7 +100,7 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
100
100
|
const jti = issued.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
|
|
101
101
|
const resolveStore = this.#resolveStore;
|
|
102
102
|
if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
|
|
103
|
-
Nodefony.getKernel()?.log
|
|
103
|
+
Nodefony.getKernel()?.log(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
|
|
104
104
|
return () => Promise.resolve(false);
|
|
105
105
|
}
|
|
106
106
|
return async (nowMs = Date.now()) => {
|
|
@@ -149,7 +149,7 @@ function buildSessionRevalidator(identifier) {
|
|
|
149
149
|
const id = session?.id;
|
|
150
150
|
const storage = session?.storage;
|
|
151
151
|
if (typeof id !== "string" || !storage || typeof storage.read !== "function") {
|
|
152
|
-
Nodefony.getKernel()?.log
|
|
152
|
+
Nodefony.getKernel()?.log(`Realtime session token "${identifier}" has no revalidatable session at handshake — connection will be revoked (fail-closed). Ensure the realtime zone runs after startSession.`, "WARNING");
|
|
153
153
|
return () => Promise.resolve(false);
|
|
154
154
|
}
|
|
155
155
|
return async () => {
|
|
@@ -58,7 +58,7 @@ var JwtAuthenticator = class {
|
|
|
58
58
|
* qu'un credential qui disparaîtrait sans laisser de trace.
|
|
59
59
|
*/
|
|
60
60
|
supports(context) {
|
|
61
|
-
const auth = context.request?.headers
|
|
61
|
+
const auth = context.request?.headers.authorization;
|
|
62
62
|
if (typeof auth !== "string") return false;
|
|
63
63
|
const token = bearerToken(auth);
|
|
64
64
|
if (token === null || !COMPACT_JWS.test(token)) return false;
|
|
@@ -67,7 +67,7 @@ var JwtAuthenticator = class {
|
|
|
67
67
|
}
|
|
68
68
|
/** Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`. */
|
|
69
69
|
createToken(context) {
|
|
70
|
-
const auth = context.request?.headers
|
|
70
|
+
const auth = context.request?.headers.authorization;
|
|
71
71
|
return Promise.resolve(new UserToken("jwt", bearerToken(auth) ?? ""));
|
|
72
72
|
}
|
|
73
73
|
/**
|