@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
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Corps JSON d'une livraison webhook, tel que le destinataire le reçoit — le type à poser sur le `JSON.parse` du récepteur.
3
+ *
4
+ * Signé avec ses en-têtes (`webhook-id`, `webhook-timestamp`, `webhook-signature`,
5
+ * convention Standard Webhooks) : vérifier la signature AVANT de lire le corps.
6
+ *
7
+ * @typeParam T - la forme de `data` ; `unknown` tant que le récepteur ne l'a pas vérifiée.
8
+ */
9
+ export interface IWebhookPayload<T = unknown> {
10
+ /**
11
+ * Identifiant du MESSAGE, égal à l'en-tête `webhook-id` et stable d'un réessai à l'autre : la clé de déduplication du récepteur.
12
+ */
13
+ readonly id: string;
14
+ /**
15
+ * Heure de la TENTATIVE de livraison (ISO 8601), pas celle de l'événement : un réessai porte une heure plus tardive.
16
+ *
17
+ * L'heure de l'événement vit dans `data` (`ts` pour un événement d'audit).
18
+ */
19
+ readonly timestamp: string;
20
+ /** Nom de l'événement — l'action d'audit, ex. `"login.success"` ; c'est lui que filtrent les motifs d'abonnement. */
21
+ readonly type: string;
22
+ /** L'événement lui-même ; pour l'audit, un `IAuditEvent`. */
23
+ readonly data: T;
24
+ }
@@ -0,0 +1,15 @@
1
+ import { nodefonyError } from "nodefony";
2
+ /** Cause d'un échec de {@link FetchPublicError}. */
3
+ export type FetchPublicFailure = "timeout" | "too-many-redirects" | "network";
4
+ /**
5
+ * Échec d'une requête sortante vers un site tiers (`fetchPublic`) qui n'est PAS
6
+ * un refus de cible : délai total dépassé (`504`), trop de redirections ou
7
+ * échec réseau (`502`). Une cible interdite lève `SsrfError` (422), pas
8
+ * celle-ci — l'appelant distingue ainsi « tu n'as pas le droit d'y aller » de
9
+ * « le site n'a pas répondu ».
10
+ */
11
+ export declare class FetchPublicError extends nodefonyError {
12
+ /** Cause de l'échec, stable et exploitable sans lire le message. */
13
+ readonly reason: FetchPublicFailure;
14
+ constructor(reason: FetchPublicFailure, message: string);
15
+ }
@@ -8,6 +8,13 @@ import { nodefonyError } from "nodefony";
8
8
  * d'un webhook invalide).
9
9
  */
10
10
  export declare class SsrfError extends nodefonyError {
11
- constructor(message?: string | Error);
11
+ /**
12
+ * `forbidden` : la cible est interdite (protocole, identifiants, IP non
13
+ * publique). `unresolvable` : l'hôte n'a aucune adresse — rien d'interne n'est
14
+ * visé, le site est simplement injoignable ; un appelant qui rend ces deux cas
15
+ * à l'utilisateur les distingue par ce champ, jamais par le texte du message.
16
+ */
17
+ readonly reason: "forbidden" | "unresolvable";
18
+ constructor(message?: string | Error, reason?: "forbidden" | "unresolvable");
12
19
  }
13
20
  export default SsrfError;
@@ -0,0 +1,17 @@
1
+ import { AuthenticationError } from "./AuthenticationError.js";
2
+ /**
3
+ * Session PÉRIMÉE — elle ne désigne plus personne de valide : compte supprimé,
4
+ * verrouillé, désactivé, recréé sous le même identifiant, mot de passe changé
5
+ * depuis son ouverture, ou blob vidé. C'est un ÉTAT à purger, pas une preuve
6
+ * fausse présentée par un tiers : le `SessionAuthenticator` la détruit côté
7
+ * serveur ET côté navigateur (cookie expiré, RFC 6265 §5.3), puis le firewall
8
+ * poursuit comme si aucune session n'avait été présentée — une page ouverte
9
+ * aux anonymes s'affiche, une zone fermée redemande la connexion.
10
+ *
11
+ * Jamais levée pour une PANNE de la source d'identité : une base injoignable
12
+ * ne dit rien de la session, qui doit survivre à l'incident.
13
+ */
14
+ export declare class StaleSessionError extends AuthenticationError {
15
+ constructor(message?: string | Error);
16
+ }
17
+ export default StaleSessionError;
@@ -1,9 +1,12 @@
1
1
  export { AuthenticationError } from "./AuthenticationError.js";
