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

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 (74) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.157.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/index.js +60 -60
  3. package/dist/nodefony/config/config.js +13 -5
  4. package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
  5. package/dist/nodefony/errors/index.js +4 -4
  6. package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
  7. package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
  8. package/dist/nodefony/service/accessTokenVerifier.js +5 -5
  9. package/dist/nodefony/service/apiKeys.js +8 -4
  10. package/dist/nodefony/service/authFlow.js +3 -3
  11. package/dist/nodefony/service/authorization.js +1 -1
  12. package/dist/nodefony/service/firewall.js +233 -41
  13. package/dist/nodefony/service/keycloak.js +1 -1
  14. package/dist/nodefony/service/oauth2.js +8 -4
  15. package/dist/nodefony/service/securityHeaders.js +340 -47
  16. package/dist/nodefony/service/tokenService.js +8 -8
  17. package/dist/nodefony/service/totp.js +3 -3
  18. package/dist/nodefony/service/webAuthn.js +1 -1
  19. package/dist/nodefony/service/webhooks.js +11 -4
  20. package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
  21. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
  22. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
  23. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
  24. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +5 -2
  25. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +1 -1
  26. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  27. package/dist/nodefony/src/csp.js +265 -10
  28. package/dist/nodefony/src/cspReports.js +307 -0
  29. package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
  30. package/dist/nodefony/src/oauth/metadata.js +1 -1
  31. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
  32. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  33. package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
  34. package/dist/nodefony/src/realtime/frameAuthorizer.js +7 -6
  35. package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
  36. package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
  37. package/dist/nodefony/src/totp/totpOperations.js +1 -1
  38. package/dist/nodefony/src/voter/RoleVoter.js +1 -1
  39. package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
  40. package/dist/nodefony/src/webhook/webhookDelivery.js +3 -3
  41. package/dist/nodefony/testing/index.js +98 -0
  42. package/dist/types/index.d.ts +1 -0
  43. package/dist/types/nodefony/config/config.d.ts +2 -0
  44. package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
  45. package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
  46. package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
  47. package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
  48. package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
  49. package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
  50. package/dist/types/nodefony/service/firewall.d.ts +28 -7
  51. package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
  52. package/dist/types/nodefony/service/webhooks.d.ts +7 -1
  53. package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
  54. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
  55. package/dist/types/nodefony/src/csp.d.ts +142 -0
  56. package/dist/types/nodefony/src/cspReports.d.ts +73 -0
  57. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
  58. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
  59. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  60. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +25 -17
  61. package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
  62. package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
  63. package/dist/types/nodefony/testing/index.d.ts +57 -0
  64. package/docs/api-keys.md +3 -1
  65. package/docs/authenticators.md +6 -4
  66. package/docs/csrf.md +3 -2
  67. package/docs/external-jwt.md +5 -1
  68. package/docs/firewall.md +3 -2
  69. package/docs/headers.md +427 -71
  70. package/docs/login-page.md +4 -3
  71. package/docs/totp.md +2 -2
  72. package/docs/webauthn.md +2 -2
  73. package/docs/webhooks.md +57 -11
  74. package/package.json +20 -14
@@ -1,20 +1,20 @@
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";
16
14
  import { ThrottledError } from "../errors/ThrottledError.js";
17
15
  import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
16
+ import { InsecureTransportError } from "../errors/InsecureTransportError.js";
17
+ import { CsrfError } from "../errors/CsrfError.js";
18
18
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
19
19
  import { getAuthenticatorFactory, listAuthenticatorFactories } from "../src/authenticator/authenticatorRegistry.js";
20
20
  import { FirewallRealtimeAuthenticator } from "../src/authenticator/FirewallRealtimeAuthenticator.js";
@@ -23,7 +23,8 @@ import { recordAudit } from "../src/audit/recordAudit.js";
23
23
  import { readAuditContext } from "../src/audit/readAuditContext.js";
24
24
  import { SECURITY_AUDIT_CHANNEL, createAuditBridge } from "../src/audit/auditBridge.js";
25
25
  import { findZonesOpenToSignup } from "../src/openToSignup.js";
