@nodefony/security 10.0.0-beta.5 → 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 (104) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +63 -61
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +30 -11
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  8. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  9. package/dist/nodefony/errors/SsrfError.js +9 -1
  10. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  11. package/dist/nodefony/errors/index.js +7 -5
  12. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  13. package/dist/nodefony/keycloak/scaffold.js +31 -14
  14. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  15. package/dist/nodefony/service/accessTokenVerifier.js +6 -6
  16. package/dist/nodefony/service/apiKeys.js +8 -4
  17. package/dist/nodefony/service/auditService.js +48 -3
  18. package/dist/nodefony/service/authFlow.js +9 -6
  19. package/dist/nodefony/service/authorization.js +1 -1
  20. package/dist/nodefony/service/firewall.js +281 -44
  21. package/dist/nodefony/service/keycloak.js +1 -1
  22. package/dist/nodefony/service/oauth2.js +8 -4
  23. package/dist/nodefony/service/securityHeaders.js +340 -47
  24. package/dist/nodefony/service/tokenService.js +16 -11
  25. package/dist/nodefony/service/totp.js +10 -5
  26. package/dist/nodefony/service/webAuthn.js +46 -7
  27. package/dist/nodefony/service/webhooks.js +11 -4
  28. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  29. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  30. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  31. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  32. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  33. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
  34. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
  35. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
  36. package/dist/nodefony/src/csp.js +265 -10
  37. package/dist/nodefony/src/cspReports.js +307 -0
  38. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  39. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  40. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  41. package/dist/nodefony/src/oauth/metadata.js +1 -1
  42. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  43. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  44. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  45. package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
  46. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  47. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  48. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  49. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  50. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  51. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  52. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  53. package/dist/nodefony/testing/index.js +26 -36
  54. package/dist/types/index.d.ts +5 -1
  55. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  56. package/dist/types/nodefony/config/config.d.ts +8 -0
  57. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  58. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  59. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  60. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  61. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  62. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  63. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  64. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  65. package/dist/types/nodefony/errors/index.d.ts +3 -0
  66. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  67. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  68. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  69. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  70. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  71. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  72. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  73. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  74. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  75. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  76. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  77. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  78. package/dist/types/nodefony/src/csp.d.ts +142 -0
  79. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  80. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  81. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  82. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  83. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  84. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
  85. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  86. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  87. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  88. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  89. package/dist/types/nodefony/testing/index.d.ts +6 -0
  90. package/docs/api-keys.md +3 -1
  91. package/docs/audit.md +112 -28
  92. package/docs/authenticators.md +6 -4
  93. package/docs/csrf.md +3 -2
  94. package/docs/external-jwt.md +5 -1
  95. package/docs/firewall.md +3 -2
  96. package/docs/headers.md +427 -71
  97. package/docs/keycloak.md +4 -4
  98. package/docs/login-page.md +4 -3
  99. package/docs/obtenir-un-jeton.md +17 -10
  100. package/docs/tokens.md +1 -1
  101. package/docs/totp.md +2 -2
  102. package/docs/webauthn.md +2 -2
  103. package/docs/webhooks.md +63 -18
  104. package/package.json +17 -17
@@ -3,12 +3,32 @@
3
3
  * Dérive les paramètres effectifs depuis la config sécurité. `issuer` omis →
4
4
  * `"nodefony"` (DEVRAIT être surchargé en prod) ; `audiences` vide → l'app est sa
5
5
  * propre audience (`[issuer]`).