2
+ export { StaleSessionError } from "./StaleSessionError.js";
2
3
  export { AccessDeniedError } from "./AccessDeniedError.js";
3
4
  export { ThrottledError } from "./ThrottledError.js";
4
5
  export { UnverifiableTokenError } from "./UnverifiableTokenError.js";
5
6
  export { InvalidTargetError } from "./InvalidTargetError.js";
6
7
  export { CsrfError } from "./CsrfError.js";
7
8
  export { SsrfError } from "./SsrfError.js";
9
+ export { FetchPublicError } from "./FetchPublicError.js";
10
+ export type { FetchPublicFailure } from "./FetchPublicError.js";
8
11
  export { WebAuthnError } from "./WebAuthnError.js";
9
12
  export { InsecureTransportError } from "./InsecureTransportError.js";
@@ -7,13 +7,13 @@ export declare const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.jso
7
7
  export declare const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
8
8
  export declare const APP_KEYCLOAK_THEME_DIR = "docker/keycloak/themes/nodefony";
9
9
  /**
10
- * Ressource de l'API partenaire d'une application neuve en développement :
11
- * ce que rend `partnerResource()` du gabarit `complete` sans `NF_JWT_ISSUER`
12
- * ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
13
- * realm. Deux paquets, une valeur : le test de contribution compare le
14
- * gabarit rendu à cette constante.
10
+ * Ressource de l'API partenaire de la démo en développement : le chemin de sa
11
+ * zone (`/demo/api/partner`) rendu absolu contre l'émetteur de développement
12
+ * — le port HTTPS par défaut, comme les URL de retour du realm. Deux paquets,
13
+ * une valeur : le test de contribution la confronte à une application
14
+ * `--demo` réellement rendue.
15
15
  */
16
- export declare const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/api/partner";
16
+ export declare const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/demo/api/partner";
17
17
  /** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
18
18
  declare const THEME_TYPES: readonly ["login", "account", "email", "admin"];
19
19
  type TThemeType = (typeof THEME_TYPES)[number];
@@ -68,6 +68,12 @@ export declare function devMachineSecret(appName: string): string;
68
68
  * rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
69
69
  * audiences des zones `external-jwt` que la configuration rendue déclare.
70
70
  *
71
+ * Le client « machine » (`<app>-machine`, `client_credentials`) n'existe que
72
+ * s'il a une audience à recevoir : une application neuve n'ouvre aucune zone à
73
+ * jeton tiers (#746), et un client que rien n'accepte serait un secret de plus
74
+ * à garder pour rien. La démo, qui apporte l'API entre programmes, le fait
75
+ * naître en déclarant sa ressource.
76
+ *
71
77
  * @param appName - nom de l'application (= realm = identifiant de client)
72
78
  * @param themes - types de thème à déclarer
73
79
  * @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
@@ -49,6 +49,9 @@ declare class ApiKeyService extends Service {
49
49
  * @param roles - rôles du porteur tels que son compte les porte ; omis =
50
50
  * aucun rôle, donc tout scope réservé est refusé (fail-closed).
51
51
  * @throws ApiKeyError 400 — nom vide/trop long, scope hors catalogue, expiry invalide.
52
+ * @throws ApiKeyError 400 — `tenantId` rempli : le périmètre d'un locataire n'est
53
+ * pas encore appliqué (aucun lecteur ne cloisonne sur lui) — l'accepter rendrait
54
+ * la clé trompeuse, et l'appliquer un jour lui retirerait des accès (rupture).
52
55
  * @throws ApiKeyError 403 — scope réservé à des rôles que le porteur n'a pas.
53
56
  * @throws ApiKeyError 409 — plafond `maxPerSubject` atteint.
54
57
  * @throws ApiKeyError 403 — aucun compte sous cet identifiant (la clé est liée
@@ -132,8 +132,8 @@ declare class AuthFlow extends Service {
132
132
  * (mêmes contrôles que le `SessionAuthenticator` — source unique).
133
133
  *
134
134
  * @returns la projection publique, ou `null` (pas de session, session
135
- * orpheline, compte verrouillé/désactivé) — le handler répond `200`
136
- * `{ user: null }`.
135
+ * orpheline, compte verrouillé/désactivé — la session périmée est alors
136
+ * détruite, cookie compris) — le handler répond `200` `{ user: null }`.
137
137
  */