26
- import { ROLES_WITHIN, RequestContext, Service, logColor } from "nodefony";
26
+ import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
27
+ import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
27
28
  import { randomBytes } from "node:crypto";
28
29
  import { encoderFromConfig } from "@nodefony/user";
29
30
  // nodefony/service/firewall.ts
@@ -35,29 +36,27 @@ const serviceName = "firewall";
35
36
  * genre de dépense que la règle de lazy-allocation proscrit.
36
37
  */
37
38
  const EMPTY_PROTECTED_RESOURCES = Object.freeze([]);
39
+ /**
40
+ * `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
41
+ * gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
42
+ * toujours redemandée avant réutilisation (ADR-0024 §C2).
43
+ */
44
+ const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
38
45
  function headerValue(headers, name) {
39
46
  const v = headers?.[name];
40
47
  return Array.isArray(v) ? v[0] : v;
41
48
  }
42
49
  /**
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.
50
+ * Valeur d'un cookie de la requête, lue sur le contexte : l'en-tête `Cookie`
51
+ * n'a qu'UN lecteur, `cookiesParser` (`@nodefony/http`), qui tourne à la
52
+ * construction du contexte — donc avant toute étape du pare-feu. Une seconde
53
+ * lecture ici divergeait (décodage, doublons) et levait sur un `%` isolé.
46
54
  */
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
- }
55
+ function requestCookie(context, name) {
56
+ const value = context.cookies[name]?.value;
57
+ return typeof value === "string" ? value : void 0;
54
58
  }
55
59
  /**
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
60
  * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
62
61
  *
63
62
  * `isSecure()` (hot-path, court-circuit si aucune zone) ne fait QUE matcher la
@@ -76,7 +75,7 @@ const FALLBACK_CHALLENGE = "Session realm=\"nodefony\"";
76
75
  *
77
76
  * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
78
77
  * §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}.
78
+ * le défi de repli {@link SESSION_AUTH_CHALLENGE}.
80
79
  *
81
80
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
82
81
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -91,6 +90,11 @@ var Firewall = class extends Service {
91
90
  #cors = null;
92
91
  #securityHeaders = null;
93
92
  #cspFragments = null;
93
+ #cspMerged = null;
94
+ #cspBase = "";
95
+ #appCspSources = null;
96
+ #cspReportUrl = null;
97
+ #reportingEndpoints = null;
94
98
  #config = null;
95
99
  #configError = null;
96
100
  #longLivedRevalidation = null;
@@ -121,7 +125,25 @@ var Firewall = class extends Service {
121
125
  this.#csrfTokens = new CsrfTokenManager(secret);
122
126
  }
123
127
  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);
128
+ if (this.#config.headers.enabled) {
129
+ this.#appCspSources = this.#validatedAppCspSources(this.#config.headers.cspSources);
130
+ this.#cspReportUrl = this.#validatedCspReportUrl(this.#config.headers.cspReport);
131
+ this.#cspBase = this.#composeCspBase(this.#config.headers.csp);
132
+ this.#securityHeaders = this.#newSecurityHeaders({
133
+ ...this.#config.headers,
134
+ csp: this.#cspBase
135
+ });
136
+ if (this.#reportingEndpoints !== null && this.#cspReportUrl === CSP_REPORT_PATH) this.container?.set("cspReports", new CspReportCollector({
137
+ endpoint: CSP_REPORT_PATH,
138
+ log: (message, severity) => this.log(message, severity)
139
+ }));
140
+ this.#cspMerged = null;
141
+ if (this.#cspFragments !== null && this.#cspFragments.size > 0) {
142
+ this.#rebuildSecurityHeaders();
143
+ for (const [name, fragment] of this.#cspFragments) this.#warnInertScriptSources(name, fragment);
144
+ }
145
+ 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");
146
+ }
125
147
  const areas = this.#config.areas;
126
148
  if (Object.keys(areas).length) {
127
149
  const list = [];
@@ -258,7 +280,7 @@ var Firewall = class extends Service {
258
280
  * @param config - config security, utilisée en repli si le HttpKernel est absent.
259
281
  */
260
282
  #transportHeaderState(config) {
