@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
@@ -1,20 +1,21 @@
1
- import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
2
1
  import { SecuredArea } from "../src/SecuredArea.js";
3
2
  import { entryPointLocation } from "../src/entryPoint.js";
4
3
  import { LoginThrottler } from "../src/throttle/LoginThrottler.js";
5
- import { InsecureTransportError } from "../errors/InsecureTransportError.js";
6
4
  import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "../src/transport/CredentialTransportPolicy.js";
7
5
  import { RoleHierarchyWalker } from "../src/RoleHierarchyWalker.js";
8
6
  import { rolesWithin } from "../src/rolesWithin.js";
9
- import { mergeCspFragments } from "../src/csp.js";
7
+ import { assertCspFragment, findInertScriptSources, findOverbroadSources, mergeCspFragments, parseCsp, reportUrlProblem, withFrameAncestors, withViolationReports, withoutOverbroadSources } from "../src/csp.js";
8
+ import { CspReportCollector } from "../src/cspReports.js";
10
9
  import { CsrfTokenManager } from "../src/csrfToken.js";
11
- import { CsrfError } from "../errors/CsrfError.js";
12
10
  import { Csrf } from "./csrf.js";
13
11
  import { Cors } from "./cors.js";
14
- import { SecurityHeaders } from "./securityHeaders.js";
12
+ import { SecurityHeaders, responseKindOf } from "./securityHeaders.js";
15
13
  import { AuthenticationError } from "../errors/AuthenticationError.js";
14
+ import { StaleSessionError } from "../errors/StaleSessionError.js";
16
15
  import { ThrottledError } from "../errors/ThrottledError.js";
17
16
  import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
17
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
18
+ import { CsrfError } from "../errors/CsrfError.js";
18
19
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
19
20
  import { getAuthenticatorFactory, listAuthenticatorFactories } from "../src/authenticator/authenticatorRegistry.js";
20
21
  import { FirewallRealtimeAuthenticator } from "../src/authenticator/FirewallRealtimeAuthenticator.js";
@@ -23,8 +24,10 @@ import { recordAudit } from "../src/audit/recordAudit.js";
23
24
  import { readAuditContext } from "../src/audit/readAuditContext.js";
24
25
  import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
25
26
  import { findZonesOpenToSignup } from "../src/openToSignup.js";
26
- import { ROLES_WITHIN, RequestContext, Service, logColor } from "nodefony";
27
+ import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
28
+ import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
27
29
  import { randomBytes } from "node:crypto";
30
+ import { rpcCorsLists } from "@nodefony/http";
28
31
  import { encoderFromConfig } from "@nodefony/user";
29
32
  // nodefony/service/firewall.ts
30
33
  const serviceName = "firewall";
@@ -35,29 +38,27 @@ const serviceName = "firewall";
35
38
  * genre de dépense que la règle de lazy-allocation proscrit.
36
39
  */
37
40
  const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
41
+ /**
42
+ * `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
43
+ * gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
44
+ * toujours redemandée avant réutilisation (ADR-0024 §C2).
45
+ */
46
+ const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
38
47
  function headerValue(headers, name) {
39
48
  const v = headers?.[name];
40
49
  return Array.isArray(v) ? v[0] : v;
41
50
  }
42
51
  /**
43
- * Extrait la valeur d'un cookie de l'en-tête `Cookie` brut (lecture directe, sans
44
- * dépendre du parse du Context — robuste quel que soit l'ordre du pipeline).
45
- * Retient la 1ʳᵉ occurrence du nom. Utilisé par la défense synchronizer CSRF.
52
+ * Valeur d'un cookie de la requête, lue sur le contexte : l'en-tête `Cookie`
53
+ * n'a qu'UN lecteur, `cookiesParser` (`@nodefony/http`), qui tourne à la
54
+ * construction du contexte — donc avant toute étape du pare-feu. Une seconde
55
+ * lecture ici divergeait (décodage, doublons) et levait sur un `%` isolé.
46
56
  */
