@nodefony/security 10.0.0-beta.6 → 10.0.0-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +4 -2
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +17 -6
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  8. package/dist/nodefony/errors/SsrfError.js +9 -1
  9. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  10. package/dist/nodefony/errors/index.js +3 -1
  11. package/dist/nodefony/keycloak/scaffold.js +31 -14
  12. package/dist/nodefony/service/accessTokenVerifier.js +1 -1
  13. package/dist/nodefony/service/auditService.js +48 -3
  14. package/dist/nodefony/service/authFlow.js +6 -3
  15. package/dist/nodefony/service/firewall.js +48 -3
  16. package/dist/nodefony/service/tokenService.js +8 -3
  17. package/dist/nodefony/service/totp.js +7 -2
  18. package/dist/nodefony/service/webAuthn.js +45 -6
  19. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  20. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
  21. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
  22. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  23. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  24. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  25. package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
  26. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  27. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  28. package/dist/types/index.d.ts +4 -1
  29. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  30. package/dist/types/nodefony/config/config.d.ts +6 -0
  31. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  32. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  33. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  34. package/dist/types/nodefony/errors/index.d.ts +3 -0
  35. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  36. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  37. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  38. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  39. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  40. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  41. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  42. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  43. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
  44. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  45. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  46. package/docs/audit.md +112 -28
  47. package/docs/keycloak.md +4 -4
  48. package/docs/obtenir-un-jeton.md +17 -10
  49. package/docs/tokens.md +1 -1
  50. package/docs/webhooks.md +15 -7
  51. package/package.json +13 -13
package/README.md CHANGED
@@ -148,6 +148,11 @@ le trafic courant ne passe pas par l'audit (cold-path)
148
148
  → aucun coût ajouté au hot-path nominal. Activé par défaut (`audit.enabled`, OWASP A09), coût nul si
149
149
  désactivé. Lecture : `GET /nodefony/security/api/audit/events` (RBAC `ROLE_NODEFONY_ADMIN`).
150
150
 
151
+ Un **flot de refus** d'une même source (même IP, même motif) ne s'écrit pas ligne à ligne : les
152
+ 10 premiers refus par fenêtre de 60 s sont journalisés tels quels, les suivants comptés, puis une ligne
153
+ de synthèse porte le total (`metadata.aggregated`). Les succès ne sont jamais agrégés. Réglage :
154
+ `audit.aggregate` — détail dans [docs/audit.md](docs/audit.md).
155
+
151
156
  **Flux temps réel** (lot 4) : canal WS **`nodefony:audit`**, réservé `ROLE_NODEFONY_ADMIN`