138
138
  me(context: ContextType): Promise<ISafeUser | null>;
139
139
  /**
@@ -8,6 +8,12 @@ import type { IFirewall } from "../contracts/IFirewall.js";
8
8
  import type { IAuthenticator } from "../contracts/IAuthenticator.js";
9
9
  import type { ISecuredArea } from "../contracts/ISecuredArea.js";
10
10
  import type { IFirewallDescription, IRoleHierarchyDescription } from "../contracts/IFirewallDescription.js";
11
+ /**
12
+ * `Cache-Control` par défaut d'une réponse dont la CSP porte un nonce : jamais
13
+ * gardée par un cache partagé (le nonce y serait servi à plusieurs visiteurs),
14
+ * toujours redemandée avant réutilisation (ADR-0024 §C2).
15
+ */
16
+ export declare const NONCED_DOCUMENT_CACHE_CONTROL = "private, no-cache";
11
17
  /**
12
18
  * Orchestrateur de sécurité Nodefony — refonte 2026 (P6).
13
19
  *
@@ -27,7 +33,7 @@ import type { IFirewallDescription, IRoleHierarchyDescription } from "../contrac
27
33
  *
28
34
  * Conformité : tout 401 porte un challenge `WWW-Authenticate` (RFC 9110
29
35
  * §15.5.2) — celui du premier authenticator de la zone qui en déclare un, sinon
30
- * le défi de repli {@link FALLBACK_CHALLENGE}.
36
+ * le défi de repli {@link SESSION_AUTH_CHALLENGE}.
31
37
  *
32
38
  * CORS, CSRF et autorisation par décorateurs viennent se brancher en S4/S5.
33
39
  * Toutes les structures sont **lazy** (perf : une app sans zone = zéro alloc).
@@ -141,20 +147,35 @@ declare class Firewall extends Service implements IFirewall {
141
147
  /**
142
148
  * En-têtes de sécurité APPLICATIFS (P6 J5) — CSP, Referrer-Policy, isolation
143
149
  * cross-origin (COOP/COEP/CORP), Origin-Agent-Cluster, Permissions-Policy.
144
- * Posés sur toute réponse du pipeline (branché dans `handleHttp`). Complète le
145
- * socle transport de `@nodefony/http` (nosniff/frame/HSTS, posé à l'entrée brute)
146
- * SANS le ré-émettre. No-op si désactivé ou réponse non-HTTP (WS).
150
+ * Appelé par la réponse juste avant son `writeHead`, une fois son type fixé :
151
+ * la politique suit ce type (ADR-0024 §C11). Complète le socle transport de
152
+ * `@nodefony/http` (nosniff/frame/HSTS) SANS le ré-émettre. No-op si désactivé
153
+ * ou réponse non-HTTP (WS).
147
154
  *
148
155
  * En-têtes constants = table figée pré-calculée au boot (0 alloc/concat). Le CSP
149
- * nonce/req (étape B) ajoute 1 `join` + 1 `setHeader` UNIQUEMENT si activé.
156
+ * nonce/req ajoute 1 `join` + 1 `setHeader`, sur un document seulement.
157
+ *
158
+ * @param context - contexte de la réponse.
159
+ * @param contentType - `Content-Type` de la réponse ; `null` = sans contenu ;
160
+ * omis = type inconnu, servi comme un document (politique complète).
161
+ * @param servedAsFile - le corps est servi tel quel (fichier statique,
162
+ * relais) : aucun gabarit ne l'a écrit, il ne peut pas porter de nonce. Un
163
+ * document reçoit alors la politique sans nonce ni `'strict-dynamic'`
164
+ * (ADR-0024 §C1) ; les autres types n'en changent pas.
150
165
  */
151
- applySecurityHeaders(context: ContextType): void;
166
+ applySecurityHeaders(context: ContextType, contentType?: string | null, servedAsFile?: boolean): void;
152
167
  /**
153
168
  * Déclare des directives CSP additionnelles pour `moduleName` (cf `IFirewall`).
154
169
  * No-op si les en-têtes applicatifs sont désactivés (pas de CSP à étendre).
155
170
  * Recompose `#securityHeaders` (merge + re-split nonce) — hors hot-path.
156
171
  */