261
- const emitted = (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
283
+ const emitted = this.#emittedTransportHeaders();
262
284
  if (!emitted) return {
263
285
  hsts: config.headers.hsts,
264
286
  hstsMaxAgeS: config.headers.hstsMaxAgeS,
@@ -597,7 +619,7 @@ var Firewall = class extends Service {
597
619
  host: headerValue(headers, "host") ?? headerValue(headers, ":authority") ?? context.domain
598
620
  });
599
621
  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");
622
+ if (!this.#csrfTokens.verify(headerValue(headers, "x-csrf-token"), requestCookie(context, "csrf-token"))) throw new CsrfError("Missing or invalid CSRF token");
601
623
  }
602
624
  }
603
625
  /**
@@ -643,39 +665,195 @@ var Firewall = class extends Service {
643
665
  /**
644
666
  * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
645
667
  * 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).
668
+ * Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
669
+ * la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
670
+ * `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
671
+ * ou réponse non-HTTP (WS).
649
672
  *
650
673
  * 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é.
674
+ * nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
675
+ *
676
+ * @param context - contexte de la réponse.
677
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
678
+ * omis = type inconnu, servi comme un document (politique complète).
679
+ * @param servedAsFile - le corps est servi tel quel (fichier statique,
680
+ * relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
681
+ * document reçoit alors la politique sans nonce ni `'strict-dynamic'`
682
+ * (ADR-0024 §C1) ; les autres types n'en changent pas.
652
683
  */
653
- applySecurityHeaders(context) {
684
+ applySecurityHeaders(context, contentType, servedAsFile = false) {
654
685
  const sh = this.#securityHeaders;
655
686
  if (!sh) return;
656
687
  const response = context.response;
657
688
  if (typeof response?.setHeader !== "function") return;
658
- const headers = sh.headers;
689
+ const extra = context.cspDirectives;
690
+ const tls = context.scheme === "https";
691
+ const validated = typeof response.setDefaultHeader === "function";
692
+ if (extra == null) {
693
+ const kind = responseKindOf(contentType);
694
+ if (kind !== "document" || servedAsFile) {
695
+ const table = kind === "document" ? sh.headersForServedFile(tls) : sh.headersForResource(kind, tls);
696
+ for (const name in table) {
697
+ const value = table[name];
698
+ if (value === void 0) continue;
699
+ if (validated) response.setDefaultHeader?.(name, value);
700
+ else response.setHeader(name, value);
701
+ }
702
+ return;
703
+ }
704
+ }
705
+ const headers = sh.headersFor(tls, extra != null && !sh.hasNonce);
659
706
  for (const name in headers) {
660
707
  const value = headers[name];
661
- if (value !== void 0) response.setHeader(name, value);
708
+ if (value === void 0) continue;
709
+ if (validated) response.setDefaultHeader?.(name, value);
710
+ else response.setHeader(name, value);
662
711
  }
663
- const extra = context.cspDirectives;
712
+ let csp = null;
664
713
  if (sh.hasNonce) {
665
714
  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));
715
+ csp = extra ? sh.cspForExtra(nonce, extra, tls) : sh.cspFor(nonce, tls);
716
+ } else if (extra) csp = sh.cspForExtra("", extra, tls);
717
+ if (csp !== null) {
718
+ if (validated) response.setDefaultHeader?.("Content-Security-Policy", csp);
719
+ else response.setHeader("Content-Security-Policy", csp);
720
+ }
721
+ if (sh.hasNonce) {
722
+ if (validated) response.setDefaultHeader?.("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
723
+ else response.setHeader("Cache-Control", NONCED_DOCUMENT_CACHE_CONTROL);
724
+ }
668
725
  }
669
726
  /**
670
727
  * Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
671
728
  * No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
672
729
  * Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
673
730
  */