6
+ *
7
+ * La ressource d'une ZONE (`areas.<zone>.resource`) entre aussi dans la liste,
8
+ * APRÈS les audiences déclarées : c'est une ressource que ce serveur protège, et
9
+ * la redéclarer dans `jwt.audiences` était une seconde écriture de la même
10
+ * valeur — celle qu'on oublie, et dont le seul symptôme est un `invalid_target`
11
+ * à l'émission. Ajoutée en fin de liste, elle ne change jamais l'audience par
12
+ * défaut ({@link defaultAudience}).
13
+ *
14
+ * Elle n'ouvre rien de plus à la VÉRIFICATION : chaque zone, `jwt` comme
15
+ * `external-jwt`, exige SA ressource (l'audience par défaut pour une zone `jwt`
16
+ * qui n'en déclare pas) — un jeton émis pour une zone n'en ouvre pas une autre.
17
+ *
18
+ * @param jwt - section `jwt` de la config validée
19
+ * @param areas - zones du pare-feu ; omises, seules les audiences déclarées comptent
20
+ * @returns les paramètres partagés par l'émetteur et les vérificateurs
6
21
  */
7
- function resolveJwtRuntime(jwt) {
22
+ function resolveJwtRuntime(jwt, areas) {
8
23
  const issuer = jwt.issuer && jwt.issuer.length > 0 ? jwt.issuer : "nodefony";
24
+ const audiences = jwt.audiences.length > 0 ? [...jwt.audiences] : [issuer];
25
+ if (areas !== void 0) for (const name in areas) {
26
+ const resource = areas[name]?.resource;
27
+ if (resource !== void 0 && !audiences.includes(resource)) audiences.push(resource);
28
+ }
9
29
  return {
10
30
  issuer,
11
- audiences: jwt.audiences.length > 0 ? [...jwt.audiences] : [issuer],
31
+ audiences,
12
32
  accessTtlS: jwt.accessTtlS,
13
33
  refreshTtlS: jwt.refreshTtlS,
14
34
  rotateRefresh: jwt.rotateRefresh,
@@ -0,0 +1,34 @@
1
+ // nodefony/src/token/tokenTimeClaims.ts
2
+ /**
3
+ * Bornes temporelles qu'un jeton d'accès JWT DOIT porter : `exp` et `iat`
4
+ * (RFC 9068 §2.2, l'un et l'autre REQUIRED).
5
+ *
6
+ * UNE règle pour les deux chemins de vérification — jeton de l'application
7
+ * (`JwtAuthenticator`) et jeton d'un émetteur tiers (`RemoteJwtVerifier`). Sans
8
+ * `exp`, une connexion ouverte avec le jeton n'a plus de borne propre : le
9
+ * revalidateur d'une socket ne sait alors la fermer que par le store de
10
+ * révocation, et une clé volée suffit à signer un accès éternel.
11
+ */
12
+ const REQUIRED_TIME_CLAIMS = Object.freeze(["exp", "iat"]);
13
+ /**
14
+ * Tolérance d'horloge entre l'émetteur et ce serveur, en secondes : appliquée à
15
+ * `exp`/`nbf` par jose, et à `iat` par {@link isIssuedInFuture}.
16
+ */
17
+ const CLOCK_TOLERANCE_S = 5;
18
+ /**
19
+ * Le jeton se dit-il émis dans le futur, au-delà de la tolérance d'horloge ?
20
+ *
21
+ * jose ne contrôle `iat` que lorsqu'un âge maximal est imposé (`maxTokenAge`),
22
+ * ce que Nodefony ne fait pas. Or un `iat` daté du futur rend inopérante la
23
+ * révocation en masse (`invalidBefore`), qui compare `iat` à un seuil passé :
24
+ * un jeton forgé à `iat = maintenant + 10 ans` ne serait jamais « émis avant ».
25
+ *
26
+ * @param payload - claims VÉRIFIÉS du jeton (signature déjà contrôlée)
27
+ * @param nowS - instant présent, en secondes epoch
28
+ * @param toleranceS - écart d'horloge admis, en secondes
29
+ * @returns `true` si `iat` dépasse `nowS + toleranceS`
30
+ */
31
+ function isIssuedInFuture(payload, nowS, toleranceS) {
32
+ return typeof payload.iat === "number" && payload.iat > nowS + toleranceS;
33
+ }
34
+ export { CLOCK_TOLERANCE_S, REQUIRED_TIME_CLAIMS, isIssuedInFuture };
@@ -1,6 +1,6 @@
1
1
  import { decryptSecret, encryptSecret } from "../crypto/secretCipher.js";
2
- import "./totpCipher.js";
3
2
  import { base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, matchRecoveryCode, verifyTotp } from "./totpCrypto.js";
3
+ import "./totpCipher.js";
4
4
  // nodefony/src/totp/totpOperations.ts
5
5
  /**
6
6
  * Démarre l'enrôlement : génère un secret aléatoire, le **chiffre** au repos et
@@ -1,5 +1,5 @@
1
- import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
2
1
  import "../../contracts/IAccessVoter.js";
2
+ import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
3
3
  // nodefony/src/voter/RoleVoter.ts
4
4
  /**
5
5
  * Voter built-in **niveau A** — résout les attributs `ROLE_*` via la hiérarchie
@@ -1,4 +1,5 @@
1
1
  import { webhookSignatureHeaders } from "./webhookSignature.js";
2
+ import { MAX_CHANNEL_PATTERN_WILDCARDS, compileChannelPattern, countWildcards } from "nodefony";
2
3
  // nodefony/src/webhook/WebhookDispatcher.ts
3
4
  /**
4
5
  * Dispatcher de webhooks sortants — abonné au journal d'audit, il filtre les
@@ -20,15 +21,60 @@ import { webhookSignatureHeaders } from "./webhookSignature.js";
20
21
  */
21
22
  const MAX_BACKOFF_MS = 3e5;
22
23
  const BASE_BACKOFF_MS = 5e3;
23
- /** Une souscription matche-t-elle une action d'audit ? `*` = toutes, `x.*` = préfixe. */
24
- function matchesSubscription(patterns, action) {
25
- for (const p of patterns) {
26
- if (p === "*" || p === action) return true;
27
- if (p.endsWith(".*") && action.startsWith(p.slice(0, -1))) return true;
24
+ /**
25
+ * Motifs compilés par liste d'abonnement — une fois par tableau `events`, jamais par
26
+ * événement. La clé est le tableau lui-même : un abonnement modifié porte un tableau
27
+ * neuf, donc se recompile, et l'ancien part avec lui au ramasse-miettes.
28
+ */
29
+ const compiledSubscriptions = /* @__PURE__ */ new WeakMap();
30
+ /**
31
+ * Rend les motifs compilés d'une liste d'abonnement, compilés à sa première lecture puis relus du cache.
32
+ *
33
+ * Deux appels sur le MÊME tableau rendent la même liste : c'est ce qui garantit
34
+ * qu'un abonnement n'est jamais recompilé par événement. Un motif trop large
35
+ * (refusé par `compileChannelPattern`) n'y figure pas.
36
+ *
37
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
38
+ * @returns les expressions compilées, une par motif valide.
39
+ */
40
+ function compiledPatterns(patterns) {
41
+ let compiled = compiledSubscriptions.get(patterns);
42
+ if (compiled === void 0) {
43
+ const list = [];
44
+ for (const p of patterns) {
45
+ const re = compileChannelPattern(p);
46
+ if (re !== null) list.push(re);
47
+ }
48
+ compiled = list;
49
+ compiledSubscriptions.set(patterns, compiled);
28
50
  }
51
+ return compiled;
52
+ }
53
+ /**
54
+ * Dit si un abonnement webhook désigne une action d'audit, selon la grammaire commune aux canaux et aux webhooks (ADR-0023 W1).
55
+ *
56
+ * `*` vaut n'importe quelle suite de caractères : `*` désigne tout, `login.*` les
57
+ * actions qui commencent par `login.`, `commande:*` aussi bien que `*:créée`
58
+ * (`compileChannelPattern`, cœur). Chaque liste n'est compilée qu'une fois.
59
+ *
60
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
61
+ * @param action - le nom de l'action d'audit.
62
+ * @returns `true` si un motif au moins désigne l'action.
63
+ */
64
+ function matchesSubscription(patterns, action) {
65
+ for (const re of compiledPatterns(patterns)) if (re.test(action)) return true;
29
66
  return false;
30
67
  }
31
68
  /**
69
+ * Refuse une liste d'abonnement dont un motif porte trop de `*`, en le nommant.
70
+ *
71
+ * @param patterns - les motifs proposés à l'enregistrement ou à la mise à jour.
72
+ * @throws Error si un motif dépasse `MAX_CHANNEL_PATTERN_WILDCARDS` étoiles.
73
+ */
74
+ function assertSubscriptionPatterns(patterns) {
75
+ for (const p of patterns) if (countWildcards(p) > MAX_CHANNEL_PATTERN_WILDCARDS) throw new Error(`webhooks: le motif « ${p} » porte ${countWildcards(p)} caractères « * » (maximum ${MAX_CHANNEL_PATTERN_WILDCARDS}) — un motif en demande un, deux au pire.`);
76
+ }
77
+ /**
32
78
  * Classe le résultat d'une livraison : 2xx = succès ; réseau/timeout/429/408/5xx =
33
79
  * réessayable ; 3xx/4xx (config cliente erronée) = échec définitif (pas de retry).
34
80
  */
@@ -71,7 +117,8 @@ var WebhookDispatcher = class {
71
117
  this.#enqueue({
72
118
  ep,
73
119
  event,
74
- attempt: 0
120
+ attempt: 0,
121
+ messageId: null
75
122
  });
76
123
  }
77
124
  }
@@ -112,17 +159,20 @@ var WebhookDispatcher = class {
112
159
  }
113
160
  /** Signe + livre une tentative ; gère succès / retry / échec définitif. */
114
161
  async #process(job) {
115
- const { ep, event, attempt } = job;
162
+ const { event, attempt } = job;
163
+ const ep = this.#deps.currentEndpoint(job.ep.id);
164
+ if (ep === null || !ep.enabled || !matchesSubscription(ep.events, event.action)) return;
116
165
  try {
117
- const id = this.#deps.newMessageId();
166
+ const id = job.messageId ?? this.#deps.newMessageId();
118
167
  const nowMs = this.#deps.now();
119
168
  const tsS = Math.floor(nowMs / 1e3);
120
- const body = JSON.stringify({
169
+ const payload = {
121
170
  id,
122
171
  timestamp: new Date(nowMs).toISOString(),
123
172
  type: event.action,
124
173
  data: event
125
- });
174
+ };
175
+ const body = JSON.stringify(payload);
126
176
  const headers = {
127
177
  "content-type": "application/json",
128
178
  "user-agent": "Nodefony-Webhooks/1.0",
@@ -132,7 +182,7 @@ var WebhookDispatcher = class {
132
182
  try {
133
183
  addresses = await this.#deps.resolveTarget(ep.url);
134
184
  } catch (e) {
135
- const error = `ssrf: ${e.message}`;
185
+ const error = `ssrf: ${e instanceof Error ? e.message : String(e)}`;
136
186
  await this.#deps.markDelivery(ep.id, {
137
187
  ok: false,
138
188
  status: null,
@@ -157,7 +207,7 @@ var WebhookDispatcher = class {
157
207
  allowHttp: this.#deps.policy.allowHttp
158
208
  });
159
209
  if (classifyDelivery(result) === "retry" && attempt < this.#deps.policy.maxRetries) {
160
- this.#scheduleRetry(job);
210
+ this.#scheduleRetry(job, id);
161
211
  return;
162
212
  }
163
213
  await this.#deps.markDelivery(ep.id, result);
@@ -173,11 +223,14 @@ var WebhookDispatcher = class {
173
223
  responseBody: result.responseBody ?? null
174
224
  });
175
225
  } catch (e) {
176
- this.#deps.log(`webhook dispatch ${ep.id}: ${e.message}`);
226
+ this.#deps.log(`webhook dispatch ${ep.id}: ${e instanceof Error ? e.message : String(e)}`);
177
227
  }
178
228
  }
179
- /** Replanifie la livraison après backoff (repasse par la file bornée). */
180
- #scheduleRetry(job) {
229
+ /**
230
+ * Replanifie la livraison après backoff (repasse par la file bornée), sous le
231
+ * MÊME `webhook-id` que la tentative échouée.
232
+ */
233
+ #scheduleRetry(job, messageId) {
181
234
  if (this.#stopped) return;
182
235
  this.#timers ??= /* @__PURE__ */ new Set();
183
236
  const cancel = this.#deps.schedule(() => {
@@ -185,7 +238,8 @@ var WebhookDispatcher = class {
185
238
  this.#enqueue({
186
239
  ep: job.ep,
187
240
  event: job.event,
188
- attempt: job.attempt + 1
241
+ attempt: job.attempt + 1,
242
+ messageId
189
243
  });
190
244
  }, backoffMs(job.attempt));
191
245
  this.#timers.add(cancel);
@@ -205,4 +259,4 @@ var WebhookDispatcher = class {
205
259
  this.#queue = null;
206
260
  }
207
261
  };