157
- registerCspOrigins(moduleName: string, fragment: CspFragment): void;
172
+ registerCspOrigins(moduleName: string, declared: CspFragment): void;
173
+ /**
174
+ * Le noyau HTTP a recalculé ses en-têtes de transport (édition à chaud de sa
175
+ * config) : la politique suit `frameOptions`, qui pilote aussi
176
+ * `frame-ancestors` (ADR-0024 §C5). Hors chemin chaud.
177
+ */
178
+ onTransportHeadersChanged(): void;
158
179
  /** Retire les directives CSP de `moduleName` et recompose si nécessaire. */
159
180
  unregisterCspOrigins(moduleName: string): void;
160
181
  log(pci: unknown, severity?: Severity, msgid?: Msgid, msg?: Message): Pdu;
@@ -1,10 +1,26 @@
1
1
  import { type CspFragment } from "../src/csp.js";
2
+ /**
3
+ * Famille d'une réponse au regard de la politique de contenu (ADR-0024 §C11) :
4
+ * `document` (HTML, XHTML, SVG — politique complète avec nonce et
5
+ * `Permissions-Policy`), `script` (JavaScript — politique complète SANS nonce,
6
+ * celle que prend un worker), `other` (tout le reste — politique courte figée).
7
+ */
8
+ export type ResponseKind = "document" | "script" | "other";
9
+ /**
10
+ * Range une réponse dans sa famille d'après son `Content-Type`.
11
+ *
12
+ * @param contentType - valeur de `Content-Type` (paramètres admis) ; `null` =
13
+ * réponse sans contenu ; `undefined` = type INCONNU de l'appelant, traité en
14
+ * document — la politique la plus complète, jamais une plus faible par défaut.
15
+ * @returns la famille dont la réponse reçoit la politique.
16
+ */
17
+ export declare function responseKindOf(contentType: string | null | undefined): ResponseKind;
2
18
  /**
3
19
  * Sous-ensemble APPLICATIF de la config `headers` (cf defineSecurityConfig).
4
20
  *
5
21
  * `hsts` / `frameguard` / `noSniff` n'y figurent PAS volontairement : ces trois
6
- * en-têtes « transport » sont posés par `@nodefony/http` à l'entrée brute
7
- * (`onHttpRequest`, AVANT le pipeline) pour couvrir AUSSI les fichiers statiques,
22
+ * en-têtes « transport » sont posés par `@nodefony/http` sur toute réponse
23
+ * (`HttpKernel.transportHeaders`, figés au démarrage) pour couvrir AUSSI les fichiers statiques,
8
24
  * les erreurs précoces et un serveur sans module security (secure-by-default).
9
25
  * `@nodefony/security` ne les ré-émet pas → une seule source par en-tête.
10
26
  */
@@ -18,6 +34,12 @@ export interface ISecurityHeadersOptions {
18
34
  corp?: string | undefined;
19
35
  originAgentCluster?: boolean | undefined;
20
36
  permissionsPolicy?: string | undefined;
37
+ /**
38
+ * Valeur de `Reporting-Endpoints` (`groupe="url"`), quand la politique
39
+ * désigne un groupe `report-to` composé par le framework (ADR-0024 §C7).
40
+ * Posé là où la politique complète part : document, script, fichier servi.
41
+ */
42
+ reportingEndpoints?: string | null | undefined;
21
43
  }
22
44
  /**
23
45
  * En-têtes de sécurité **applicatifs** de Nodefony (couche `@nodefony/security`,
@@ -26,19 +48,65 @@ export interface ISecurityHeadersOptions {
26
48
  * cross-origin COOP/COEP/CORP, Origin-Agent-Cluster, Permissions-Policy) → zéro
27
49
  * alloc, zéro concat par requête (le firewall la pose telle quelle).
28
50
  *
29
- * **Étape A+B** : en-têtes constants pré-calculés au boot (Referrer/COOP/…). Pour le
30
- * CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via `headers`,
31
- * 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder `{{nonce}}` dans le
32
- * CSP) → segments pré-split au boot, recomposés par requête via `cspFor(nonce)` (1
33
- * `join`, aucun parse/regex dans le hot-path). Le nonce lui-même vit sur `Context`.
51
+ * Pour le CSP, deux régimes mutuellement exclusifs : **statique** (posé tel quel via
52
+ * `headers`, 0 alloc/req) OU **nonce par requête** (`cspNonces` + placeholder
53
+ * `{{nonce}}`) → segments pré-split au boot, recomposés par requête via
54
+ * `cspFor(nonce)` (1 concaténation, aucun parse/regex dans le hot-path).
55
+ *
56
+ * **Deux variantes, composées au boot** (ADR-0024 §C6) : la politique telle
57
+ * qu'écrite, servie en TLS, et la même SANS `upgrade-insecure-requests`, servie
58
+ * en clair — sur une page `http`, la directive réécrirait les sous-ressources en
59
+ * `https`, que le serveur ne sert pas. Sans la directive, les deux variantes sont
60
+ * le MÊME objet : par requête, un booléen choisit, rien de plus.
34
61
  *
35
62
  * @see Fetch Metadata · W3C CSP Level 3 · WHATWG (COOP/COEP/CORP).
36
63
  */