47
- function cookieValue(cookieHeader, name) {
48
- if (!cookieHeader) return void 0;
49
- for (const part of cookieHeader.split(";")) {
50
- const eq = part.indexOf("=");
51
- if (eq === -1) continue;
52
- if (part.slice(0, eq).trim() === name) return decodeURIComponent(part.slice(eq + 1).trim());
53
- }
57
+ function requestCookie(context, name) {
58
+ const value = context.cookies[name]?.value;
59
+ return typeof value === "string" ? value : void 0;
54
60
  }
55
61
  /**
56
- * Défi posé sur un 401 quand aucun authenticator de la zone n'en déclare
57
- * (RFC 9110 §15.5.2). Schéma inconnu des navigateurs : aucune fenêtre de login.
58
- */
59
- const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
60
- /**
61
62
  * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
62
63
  *
63
64
  * `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
@@ -76,7 +77,7 @@ const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
76
77
  *
77
78
  * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
78
79
  * §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
79
- * le défi de repli {@link FALLBACK_CHALLENGE}.
80
+ * le défi de repli {@link SESSION_AUTH_CHALLENGE}.
80
81
  *
81
82
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
82
83
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -91,6 +92,11 @@ var Firewall = class extends Service {
91
92
  #cors = null;
92
93
  #securityHeaders = null;
93
94
  #cspFragments = null;
95
+ #cspMerged = null;
96
+ #cspBase = "";
97
+ #appCspSources = null;
98
+ #cspReportUrl = null;
99
+ #reportingEndpoints = null;
94
100
  #config = null;
95
101
  #configError = null;
96
102
  #longLivedRevalidation = null;
@@ -98,6 +104,29 @@ var Firewall = class extends Service {
98
104
  super(serviceName, module.container, module.notificationsCenter, module.options);
99
105
  this.module = module;
100
106
  this.kernel?.once("onBoot", () => this.#build());
107
+ this.kernel?.once("onReady", () => this.#widenCorsForRpc());
108
+ }
109
+ /**
110
+ * Ajoute à la politique CORS les méthodes et en-têtes que demandent les
111
+ * protocoles RPC — dès qu'un service gRPC est déclaré et que le CORS est
112
+ * actif (ADR-0026 D9 point 2). Le préflight part AVANT le routage et ne
113
+ * connaît pas la route : l'union est la seule voie. Une seule politique,
114
+ * celle de l'application ; calculée une fois, annoncée au démarrage.
115
+ */
116
+ #widenCorsForRpc() {
117
+ const config = this.#config;
118
+ const rpc = rpcCorsLists();
119
+ if (this.#cors === null || config === null || rpc === null) return;
120
+ const methods = withMissing(config.cors.methods, rpc.allowedMethods);
121
+ const allowedHeaders = withMissing(config.cors.allowedHeaders, rpc.allowedHeaders);
122
+ const added = [...methods.added, ...allowedHeaders.added];
123
+ if (added.length === 0) return;
124
+ this.#cors = new Cors({
125
+ ...config.cors,
126
+ methods: methods.list,
127
+ allowedHeaders: allowedHeaders.list
128
+ });
129
+ this.log(`CORS : service gRPC déclaré — le préflight autorise aussi ${added.join(", ")} (ADR-0026 D9)`, "INFO");
101
130
  }
102
131
  #build() {