208
- export { WebhookDispatcher, backoffMs, classifyDelivery, matchesSubscription };
262
+ export { WebhookDispatcher, assertSubscriptionPatterns, backoffMs, classifyDelivery, compiledPatterns, matchesSubscription };
@@ -1,101 +1,52 @@
1
- import { Buffer } from "node:buffer";
2
- import { isIP } from "node:net";
3
- import { request } from "node:https";
4
- import { request as request$1 } from "node:http";
1
+ import { fetchPublic } from "../net/fetchPublic.js";
2
+ import { FetchPublicError } from "../../errors/FetchPublicError.js";
5
3
  // nodefony/src/webhook/webhookDelivery.ts
6
4
  /**
7
- * Émetteur HTTP d'une livraison webhook — `node:http(s)` natif (0 dép). Trois
8
- * propriétés de sécurité par rapport à `fetch` :
9
- *
10
- * 1. **Pas de suivi de redirection** : `node:http(s)` ne suit JAMAIS les 3xx
11
- * (contrairement à `fetch`) → un `302 → 169.254.169.254` ne contourne pas le
12
- * contrôle SSRF ; le 3xx est rendu tel quel (échec de config).
13
- * 2. **Pin de l'IP validée** (anti DNS-rebinding) : `lookup` force la connexion TCP
14
- * vers l'IP déjà contrôlée par `assertPublicUrl`, tout en gardant le hostname
15
- * pour le SNI/TLS et l'en-tête `Host` → pas de re-résolution entre contrôle et
16
- * connexion.
17
- * 3. **Timeout dur** : la requête est détruite au-delà du délai.
5
+ * Émetteur HTTP d'une livraison webhook — délègue à {@link fetchPublic}, la
6
+ * primitive unique des requêtes sortantes : pas de suivi de redirection, IP
7
+ * vérifiée épinglée (anti DNS-rebinding), délai total, lecture bornée.
18
8
  */