37
64
  export declare class SecurityHeaders {
38
65
  #private;
39
- constructor(o: ISecurityHeadersOptions);
40
- /** Table d'en-têtes applicatifs CONSTANTS (figée), posée telle quelle par le firewall. */
66
+ /**
67
+ * @param o - options des en-têtes (config `security.headers`, CSP déjà fusionnée).
68
+ * @param onRejectedExtra - appelé quand les directives `@Csp` d'une route sortent
69
+ * de la grammaire CSP3 : la route sert alors la politique de base.
70
+ */
71
+ constructor(o: ISecurityHeadersOptions, onRejectedExtra?: (error: unknown) => void);
72
+ /**
73
+ * Table d'en-têtes figée d'un DOCUMENT servi tel quel — fichier statique,
74
+ * réponse relayée — dont le corps ne passe par aucun gabarit et ne peut donc
75
+ * pas porter de nonce (ADR-0024 §C1) : politique complète sans nonce ni
76
+ * `'strict-dynamic'` orphelin, et tous les en-têtes d'un document.
77
+ *
78
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
79
+ * @returns la table à poser telle quelle.
80
+ */
81
+ headersForServedFile(tls: boolean): Readonly<Record<string, string>>;
82
+ /**
83
+ * Table d'en-têtes figée d'une réponse qui n'est pas un document (ADR-0024
84
+ * §C11) : JavaScript (politique complète sans nonce, Referrer-Policy, COEP,
85
+ * CORP) ou reste (politique courte `default-src 'none'`, Referrer-Policy,
86
+ * CORP). Ni l'une ni l'autre ne porte de nonce ; COOP, Origin-Agent-Cluster
87
+ * et Permissions-Policy, lus sur un document seulement, n'y figurent pas.
88
+ *
89
+ * @param kind - famille de la réponse (cf {@link responseKindOf}).
90
+ * @param tls - la page est servie en `https` (garde `upgrade-insecure-requests`).
91
+ * @returns la table à poser telle quelle.
92
+ */
93
+ headersForResource(kind: Exclude<ResponseKind, "document">, tls: boolean): Readonly<Record<string, string>>;
94
+ /** Table d'en-têtes applicatifs CONSTANTS (figée) d'une réponse en clair. */
41
95
  get headers(): Readonly<Record<string, string>>;
96
+ /**
97
+ * Table constante SANS la CSP statique d'une réponse en clair — pour une
98
+ * réponse qui pose sa propre politique (route `@Csp`), afin que la CSP ne
99
+ * soit écrite qu'une fois.
100
+ */
101
+ get headersWithoutCsp(): Readonly<Record<string, string>>;
102
+ /**
103
+ * Table d'en-têtes constants selon le transport de la réponse.
104
+ *
105
+ * @param tls - la page est servie en `https` (proxy de confiance compris).
106
+ * @param withoutCsp - omettre la CSP statique (route `@Csp` qui pose la sienne).
107
+ * @returns la table figée à poser telle quelle.
108
+ */
109
+ headersFor(tls: boolean, withoutCsp?: boolean): Readonly<Record<string, string>>;
42
110
  /** `true` si le CSP est recomposé par requête avec un nonce (→ `cspFor`). */
43
111
  get hasNonce(): boolean;
44
112
  /**
@@ -48,9 +116,11 @@ export declare class SecurityHeaders {
48
116
  * `'`/`;`/espace) → aucune évasion possible du token CSP.
49
117
  *
50
118
  * @param nonce - nonce base64 de la requête (`Context.cspNonce`).
119
+ * @param tls - la page est servie en `https` : la politique garde
120
+ * `upgrade-insecure-requests`. Défaut : en clair.
51
121
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
52
122
  */
53
- cspFor(nonce: string): string;
123
+ cspFor(nonce: string, tls?: boolean): string;
54
124
  /**
55
125
  * Recompose le CSP en fusionnant ADDITIVEMENT les directives `extra` d'une
56
126
  * route (`@Csp`) dans le CSP de base, PUIS en substituant le `nonce`. Le merge
@@ -60,8 +130,9 @@ export declare class SecurityHeaders {
60
130
  *
61
131
  * @param nonce - nonce base64 de la requête (ignoré si le CSP n'a pas de nonce).
62
132
  * @param extra - directives additionnelles de la route (`directive → sources`).
133
+ * @param tls - la page est servie en `https`. Défaut : en clair.
63
134
  * @returns la valeur `Content-Security-Policy` à poser sur la réponse.
64
135
  */
65
- cspForExtra(nonce: string, extra: CspFragment): string;
136
+ cspForExtra(nonce: string, extra: CspFragment, tls?: boolean): string;
66
137
  }