674
- registerCspOrigins(moduleName, fragment) {
731
+ registerCspOrigins(moduleName, declared) {
732
+ const config = this.#config;
733
+ if (config !== null && this.#securityHeaders === null) return;
734
+ try {
735
+ assertCspFragment(declared, moduleName);
736
+ } catch (error) {
737
+ this.log(`CSP : contribution de ${moduleName} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
738
+ return;
739
+ }
740
+ const { fragment, dropped } = withoutOverbroadSources(declared);
741
+ 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");
742
+ const fragments = this.#cspFragments ??= /* @__PURE__ */ new Map();
743
+ if (config === null) {
744
+ fragments.set(moduleName, fragment);
745
+ return;
746
+ }
747
+ this.#warnInertScriptSources(moduleName, fragment);
748
+ const previous = fragments.get(moduleName);
749
+ if (previous !== void 0 || this.#cspMerged === null && fragments.size > 0) {
750
+ fragments.set(moduleName, fragment);
751
+ try {
752
+ this.#rebuildSecurityHeaders();
753
+ } catch (error) {
754
+ if (previous === void 0) fragments.delete(moduleName);
755
+ else fragments.set(moduleName, previous);
756
+ throw error;
757
+ }
758
+ return;
759
+ }
760
+ const csp = mergeCspFragments(this.#cspMerged ?? this.#cspBase, [fragment]);
761
+ fragments.set(moduleName, fragment);
762
+ this.#cspMerged = csp;
763
+ this.#securityHeaders = this.#newSecurityHeaders({
764
+ ...config.headers,
765
+ csp
766
+ });
767
+ }
768
+ /**
769
+ * Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
770
+ * config) : la politique suit `frameOptions`, qui pilote aussi
771
+ * `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
772
+ */
773
+ onTransportHeadersChanged() {
675
774
  if (!this.#securityHeaders || !this.#config) return;
676
- (this.#cspFragments ??= /* @__PURE__ */ new Map()).set(moduleName, fragment);
775
+ const base = this.#composeCspBase(this.#config.headers.csp);
776
+ if (base === this.#cspBase) return;
777
+ this.#cspBase = base;
677
778
  this.#rebuildSecurityHeaders();
678
779
  }
780
+ /**
781
+ * Politique de base : la config, complétée de `frame-ancestors` dérivé de ce
782
+ * que le noyau HTTP émet en `X-Frame-Options`. Lu chez l’émetteur, par son
783
+ * contrat d’introspection ; sans noyau HTTP, rien n’est dérivé.
784
+ */
785
+ #composeCspBase(csp) {
786
+ const emitted = this.#emittedTransportHeaders();
787
+ const framed = emitted ? withFrameAncestors(csp, emitted.frameOptions) : csp;
788
+ const sources = this.#appCspSources;
789
+ const merged = sources === null || csp.trim() === "" ? framed : mergeCspFragments(framed, sources);
790
+ if (this.#cspReportUrl === null) {
791
+ this.#reportingEndpoints = null;
792
+ return merged;
793
+ }
794
+ const reported = withViolationReports(merged, this.#cspReportUrl, CSP_REPORT_GROUP);
795
+ this.#reportingEndpoints = reported.added ? `${CSP_REPORT_GROUP}="${this.#cspReportUrl}"` : null;
796
+ return reported.csp;
797
+ }
798
+ /**
799
+ * Lit `headers.cspReport` : `true` = la route du framework, une chaîne = le
800
+ * collecteur de l’application, `false` = aucun rapport. Une URL qu’un en-tête
801
+ * ne peut pas porter est RETIRÉE et dite (ADR-0024 C0 : une faute de
802
+ * configuration de sécurité n’empêche pas le démarrage).
803
+ */
804
+ #validatedCspReportUrl(value) {
805
+ if (value === false) return null;
806
+ if (value === true) return CSP_REPORT_PATH;
807
+ const problem = reportUrlProblem(value);
808
+ if (problem === null) return value;
809
+ this.log(`CSP : headers.cspReport « ${value.slice(0, 120)} » IGNORÉE — ${problem} ; aucun rapport de violation demandé.`, "ERROR");
810
+ return null;
811
+ }
812
+ /**
813
+ * Dit, une fois, les sources de scripts d'un module que `'strict-dynamic'`
814
+ * rend inertes (cf `findInertScriptSources`) : gardées pour un navigateur
815
+ * CSP2, elles n'ouvrent rien aux autres — le module doit charger son script
816
+ * depuis un script signé.
817
+ *
818
+ * @param moduleName - module qui a déclaré le fragment
819
+ * @param fragment - fragment retenu (sources trop larges déjà retirées)
820
+ */
821
+ #warnInertScriptSources(moduleName, fragment) {
822
+ 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");
823
+ }
824
+ /**
825
+ * Valide les contributions nommées de `headers.cspSources`, une fois. Une
826
+ * contribution malformée est IGNORÉE et dite (l'application démarre, plus
827
+ * stricte) ; une source ouverte à tout hôte est GARDÉE — choix écrit par
828
+ * l'application, comme sa politique — et dite.
829
+ *
830
+ * @param declared - contributions par nom
831
+ * @returns les fragments retenus, ou `null` s'il n'y en a aucun
832
+ */
833
+ #validatedAppCspSources(declared) {
834
+ let kept = null;
835
+ for (const [name, fragment] of Object.entries(declared)) {
836
+ try {
837
+ assertCspFragment(fragment, name);
838
+ } catch (error) {
839
+ this.log(`CSP : headers.cspSources.${name} IGNORÉE — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
840
+ continue;
841
+ }
842
+ 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");
843
+ (kept ??= []).push(fragment);
844
+ }
845
+ return kept;
846
+ }
847
+ /**
848
+ * En-têtes de transport que le noyau HTTP ÉMET, lus par son contrat
849
+ * d’introspection, jamais par ses champs internes : `security` ne dépend pas
850
+ * de la mécanique de cache du kernel HTTP, seulement de ce qu’il déclare.
851
+ *
852
+ * @returns les valeurs émises, `undefined` sans noyau HTTP au conteneur.
853
+ */
854
+ #emittedTransportHeaders() {
855
+ return (this.container?.get("HttpKernel"))?.describeTransportSecurityHeaders?.();
856
+ }
679
857
  /** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
680
858
  unregisterCspOrigins(moduleName) {
681
859
  if (!this.#cspFragments?.delete(moduleName)) return;
@@ -690,13 +868,27 @@ var Firewall = class extends Service {
690
868
  #rebuildSecurityHeaders() {
691
869
  const headers = this.#config?.headers;
692
870
  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({
871
+ const base = this.#cspBase;
872
+ const csp = this.#cspFragments && this.#cspFragments.size > 0 ? mergeCspFragments(base, this.#cspFragments.values()) : base;
873
+ this.#cspMerged = csp === base ? null : csp;
874
+ this.#securityHeaders = this.#newSecurityHeaders({
695
875
  ...headers,
696
876
  csp
697
877
  });
698
878
  }
699
879
  /**
880
+ * Construit la table des en-têtes applicatifs, branchée sur le journal : un
881
+ * `@Csp` de route hors grammaire est IGNORÉ (politique de base servie) et dit.
882
+ */
883
+ #newSecurityHeaders(headers) {
884
+ return new SecurityHeaders({
885
+ ...headers,
886
+ reportingEndpoints: this.#reportingEndpoints
887
+ }, (error) => {
888
+ this.log(`CSP : directives @Csp d’une route IGNORÉES, la politique de base est servie — ${error instanceof Error ? error.message : String(error)}`, "ERROR");
889
+ });
890
+ }
891
+ /**
700
892
  * Exécute la chaîne d'authenticators de la zone selon son `mode`.
701
893
  *
702
894
  * `first` : le premier dont `supports()` est vrai authentifie — un credential
@@ -792,11 +984,11 @@ var Firewall = class extends Service {
792
984
  return;
793
985
  }
794
986
  }
795
- response.setHeader("WWW-Authenticate", FALLBACK_CHALLENGE);
987
+ response.setHeader("WWW-Authenticate", SESSION_AUTH_CHALLENGE);
796
988
  }
797
989
  log(pci, severity, msgid, msg) {
798
990
  if (!msgid) msgid = logColor.cyan("FIREWALL");
799
991
  return super.log(pci, severity, msgid, msg);
800
992
  }
801
993
  };
802
- export { Firewall, Firewall as default };
994
+ 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
  }