19
9
  /** Taille max du corps de réponse capturé (observabilité, anti-mémoire). */
20
10
  const RESPONSE_BODY_CAP = 2048;
21
11
  /**
22
- * POST le corps signé vers l'URL, en pinnant l'IP validée et sans suivre les
23
- * redirections. Ne lève jamais : tout échec est encodé dans {@link IDeliveryResult}.
12
+ * POST le corps signé vers l'URL, par {@link fetchPublic} : IP vérifiée épinglée,
13
+ * aucune redirection suivie, délai TOTAL (un destinataire qui goutte un octet
14
+ * ne retient pas la livraison) et lecture du corps coupée à
15
+ * {@link RESPONSE_BODY_CAP} (connexion détruite, pas drainée). Ne lève jamais :
16
+ * tout échec est encodé dans {@link IDeliveryResult}.
24
17
  */
25
- function deliverWebhook(url, body, headers, opts) {
26
- let u;
18
+ async function deliverWebhook(url, body, headers, opts) {
27
19
  try {
28
- u = new URL(url);
29
- } catch {
30
- return Promise.resolve({
20
+ const vetted = opts.addresses !== void 0 && opts.addresses.length > 0;
21
+ const r = await fetchPublic(url, {
22
+ method: "POST",
23
+ headers,
24
+ body,
25
+ maxBytes: RESPONSE_BODY_CAP,
26
+ timeoutMs: opts.timeoutMs,
27
+ allowHttp: opts.allowHttp === true,
28
+ allowPrivate: !vetted,
29
+ ...vetted ? { vettedAddresses: opts.addresses } : {}
30
+ });
31
+ const ok = r.status >= 200 && r.status < 300;
32
+ const text = r.body.length > 0 ? new TextDecoder().decode(r.body) : "";
33
+ return {
34
+ ok,
35
+ status: r.status,
36
+ error: ok ? null : `HTTP ${r.status}`,
37
+ responseBody: text.length > 0 ? text : null
38
+ };
39
+ } catch (e) {
40
+ if (e instanceof FetchPublicError && e.reason === "timeout") return {
31
41
  ok: false,
32
42
  status: null,
33
- error: "URL invalide"
34
- });
35
- }
36
- const isHttps = u.protocol === "https:";
37
- if (!isHttps && !(u.protocol === "http:" && opts.allowHttp)) return Promise.resolve({
38
- ok: false,
39
- status: null,
40
- error: `protocole ${u.protocol} non autorisé`
41
- });
42
- const pinned = opts.addresses && opts.addresses.length > 0 ? opts.addresses[0] : null;
43
- const lookup = pinned ? (_hostname, options, callback) => {
44
- const family = isIP(pinned) === 6 ? 6 : 4;
45
- if (options.all) callback(null, [{
46
- address: pinned,
47
- family
48
- }], family);
49
- else callback(null, pinned, family);
50
- } : void 0;
51
- const payload = Buffer.from(body, "utf8");
52
- const requestFn = isHttps ? request : request$1;
53
- return new Promise((resolve) => {
54
- let settled = false;
55
- const done = (r) => {
56
- if (!settled) {
57
- settled = true;
58
- resolve(r);
59
- }
43
+ error: "timeout"
60
44
  };
61
- const req = requestFn(url, {
62
- method: "POST",
63
- headers: {
64
- ...headers,
65
- "content-length": String(payload.length)
66
- },
67
- lookup
68
- }, (res) => {
69
- const status = res.statusCode ?? 0;
70
- const ok = status >= 200 && status < 300;
71
- let resBody = "";
72
- res.on("data", (c) => {
73
- if (resBody.length < RESPONSE_BODY_CAP) {
74
- resBody += c.toString("utf8");
75
- if (resBody.length > RESPONSE_BODY_CAP) resBody = resBody.slice(0, RESPONSE_BODY_CAP);
76
- }
77
- });
78
- res.on("end", () => done({
79
- ok,
80
- status,
81
- error: ok ? null : `HTTP ${status}`,
82
- responseBody: resBody.length > 0 ? resBody : null
83
- }));
84
- res.on("error", (e) => done({
85
- ok: false,
86
- status,
87
- error: e.message,
88
- responseBody: resBody.length > 0 ? resBody : null
89
- }));
90
- });
91
- req.setTimeout(opts.timeoutMs, () => req.destroy(/* @__PURE__ */ new Error("timeout")));
92
- req.on("error", (e) => done({
45
+ return {
93
46
  ok: false,
94
47
  status: null,
95
- error: e.message
96
- }));
97
- req.write(payload);
98
- req.end();
99
- });
48
+ error: e instanceof Error ? e.message : String(e)
49
+ };
50
+ }
100
51
  }
101
52
  export { deliverWebhook };
@@ -2,40 +2,6 @@ import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
2
2
  import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer } from "../src/realtime/frameAuthorizer.js";