67
138
  export default SecurityHeaders;
@@ -143,3 +143,16 @@ declare class WebAuthnService extends Service {
143
143
  }
144
144
  export default WebAuthnService;
145
145
  export { WebAuthnService };
146
+ /**
147
+ * Origines WebAuthn rendues en ASCII (punycode), forme sous laquelle le
148
+ * navigateur les présente — `https://café.example` → `https://xn--caf-dma.example`.
149
+ *
150
+ * Une entrée qui n'est pas une origine (`scheme://hôte[:port]`) est écartée et
151
+ * signalée : la garder ferait échouer CHAQUE cérémonie, sans que personne ne
152
+ * sache pourquoi.
153
+ *
154
+ * @param origins - origines telles qu'écrites dans la config.
155
+ * @param onInvalid - appelé pour chaque entrée écartée.
156
+ * @returns les origines valides, sérialisées en ASCII, dans l'ordre reçu.
157
+ */
158
+ export declare function asciiOrigins(origins: readonly string[], onInvalid: (origin: string) => void): string[];
@@ -17,7 +17,10 @@ export interface IWebhookRegisterInput {
17
17
  readonly enabled?: boolean;
18
18
  /** Identité de l'admin créateur (traçabilité). */
19
19
  readonly createdBy?: string | null;
20
- /** Slot multi-tenant (réservé). */
20
+ /**
21
+ * Périmètre d'un locataire — réservé, **refusé s'il est rempli** tant que le
22
+ * multi-tenant n'existe pas (ADR-0023 W2). Absent ou `null` : abonnement sans périmètre.
23
+ */
21
24
  readonly tenantId?: string | null;
22
25
  /** Métadonnées extensibles. */
23
26
  readonly metadata?: Record<string, unknown>;
@@ -79,6 +82,9 @@ declare class WebhookService extends Service {
79
82
  * (la seule occasion de le lire pour le copier).
80
83
  *
81
84
  * @throws SsrfError si l'URL est invalide / cible non publique.
85
+ * @throws Error si un motif de `events` porte plus de trois `*` (ADR-0023 W1).
86
+ * @throws Error si `tenantId` est rempli : le périmètre d'un locataire n'est pas
87
+ * encore appliqué à la livraison (ADR-0023 W2) — l'accepter le rendrait trompeur.
82
88
  */
83
89
  register(input: IWebhookRegisterInput): Promise<IWebhookSecretReveal>;
84
90
  /**
@@ -52,6 +52,9 @@ export declare function parseAuditQuery(query: Readonly<Record<string, string |
52
52
  * - `GET /nodefony/security/api/firewall` — introspection du firewall (zones,
53
53
  * authenticators montés, défenses) — état RUNTIME, secrets exclus.
54
54
  * - `GET /nodefony/security/api/roleHierarchy` — hiérarchie de rôles + résolution.
55
+ * - `GET /nodefony/security/api/csp-reports` — violations CSP reçues (ADR-0024
56
+ * §C7), dédoublonnées, la plus récente d’abord ; champs écrits par le
57
+ * navigateur, donc à afficher comme du texte.
55
58
  * - `GET /nodefony/security/api/apikeys` — toutes les clés API (gouvernance),
56
59
  * `GET …/apikeys/status` — backend du token store (« où on écrit »),
57
60
  * `POST …/apikeys/{id}/revoke` — révocation par id (réponse à incident).
@@ -0,0 +1,55 @@
1
+ import type { IAuditEventDraft } from "../../contracts/IAuditEvent.js";
2
+ /** Réglages de l'agrégation des refus (section `security.audit.aggregate`). */
3
+ export interface IAuditAggregateOptions {
4
+ /** Durée d'une fenêtre, en ms, ouverte au premier refus d'une clé. */
5
+ windowMs: number;
6
+ /** Refus d'une clé écrits tels quels par fenêtre ; les suivants sont comptés. */
7
+ burst: number;
8
+ /** Clés suivies au plus ; au-delà, les refus se regroupent sans l'IP. */
9
+ maxKeys: number;
10
+ }
11
+ /**
12
+ * Agrège les REFUS du journal d'audit par source, pour qu'un flot ne se paie pas
13
+ * d'une ligne par requête (OWASP Logging Cheat Sheet : la journalisation ne doit
14
+ * pas épuiser les ressources ni le journal de la base).
15
+ *
16
+ * Clé : catégorie, action, motif, ressource et IP — l'IP résolue par le contexte
17
+ * (`trustProxy`), donc non forgeable par le client. L'acteur n'entre PAS dans la
18
+ * clé : un bourrage d'identifiants change d'acteur à chaque essai, c'est sa
19
+ * source qui le trahit.
20
+ *
21
+ * Par clé et par fenêtre (ouverte au premier refus) : les `burst` premiers refus
22
+ * sont écrits tels quels — l'enquêteur voit les premiers essais, acteur compris ;
23
+ * les suivants sont seulement comptés, et la fenêtre close émet UNE ligne de
24
+ * synthèse (`metadata.aggregated`). Mémoire bornée : au-delà de `maxKeys` clés,
25
+ * les refus se regroupent sous l'IP `*`.
26
+ *
27
+ * Pur : l'horloge est passée à chaque appel, la synthèse part par `emit`. Les
28
+ * succès n'y passent jamais (l'appelant les écarte).
29
+ */
30
+ export declare class AuditRefusalAggregator {
31
+ #private;
32
+ /**
33
+ * @param options - fenêtre, rafale écrite telle quelle, borne de clés.
34
+ * @param emit - écrit une ligne de synthèse dans le journal.
35
+ */
36
+ constructor(options: IAuditAggregateOptions, emit: (draft: IAuditEventDraft) => void);
37
+ /** Nombre de clés suivies (fenêtres ouvertes). */
38
+ get size(): number;
39
+ /**
40
+ * Compte un refus.
41
+ *
42
+ * @param draft - l'événement de refus.
43
+ * @param now - horloge, en ms.
44
+ * @returns `true` : l'écrire tel quel ; `false` : il est compté dans la synthèse.
45
+ */
46
+ admit(draft: IAuditEventDraft, now: number): boolean;
47
+ /**
48
+ * Ferme les fenêtres échues : émet leur synthèse et libère leurs clés.
49
+ *
50
+ * @param now - horloge, en ms.
51
+ */
52
+ sweep(now: number): void;
53
+ /** Émet la synthèse de toutes les fenêtres ouvertes (arrêt) et vide la table. */
54
+ flush(): void;
55
+ }
@@ -6,6 +6,17 @@ import type { ISecurityConfig } from "../../config/defineModuleConfig.js";
6
6
  import { type IProviderRoleMapping } from "../oauth/providerRoles.js";
7
7
  /** Comment le sujet d'un jeton devient un utilisateur de cette application. */
8
8
  export type ExternalSubjectPolicy = "require" | "ephemeral";
9
+ /**
10
+ * Cet utilisateur a-t-il été fabriqué pour un appelant machine SANS compte local (mode `ephemeral`) ?
11
+ *
12
+ * L'identité d'admission temps réel en tire `account: false` : rien ne
13
+ * pourrait relire cet appelant par son `id` (`<émetteur>#<sujet>`), et un
14
+ * abonnement durable ne doit pas s'y adosser (ADR-0021 §S10 point 6). Lu sur
15
+ * le FAIT (l'objet vient du résolveur), jamais déduit de la forme de `id`.
16
+ *
17
+ * @param user - utilisateur résolu par le pare-feu.
18
+ */
19
+ export declare function isEphemeralSubject(user: IUser): boolean;
9
20
  /** Un émetteur reconnu, et la façon dont ses sujets entrent chez nous. */
10
21
  export interface IExternalIssuerBinding {
11
22
  /** Émetteur de confiance, sous sa forme canonique. */
@@ -3,7 +3,7 @@ import type { ContextType } from "@nodefony/http";
3
3
  import type { IUser } from "@nodefony/user";
4
4
  import type { IAuthenticator } from "../../contracts/IAuthenticator.js";
5
5
  import type { IToken } from "../../contracts/IToken.js";
6
- import type { IJwtRuntime } from "../token/jwtRuntime.js";
6
+ import { type IJwtRuntime } from "../token/jwtRuntime.js";
7
7
  /**
8
8
  * Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
9
9
  * agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
@@ -15,7 +15,8 @@ import type { IJwtRuntime } from "../token/jwtRuntime.js";
15
15
  * - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
16
16
  * `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
17
17
  * - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
18
- * access et refresh) + `exp`/`nbf` (jose).
18
+ * access et refresh) + `exp`/`nbf` (jose). L'audience est celle de la ZONE
19
+ * (`resource`, ou l'audience par défaut), jamais toute la liste `jwt.audiences`.
19
20
  * - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
20
21
  * est auto-porté et non révocable sans état serveur).
21
22
  * - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
@@ -48,12 +49,25 @@ export declare class JwtAuthenticator implements IAuthenticator {
48
49
  * qu'un credential qui disparaîtrait sans laisser de trace.
49
50
  */
50
51
  supports(context: ContextType): boolean;
51
- /** Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`. */
52
+ /**
53
+ * Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`,
54
+ * avec la ressource de la zone quand elle en déclare une.
55
+ *
56
+ * L'audience transite par le token parce que `authenticate()` ne reçoit pas
57
+ * le contexte (même patron que `ExternalJwtAuthenticator`). Rien n'est posé
58
+ * pour une zone sans ressource : l'audience par défaut s'y applique.
59
+ */
52
60
  createToken(context: ContextType): Promise<IToken>;
53
61
  /**
54
62
  * Vérifie la signature + les claims du JWT, applique la révocation et résout le
55
63
  * sujet — ou lève un 401 au message uniforme.
56
64
  *
65
+ * L'audience exigée est celle de la ZONE, jamais la liste blanche d'émission
66
+ * entière (RFC 8707, RFC 9068 §4) : la `resource` de la zone si elle en
67
+ * déclare une, sinon l'audience par défaut — l'application elle-même. Un
68
+ * jeton émis pour la porte MCP, ou pour la ressource d'une autre zone, figure
69
+ * dans `jwt.audiences` sans pour autant ouvrir celle-ci.
70
+ *
57
71
  * @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
58
72
  * sujet disparu/banni.
59
73
  * @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
@@ -57,8 +57,10 @@ export declare class SessionAuthenticator implements IAuthenticator {
57
57
  * token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
58
58
  * {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
59
59
  *
60
- * @throws AuthenticationError (401, message uniforme) — session orpheline,
61
- * compte verrouillé ou désactivé.
60
+ * @throws StaleSessionError (401, message uniforme) — session orpheline,
61
+ * compte verrouillé ou désactivé, liaison au compte rompue : un état à
62
+ * purger ({@link onFailure}). Une PANNE de la source d'identité remonte
63
+ * telle quelle, et ne purge rien.
62
64
  */
63
65
  authenticate(token: IToken): Promise<IToken>;
64
66
  /**
@@ -66,7 +68,12 @@ export declare class SessionAuthenticator implements IAuthenticator {
66
68
  * (`saveSession`) lie le blob au principal courant (string attendu).
67
69
  */
68
70
  onSuccess(context: ContextType, token: IToken): Promise<void>;
69
- /** Slot audit (P6.14). Le 401 est posé par le firewall. */
70
- onFailure(_context: ContextType, _error: Error): Promise<void>;
71
+ /**
72
+ * Une session périmée est DÉTRUITE, côté serveur (entrée du stockage) et
73
+ * côté navigateur (cookie expiré, RFC 6265 §5.3) — sans quoi le navigateur la
74
+ * renvoie à chaque requête et chaque page lui répond 401, sans issue pour
75
+ * l'utilisateur. Le firewall poursuit ensuite la chaîne de la zone.
76
+ */
77
+ onFailure(context: ContextType, error: Error): Promise<void>;
71
78
  }
72
79
  export default SessionAuthenticator;