@nodefony/security 10.0.0-alpha.9 → 10.0.0-beta.2
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 +7 -5
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.152.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-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +3 -3
- 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 +13 -2
- package/dist/nodefony/service/tokenService.js +27 -18
- package/dist/nodefony/service/webAuthn.js +38 -31
- package/dist/nodefony/service/webhooks.js +50 -41
- 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 +1 -1
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +6 -2
- 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 +7 -6
- 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/docs/api-keys.md +16 -16
- package/docs/audit.md +27 -20
- package/docs/authenticators.md +8 -8
- package/docs/authorization.md +15 -15
- package/docs/cors.md +7 -7
- package/docs/csrf.md +8 -8
- package/docs/external-jwt.md +19 -10
- package/docs/firewall.md +28 -28
- package/docs/headers.md +20 -20
- package/docs/index.md +6 -4
- package/docs/lexique.md +15 -15
- package/docs/oauth2.md +23 -20
- package/docs/tokens.md +11 -10
- package/docs/totp.md +15 -14
- package/docs/webauthn.md +51 -54
- package/docs/webhooks.md +27 -30
- package/package.json +13 -13
|
@@ -135,10 +135,10 @@ var WebAuthnService = class extends Service {
|
|
|
135
135
|
* pour empêcher un double enregistrement sur le même authenticator (§7.1).
|
|
136
136
|
*/
|
|
137
137
|
async generateRegistrationOptions(user) {
|
|
138
|
-
this.#ensureReady();
|
|
138
|
+
const { store, config } = this.#ensureReady();
|
|
139
139
|
const lib = await this.#ensureLib();
|
|
140
|
-
const pk =
|
|
141
|
-
const existing = await
|
|
140
|
+
const pk = config.passkeys;
|
|
141
|
+
const existing = await store.findByUser(user.id);
|
|
142
142
|
return lib.generateRegistrationOptions({
|
|
143
143
|
rpName: this.#rpName,
|
|
144
144
|
rpID: this.#rpID,
|
|
@@ -169,23 +169,23 @@ var WebAuthnService = class extends Service {
|
|
|
169
169
|
* @throws WebAuthnError (409) — plafond `passkeys.maxPerUser` atteint.
|
|
170
170
|
*/
|
|
171
171
|
async verifyRegistration(response, expectedChallenge, userId, requestOrigin) {
|
|
172
|
-
this.#ensureReady();
|
|
172
|
+
const { store, config } = this.#ensureReady();
|
|
173
173
|
const lib = await this.#ensureLib();
|
|
174
|
-
const pk =
|
|
174
|
+
const pk = config.passkeys;
|
|
175
175
|
let verification;
|
|
176
176
|
try {
|
|
177
177
|
verification = await lib.verifyRegistrationResponse({
|
|
178
178
|
response,
|
|
179
179
|
expectedChallenge,
|
|
180
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
180
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
181
181
|
expectedRPID: this.#rpID,
|
|
182
182
|
requireUserVerification: pk.userVerification === "required"
|
|
183
183
|
});
|
|
184
184
|
} catch {
|
|
185
185
|
throw new AuthenticationError("WebAuthn registration failed");
|
|
186
186
|
}
|
|
187
|
-
if (!verification.verified
|
|
188
|
-
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);
|
|
189
189
|
const info = verification.registrationInfo;
|
|
190
190
|
const credential = {
|
|
191
191
|
id: info.credential.id,
|
|
@@ -199,7 +199,7 @@ var WebAuthnService = class extends Service {
|
|
|
199
199
|
createdAt: Date.now(),
|
|
200
200
|
lastUsedAt: null
|
|
201
201
|
};
|
|
202
|
-
await
|
|
202
|
+
await store.save(credential);
|
|
203
203
|
return credential;
|
|
204
204
|
}
|
|
205
205
|
/**
|
|
@@ -216,17 +216,17 @@ var WebAuthnService = class extends Service {
|
|
|
216
216
|
* `WebAuthnController.loginOptions()` ne cible que depuis la session.
|
|
217
217
|
*/
|
|
218
218
|
async generateAuthenticationOptions(userId) {
|
|
219
|
-
this.#ensureReady();
|
|
219
|
+
const { store, config } = this.#ensureReady();
|
|
220
220
|
const lib = await this.#ensureLib();
|
|
221
|
-
const pk =
|
|
221
|
+
const pk = config.passkeys;
|
|
222
222
|
let allowCredentials;
|
|
223
|
-
if (userId) allowCredentials = (await
|
|
223
|
+
if (userId) allowCredentials = (await store.findByUser(userId)).map((c) => ({
|
|
224
224
|
id: c.id,
|
|
225
225
|
transports: [...c.transports]
|
|
226
226
|
}));
|
|
227
227
|
return lib.generateAuthenticationOptions({
|
|
228
228
|
rpID: this.#rpID,
|
|
229
|
-
allowCredentials,
|
|
229
|
+
...allowCredentials !== void 0 && { allowCredentials },
|
|
230
230
|
userVerification: pk.userVerification,
|
|
231
231
|
timeout: pk.timeoutMs
|
|
232
232
|
});
|
|
@@ -240,17 +240,17 @@ var WebAuthnService = class extends Service {
|
|
|
240
240
|
* @throws AuthenticationError (401) — credential inconnu ou vérification échouée.
|
|
241
241
|
*/
|
|
242
242
|
async verifyAuthentication(response, expectedChallenge, requestOrigin) {
|
|
243
|
-
this.#ensureReady();
|
|
243
|
+
const { store, config } = this.#ensureReady();
|
|
244
244
|
const lib = await this.#ensureLib();
|
|
245
|
-
const pk =
|
|
246
|
-
const stored = await
|
|
245
|
+
const pk = config.passkeys;
|
|
246
|
+
const stored = await store.findById(response.id);
|
|
247
247
|
if (!stored) throw new AuthenticationError("WebAuthn authentication failed");
|
|
248
248
|
let verification;
|
|
249
249
|
try {
|
|
250
250
|
verification = await lib.verifyAuthenticationResponse({
|
|
251
251
|
response,
|
|
252
252
|
expectedChallenge,
|
|
253
|
-
expectedOrigin: this.#expectedOrigin(requestOrigin),
|
|
253
|
+
expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
|
|
254
254
|
expectedRPID: this.#rpID,
|
|
255
255
|
credential: {
|
|
256
256
|
id: stored.id,
|
|
@@ -265,7 +265,7 @@ var WebAuthnService = class extends Service {
|
|
|
265
265
|
}
|
|
266
266
|
if (!verification.verified) throw new AuthenticationError("WebAuthn authentication failed");
|
|
267
267
|
const info = verification.authenticationInfo;
|
|
268
|
-
await
|
|
268
|
+
await store.update(stored.id, {
|
|
269
269
|
signCount: info.newCounter,
|
|
270
270
|
backupState: info.credentialBackedUp,
|
|
271
271
|
uvInitialized: stored.uvInitialized || info.userVerified,
|
|
@@ -278,8 +278,7 @@ var WebAuthnService = class extends Service {
|
|
|
278
278
|
}
|
|
279
279
|
/** Liste les credentials d'un utilisateur (UX « mes appareils »). */
|
|
280
280
|
listUserCredentials(userId) {
|
|
281
|
-
this.#ensureReady();
|
|
282
|
-
return this.#store.findByUser(userId);
|
|
281
|
+
return this.#ensureReady().store.findByUser(userId);
|
|
283
282
|
}
|
|
284
283
|
/**
|
|
285
284
|
* Page de passkeys pour le data plane admin (vue TRANSVERSE : « quels appareils
|
|
@@ -290,21 +289,18 @@ var WebAuthnService = class extends Service {
|
|
|
290
289
|
* du store exclut la clé publique.
|
|
291
290
|
*/
|
|
292
291
|
listCredentialsPage(query) {
|
|
293
|
-
this.#ensureReady();
|
|
294
|
-
return this.#store.listPage(query);
|
|
292
|
+
return this.#ensureReady().store.listPage(query);
|
|
295
293
|
}
|
|
296
294
|
/**
|
|
297
295
|
* Nombre de passkeys correspondant aux filtres, ou `-1` si le backend ne sait
|
|
298
296
|
* pas compter à coût raisonnable (Redis).
|
|
299
297
|
*/
|
|
300
298
|
countCredentials(query) {
|
|
301
|
-
this.#ensureReady();
|
|
302
|
-
return this.#store.countCredentials(query);
|
|
299
|
+
return this.#ensureReady().store.countCredentials(query);
|
|
303
300
|
}
|
|
304
301
|
/** Révoque un credential (retrait d'un appareil). */
|
|
305
302
|
removeCredential(credentialId) {
|
|
306
|
-
this.#ensureReady();
|
|
307
|
-
return this.#store.delete(credentialId);
|
|
303
|
+
return this.#ensureReady().store.delete(credentialId);
|
|
308
304
|
}
|
|
309
305
|
/**
|
|
310
306
|
* Supprime un credential **du propriétaire** (self-service, anti-IDOR) : la
|
|
@@ -313,10 +309,10 @@ var WebAuthnService = class extends Service {
|
|
|
313
309
|
* credential d'autrui).
|
|
314
310
|
*/
|
|
315
311
|
async removeUserCredential(userId, credentialId) {
|
|
316
|
-
this.#ensureReady();
|
|
317
|
-
const cred = await
|
|
312
|
+
const { store } = this.#ensureReady();
|
|
313
|
+
const cred = await store.findById(credentialId);
|
|
318
314
|
if (!cred || cred.userId !== userId) return false;
|
|
319
|
-
await
|
|
315
|
+
await store.delete(credentialId);
|
|
320
316
|
return true;
|
|
321
317
|
}
|
|
322
318
|
/**
|
|
@@ -324,9 +320,11 @@ var WebAuthnService = class extends Service {
|
|
|
324
320
|
* priorité (prod). À défaut : l'origine de la requête est acceptée **seulement
|
|
325
321
|
* si son hostname == rpID** (dev : `localhost:port` quel que soit le port, sans
|
|
326
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.
|
|
327
326
|
*/
|
|
328
|
-
#expectedOrigin(requestOrigin) {
|
|
329
|
-
const origins = this.#config.passkeys.origins;
|
|
327
|
+
#expectedOrigin(origins, requestOrigin) {
|
|
330
328
|
if (origins.length > 0) return [...origins];
|
|
331
329
|
if (requestOrigin) try {
|
|
332
330
|
if (new URL(requestOrigin).hostname === this.#rpID) return requestOrigin;
|
|
@@ -336,8 +334,17 @@ var WebAuthnService = class extends Service {
|
|
|
336
334
|
async #ensureLib() {
|
|
337
335
|
return this.#lib ??= await import("@simplewebauthn/server");
|
|
338
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
|
+
*/
|
|
339
342
|
#ensureReady() {
|
|
340
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
|
+
};
|
|
341
348
|
}
|
|
342
349
|
};
|
|
343
350
|
//#endregion
|
|
@@ -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. */
|
|
@@ -262,14 +263,14 @@ var WebhookService = class extends Service {
|
|
|
262
263
|
* @throws SsrfError si l'URL est invalide / cible non publique.
|
|
263
264
|
*/
|
|
264
265
|
async register(input) {
|
|
265
|
-
this.#assertReady();
|
|
266
|
-
await this.#assertSafeUrl(input.url);
|
|
266
|
+
const { store, key, config } = this.#assertReady();
|
|
267
|
+
await this.#assertSafeUrl(config, input.url);
|
|
267
268
|
const secret = generateSecret();
|
|
268
269
|
const now = Date.now();
|
|
269
270
|
const endpoint = {
|
|
270
271
|
id: generateId(),
|
|
271
272
|
url: input.url,
|
|
272
|
-
secretEnc: encryptSecret(Buffer.from(secret, "utf8"),
|
|
273
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), key),
|
|
273
274
|
events: [...input.events],
|
|
274
275
|
enabled: input.enabled ?? true,
|
|
275
276
|
description: input.description ?? null,
|
|
@@ -283,7 +284,7 @@ var WebhookService = class extends Service {
|
|
|
283
284
|
failureCount: 0,
|
|
284
285
|
metadata: input.metadata ?? {}
|
|
285
286
|
};
|
|
286
|
-
await
|
|
287
|
+
await store.save(endpoint);
|
|
287
288
|
this.#endpoints?.set(endpoint.id, endpoint);
|
|
288
289
|
return {
|
|
289
290
|
endpoint: toSummary(endpoint),
|
|
@@ -298,8 +299,7 @@ var WebhookService = class extends Service {
|
|
|
298
299
|
* @returns la page, chaque endpoint réduit à sa vue publique.
|
|
299
300
|
*/
|
|
300
301
|
async listPage(query) {
|
|
301
|
-
this.#assertReady();
|
|
302
|
-
const page = await this.#store.listPage(query);
|
|
302
|
+
const page = await this.#assertReady().store.listPage(query);
|
|
303
303
|
return {
|
|
304
304
|
...page,
|
|
305
305
|
items: page.items.map(toSummary)
|
|
@@ -312,8 +312,7 @@ var WebhookService = class extends Service {
|
|
|
312
312
|
* @returns le compte exact, ou `-1` si le backend ne sait pas compter.
|
|
313
313
|
*/
|
|
314
314
|
async countEndpoints(query) {
|
|
315
|
-
this.#assertReady();
|
|
316
|
-
return this.#store.countEndpoints(query);
|
|
315
|
+
return this.#assertReady().store.countEndpoints(query);
|
|
317
316
|
}
|
|
318
317
|
/**
|
|
319
318
|
* Les compteurs de tête de la console — posés sur la collection ENTIÈRE, pas
|
|
@@ -326,16 +325,15 @@ var WebhookService = class extends Service {
|
|
|
326
325
|
* @param query - filtres à appliquer avant comptage (sans fenêtre).
|
|
327
326
|
*/
|
|
328
327
|
async countWebhookFacets(query) {
|
|
329
|
-
this.#assertReady();
|
|
330
|
-
return countFacets(WEBHOOK_FACETS, (facet) =>
|
|
328
|
+
const { store } = this.#assertReady();
|
|
329
|
+
return countFacets(WEBHOOK_FACETS, (facet) => store.countEndpoints({
|
|
331
330
|
...query,
|
|
332
331
|
...facet
|
|
333
332
|
}));
|
|
334
333
|
}
|
|
335
334
|
/** Un endpoint par id (vue publique), ou `null`. */
|
|
336
335
|
async getEndpoint(id) {
|
|
337
|
-
this.#assertReady();
|
|
338
|
-
const found = await this.#store.findById(id);
|
|
336
|
+
const found = await this.#assertReady().store.findById(id);
|
|
339
337
|
return found ? toSummary(found) : null;
|
|
340
338
|
}
|
|
341
339
|
/**
|
|
@@ -344,15 +342,15 @@ var WebhookService = class extends Service {
|
|
|
344
342
|
* ou `null` si absent.
|
|
345
343
|
*/
|
|
346
344
|
async update(id, patch) {
|
|
347
|
-
this.#assertReady();
|
|
348
|
-
const current = await
|
|
345
|
+
const { store, config } = this.#assertReady();
|
|
346
|
+
const current = await store.findById(id);
|
|
349
347
|
if (!current) return null;
|
|
350
|
-
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);
|
|
351
349
|
const applied = {
|
|
352
350
|
...patch,
|
|
353
351
|
updatedAt: Date.now()
|
|
354
352
|
};
|
|
355
|
-
await
|
|
353
|
+
await store.update(id, applied);
|
|
356
354
|
const next = {
|
|
357
355
|
...current,
|
|
358
356
|
...applied
|
|
@@ -369,15 +367,15 @@ var WebhookService = class extends Service {
|
|
|
369
367
|
* L'ancien cesse immédiatement d'être valide. `null` si l'endpoint est absent.
|
|
370
368
|
*/
|
|
371
369
|
async rotateSecret(id) {
|
|
372
|
-
this.#assertReady();
|
|
373
|
-
const current = await
|
|
370
|
+
const { store, key } = this.#assertReady();
|
|
371
|
+
const current = await store.findById(id);
|
|
374
372
|
if (!current) return null;
|
|
375
373
|
const secret = generateSecret();
|
|
376
374
|
const patch = {
|
|
377
|
-
secretEnc: encryptSecret(Buffer.from(secret, "utf8"),
|
|
375
|
+
secretEnc: encryptSecret(Buffer.from(secret, "utf8"), key),
|
|
378
376
|
updatedAt: Date.now()
|
|
379
377
|
};
|
|
380
|
-
await
|
|
378
|
+
await store.update(id, patch);
|
|
381
379
|
const next = {
|
|
382
380
|
...current,
|
|
383
381
|
...patch
|
|
@@ -393,16 +391,16 @@ var WebhookService = class extends Service {
|
|
|
393
391
|
* par l'appelant). `null` si absent.
|
|
394
392
|
*/
|
|
395
393
|
async revealSecret(id) {
|
|
396
|
-
this.#assertReady();
|
|
397
|
-
const current = await
|
|
394
|
+
const { store, key } = this.#assertReady();
|
|
395
|
+
const current = await store.findById(id);
|
|
398
396
|
if (!current) return null;
|
|
399
|
-
return decryptSecret(current.secretEnc,
|
|
397
|
+
return decryptSecret(current.secretEnc, key).toString("utf8");
|
|
400
398
|
}
|
|
401
399
|
/** Supprime un endpoint. Retourne `false` si absent. */
|
|
402
400
|
async delete(id) {
|
|
403
|
-
this.#assertReady();
|
|
404
|
-
if (!await
|
|
405
|
-
await
|
|
401
|
+
const { store } = this.#assertReady();
|
|
402
|
+
if (!await store.findById(id)) return false;
|
|
403
|
+
await store.delete(id);
|
|
406
404
|
this.#endpoints?.delete(id);
|
|
407
405
|
this.#deliveries?.delete(id);
|
|
408
406
|
return true;
|
|
@@ -422,7 +420,7 @@ var WebhookService = class extends Service {
|
|
|
422
420
|
* sur l'issue FINALE d'une livraison.
|
|
423
421
|
*/
|
|
424
422
|
#recordDelivery(id, rec) {
|
|
425
|
-
|
|
423
|
+
this.#deliveries ??= /* @__PURE__ */ new Map();
|
|
426
424
|
let ring = this.#deliveries.get(id);
|
|
427
425
|
if (ring === void 0) {
|
|
428
426
|
ring = [];
|
|
@@ -461,13 +459,11 @@ var WebhookService = class extends Service {
|
|
|
461
459
|
}
|
|
462
460
|
/** Déchiffre le secret de signature d'un endpoint (pour signer une livraison). */
|
|
463
461
|
decryptEndpointSecret(endpoint) {
|
|
464
|
-
this.#assertReady();
|
|
465
|
-
return decryptSecret(endpoint.secretEnc, this.#key);
|
|
462
|
+
return decryptSecret(endpoint.secretEnc, this.#assertReady().key);
|
|
466
463
|
}
|
|
467
464
|
/** Politique de livraison (tolérance/retries/timeout…) issue de la config. */
|
|
468
465
|
getDeliveryPolicy() {
|
|
469
|
-
this.#assertReady();
|
|
470
|
-
const w = this.#config.webhooks;
|
|
466
|
+
const w = this.#assertReady().config.webhooks;
|
|
471
467
|
return {
|
|
472
468
|
timestampToleranceS: w.timestampToleranceS,
|
|
473
469
|
maxRetries: w.maxRetries,
|
|
@@ -485,12 +481,14 @@ var WebhookService = class extends Service {
|
|
|
485
481
|
* l'endpoint au-delà du seuil (façon GitHub). Le succès remet le compteur à 0.
|
|
486
482
|
*/
|
|
487
483
|
async markDelivery(id, result) {
|
|
488
|
-
|
|
489
|
-
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);
|
|
490
488
|
if (!current) return;
|
|
491
489
|
const failureCount = result.ok ? 0 : current.failureCount + 1;
|
|
492
490
|
const now = Date.now();
|
|
493
|
-
const threshold =
|
|
491
|
+
const threshold = config.webhooks.autoDisableThreshold;
|
|
494
492
|
const disable = !result.ok && threshold > 0 && failureCount >= threshold;
|
|
495
493
|
const patch = {
|
|
496
494
|
lastDeliveryAt: now,
|
|
@@ -511,17 +509,28 @@ var WebhookService = class extends Service {
|
|
|
511
509
|
reason: "max_failures"
|
|
512
510
|
});
|
|
513
511
|
}
|
|
514
|
-
await
|
|
512
|
+
await store.update(id, patch);
|
|
515
513
|
this.#endpoints?.set(id, {
|
|
516
514
|
...current,
|
|
517
515
|
...patch
|
|
518
516
|
});
|
|
519
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
|
+
*/
|
|
520
524
|
#assertReady() {
|
|
521
|
-
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
|
+
};
|
|
522
531
|
}
|
|
523
|
-
async #assertSafeUrl(url) {
|
|
524
|
-
const w =
|
|
532
|
+
async #assertSafeUrl(config, url) {
|
|
533
|
+
const w = config.webhooks;
|
|
525
534
|
await assertPublicUrl(url, {
|
|
526
535
|
allowPrivate: !w.denyPrivateIps,
|
|
527
536
|
allowHttp: w.allowHttp
|
|
@@ -529,7 +538,7 @@ var WebhookService = class extends Service {
|
|
|
529
538
|
}
|
|
530
539
|
/** Re-contrôle SSRF avant livraison → IP validées à pinner (anti-rebinding). */
|
|
531
540
|
async #resolveTarget(url) {
|
|
532
|
-
const w = this.#config.webhooks;
|
|
541
|
+
const w = this.#assertReady().config.webhooks;
|
|
533
542
|
const { addresses } = await assertPublicUrl(url, {
|
|
534
543
|
allowPrivate: !w.denyPrivateIps,
|
|
535
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
|
/**
|
|
@@ -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 ? {
|