3
3
  // nodefony/testing/index.ts
4
4
  /**
5
- * `@nodefony/security/testing` — **identité d'essai et VRAI verrou de frame**,
6
- * pour éprouver un canal ou une action temps réel protégés.
7
- *
8
- * Le harnais de `@nodefony/realtime/testing` accepte une identité et un verrou,
9
- * mais n'en fournit aucun : `@nodefony/realtime` ne connaît pas la sécurité.
10
- * Faute de mieux, une application écrivait un jeton factice et un verrou qui ne
11
- * regardait que `isAuthenticated()` — son test passait, et n'éprouvait PAS la
12
- * politique de rôles du canal. Un canal mal protégé y était vert. Deux agents
13
- * sur deux l'ont fait, en recopiant le jeton que montrait le test généré.
14
- *
15
- * Ce module rend les deux pièces, et le verrou rendu est celui du pare-feu —
16
- * {@link buildFrameAuthorizer}, la même fabrique que le boot branche sur le hub,
17
- * avec la même hiérarchie de rôles et les mêmes canaux système. Seuls manquent
18
- * le journal d'audit (un refus en test n'est pas une transition de sécurité) et
19
- * les ZONES HTTP : une frame `api.request` y passe comme hors de toute zone, et
20
- * la garde d'une route s'éprouve par HTTP, pas ici.
21
- *
22
- * ```ts
23
- * import { getRealtimeHub } from "@nodefony/realtime";
24
- * import { createRealtimeHarness } from "@nodefony/realtime/testing";
25
- * import {
26
- * createTestFrameAuthorizer,
27
- * createTestIdentity,
28
- * } from "@nodefony/security/testing";
29
- *
30
- * const h = createRealtimeHarness((ctx) => new ChatController(ctx), {
31
- * identity: createTestIdentity({ identifier: "alice", roles: ["ROLE_USER"] }),
32
- * frameAuthorizer: createTestFrameAuthorizer(getRealtimeHub()),
33
- * });
34
- * ```
35
- *
36
- * @packageDocumentation
37
- */
38
- /**
39
5
  * Une identité de connexion AUTHENTIFIÉE, telle que le pare-feu l'aurait posée
40
6
  * au handshake.
41
7
  *
@@ -48,7 +14,7 @@ import { DEFAULT_SYSTEM_RULES, buildFrameAuthorizer } from "../src/realtime/fram
48
14
  function createTestIdentity(options = {}) {
49
15
  const identifier = options.identifier ?? "alice";
50
16
  const roles = options.roles ?? ["ROLE_USER"];
51
- return new TestToken(identifier, roles, options.scopes ?? [], options.user ?? {
17
+ return new TestToken(identifier, options.id ?? identifier, roles, options.scopes ?? [], options.user ?? {
52
18
  identifier,
53
19
  roles: [...roles]
54
20
  });
@@ -56,16 +22,40 @@ function createTestIdentity(options = {}) {
56
22
  /** Le jeton que rend {@link createTestIdentity}. */