103
132
  try {
@@ -121,7 +150,25 @@ var Firewall = class extends Service {
121
150
  this.#csrfTokens = new CsrfTokenManager(secret);
122
151
  }
123
152
  if (this.#config.cors.enabled) this.#cors = new Cors(this.#config.cors);
124
- if (this.#config.headers.enabled) this.#securityHeaders = new SecurityHeaders(this.#config.headers);
153
+ if (this.#config.headers.enabled) {
154
+ this.#appCspSources = this.#validatedAppCspSources(this.#config.headers.cspSources);
155
+ this.#cspReportUrl = this.#validatedCspReportUrl(this.#config.headers.cspReport);
156
+ this.#cspBase = this.#composeCspBase(this.#config.headers.csp);
157
+ this.#securityHeaders = this.#newSecurityHeaders({
158
+ ...this.#config.headers,
159
+ csp: this.#cspBase
160
+ });
161
+ if (this.#reportingEndpoints !== null && this.#cspReportUrl === CSP_REPORT_PATH) this.container?.set("cspReports", new CspReportCollector({
162
+ endpoint: CSP_REPORT_PATH,
163
+ log: (message, severity) => this.log(message, severity)
164
+ }));
165
+ this.#cspMerged = null;
166
+ if (this.#cspFragments !== null && this.#cspFragments.size > 0) {
167
+ this.#rebuildSecurityHeaders();
168
+ for (const [name, fragment] of this.#cspFragments) this.#warnInertScriptSources(name, fragment);
169
+ }
170
+ for (const [directive, source] of findOverbroadSources(parseCsp(this.#config.headers.csp))) this.log(`CSP de l’application : « ${source} » dans ${directive} ouvre la directive à n’importe quel contenu — conservée (choix de la configuration) ; préférer une origine exacte.`, "WARNING");
171
+ }
125
172
  const areas = this.#config.areas;
126
173
  if (Object.keys(areas).length) {
127
174
  const list = [];
@@ -258,7 +305,7 @@ var Firewall = class extends Service {
258
305
  * @param config - config security, utilisée en repli si le HttpKernel est absent.
259
306
  */
260
307
  #transportHeaderState(config) {
261
- const emitted = (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
308
+ const emitted = this.#emittedTransportHeaders();
262
309
  if (!emitted) return {
263
310
  hsts: config.headers.hsts,
264
311
  hstsMaxAgeS: config.headers.hstsMaxAgeS,
@@ -530,8 +577,7 @@ var Firewall = class extends Service {
530
577
  * @returns la trace fraîche (posée sur le context), ou `null` hors profiling.
531
578
  */
532
579
  #startTrace(context) {
533
- const ctx = context;
534
- if (!ctx.profiling) return null;
580
+ if (!context.profiling) return null;
535
581
  const trace = {
536
582
  authenticator: null,
537
583
  outcome: "denied",
@@ -539,7 +585,7 @@ var Firewall = class extends Service {
539
585
  user: null,
540
586
  roles: null
541
587
  };
542
- ctx.securityTrace = trace;
588
+ context.securityTrace = trace;
543
589
  return trace;
544
590
  }
545
591
  #recordAuth(context, area, action, outcome, reason, actor) {
@@ -597,7 +643,7 @@ var Firewall = class extends Service {
597
643
  host: headerValue(headers, "host") ?? headerValue(headers, ":authority") ?? context.domain
598
644
  });
599
645
  if (ctx.csrfProtect && this.#csrfTokens) {
600
- if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), cookieValue(headerValue(headers, "cookie"), "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
646
+ if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), requestCookie(context, "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
601
647
  }
602
648
  }
603
649
  /**
@@ -643,39 +689,195 @@ var Firewall = class extends Service {
643
689
  /**
644
690
  * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
645
691
  * cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
646
- * Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
647
- * socle transport de `@nodefony/http` (nosniff/frame/HSTS, posé à l'entrée brute)
648
- * SANS le ré-émettre. No-op si désactivé ou réponse non-HTTP (WS).
692
+ * Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
693
+ * la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
694
+ * `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
695
+ * ou réponse non-HTTP (WS).
649
696
  *
650
697
  * En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
651
- * nonce/req (étape B) ajoute 1 `join` + 1 `setHeader` UNIQUEMENT si activé.
698
+ * nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
699
+ *
700
+ * @param context - contexte de la réponse.
701
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
702
+ * omis = type inconnu, servi comme un document (politique complète).
703
+ * @param servedAsFile - le corps est servi tel quel (fichier statique,
704
+ * relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
705
+ * document reçoit alors la politique sans nonce ni `'strict-dynamic'`
706
+ * (ADR-0024 §C1) ; les autres types n'en changent pas.
652
707
  */
653
- applySecurityHeaders(context) {
708
+ applySecurityHeaders(context, contentType, servedAsFile = false) {
654
709
  const sh = this.#securityHeaders;
655
710
  if (!sh) return;
656
711
  const response = context.response;
657
712
  if (typeof response?.setHeader !== "function") return;
658
- const headers = sh.headers;
713
+ const extra = context.cspDirectives;
714
+ const tls = context.scheme === "https";
715
+ const validated = typeof response.setDefaultHeader === "function";
716
+ if (extra == null) {
717
+ const kind = responseKindOf(contentType);
718
+ if (kind !== "document" || servedAsFile) {
719
+ const table = kind === "document" ? sh.headersForServedFile(tls) : sh.headersForResource(kind, tls);
720
+ for (const name in table) {
721
+ const value = table[name];
722
+ if (value === void 0) continue;
723
+ if (validated) response.setDefaultHeader?.(name, value);
724
+ else response.setHeader(name, value);
725
+ }
726
+ return;
727
+ }
728
+ }
729
+ const headers = sh.headersFor(tls, extra != null && !sh.hasNonce);
659
730
  for (const name in headers) {
660
731
  const value = headers[name];
661
- if (value !== void 0) response.setHeader(name, value);
732
+ if (value === void 0) continue;
733
+ if (validated) response.setDefaultHeader?.(name, value);
734
+ else response.setHeader(name, value);
662
735
  }
663
- const extra = context.cspDirectives;
736
+ let csp = null;
664
737
  if (sh.hasNonce) {
665
738
  const nonce = context.cspNonce;
666
- response.setHeader("Content-Security-Policy", extra ? sh.cspForExtra(nonce, extra) : sh.cspFor(nonce));
667
- } else if (extra) response.setHeader("Content-Security-Policy", sh.cspForExtra("", extra));
739
+ csp = extra ? sh.cspForExtra(nonce, extra, tls) : sh.cspFor(nonce, tls);
740
+ } else if (extra) csp = sh.cspForExtra("", extra, tls);
741
+ if (csp !== null) {
742
+ if (validated) response.setDefaultHeader?.("Content-Security-Policy", csp);
743
+ else response.setHeader("Content-Security-Policy", csp);
744
+ }
745
+ if (sh.hasNonce) {
746
+ if (validated) response.setDefaultHeader?.("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
747
+ else response.setHeader("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
748
+ }
668
749
  }
669
750
  /**
670
751
  * Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
671
752
  * No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
672
753
  * Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
673
754
  */
674
- registerCspOrigins(moduleName, fragment) {
755
+ registerCspOrigins(moduleName, declared) {
756
+ const config = this.#config;
757
+ if (config !== null && this.#securityHeaders === null) return;
758
+ try {
759
+ assertCspFragment(declared, moduleName);
760
+ } catch (error) {
761
+ this.log(`CSP : contribution de ${moduleName} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
762
+ return;
763
+ }
764
+ const { fragment, dropped } = withoutOverbroadSources(declared);
765
+ for (const [directive, source] of dropped) this.log(`CSP : « ${source} » déclarée par ${moduleName} dans ${directive} ouvre la directive à n’importe quel contenu — RETIRÉE de la politique ; déclarer l’origine exacte.`, "WARNING");
766
+ const fragments = this.#cspFragments ??= /* @__PURE__ */ new Map();
767
+ if (config === null) {
768
+ fragments.set(moduleName, fragment);
769
+ return;
770
+ }
771
+ this.#warnInertScriptSources(moduleName, fragment);
772
+ const previous = fragments.get(moduleName);
773
+ if (previous !== void 0 || this.#cspMerged === null && fragments.size > 0) {
774
+ fragments.set(moduleName, fragment);
775
+ try {
776
+ this.#rebuildSecurityHeaders();
777
+ } catch (error) {
778
+ if (previous === void 0) fragments.delete(moduleName);
779
+ else fragments.set(moduleName, previous);
780
+ throw error;
781
+ }
782
+ return;
783
+ }
784
+ const csp = mergeCspFragments(this.#cspMerged ?? this.#cspBase, [fragment]);
785
+ fragments.set(moduleName, fragment);
786
+ this.#cspMerged = csp;
787
+ this.#securityHeaders = this.#newSecurityHeaders({
788
+ ...config.headers,
789
+ csp
790
+ });
791
+ }
792
+ /**
793
+ * Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
794
+ * config) : la politique suit `frameOptions`, qui pilote aussi
795
+ * `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
796
+ */
797
+ onTransportHeadersChanged() {
675
798
  if (!this.#securityHeaders || !this.#config) return;
676
- (this.#cspFragments ??= /* @__PURE__ */ new Map()).set(moduleName, fragment);
799
+ const base = this.#composeCspBase(this.#config.headers.csp);
800
+ if (base === this.#cspBase) return;
801
+ this.#cspBase = base;
677
802
  this.#rebuildSecurityHeaders();
678
803
  }
804
+ /**
805
+ * Politique de base : la config, complétée de `frame-ancestors` dérivé de ce
806
+ * que le noyau HTTP émet en `X-Frame-Options`. Lu chez l’émetteur, par son
807
+ * contrat d’introspection ; sans noyau HTTP, rien n’est dérivé.
808
+ */
809
+ #composeCspBase(csp) {
810
+ const emitted = this.#emittedTransportHeaders();
811
+ const framed = emitted ? withFrameAncestors(csp, emitted.frameOptions) : csp;
812
+ const sources = this.#appCspSources;
813
+ const merged = sources === null || csp.trim() === "" ? framed : mergeCspFragments(framed, sources);
814
+ if (this.#cspReportUrl === null) {
815
+ this.#reportingEndpoints = null;
816
+ return merged;
817
+ }
818
+ const reported = withViolationReports(merged, this.#cspReportUrl, CSP_REPORT_GROUP);
819
+ this.#reportingEndpoints = reported.added ? `${CSP_REPORT_GROUP}="${this.#cspReportUrl}"` : null;
820
+ return reported.csp;
821
+ }
822
+ /**
823
+ * Lit `headers.cspReport` : `true` = la route du framework, une chaîne = le
824
+ * collecteur de l’application, `false` = aucun rapport. Une URL qu’un en-tête
825
+ * ne peut pas porter est RETIRÉE et dite (ADR-0024 C0 : une faute de
826
+ * configuration de sécurité n’empêche pas le démarrage).
827
+ */
828
+ #validatedCspReportUrl(value) {
829
+ if (value === false) return null;
830
+ if (value === true) return CSP_REPORT_PATH;
831
+ const problem = reportUrlProblem(value);
832
+ if (problem === null) return value;
833
+ this.log(`CSP : headers.cspReport « ${value.slice(0, 120)} » IGNORÉE — ${problem} ; aucun rapport de violation demandé.`, "ERROR");
834
+ return null;
835
+ }
836
+ /**
837
+ * Dit, une fois, les sources de scripts d'un module que `'strict-dynamic'`
838
+ * rend inertes (cf `findInertScriptSources`) : gardées pour un navigateur
839
+ * CSP2, elles n'ouvrent rien aux autres — le module doit charger son script
840
+ * depuis un script signé.
841
+ *
842
+ * @param moduleName - module qui a déclaré le fragment
843
+ * @param fragment - fragment retenu (sources trop larges déjà retirées)
844
+ */
845
+ #warnInertScriptSources(moduleName, fragment) {
846
+ for (const [directive, source] of findInertScriptSources(fragment, this.#cspBase)) this.log(`CSP : « ${source} » déclarée par ${moduleName} dans ${directive} est INERTE sous 'strict-dynamic' (navigateurs CSP3) — conservée pour CSP2 ; charger ce script depuis un script signé.`, "WARNING");
847
+ }
848
+ /**
849
+ * Valide les contributions nommées de `headers.cspSources`, une fois. Une
850
+ * contribution malformée est IGNORÉE et dite (l'application démarre, plus
851
+ * stricte) ; une source ouverte à tout hôte est GARDÉE — choix écrit par
852
+ * l'application, comme sa politique — et dite.
853
+ *
854
+ * @param declared - contributions par nom
855
+ * @returns les fragments retenus, ou `null` s'il n'y en a aucun
856
+ */
857
+ #validatedAppCspSources(declared) {
858
+ let kept = null;
859
+ for (const [name, fragment] of Object.entries(declared)) {
860
+ try {
861
+ assertCspFragment(fragment, name);
862
+ } catch (error) {
863
+ this.log(`CSP : headers.cspSources.${name} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
864
+ continue;
865
+ }
866
+ for (const [directive, source] of withoutOverbroadSources(fragment).dropped) this.log(`CSP : « ${source} » dans headers.cspSources.${name} (${directive}) ouvre la directive à n’importe quel contenu — conservée (choix de la configuration) ; préférer une origine exacte.`, "WARNING");
867
+ (kept ??= []).push(fragment);
868
+ }
869
+ return kept;
870
+ }
871
+ /**
872
+ * En-têtes de transport que le noyau HTTP ÉMET, lus par son contrat
873
+ * d’introspection, jamais par ses champs internes : `security` ne dépend pas
874
+ * de la mécanique de cache du kernel HTTP, seulement de ce qu’il déclare.
875
+ *
876
+ * @returns les valeurs émises, `undefined` sans noyau HTTP au conteneur.
877
+ */
878
+ #emittedTransportHeaders() {
879
+ return (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
880
+ }
679
881
  /** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
680
882
  unregisterCspOrigins(moduleName) {
681
883
  if (!this.#cspFragments?.delete(moduleName)) return;
@@ -690,13 +892,27 @@ var Firewall = class extends Service {
690
892
  #rebuildSecurityHeaders() {
691
893
  const headers = this.#config?.headers;
692
894
  if (!headers) return;
693
- const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(headers.csp, this.#cspFragments.values()) : headers.csp;
694
- this.#securityHeaders = new SecurityHeaders({
895
+ const base = this.#cspBase;
896
+ const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(base, this.#cspFragments.values()) : base;
897
+ this.#cspMerged = csp === base ? null : csp;
898
+ this.#securityHeaders = this.#newSecurityHeaders({
695
899
  ...headers,
696
900
  csp
697
901
  });
698
902
  }
699
903
  /**
904
+ * Construit la table des en-têtes applicatifs, branchée sur le journal : un
905
+ * `@Csp` de route hors grammaire est IGNORÉ (politique de base servie) et dit.
906
+ */
907
+ #newSecurityHeaders(headers) {
908
+ return new SecurityHeaders({
909
+ ...headers,
910
+ reportingEndpoints: this.#reportingEndpoints
911
+ }, (error) => {
912
+ this.log(`CSP : directives @Csp d’une route IGNORÉES, la politique de base est servie — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
913
+ });
914
+ }
915
+ /**
700
916
  * Exécute la chaîne d'authenticators de la zone selon son `mode`.
701
917
  *
702
918
  * `first` : le premier dont `supports()` est vrai authentifie — un credential
@@ -731,6 +947,10 @@ var Firewall = class extends Service {
731
947
  authenticated = await authenticator.authenticate(created);
732
948
  } catch (error) {
733
949
  await authenticator.onFailure(context, error);
950
+ if (error instanceof StaleSessionError && area.mode === "first") {
951
+ this.log(`session périmée purgée (area "${area.name}", authenticator "${name}") — la requête continue sans elle`, "WARNING");
952
+ continue;
953
+ }
734
954
  if (error instanceof AuthenticationError) {
735
955
  this.log(`authentication failed (area "${area.name}", authenticator "${name}")`, "WARNING");
736
956
  throw error;
@@ -792,11 +1012,28 @@ var Firewall = class extends Service {
792
1012
  return;
793
1013
  }
794
1014
  }
795
- response.setHeader("WWW-Authenticate", FALLBACK_CHALLENGE);
1015
+ response.setHeader("WWW-Authenticate", SESSION_AUTH_CHALLENGE);
796
1016
  }
797
1017
  log(pci, severity, msgid, msg) {
798
1018
  if (!msgid) msgid = logColor.cyan("FIREWALL");
799
1019
  return super.log(pci, severity, msgid, msg);
800
1020
  }
801
1021
  };
802
- export { Firewall, Firewall as default };
1022
+ /**
1023
+ * La liste `base` complétée des entrées de `extra` qu'elle ne porte pas encore
1024
+ * — comparaison sans casse (les noms d'en-têtes et de méthodes HTTP ne
1025
+ * distinguent pas la casse) ; l'ordre et l'écriture de `base` sont gardés.
1026
+ *
1027
+ * @param base - la liste de la configuration.
1028
+ * @param extra - les entrées à garantir.
1029
+ * @returns la liste complétée et les entrées ajoutées.
1030
+ */
1031
+ function withMissing(base, extra) {
1032
+ const known = new Set(base.map((entry) => entry.toLowerCase()));
1033
+ const added = extra.filter((entry) => !known.has(entry.toLowerCase()));
1034
+ return {
1035
+ list: [...base, ...added],
1036
+ added
1037
+ };
1038
+ }
1039
+ export { Firewall, Firewall as default, NONCED_DOCUMENT_CACHE_CONTROL };
@@ -1,5 +1,5 @@
1
- import { readIfPresentSync } from "../src/token/secretFile.js";
2
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
+ import { readIfPresentSync } from "../src/token/secretFile.js";
3
3
  import { buildKeycloakRealm, mergeKeycloakRealm, renderKeycloakRealm } from "../keycloak/keycloakRealm.js";
4
4
  import { KeycloakRealmError, deriveKeycloakRealmInput, keycloakLoginTheme } from "../keycloak/keycloakRealmInput.js";
5
5
  import { KeycloakAdminClient, keycloakAdminTarget } from "../keycloak/keycloakAdmin.js";
@@ -1,14 +1,16 @@
1
- import { compileProviderRoleMapping, isPlatformRole, mapProviderRoles } from "../src/oauth/providerRoles.js";
2
- import { AuthenticationError } from "../errors/AuthenticationError.js";
3
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
4
- import { recordAudit } from "../src/audit/recordAudit.js";
2
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
5
3
  import { keycloakLoginThemeCheck } from "../keycloak/keycloakAdmin.js";
4
+ import { recordAudit } from "../src/audit/recordAudit.js";
5
+ import { getOAuthProviderFactory, listOAuthProviders, oauthProviderImageSources, oauthProviderRequiresIssuer } from "../src/oauth/oauthProviderRegistry.js";
6
6
  import { generateCodeVerifier, generateState } from "../src/oauth/oauth2Client.js";
7
- import { getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer } from "../src/oauth/oauthProviderRegistry.js";
8
7
  import { diagnoseOAuthProvider } from "../src/oauth/providerDiagnosis.js";
8
+ import { compileProviderRoleMapping, isPlatformRole, mapProviderRoles } from "../src/oauth/providerRoles.js";
9
9
  import { BootConfigurationError, LOGIN_PAGE_PATH, Service, canonicalIssuer } from "nodefony";
10
10
  // nodefony/service/oauth2.ts
11
11
  const serviceName = "oauth2";
12
+ /** Nom sous lequel la connexion sociale contribue à la politique de contenu. */
13
+ const OAUTH2_CSP_CONTRIBUTOR = "oauth2";
12
14
  /**
13
15
  * Clé de session de l'indice de déconnexion. Lue et écrite par CE service
14
16
  * seulement ({@link OAuth2Service.rememberLogout},
@@ -242,6 +244,8 @@ var OAuth2Service = class extends Service {
242
244
  }
243
245
  this.#config = config;
244
246
  this.#ready = true;
247
+ const imageSources = oauthProviderImageSources(active);
248
+ if (imageSources.length > 0) this.get("firewall")?.registerCspOrigins(OAUTH2_CSP_CONTRIBUTOR, { "img-src": imageSources });
245
249
  this.log(`oauth2 ready — providers: [${active.join(", ") || "aucun"}]`, "DEBUG");
246
250
  if (this.kernel?.runProfile.servers === true) for (const name of active) this.#probe(name);
247
251
  }