152
157
  (plancher système, un cran au-dessus de l'observabilité générique `ROLE_ADMIN`). Enregistré comme
153
158
  **canal système** sur le hub realtime (servable par tout endpoint, sans couplage à Studio) ;
@@ -178,6 +183,25 @@ Un **secret n'entre jamais** dans un événement — seule sa _présence_ est tr
178
183
 
179
184
  - `AuthenticationError` → `401` (non authentifié).
180
185
  - `AccessDeniedError` → `403` (authentifié mais non autorisé).
186
+ - `SsrfError` → `422` (adresse sortante interdite, redirection comprise).
187
+ - `FetchPublicError` → `504` (délai total) ou `502` (trop de redirections, réseau).
188
+
189
+ ## Requêtes sortantes sûres
190
+
191
+ `fetchPublic(url, options)` lit une adresse fournie par un humain sans atteindre le réseau interne : IP vérifiée
192
+ puis épinglée à chaque saut, redirections comptées et revérifiées (`maxRedirects`, 0 par défaut), délai TOTAL
193
+ (`timeoutMs`), corps coupé à `maxBytes` et non décodé, ni cookie ni `Authorization`. Les webhooks passent par elle.
194
+
195
+ ```ts
196
+ import { fetchPublic } from "@nodefony/security";
197
+
198
+ const page = await fetchPublic(url, {
199
+ maxRedirects: 3,
200
+ maxBytes: 262_144,
201
+ timeoutMs: 4000,
202
+ });
203
+ console.log(page.status, new TextDecoder().decode(page.body));
204
+ ```
181
205
 
182
206
  ## Licence
183
207
 
@@ -1,4 +1,4 @@
1
- // \0@oxc-project+runtime@0.157.0/helpers/esm/decorate.js
1
+ // \0@oxc-project+runtime@0.158.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
package/dist/index.js CHANGED
@@ -28,7 +28,7 @@ import SecurityOAuthDoctor from "./nodefony/command/security-oauth-doctor.js";
28
28
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
29
29
  import { registerUserRevocationCascade, usersEventBusNotice } from "./nodefony/src/admin/userRevocationCascade.js";
30
30
  import { defineSecurityConfig, securityConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
31
- import __decorate from "./_virtual/_@oxc-project_runtime@0.157.0/helpers/esm/decorate.js";
31
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
32
32
  import { RemoteJwtVerifier } from "./nodefony/src/token/RemoteJwtVerifier.js";
33
33
  import { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
34
34
  import { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
@@ -73,6 +73,7 @@ import { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakL
73
73
  import { createDiscoveredOidcProvider, createOidcProvider, readLogoutTokenClaims } from "./nodefony/src/oauth/providers/oidc.js";
74
74
  import { SsrfError } from "./nodefony/errors/SsrfError.js";
75
75
  import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
76
+ import { FetchPublicError } from "./nodefony/errors/FetchPublicError.js";
76
77
  import { MemoryAuditStore } from "./nodefony/src/audit/MemoryAuditStore.js";
77
78
  import { getAuditStoreFactory, listAuditStores, registerAuditStore } from "./nodefony/src/audit/auditStoreRegistry.js";
78
79
  import { recordAudit } from "./nodefony/src/audit/recordAudit.js";
@@ -86,6 +87,7 @@ import { MemoryWebhookStore } from "./nodefony/src/webhook/MemoryWebhookStore.js
86
87
  import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "./nodefony/src/webhook/webhookSort.js";
87
88
  import { getWebhookStoreFactory, listWebhookStores, registerWebhookStore } from "./nodefony/src/webhook/webhookStoreRegistry.js";
88
89
  import { assertPublicUrl, isBlockedAddress } from "./nodefony/src/net/ssrfGuard.js";
90
+ import { fetchPublic } from "./nodefony/src/net/fetchPublic.js";
89
91
  import { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey } from "./nodefony/src/crypto/secretCipher.js";
90
92
  import { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
91
93
  import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "./nodefony/src/transport/CredentialTransportPolicy.js";
@@ -186,4 +188,4 @@ Security = __decorate([services([
186
188
  KeycloakService
187
189
  ])], Security);
188
190
  var security_default = Security;
189
- export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, CredentialTransportPolicy, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InsecureTransportError, InvalidTargetError, JwtAuthenticator, JwtKeystore, KeycloakAdminClient, KeycloakAdminError, KeycloakService, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, amrForFactor, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, diagnoseOAuthProvider, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, keycloakAdminTarget, keycloakLoginThemeCheck, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, oauthProviderRequiresIssuer, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, readKeycloakLoginTheme, readLogoutTokenClaims, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, requestClientCredentialsToken, resolveJwtRuntime, securityConfigJsonSchema, shouldEnforceCredentialTransport, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
191
+ export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, CredentialTransportPolicy, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, FetchPublicError, Firewall, InsecureTransportError, InvalidTargetError, JwtAuthenticator, JwtKeystore, KeycloakAdminClient, KeycloakAdminError, KeycloakService, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, amrForFactor, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, diagnoseOAuthProvider, disableTotp, discoverAuthorizationServer, encryptSecret, fetchPublic, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, keycloakAdminTarget, keycloakLoginThemeCheck, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, oauthProviderRequiresIssuer, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, readKeycloakLoginTheme, readLogoutTokenClaims, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, requestClientCredentialsToken, resolveJwtRuntime, securityConfigJsonSchema, shouldEnforceCredentialTransport, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
@@ -1,5 +1,5 @@
1
1
  import { messageNonRestreint, modeNonRestreint, readIfPresentSync, writeSecretSync } from "../src/token/secretFile.js";
2
- import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
2
+ import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents, stripTrailingSlashes } from "nodefony";
3
3
  import path from "node:path";
4
4
  import { existsSync } from "node:fs";
5
5
  import { spawnSync } from "node:child_process";
@@ -61,6 +61,30 @@ function tokenTtlSeconds(raw, write) {
61
61
  if (raw !== void 0) return ttlSeconds(raw);
62
62
  return write ? AGENT_TTL_MINUTES * 60 : void 0;
63
63
  }
64
+ /** Une URI de ressource sous une forme comparable : sans barre finale. */
65
+ function comparableResource(uri) {
66
+ try {
67
+ const url = new URL(uri);
68
+ return `${url.origin}${stripTrailingSlashes(url.pathname)}`;
69
+ } catch {
70
+ return stripTrailingSlashes(uri.trim());
71
+ }
72
+ }
73
+ /**
74
+ * Le jeton vise-t-il une AUTRE porte que celle de l'outillage ?
75
+ *
76
+ * `NF_MCP_TOKEN` et `--write` servent la porte par défaut, et elle seule : un
77
+ * jeton d'une autre audience (RFC 8707) y prendrait sa place, et cette porte le
78
+ * refuserait — ses outils réservés disparaîtraient sans un mot.
79
+ *
80
+ * @param resource - la valeur de `--resource`, ou rien
81
+ * @param defaultResource - la porte par défaut de l'application
82
+ * @returns `true` quand `--resource` désigne une autre porte
83
+ */
84
+ function targetsOtherGate(resource, defaultResource) {
85
+ if (resource === void 0) return false;
86
+ return comparableResource(resource) !== comparableResource(defaultResource);
87
+ }
64
88
  /** « 8 h », « 1 h 30 », « 15 min » — la durée telle qu'on la dit. */
65
89
  function humanDuration(seconds) {
66
90
  const minutes = Math.round(seconds / 60);
@@ -274,11 +298,20 @@ var SecurityMcpToken = class extends Command {
274
298
  process.exitCode = 1;
275
299
  return this;
276
300
  }
277
- const resource = opts.resource ?? this.#defaultResource();
301
+ const defaultResource = this.#defaultResource();
302
+ const resource = opts.resource ?? defaultResource;
303
+ const otherGate = targetsOtherGate(opts.resource, defaultResource);
304
+ if (otherGate && opts.write === true) {
305
+ this.log(`--write refusé pour cette porte.
306
+
307
+ --write pose ${MCP_TOKEN_ENV}, le jeton de la porte de l'outillage\n (${defaultResource}). Ce jeton-ci a pour audience ${resource} :\n il y prendrait sa place, cette porte le refuserait, et ses outils\n réservés disparaîtraient sans un mot.\n\n → relance sans --write, puis pose le jeton dans l'en-tête de\n l'agent qui parle à ${resource}.`, "ERROR");
308
+ process.exitCode = 1;
309
+ return this;
310
+ }
278
311
  const requestedScopes = (opts.scope ?? "").split(/\s+/u).filter(Boolean);
279
312
  const scopes = requestedScopes.length > 0 ? requestedScopes : [ADMIN_SCOPE_READ];
280
313
  let write = opts.write === true;
281
- if (!write && !opts.json && process.stdin.isTTY) {
314
+ if (!write && !opts.json && !otherGate && process.stdin.isTTY) {
282
315
  await this.loadPrompts();
283
316
  write = await this.prompts.confirm({
284
317
  message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
@@ -295,7 +328,7 @@ var SecurityMcpToken = class extends Command {
295
328
  try {
296
329
  issued = await tokens.issueTokens(user, scopes, resource, ttlS);
297
330
  } catch (e) {
298
- if (e.oauthError === "invalid_target") {
331
+ if ((typeof e === "object" && e !== null && "oauthError" in e ? e.oauthError : void 0) === "invalid_target") {
299
332
  const env = this.kernel?.environment ?? "?";
300
333
  const enDev = env === "development";
301
334
  this.log(`impossible d'émettre un jeton pour cette porte ici.
@@ -366,9 +399,14 @@ var SecurityMcpToken = class extends Command {
366
399
  w(`${DIM} Vibe et Codex prennent le NOM de la variable, pas le secret — déclare la porte une fois :${RESET}\n\n ${DIM}vibe mcp add nodefony --transport streamable-http \\${RESET}\n ${DIM} --url ${resource} --api-key-env ${MCP_TOKEN_ENV}${RESET}\n ${DIM}codex mcp add nodefony --url ${resource} \\${RESET}\n ${DIM} --bearer-token-env-var ${MCP_TOKEN_ENV}${RESET}\n\n`);
367
400
  return this;
368
401
  }
402
+ if (otherGate) {
403
+ w(` ${token}\n\n`);
404
+ w(`${DIM} Ce jeton ne vaut que pour ${resource} : pose-le dans l'en-tête\n de l'agent qui parle à cette porte, par exemple :\n claude mcp add --transport http <nom> ${resource} \\\n --header "Authorization: Bearer <ce jeton>"\n Pas dans ${MCP_TOKEN_ENV} : elle porte le jeton de ${defaultResource}.${RESET}\n\n`);
405
+ return this;
406
+ }
369
407
  w(` export ${MCP_TOKEN_ENV}=${token}\n\n`);
370
408
  w(`${DIM} --write pose la valeur chez les agents présents · nodefony ai:mcp --auth câble .mcp.json${RESET}\n\n`);
371
409
  return this;
372
410
  }
373
411
  };
374
- export { SecurityMcpToken as default, tokenTtlSeconds, ttlSeconds };
412
+ export { SecurityMcpToken as default, targetsOtherGate, tokenTtlSeconds, ttlSeconds };
@@ -51,7 +51,7 @@ const areaSchema = z.strictObject({
51
51
  roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
52
52
  entryPoint: z.string().refine((value) => safeRedirectPath(value, "") === value, { message: "entryPoint doit être un chemin de CETTE origine (`/login`), jamais une adresse externe" }).optional().describe("Page de connexion vers laquelle conduire un visiteur anonyme qui NAVIGUE (GET ou HEAD, `Accept: text/html`, `Sec-Fetch-Mode: navigate` s'il est présent) : 302 vers `<entryPoint>?from=<chemin demandé>`, et la page de connexion ramène au chemin demandé. Tout le reste — appel d'API, `fetch`, POST, WebSocket — garde son 401 et son `WWW-Authenticate`. Le refus reste journalisé (`auth.denied`). Absent (défaut) : 401 pour tous."),
53
53
  host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
54
- resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
54
+ resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. L'application peut aussi ÉMETTRE des jetons pour cette ressource (`resource` à l'émission, RFC 8707) sans la recopier dans `jwt.audiences` : elle y est ajoutée après les audiences déclarées, qui gardent l'audience par défaut. Un CHEMIN (« /api/partner ») est rendu absolu contre `jwt.issuer`, l'adresse publique de l'application — la forme d'un module qui apporte sa zone sans connaître cette adresse ; le démarrage refuse si l'émetteur n'est pas une URL http(s). Ex. « https://api.example.com/mcp »."),
55
55
  realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`."),
56
56
  subjectPolicy: z.enum(["require", "ephemeral"]).optional().describe("Surcharge, pour CETTE zone, de `resourceServer.subjectPolicy` — comment le sujet d'un jeton TIERS devient un utilisateur. Omis (défaut) = la politique globale. Sert à faire cohabiter une zone d'appelants machine (« ephemeral » : aucun compte local exigé) et une zone de personnes (« require ») dans la même application ; une politique globale ne sait pas l'exprimer. N'a de sens que si la zone liste `external-jwt` : déclarée ailleurs, le boot la refuse."),
57
57
  ephemeralRoles: z.array(z.string().min(1)).optional().describe("Rôles accordés à l'appelant accepté par CETTE zone en mode « ephemeral ». Exige `subjectPolicy: \"ephemeral\"` écrit dans la zone même : un pouvoir local ne s'hérite pas d'un réglage écrit ailleurs. Une zone qui déclare « ephemeral » sans ses rôles n'en accorde AUCUN — jamais ceux de `resourceServer.ephemeralRoles`, qui protègent une autre surface.")
@@ -191,9 +191,9 @@ const tokenStoreSchema = z.strictObject({
191
191
  }).describe("Store de jetons serveur (refresh, PAT, denylist `jti`, seuil de révocation par porteur) + maintenance gc.");
192
192
  const passkeysSchema = z.strictObject({
193
193
  enabled: z.boolean().default(true).describe("Active WebAuthn/passkeys (MFA phishing-resistant, NIST AAL2)."),
194
- rpId: z.string().optional().describe("Relying Party ID (domaine). Omis = domaine de l'app au boot."),
194
+ rpId: z.string().optional().describe("Relying Party ID (domaine). Omis = domaine de l'app au boot. Un domaine international s'écrit tel quel : il est converti en punycode (`xn--…`) au démarrage, forme que présente le navigateur."),
195
195
  rpName: z.string().optional().describe("Nom lisible de la Relying Party affiché dans l'invite OS/navigateur. Omis = nom de l'app."),
196
- origins: z.array(z.string()).default([]).describe("Origines autorisées aux ceremonies. Vide = origine de l'app."),
196
+ origins: z.array(z.string()).default([]).describe("Origines autorisées aux cérémonies (`scheme://hôte[:port]`). Vide = origine de l'app. Converties en punycode au démarrage ; une entrée qui n'est pas une origine est écartée et nommée en ERROR."),
197
197
  userVerification: z.enum([
198
198
  "required",
199
199
  "preferred",
@@ -321,7 +321,18 @@ const auditSchema = z.strictObject({
321
321
  reserved: true,
322
322
  description: "INERTE — la diffusion realtime dépend de la présence d'abonnés WS (canal `nodefony:audit`), pas de ce drapeau. Le journal est diffusé dès qu'un client s'abonne."
323
323
  }),
324
- retentionDays: z.number().int().default(365)
324
+ retentionDays: z.number().int().default(365),
325
+ aggregate: z.strictObject({
326
+ enabled: z.boolean().default(true).describe("Agrège les REFUS (failure/denied) d'une même source : un flot ne s'écrit pas d'une ligne par requête. Les succès ne sont jamais agrégés."),
327
+ windowMs: z.number().int().positive().default(6e4).describe("Durée d'une fenêtre, en ms, ouverte au premier refus d'une clé (catégorie, action, issue, motif, ressource, IP)."),
328
+ burst: z.number().int().nonnegative().default(10).describe("Refus d'une clé écrits TELS QUELS par fenêtre (acteur compris) ; les suivants sont comptés, puis UNE ligne de synthèse (`metadata.aggregated`) clôt la fenêtre."),
329
+ maxKeys: z.number().int().positive().default(1e4).describe("Clés suivies au plus (mémoire bornée) ; au-delà, les refus se regroupent sous l'IP `*`.")
330
+ }).default(() => ({
331
+ enabled: true,
332
+ windowMs: 6e4,
333
+ burst: 10,
334
+ maxKeys: 1e4
335
+ }))
325
336
  }).describe("Journal d'audit sécurité (login, accès refusé, clés, webhooks).");
326
337
  const studioSchema = z.strictObject({
327
338
  enabled: z.boolean().default(false).describe("Console admin Studio. OFF par défaut (surtout en prod)."),
@@ -425,7 +436,7 @@ const grantableScopesSchema = z.record(z.string(), z.array(z.string().min(1)).mi
425
436
  * cet axe. Étend OU surcharge les défauts système (placée AVANT eux à l'évaluation).
426
437
  */
427
438
  const realtimeChannelRuleSchema = z.strictObject({
428
- pattern: z.string().describe("Préfixe de canal (match `startsWith`) : ex. 'orm:' couvre nodefony:orm:health/nodefony:orm:flow. Placé AVANT les défauts système → permet de surcharger (assouplir/durcir) un namespace réservé."),
439
+ pattern: z.string().describe("Préfixe de canal (match `startsWith`) : ex. 'nodefony:orm:' couvre nodefony:orm:health/nodefony:orm:flow, 'net:' couvre net:health. Placé AVANT les défauts système → permet de surcharger un namespace réservé (le durcir, le re-cibler sur un autre rôle — jamais l'ouvrir à l'anonyme) ou d'ouvrir à l'application un canal à suffixe :health/:stats."),
429
440
  authenticated: z.boolean().optional().describe("Exige une connexion authentifiée (token non anonyme)."),
430
441
  roles: z.array(z.string()).optional().describe("Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles)."),
431
442
  scopes: z.array(z.string()).optional().describe("Un de ces scopes suffit (axe API : JWT/clé API ; une session BFF n'en porte pas).")
@@ -435,7 +446,7 @@ const securityConfigSchema = z.strictObject({
435
446
  roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
436
447
  grantableScopes: grantableScopesSchema,
437
448
  areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
438
- realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
449
+ realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (espace nodefony: + suffixes :health/:stats dans TOUT espace de noms = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
439
450
  cors: corsSchema.default(() => corsSchema.parse({})),
440
451
  csrf: csrfSchema.default(() => csrfSchema.parse({})),
441
452
  headers: headersSchema.default(() => headersSchema.parse({})),
@@ -1,5 +1,5 @@
1
1
  import { securityConfigSchema } from "./config.js";
2
- import { BootConfigurationError, parseModuleConfig } from "nodefony";
2
+ import { BootConfigurationError, parseModuleConfig, stripTrailingSlashes } from "nodefony";
3
3
  import { z } from "zod";
4
4
  // nodefony/config/defineModuleConfig.ts
5
5
  /**
@@ -12,9 +12,60 @@ import { z } from "zod";
12
12
  function defineSecurityConfig(config = {}) {
13
13
  const validated = parseModuleConfig(securityConfigSchema, config, "@nodefony/security");
14
14
  detectConflicts(validated.areas);
15
+ resolveAreaResources(validated.areas, validated.jwt.issuer);
15
16
  return Object.freeze(validated);
16
17
  }
17
18
  /**
19
+ * Rend absolue la `resource` d'une zone écrite comme un CHEMIN (`/api/partner`),
20
+ * contre l'émetteur des jetons de l'application (`jwt.issuer`) — son URL
21
+ * publique, celle que voient les clients.
22
+ *
23
+ * Pourquoi : un MODULE qui apporte sa zone à jeton (surcharge
24
+ * `module-security`) ne connaît pas l'adresse publique de l'application — elle
25
+ * vient de l'environnement, que seul `env.ts` lit. Le chemin est ce qu'il
26
+ * connaît ; l'origine est ce que l'application a déjà ÉCRIT une fois. La valeur
27
+ * reste donc écrite, jamais dérivée du `Host`.
28
+ *
29
+ * Concaténation, pas `new URL(chemin, base)` : un émetteur qui porte un chemin
30
+ * (`https://exemple.com/auth`) le garde, comme le fait déjà l'adresse publique
31
+ * des gabarits (`${publicBaseUrl}/api/…`).
32
+ *
33
+ * @param areas - zones validées (mutées sur place, avant le gel)
34
+ * @param issuer - `jwt.issuer` validé
35
+ * @throws BootConfigurationError si un chemin est relatif au protocole (`//…`),
36
+ * ou si l'émetteur n'est pas une URL http(s) — retirer la zone ouvrirait ses
37
+ * routes à la zone voisine, donc on refuse de démarrer en nommant le geste.
38
+ */
39
+ function resolveAreaResources(areas, issuer) {
40
+ for (const name in areas) {
41
+ const area = areas[name];
42
+ const resource = area?.resource;
43
+ if (area === void 0 || resource === void 0) continue;
44
+ if (!resource.startsWith("/")) continue;
45
+ if (resource.startsWith("//")) throw new BootConfigurationError(`[@nodefony/security] areas.${name}.resource « ${resource} » : un chemin commence par UN seul « / » — « // » désigne un autre hôte.`);
46
+ const base = publicIssuerBase(issuer);
47
+ if (base === null) throw new BootConfigurationError(`[@nodefony/security] areas.${name}.resource « ${resource} » est un chemin, rendu absolu contre jwt.issuer — qui n'est pas une URL http(s) (« ${issuer ?? "nodefony"} »). Pose jwt.issuer à l'adresse publique de l'application (une application générée la lit dans NF_JWT_ISSUER), ou écris la ressource en entier (https://…).`);
48
+ area.resource = `${base}${resource}`;
49
+ }
50
+ }
51
+ /**
52
+ * L'émetteur, sans `/` final, s'il est une URL http(s) — sinon `null`.
53
+ *
54
+ * @param issuer - `jwt.issuer` validé
55
+ * @returns la base contre laquelle résoudre un chemin, ou `null`
56
+ */
57
+ function publicIssuerBase(issuer) {
58
+ if (issuer === void 0) return null;
59
+ let url;
60
+ try {
61
+ url = new URL(issuer);
62
+ } catch {
63
+ return null;
64
+ }
65
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
66
+ return stripTrailingSlashes(issuer);
67
+ }
68
+ /**
18
69
  * JSON Schema introspectable de la config sécurité — **Studio génère son
19
70
  * formulaire d'édition depuis ça** (labels/types/défauts/descriptions),
20
71
  * sans UI hardcodée. Surface du data plane `/nodefony/security/api/config/schema`.
@@ -0,0 +1,18 @@
1
+ import { nodefonyError } from "nodefony";
2
+ // nodefony/errors/FetchPublicError.ts
3
+ /**
4
+ * Échec d'une requête sortante vers un site tiers (`fetchPublic`) qui n'est PAS
5
+ * un refus de cible : délai total dépassé (`504`), trop de redirections ou
6
+ * échec réseau (`502`). Une cible interdite lève `SsrfError` (422), pas
7
+ * celle-ci — l'appelant distingue ainsi « tu n'as pas le droit d'y aller » de
8
+ * « le site n'a pas répondu ».
9
+ */
10
+ var FetchPublicError = class extends nodefonyError {
11
+ /** Cause de l'échec, stable et exploitable sans lire le message. */
12
+ reason;
13
+ constructor(reason, message) {
14
+ super(message, reason === "timeout" ? 504 : 502);
15
+ this.reason = reason;
16
+ }
17
+ };
18
+ export { FetchPublicError };
@@ -9,8 +9,16 @@ import { nodefonyError } from "nodefony";
9
9
  * d'un webhook invalide).
10
10
  */
11
11
  var SsrfError = class extends nodefonyError {
12
- constructor(message = "URL sortante interdite (SSRF)") {
12
+ /**
13
+ * `forbidden` : la cible est interdite (protocole, identifiants, IP non
14
+ * publique). `unresolvable` : l'hôte n'a aucune adresse — rien d'interne n'est
15
+ * visé, le site est simplement injoignable ; un appelant qui rend ces deux cas
16
+ * à l'utilisateur les distingue par ce champ, jamais par le texte du message.
17
+ */
18
+ reason;
19
+ constructor(message = "URL sortante interdite (SSRF)", reason = "forbidden") {
13
20
  super(message, 422);
21
+ this.reason = reason;
14
22
  }
15
23
  };
16
24
  export { SsrfError, SsrfError as default };
@@ -0,0 +1,20 @@
1
+ import { AuthenticationError } from "./AuthenticationError.js";
2
+ // nodefony/errors/StaleSessionError.ts
3
+ /**
4
+ * Session PÉRIMÉE — elle ne désigne plus personne de valide : compte supprimé,
5
+ * verrouillé, désactivé, recréé sous le même identifiant, mot de passe changé
6
+ * depuis son ouverture, ou blob vidé. C'est un ÉTAT à purger, pas une preuve
7
+ * fausse présentée par un tiers : le `SessionAuthenticator` la détruit côté
8
+ * serveur ET côté navigateur (cookie expiré, RFC 6265 §5.3), puis le firewall
9
+ * poursuit comme si aucune session n'avait été présentée — une page ouverte
10
+ * aux anonymes s'affiche, une zone fermée redemande la connexion.
11
+ *
12
+ * Jamais levée pour une PANNE de la source d'identité : une base injoignable
13
+ * ne dit rien de la session, qui doit survivre à l'incident.
14
+ */
15
+ var StaleSessionError = class extends AuthenticationError {
16
+ constructor(message = "Invalid session") {
17
+ super(message);
18
+ }
19
+ };
20
+ export { StaleSessionError, StaleSessionError as default };
@@ -1,10 +1,12 @@
1
1
  import { AuthenticationError } from "./AuthenticationError.js";
2
+ import { StaleSessionError } from "./StaleSessionError.js";
2
3
  import { AccessDeniedError } from "./AccessDeniedError.js";
3
4
  import { ThrottledError } from "./ThrottledError.js";
4
5
  import { UnverifiableTokenError } from "./UnverifiableTokenError.js";
5
6
  import { InvalidTargetError } from "./InvalidTargetError.js";
6
7
  import { CsrfError } from "./CsrfError.js";
7
8
  import { SsrfError } from "./SsrfError.js";
9
+ import { FetchPublicError } from "./FetchPublicError.js";
8
10
  import { WebAuthnError } from "./WebAuthnError.js";
9
11
  import { InsecureTransportError } from "./InsecureTransportError.js";
10
- export { AccessDeniedError, AuthenticationError, CsrfError, InsecureTransportError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };
12
+ export { AccessDeniedError, AuthenticationError, CsrfError, FetchPublicError, InsecureTransportError, InvalidTargetError, SsrfError, StaleSessionError, ThrottledError, UnverifiableTokenError, WebAuthnError };
@@ -25,15 +25,19 @@ const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.json";
25
25
  const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
26
26
  const APP_KEYCLOAK_THEME_DIR = `${APP_KEYCLOAK_THEMES_DIR}/${KEYCLOAK_THEME_NAME}`;
27
27
  /** Fichier de configuration de sécurité d'une application générée. */
28
- const APP_SECURITY_CONFIG_FILE = "nodefony/config/security.ts";
29
28
  /**
30
- * Ressource de l'API partenaire d'une application neuve en développement :
31
- * ce que rend `partnerResource()` du gabarit `complete` sans `NF_JWT_ISSUER`
32
- * ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
33
- * realm. Deux paquets, une valeur : le test de contribution compare le
34
- * gabarit rendu à cette constante.
29
+ * Le fichier où la démo de `create app --demo` (module privé `modules/demo`)
30
+ * déclare sa zone à jeton — la surcharge `module-security` qu'elle apporte.
35
31
  */
36
- const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/api/partner";
32
+ const DEMO_SECURITY_CONFIG_FILE = "modules/demo/nodefony/config/security.ts";
33
+ /**
34
+ * Ressource de l'API partenaire de la démo en développement : le chemin de sa
35
+ * zone (`/demo/api/partner`) rendu absolu contre l'émetteur de développement
36
+ * — le port HTTPS par défaut, comme les URL de retour du realm. Deux paquets,
37
+ * une valeur : le test de contribution la confronte à une application
38
+ * `--demo` réellement rendue.
39
+ */
40
+ const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/demo/api/partner";
37
41
  /** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
38
42
  const THEME_TYPES = [
39
43
  "login",
@@ -124,6 +128,12 @@ function devMachineSecret(appName) {
124
128
  * rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
125
129
  * audiences des zones `external-jwt` que la configuration rendue déclare.
126
130
  *
131
+ * Le client « machine » (`<app>-machine`, `client_credentials`) n'existe que
132
+ * s'il a une audience à recevoir : une application neuve n'ouvre aucune zone à
133
+ * jeton tiers (#746), et un client que rien n'accepte serait un secret de plus
134
+ * à garder pour rien. La démo, qui apporte l'API entre programmes, le fait
135
+ * naître en déclarant sa ressource.
136
+ *
127
137
  * @param appName - nom de l'application (= realm = identifiant de client)
128
138
  * @param themes - types de thème à déclarer
129
139
  * @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
@@ -150,7 +160,7 @@ function appDevRealmInput(appName, themes, ids, audiences = []) {
150
160
  description: "Administrateur de l'application — traduit en ROLE_ADMIN par la table roleMapping du fournisseur keycloak."
151
161
  }],
152
162
  realmRoles: [],
153
- machine: {
163
+ machine: audiences.length === 0 ? null : {
154
164
  clientId: `${appName}-machine`,
155
165
  secret: devMachineSecret(appName),
156
166
  name: `${appName} — appelant machine (compte de service)`,
@@ -191,17 +201,24 @@ function mountsKeycloak(context) {
191
201
  ].some((file) => context.read(file)?.includes("./docker/keycloak/") === true);
192
202
  }
193
203
  /**
194
- * Les audiences que la configuration RENDUE de l'application déclare. Le
195
- * gabarit `complete` ouvre la zone `partner` (`jwt` + `external-jwt`) : sans
196
- * son audience dans le realm, le jeton du client machine y est refusé dès le
197
- * premier essai, et `security:keycloak:realm --check` trouve le realm né en
198
- * désaccord avec la configuration.
204
+ * Les audiences que la configuration RENDUE de l'application déclare. Une
205
+ * application neuve n'en déclare aucune ; la démo ouvre la zone
206
+ * `demo-partner` (`jwt` + `external-jwt`) dans son propre module : sans son
207
+ * audience dans le realm, le jeton du client machine y serait refusé dès le
208
+ * premier essai, et `security:keycloak:realm --check` trouverait le realm né
209
+ * en désaccord avec la configuration.
210
+ *
211
+ * Le fichier de zone de la démo doit écrire le CHEMIN de la ressource : un
212
+ * module qui l'aurait changé n'a pas l'audience de cette constante, et
213
+ * mieux vaut ne rien inscrire qu'une audience que la zone refuserait.
199
214
  *
200
215
  * @param context - contexte de contribution
201
216
  * @returns les ressources à inscrire dans les jetons du realm
202
217
  */
203
218
  function renderedAudiences(context) {
204
- return context.read(APP_SECURITY_CONFIG_FILE)?.includes("partnerResource(") === true ? [APP_PARTNER_DEV_RESOURCE] : [];
219
+ const demo = context.read(DEMO_SECURITY_CONFIG_FILE);
220
+ const resourcePath = new URL(APP_PARTNER_DEV_RESOURCE).pathname;
221
+ return demo?.includes(`"${resourcePath}"`) === true ? [APP_PARTNER_DEV_RESOURCE] : [];
205
222
  }
206
223
  /**
207
224
  * Recopie les thèmes livrés dans l'application — `nodefony` et ses thèmes
@@ -67,7 +67,7 @@ var AccessTokenVerifierService = class extends Service {
67
67
  } catch {
68
68
  return;
69
69
  }
70
- const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt) : null;
70
+ const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt, config.areas) : null;
71
71
  const local = runtime === null ? null : new JwtAuthenticator(this.container, runtime);
72
72
  const { issuers, ...tuning } = config.resourceServer;
73
73
  let remote = null;
@@ -1,5 +1,6 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
2
  import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
3
+ import { AuditRefusalAggregator } from "../src/audit/AuditRefusalAggregator.js";
3
4
  import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
4
5
  import { randomBytes } from "node:crypto";
5
6
  // nodefony/service/auditService.ts
@@ -30,11 +31,21 @@ var AuditService = class extends Service {
30
31
  #idPrefix = "";
31
32
  #seq = 0;
32
33
  #gc = null;
34
+ /** Agrégation des refus — `null` si désactivée par la config. */
35
+ #aggregator = null;
36
+ /** Balayage des fenêtres échues — armé au premier refus suivi, coupé à vide. */
37
+ #sweep = null;
38
+ #windowMs = 0;
33
39
  constructor(module) {
34
40
  super(serviceName, module.container, module.notificationsCenter, module.options);
35
41
  this.module = module;
36
- this.kernel?.once("onBoot", () => this.#build());
37
- this.kernel?.once("onTerminate", () => this.#shutdown());
42
+ this.kernel?.once("onBoot", () => {
43
+ try {
44
+ this.#build();
45
+ } finally {
46
+ this.kernel?.once("onTerminate", () => this.#shutdown());
47
+ }
48
+ });
38
49
  }
39
50
  #build() {
40
51
  let config;
@@ -91,9 +102,17 @@ var AuditService = class extends Service {
91
102
  onError: (e) => this.log(e, "WARNING")
92
103
  });
93
104
  this.#gc.start();
105
+ const aggregate = config.audit.aggregate;
106
+ if (aggregate.enabled) {
107
+ this.#windowMs = aggregate.windowMs;
108
+ this.#aggregator = new AuditRefusalAggregator(aggregate, (draft) => this.#write(draft, Date.now()));
109
+ }
94
110
  this.log(`audit service ready — store "${config.audit.store}", rétention ${config.audit.retentionDays}j`, "DEBUG");
95
111
  }
96
112
  #shutdown() {
113
+ this.#stopSweep();
114
+ this.#aggregator?.flush();
115
+ this.#aggregator = null;
97
116
  this.#gc?.stop();
98
117
  this.#gc = null;
99
118
  this.#listeners = null;
@@ -102,10 +121,21 @@ var AuditService = class extends Service {
102
121
  }
103
122
  record(draft) {
104
123
  if (!this.#enabled || this.#store === null) return;
124
+ const now = Date.now();
125
+ if (draft.outcome !== "success" && this.#aggregator !== null) {
126
+ const written = this.#aggregator.admit(draft, now);
127
+ if (this.#sweep === null) this.#startSweep();
128
+ if (!written) return;
129
+ }
130
+ this.#write(draft, now);
131
+ }
132
+ /** Persiste (fire-and-forget) et diffuse un événement horodaté `ts`. */
133
+ #write(draft, ts) {
134
+ if (this.#store === null) return;
105
135
  const event = {
106
136
  ...draft,
107
137
  id: `${this.#idPrefix}-${(this.#seq++).toString(36)}`,
108
- ts: Date.now()
138
+ ts
109
139
  };
110
140
  this.#store.append(event).catch((error) => this.log(error, "ERROR"));
111
141
  if (this.#listeners !== null) for (const listener of this.#listeners) try {
@@ -114,6 +144,21 @@ var AuditService = class extends Service {
114
144
  this.log(error, "ERROR");
115
145
  }
116
146
  }
147
+ /** Arme le balayage des fenêtres échues (une fois par fenêtre, `unref`). */
148
+ #startSweep() {
149
+ this.#sweep = setInterval(() => {
150
+ const aggregator = this.#aggregator;
151
+ if (aggregator === null) return this.#stopSweep();
152
+ aggregator.sweep(Date.now());
153
+ if (aggregator.size === 0) this.#stopSweep();
154
+ }, this.#windowMs);
155
+ this.#sweep.unref();
156
+ }
157
+ #stopSweep() {
158
+ if (this.#sweep === null) return;
159
+ clearInterval(this.#sweep);
160
+ this.#sweep = null;
161
+ }
117
162
  subscribe(listener) {
118
163
  this.#listeners ??= [];
119
164
  this.#listeners.push(listener);
@@ -284,8 +284,8 @@ var AuthFlow = class extends Service {
284
284
  * (mêmes contrôles que le `SessionAuthenticator` — source unique).
285
285
  *
286
286
  * @returns la projection publique, ou `null` (pas de session, session
287
- * orpheline, compte verrouillé/désactivé) — le handler répond `200`
288
- * `{ user: null }`.
287
+ * orpheline, compte verrouillé/désactivé — la session périmée est alors
288
+ * détruite, cookie compris) — le handler répond `200` `{ user: null }`.
289
289
  */
290
290
  async me(context) {
291
291
  const identifier = context.session?.user;
@@ -295,7 +295,10 @@ var AuthFlow = class extends Service {
295
295
  try {
296
296
  return toSafeUser(await resolveBoundSessionIdentity(this.#resolveUsers(), identifier, context.session?.getMetaBag(ACCOUNT_ID_KEY), context.session?.getMetaBag(CREDENTIALS_VERSION_KEY)));
297
297
  } catch (error) {
298
- if (error instanceof AuthenticationError) return null;
298
+ if (error instanceof AuthenticationError) {
299
+ await context.session?.destroy(true);
300
+ return null;
301
+ }
299
302
  throw error;
300
303
  }
301
304
  }