57
23
  var TestToken = class {
58
24
  identifier;
25
+ id;
59
26
  roles;
60
27
  scopes;
61
28
  user;
62
29
  type = "session";
63
- constructor(identifier, roles, scopes, user) {
30
+ #identity = null;
31
+ constructor(identifier, id, roles, scopes, user) {
64
32
  this.identifier = identifier;
33
+ this.id = id;
65
34
  this.roles = roles;
66
35
  this.scopes = scopes;
67
36
  this.user = user;
68
37
  }
38
+ /**
39
+ * Identité d'admission gelée — ce que reçoit la règle `allow` d'un canal.
40
+ *
41
+ * Même forme que celle du jeton du pare-feu (`UserRealtimeToken.getIdentity`) :
42
+ * sans elle, tout canal à `allow` refusait la connexion d'essai, et une
43
+ * application ne pouvait éprouver un salon privé qu'en écrivant son propre jeton.
44
+ */
45
+ getIdentity() {
46
+ if (this.#identity !== null) return this.#identity;
47
+ this.#identity = Object.freeze({
48
+ type: this.type,
49
+ authenticated: true,
50
+ id: this.id,
51
+ userIdentifier: this.identifier,
52
+ roles: Object.freeze([...this.roles]),
53
+ scopes: Object.freeze([...this.scopes]),
54
+ issuer: null,
55
+ account: true
56
+ });
57
+ return this.#identity;
58
+ }
69
59
  getUserIdentifier() {
70
60
  return this.identifier;
71
61
  }
@@ -168,9 +168,12 @@ export { WEBHOOK_SORTABLE_FIELDS, WEBHOOK_DEFAULT_ORDER, } from "./nodefony/src/
168
168
  export { registerWebhookStore, getWebhookStoreFactory, listWebhookStores, } from "./nodefony/src/webhook/webhookStoreRegistry.js";
169
169
  export type { WebhookStoreFactory, IWebhookStoreFactoryContext, } from "./nodefony/src/webhook/webhookStoreRegistry.js";
170
170
  export type { IWebhookEndpoint, WebhookEndpointUpdate, WebhookEndpointSummary, } from "./nodefony/contracts/IWebhookEndpoint.js";
171
+ export type { IWebhookPayload } from "./nodefony/contracts/IWebhookPayload.js";
171
172
  export type { IWebhookListQuery, IWebhookStore, } from "./nodefony/contracts/IWebhookStore.js";
172
173
  export { assertPublicUrl, isBlockedAddress, } from "./nodefony/src/net/ssrfGuard.js";
173
174
  export type { IAssertPublicUrlOptions, IPublicUrlResult, DnsResolver, } from "./nodefony/src/net/ssrfGuard.js";
175
+ export { fetchPublic } from "./nodefony/src/net/fetchPublic.js";
176
+ export type { IFetchPublicOptions, IFetchPublicResult, } from "./nodefony/src/net/fetchPublic.js";
174
177
  export { deriveKey, encryptSecret, decryptSecret, generateEphemeralKey, } from "./nodefony/src/crypto/secretCipher.js";
175
178
  export type { IKeyDerivation } from "./nodefony/src/crypto/secretCipher.js";
176
179
  export { defineSecurityConfig, securityConfigJsonSchema, } from "./nodefony/config/defineModuleConfig.js";
@@ -179,6 +182,7 @@ export { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
179
182
  export type { ILoginThrottleOptions } from "./nodefony/src/throttle/LoginThrottler.js";
180
183
  export { CredentialTransportPolicy, shouldEnforceCredentialTransport, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
181
184
  export type { ICredentialTransportContext, ICredentialTransportPolicyOptions, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
182
- export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, InsecureTransportError, } from "./nodefony/errors/index.js";
185
+ export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, FetchPublicError, InsecureTransportError, } from "./nodefony/errors/index.js";
186
+ export type { FetchPublicFailure } from "./nodefony/errors/index.js";
183
187
  export { VoterVote } from "./nodefony/contracts/index.js";
184
188
  export type { IToken, IAuthenticator, ISecuredArea, IFirewall, IAccessVoter, IAuthorizationService, IAccessTokenRecord, ITokenStore, ITokenListQuery, ITokenUsage, IResourcePermission, TokenRevokeReason, TokenStatus, IJwtKeystore, IJwtSigningKey, } from "./nodefony/contracts/index.js";
@@ -19,6 +19,18 @@ export declare function ttlSeconds(raw: string | undefined): number | undefined
19
19
  * @returns les secondes, `undefined` pour le défaut de configuration, une `Error` sinon
20
20
  */
21
21
  export declare function tokenTtlSeconds(raw: string | undefined, write: boolean): number | undefined | Error;
22
+ /**
23
+ * Le jeton vise-t-il une AUTRE porte que celle de l'outillage ?
24
+ *
25
+ * `NF_MCP_TOKEN` et `--write` servent la porte par défaut, et elle seule : un
26
+ * jeton d'une autre audience (RFC 8707) y prendrait sa place, et cette porte le
27
+ * refuserait — ses outils réservés disparaîtraient sans un mot.
28
+ *
29
+ * @param resource - la valeur de `--resource`, ou rien
30
+ * @param defaultResource - la porte par défaut de l'application
31
+ * @returns `true` quand `--resource` désigne une autre porte
32
+ */
33
+ export declare function targetsOtherGate(resource: string | undefined, defaultResource: string): boolean;
22
34
  /**
23
35
  * `nodefony security:mcp:token` — émet un jeton d'accès pour une porte de cette
24
36
  * application (la porte MCP par défaut).
@@ -85,7 +85,9 @@ export declare const securityConfigSchema: z.ZodObject<{
85
85
  hsts: z.ZodDefault<z.ZodBoolean>;
86
86
  hstsMaxAgeS: z.ZodDefault<z.ZodNumber>;
87
87
  csp: z.ZodDefault<z.ZodString>;
88
+ cspSources: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodRecord<z.ZodString, z.ZodArray<z.ZodString>>>>;
88
89
  cspNonces: z.ZodDefault<z.ZodBoolean>;
90
+ cspReport: z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodString]>>;
89
91
  frameguard: z.ZodDefault<z.ZodEnum<{
90
92
  deny: "deny";
91
93
  sameorigin: "sameorigin";
@@ -338,6 +340,12 @@ export declare const securityConfigSchema: z.ZodObject<{
338
340
  immutable: z.ZodDefault<z.ZodBoolean>;
339
341
  stream: z.ZodDefault<z.ZodBoolean>;
340
342
  retentionDays: z.ZodDefault<z.ZodNumber>;
343
+ aggregate: z.ZodDefault<z.ZodObject<{
344
+ enabled: z.ZodDefault<z.ZodBoolean>;
345
+ windowMs: z.ZodDefault<z.ZodNumber>;
346
+ burst: z.ZodDefault<z.ZodNumber>;
347
+ maxKeys: z.ZodDefault<z.ZodNumber>;
348
+ }, z.core.$strict>>;
341
349
  }, z.core.$strict>>;
342
350
  studio: z.ZodDefault<z.ZodObject<{
343
351
  enabled: z.ZodDefault<z.ZodBoolean>;
@@ -16,7 +16,10 @@ export interface IApiKeyView {
16
16
  subjectId: string;
17
17
  /** Discriminant du porteur. */
18
18
  subjectType: "user" | "service";
19
- /** Organisation/tenant (`null` = global). */
19
+ /**
20
+ * Périmètre d'un locataire — réservé, toujours `null` tant que le multi-tenant
21
+ * n'existe pas (`createForSubject` refuse une valeur).
22
+ */
20
23
  tenantId: string | null;
21
24
  /** Création (epoch ms). */
22
25
  createdAt: number;
@@ -70,6 +73,9 @@ export interface ICreateApiKeyOptions {
70
73
  scopes?: string[];
71
74
  /** Durée de vie en jours ; `null` = sans expiration ; omis = défaut config. */
72
75
  expiresInDays?: number | null;
73
- /** Tenant/organisation (multi-tenant) ; `null` = global. */
76
+ /**
77
+ * Périmètre d'un locataire — réservé, **refusé s'il est rempli** (`ApiKeyError` 400)
78
+ * tant que le multi-tenant n'existe pas. Absent ou `null` : clé sans périmètre.
79
+ */
74
80
  tenantId?: string | null;
75
81
  }
@@ -28,13 +28,26 @@ export interface IFirewall extends IFirewallGate {
28
28
  handleCors(context: ContextType): number | undefined;
29
29
  /**
30
30
  * Pose les en-têtes de sécurité applicatifs (CSP, Referrer-Policy, COOP/COEP/
31
- * CORP, Permissions-Policy) sur la réponse. No-op si désactivés / hors HTTP.
31
+ * CORP, Permissions-Policy) sur la réponse, selon son type (ADR-0024 §C11).
32
+ * No-op si désactivés / hors HTTP.
33
+ *
34
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
35
+ * omis = type inconnu (politique complète).
36
+ * @param servedAsFile - corps servi tel quel (fichier statique, relais), qui
37
+ * ne peut pas porter de nonce : un document y reçoit la politique sans
38
+ * nonce ni `'strict-dynamic'` (ADR-0024 §C1).
32
39
  */
33
- applySecurityHeaders(context: ContextType): void;
40
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
34
41
  /**
35
42
  * Déclare des directives CSP additionnelles pour un module (ex. `@nodefony/frontend`
36
43
  * en dev : origines Vite + `'unsafe-eval'`). Fusionnées dans le CSP de base et
37
44
  * recomposées (re-split nonce) au (dé)enregistrement — jamais par requête.
45
+ *
46
+ * Ne lève jamais : une contribution MALFORMÉE est ignorée en entier (journal
47
+ * ERROR), une source TROP LARGE (`*`, `https:`, `https://*`) est retirée
48
+ * (journal WARNING). La politique ne devient jamais plus permissive que demandé,
49
+ * et l'application démarre. Ce que l'application écrit elle-même (sa config,
50
+ * ses `@Csp`) ne passe pas par ce filtre.
38
51
  */
39
52
  registerCspOrigins(moduleName: string, fragment: CspFragment): void;
40
53
  /** Retire les directives CSP d'un module (ex. `stopDev` du frontend). */
@@ -14,7 +14,7 @@
14
14
  * 3. **seuil par porteur** (`invalidBefore`) — révocation EN MASSE (tout access
15
15
  * auto-porté émis avant un instant T est rejeté → « déconnexion globale » / ban).
16
16
  */
17
- import type { IPage, IPageQuery, ISortableSource } from "nodefony";
17
+ import type { IPage, IPageQuery, ISortableSource, TokenStatus } from "nodefony";
18
18
  /** Raison de révocation d'un jeton — tracée pour l'audit de sécurité. */
19
19
  export type TokenRevokeReason = "logout" | "rotated" | "reuse_detected" | "manual" | "compromised" | "subject_disabled" | "expired_cleanup";
20
20
  /**
@@ -153,8 +153,12 @@ export interface ITokenListQuery extends IPageQuery {
153
153
  */
154
154
  status?: TokenStatus;
155
155
  }
156
- /** État de vie d'un jeton — partition exhaustive, évaluée révoqué → expiré → actif. */
157
- export type TokenStatus = "active" | "expired" | "revoked";
156
+ /**
157
+ * État de vie d'un jeton — partition exhaustive, évaluée révoqué → expiré → actif.
158
+ * Défini au cœur (le temps réel le lit aussi, sans dépendre de la sécurité) et
159
+ * réexporté ici sous le même nom.
160
+ */
161
+ export type { TokenStatus } from "nodefony";
158
162
  /**
159
163
  * Contrat du store de jetons — implémentations enregistrées via
160
164
  * `registerTokenStore`, sélectionnées par config. Toutes les opérations sont
@@ -15,15 +15,25 @@ export interface IWebhookEndpoint {
15
15
  /** Secret de signature **chiffré** au repos (blob `gcm1.…`). Jamais en clair. */
16
16
  readonly secretEnc: string;
17
17
  /**
18
- * Actions d'audit souscrites (ex. `"login.success"`, `"user.created"`).
19
- * `"*"` = toutes. La livraison ne part que si l'action de l'événement matche.
18
+ * Motifs des actions d'audit souscrites, dans la grammaire commune aux canaux et
19
+ * aux webhooks (ADR-0023 W1) : `*` vaut n'importe quelle suite de caractères —
20
+ * `"*"` désigne tout, `"login.*"` les actions qui commencent par `login.`,
21
+ * `"commande:*"` comme `"*:créée"` ; le reste est littéral, trois `*` au plus
22
+ * (refusé à l'enregistrement au-delà). La livraison ne part que si un motif désigne
23
+ * l'action. Sans `tenantId`, l'abonnement a le périmètre de la PLATEFORME : il reçoit
24
+ * aussi les événements qu'une version ultérieure rattachera à un locataire
25
+ * (ADR-0023 W2).
20
26
  */
21
27
  readonly events: readonly string[];
22
28
  /** Endpoint actif ? (désactivé = aucune livraison). */
23
29
  readonly enabled: boolean;
24
30
  /** Libellé humain optionnel (console admin). */
25
31
  readonly description: string | null;
26
- /** Slot multi-tenant (réservé P17) — `null` = global. */
32
+ /**
33
+ * Périmètre d'un locataire — réservé, toujours `null` tant que le multi-tenant
34
+ * n'existe pas (`register` refuse une valeur). Un abonnement sans périmètre ne
35
+ * reçoit que les événements sans périmètre (ADR-0023 W2).
36
+ */
27
37
  readonly tenantId: string | null;
28
38
  /** Identité de l'admin créateur (soft ref, traçabilité). */
29
39
  readonly createdBy: string | null;