@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
@@ -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).
@@ -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. */
@@ -13,6 +13,72 @@
13
13
  */
14
14
  /** Fragment additif d'un module : directive CSP → sources à ajouter. */
15
15
  export type CspFragment = Record<string, readonly string[]>;
16
+ /**
17
+ * Dit si une source CSP ouvre la directive à n'importe quel contenu.
18
+ *
19
+ * Trop large : `*`, un schéma réseau nu (`https:`, `wss:`…), un hôte joker seul
20
+ * (`https://*`, `*:443`). Restent acceptés : une origine exacte, un joker de
21
+ * sous-domaine borné (`https://*.example.com`), un mot-clé (`'self'`) et les
22
+ * schémas locaux (`blob:`, `data:`) — SAUF dans une directive de scripts :
23
+ * `data:` y exécute le code porté par l'URL, `blob:` celui qu'un script a
24
+ * fabriqué, et sans nonce c'est une injection de script.
25
+ *
26
+ * @param source - un jeton de source CSP.
27
+ * @param directive - la directive qui porte la source ; sans elle, les schémas
28
+ * locaux sont acceptés.
29
+ * @returns `true` si la source autorise n'importe quel hôte ou contenu.
30
+ */
31
+ export declare function isOverbroadSource(source: string, directive?: string): boolean;
32
+ /**
33
+ * Retire d'un fragment de MODULE ses sources trop larges (cf {@link isOverbroadSource}).
34
+ *
35
+ * Moindre privilège : une contribution GLOBALE (`Firewall.registerCspOrigins`)
36
+ * ne déclare que SON origine, et n'ouvre pas la politique de toute l'application.
37
+ * Retirer plutôt que lever : la sécurité ne bloque pas le démarrage. Ne
38
+ * s'applique PAS à ce que l'application écrit elle-même (sa config, un `@Csp` de
39
+ * route) : un choix délibéré de son auteur n'est jamais contourné en silence.
40
+ *
41
+ * @param fragment - directives déclarées par un module.
42
+ * @returns le fragment sans les sources trop larges, et les paires retirées.
43
+ */
44
+ export declare function withoutOverbroadSources(fragment: CspFragment): {
45
+ fragment: CspFragment;
46
+ dropped: Array<[string, string]>;
47
+ };
48
+ /**
49
+ * Relève les sources trop larges d'un jeu de directives (cf {@link isOverbroadSource}).
50
+ *
51
+ * @param directives - paires `[directive, sources]` (`parseCsp(csp)` ou `Object.entries(fragment)`).
52
+ * @returns les paires `[directive, source]` trop larges, dans l'ordre de lecture.
53
+ */
54
+ export declare function findOverbroadSources(directives: Iterable<readonly [string, readonly string[]]>): Array<[string, string]>;
55
+ /**
56
+ * Relève les sources de scripts d'un fragment que `'strict-dynamic'` rend inertes.
57
+ *
58
+ * Sous `'strict-dynamic'` dans `script-src`, un navigateur CSP3 ignore les hôtes
59
+ * et les schémas : un script n'est admis que signé, ou chargé par un script
60
+ * signé (W3C CSP3 § « strict-dynamic »). La source reste utile à un navigateur
61
+ * CSP2 ; on la garde, mais son auteur doit savoir qu'elle n'ouvre rien.
62
+ *
63
+ * @param fragment - directives déclarées par un module.
64
+ * @param policy - politique à laquelle le fragment s'ajoute.
65
+ * @returns les paires `[directive, source]` inertes ; vide sans `'strict-dynamic'`.
66
+ */
67
+ export declare function findInertScriptSources(fragment: CspFragment, policy: string): Array<[string, string]>;
68
+ /**
69
+ * Refuse un fragment CSP qui sortirait de la grammaire CSP3 — seule garde contre
70
+ * l'injection de directives par une valeur de configuration.
71
+ *
72
+ * Sans elle, une source comme `https://a.example; frame-src *` ajoute une
73
+ * directive que personne n'a demandée, et `https://a.example, frame-src *` coupe
74
+ * l'en-tête en deux politiques. Appelée là où un fragment ENTRE (enregistrement
75
+ * d'un module, fusion d'une route `@Csp`), jamais par requête.
76
+ *
77
+ * @param fragment - directives additionnelles (`directive → sources`).
78
+ * @param origin - qui déclare le fragment, pour nommer la faute.
79
+ * @throws BootConfigurationError si un nom ou une source sort de la grammaire.
80
+ */
81
+ export declare function assertCspFragment(fragment: CspFragment, origin: string): void;
16
82
  /**
17
83
  * Parse une chaîne CSP en directives ordonnées `[nom, sources[]]`. L'ordre des
18
84
  * directives et des sources est préservé (déterminisme : header stable, tests
@@ -32,8 +98,84 @@ export declare function serializeCsp(directives: Array<[string, string[]]>): str
32
98
  * origines (jamais par requête). Le résultat repart dans `SecurityHeaders`, qui
33
99
  * re-split autour de `{{nonce}}` au boot → 1 `join` par requête (hot-path inchangé).
34
100
  *
101
+ * Chaque fragment passe {@link assertCspFragment} : une source hors grammaire
102
+ * lève au lieu d'injecter une directive. Aucune source n'est retirée ici : le
103
+ * filtre des sources trop larges ne vise que les contributions globales des
104
+ * modules ({@link withoutOverbroadSources}, appliqué à l'enregistrement).
105
+ *
35
106
  * @param base - CSP de configuration (peut contenir `'nonce-{{nonce}}'`).
36
107
  * @param fragments - fragments additifs (un par module enregistré).
37
108
  * @returns la chaîne CSP fusionnée.
109
+ * @throws BootConfigurationError si un fragment sort de la grammaire CSP3.
38
110
  */
39
111
  export declare function mergeCspFragments(base: string, fragments: Iterable<CspFragment>): string;
112
+ /** Directive réservée aux réponses servies en TLS (ADR-0024 §C6). */
113
+ export declare const UPGRADE_INSECURE_REQUESTS = "upgrade-insecure-requests";
114
+ /**
115
+ * Retire une directive d'une politique, sans toucher au reste.
116
+ *
117
+ * Sert à composer, au démarrage, la variante EN CLAIR d'une politique qui porte
118
+ * `upgrade-insecure-requests` : sur une page servie en `http`, la directive
119
+ * réécrirait chaque sous-ressource en `https`, que le serveur ne sert pas.
120
+ *
121
+ * @param csp - politique (peut contenir `'nonce-{{nonce}}'`).
122
+ * @param name - nom de la directive, insensible à la casse.
123
+ * @returns la politique sans la directive ; la MÊME chaîne si elle n'y est pas.
124
+ */
125
+ export declare function withoutDirective(csp: string, name: string): string;
126
+ /**
127
+ * Traduit la valeur de `X-Frame-Options` en source de `frame-ancestors`.
128
+ *
129
+ * Un seul réglage, `http.securityHeaders.frameOptions`, pilote les DEUX en-têtes :
130
+ * un navigateur CSP ignore `X-Frame-Options` dès que `frame-ancestors` est
131
+ * présent (W3C CSP3 § « `frame-ancestors` »), et l'inverse vaut pour un ancien
132
+ * navigateur. Deux réglages distincts finiraient par dire deux choses.
133
+ *
134
+ * @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
135
+ * @returns `'none'` pour `DENY`, `'self'` pour `SAMEORIGIN`, `null` sinon
136
+ * (en-tête coupé, ou valeur que `frame-ancestors` ne sait pas traduire).
137
+ */
138
+ export declare function frameAncestorsFor(frameOptions: string | null): string | null;
139
+ /**
140
+ * Ajoute à une politique la directive `frame-ancestors` dérivée de
141
+ * `X-Frame-Options` (cf {@link frameAncestorsFor}).
142
+ *
143
+ * L'application garde la main : une politique qui déclare DÉJÀ
144
+ * `frame-ancestors` est rendue telle quelle — c'est le geste qui autorise
145
+ * l'encadrement par une origine tierce. Une politique vide le reste (pas de CSP
146
+ * voulue). Appelée à la composition de la politique de base, jamais par requête.
147
+ *
148
+ * @param csp - politique de base (config `security.headers.csp`).
149
+ * @param frameOptions - valeur émise de `X-Frame-Options` (`null` = non émis).
150
+ * @returns la politique, complétée de `frame-ancestors` si besoin.
151
+ */
152
+ export declare function withFrameAncestors(csp: string, frameOptions: string | null): string;
153
+ /**
154
+ * Demande à une politique ses rapports de violation : `report-uri` (lu par
155
+ * tous les navigateurs, en http comme en https) ET `report-to` (Reporting API,
156
+ * que le navigateur n'honore que vers une origine sûre — https ou localhost).
157
+ * ADR-0024 §C7.
158
+ *
159
+ * L'application garde la main : une politique qui déclare DÉJÀ `report-uri` ou
160
+ * `report-to` est rendue telle quelle — elle a son collecteur. Une politique
161
+ * vide le reste.
162
+ *
163
+ * @param csp - politique de base.
164
+ * @param url - où poster les rapports (chemin absolu ou URL https).
165
+ * @param group - nom du groupe `report-to`, déclaré par `Reporting-Endpoints`.
166
+ * @returns la politique, et `added` vrai si les directives ont été posées ici —
167
+ * c'est alors à l'appelant d'émettre `Reporting-Endpoints`.
168
+ */
169
+ export declare function withViolationReports(csp: string, url: string, group: string): {
170
+ csp: string;
171
+ added: boolean;
172
+ };
173
+ /**
174
+ * Valide l'URL d'un collecteur de rapports écrite par l'application : un
175
+ * chemin absolu (`/csp`) ou une URL `https:` / `http:`, sans rien qu'un en-tête
176
+ * ou une directive CSP ne puisse porter (espace, `;`, `,`, guillemet).
177
+ *
178
+ * @param url - valeur de `headers.cspReport`.
179
+ * @returns la cause du refus, ou `null` si l'URL est utilisable.
180
+ */
181
+ export declare function reportUrlProblem(url: string): string | null;
@@ -0,0 +1,73 @@
1
+ import type { ICspReportsSnapshot } from "nodefony";
2
+ /** Sévérité d'une ligne du journal, au vocabulaire du syslog Nodefony. */
3
+ type LogSeverity = "WARNING" | "NOTICE";
4
+ /** Options d'un {@link CspReportCollector}. */
5
+ export interface ICspReportCollectorOptions {
6
+ /** URL que la politique désigne (rendue par la lecture). */
7
+ endpoint: string;
8
+ /** Violations distinctes gardées au plus. */
9
+ capacity?: number;
10
+ /** Rapports acceptés par adresse et par minute. */
11
+ perMinute?: number;
12
+ /** Adresses suivies au plus par le quota (borne mémoire). */
13
+ maxTrackedClients?: number;
14
+ /** Violations NEUVES journalisées au plus par minute, toutes adresses confondues. */
15
+ logPerMinute?: number;
16
+ /** Journal ; une violation neuve y part une fois, jamais à chaque répétition. */
17
+ log?: (message: string, severity: LogSeverity) => void;
18
+ /** Horloge injectable (ms), pour les tests. */
19
+ now?: () => number;
20
+ }
21
+ /** Ce que devient un rapport reçu — l'émetteur n'en saura rien (204 toujours). */
22
+ export type CspReportOutcome = "stored" | "duplicate" | "limited" | "unsupported" | "malformed";
23
+ /**
24
+ * Reçoit, expurge, dédoublonne et garde EN MÉMOIRE les rapports de violation
25
+ * CSP (ADR-0024 §C7). La porte qui les livre est publique — le navigateur
26
+ * n'envoie aucun identifiant — donc tout ce qui entre est traité comme écrit
27
+ * par un attaquant :
28
+ *
29
+ * - **quota par adresse** (fenêtre d'une minute, le limiteur de `@nodefony/http`) ;
30
+ * - **deux types de contenu seulement** : `application/csp-report` (`report-uri`)
31
+ * et `application/reports+json` (`report-to`) ;
32
+ * - **URL expurgées** de leur requête, de leur fragment et de leurs identifiants
33
+ * (la norme ne retire pas la requête : elle peut porter un jeton) ; une URL
34
+ * qui n'est pas HTTP(S) se réduit à son schéma ;
35
+ * - **l'extrait de code n'est jamais gardé** — c'est une chaîne de l'attaquant ;
36
+ * - **mémoire bornée** : au plus `capacity` violations distinctes, la moins
37
+ * récemment vue évincée d'abord ; une répétition incrémente un compteur ;
38
+ * - **journal dédoublonné et borné** : une violation neuve y part une fois, et
39
+ * pas plus de `logPerMinute` lignes par minute.
40
+ *
41
+ * Rien n'est écrit sur disque : le journal centralisé voit déjà ce qui compte.
42
+ */
43
+ export declare class CspReportCollector {
44
+ #private;
45
+ /** URL que la politique désigne. */
46
+ readonly endpoint: string;
47
+ /** Violations distinctes gardées au plus. */
48
+ readonly capacity: number;
49
+ /**
50
+ * @param options - URL désignée, bornes, journal, horloge.
51
+ */
52
+ constructor(options: ICspReportCollectorOptions);
53
+ /**
54
+ * Traite un corps de rapport déjà lu (et déjà borné par l'appelant).
55
+ *
56
+ * @param contentType - type de contenu de la requête, paramètres compris ou non.
57
+ * @param body - le corps, octets ou texte.
58
+ * @param client - adresse cliente résolue (proxy de confiance compris) ;
59
+ * `null` = inconnue, non comptée (jamais une clé commune à tous).
60
+ * @param userAgent - en-tête `User-Agent`, gardé tronqué.
61
+ * @returns ce que le rapport est devenu — pour le journal et les tests ;
62
+ * l'émetteur reçoit 204 quoi qu'il arrive.
63
+ */
64
+ receive(contentType: string | null | undefined, body: Uint8Array | string, client: string | null, userAgent?: string | null): CspReportOutcome;
65
+ /**
66
+ * Photo des violations gardées, la plus récemment vue d'abord — ce que rend
67
+ * le plan d'administration.
68
+ *
69
+ * @returns une copie : la modifier ne touche pas le collecteur.
70
+ */
71
+ snapshot(): ICspReportsSnapshot;
72
+ }
73
+ export {};
@@ -75,6 +75,13 @@ export interface IOAuthProviderRegistration {
75
75
  * émetteur. Absent : l'émetteur est facultatif (connu d'avance, ou inutile).
76
76
  */
77
77
  readonly requiresIssuer?: boolean;
78
+ /**
79
+ * Sources d'où le fournisseur sert les images du profil (avatars) —
80
+ * déclarées dans `img-src` de la politique de contenu quand, et seulement
81
+ * quand, le fournisseur est configuré. Origines exactes ; un joker de
82
+ * sous-domaine (`https://*.googleusercontent.com`) au plus.
83
+ */
84
+ readonly imageSources?: readonly string[];
78
85
  }
79
86
  /**
80
87
  * Enregistre (ou remplace) la fabrique d'un fournisseur OAuth. Appelée par les
@@ -85,6 +92,14 @@ export interface IOAuthProviderRegistration {
85
92
  * @param registration - exigences de configuration, vérifiées au démarrage.
86
93
  */
87
94
  export declare function registerOAuthProvider(name: string, factory: OAuthProviderFactory, registration?: IOAuthProviderRegistration): void;
95
+ /**
96
+ * Sources d'images (avatars) des fournisseurs nommés, sans doublon — ce que
97
+ * `img-src` doit autoriser pour afficher leurs photos de profil.
98
+ *
99
+ * @param names - fournisseurs configurés ; un nom inconnu ne rend rien
100
+ * @returns les sources déclarées par leurs enregistrements
101
+ */
102
+ export declare function oauthProviderImageSources(names: Iterable<string>): string[];
88
103
  /** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
89
104
  export declare function getOAuthProviderFactory(name: string): OAuthProviderFactory | undefined;
90
105
  /** `true` si le fournisseur nommé a déclaré exiger un émetteur en configuration. */
@@ -1,5 +1,17 @@
1
+ import type { ISubscriberIdentity, TokenStatus } from "nodefony";
1
2
  import type { IUser } from "@nodefony/user";
2
3
  import type { IRealtimeToken } from "./realtimeContracts.js";
4
+ /**
5
+ * Ce que l'identité d'admission tient du jeton d'ORIGINE et que l'utilisateur ne dit pas : son émetteur, et l'existence d'un compte local relisible.
6
+ */
7
+ export interface IRealtimeAdmissionSource {
8
+ /** `iss` du jeton vérifié ; `null` sans jeton (session, clé d'API). */
9
+ readonly issuer: string | null;
10
+ /** `false` pour un appelant machine sans compte local (`<émetteur>#<sujet>`). */
11
+ readonly account: boolean;
12
+ }
13
+ /** Une session ou une clé d'API : aucun émetteur, un compte local — figé et partagé. */
14
+ export declare const LOCAL_ACCOUNT: IRealtimeAdmissionSource;
3
15
  /**
4
16
  * Adaptateur `IUser` → `IRealtimeToken` (jeton realtime).
5
17
  *
@@ -32,8 +44,12 @@ export declare class UserRealtimeToken implements IRealtimeToken {
32
44
  * @param type - mode d'authentification réel (défaut `"session"`).
33
45
  * @param scopes - scopes délégués du jeton d'origine (défaut aucun).
34
46
  * @param confirmAccount - relecture du compte, payée sur `api.request` seul.
47
+ * @param status - la CAUSE de l'état, pour un jeton porteur (expiré ou révoqué) ;
48
+ * `null` pour une session, qui n'a qu'un verdict.
49
+ * @param admission - émetteur et compte local du jeton d'origine (défaut :
50
+ * aucun émetteur, compte local — une session).
35
51
  */
36
- constructor(user: IUser, revalidate?: ((nowMs?: number) => Promise<boolean>) | null, type?: string, scopes?: readonly string[], confirmAccount?: (() => Promise<boolean>) | null);
52
+ constructor(user: IUser, revalidate?: ((nowMs?: number) => Promise<boolean>) | null, type?: string, scopes?: readonly string[], confirmAccount?: (() => Promise<boolean>) | null, status?: ((nowMs?: number) => Promise<TokenStatus>) | null, admission?: IRealtimeAdmissionSource);
37
53
  /**
38
54
  * Zero Trust : l'identité qui a ouvert cette socket est-elle TOUJOURS vivante ?
39
55
  *
@@ -47,6 +63,14 @@ export declare class UserRealtimeToken implements IRealtimeToken {
47
63
  * sans dépendre de l'horloge de la machine.
48
64
  */
49
65
  isValid(nowMs?: number): Promise<boolean>;
66
+ /**
67
+ * La cause de l'état de l'identité — `expired` ferme la socket en 4006, `revoked`
68
+ * en 4001 (ADR-0021 §S11). Sans cause câblée (session), dérivée de
69
+ * {@link isValid} : `false` vaut `revoked`.
70
+ *
71
+ * @param nowMs - horloge injectable (epoch ms) ; par défaut l'heure courante.
72
+ */
73
+ status(nowMs?: number): Promise<TokenStatus>;
50
74
  /**
51
75
  * Le compte qui a ouvert la socket existe-t-il toujours, actif, et sous le
52
76
  * même `id` immuable ? `true` si aucune confirmation n'est câblée.
@@ -63,6 +87,16 @@ export declare class UserRealtimeToken implements IRealtimeToken {
63
87
  * « tous les droits », elle veut dire « aucun scope » — donc refus.
64
88
  */
65
89
  getScopes(): string[];
90
+ /**
91
+ * Identité d'admission gelée de la connexion — la donnée plate que reçoit la règle `allow` d'un canal (ADR-0021 §S10).
92
+ *
93
+ * Construite une fois, au premier appel, puis rendue par RÉFÉRENCE : les rôles
94
+ * y sont copiés et gelés une seule fois (là où {@link getRoles} recopie à
95
+ * chaque appel), si bien que ni le `welcome` ni l'admission ne recopient
96
+ * plus rien. Gelée : une règle `allow` ne peut pas s'ajouter un rôle.
97
+ * `id` est l'identifiant immuable du compte, jamais son login.
98
+ */
99
+ getIdentity(): ISubscriberIdentity;
66
100
  getAttribute<T = unknown>(key: string): T | undefined;
67
101
  }
68
102
  export default UserRealtimeToken;
@@ -39,7 +39,7 @@ export interface ISystemChannelRule {
39
39
  * hot-path WS. Le firewall fournit l'implémentation (closure sur son container) ;
40
40
  * absent par défaut (verrou pur, testable sans audit).
41
41
  *
42
- * @param surface - cible refusée : pont API souverain (`api.request`) ou canal
42
+ * @param surface - cible refusée : pont socket (`api.request`) ou canal
43
43
  * (subscribe/inbound).
44
44
  * @param target - pathname (api.request) ou nom de canal refusé.
45
45
  * @param reason - raison machine stable (`zone_protected` | `channel_policy`).
@@ -159,7 +159,7 @@ export declare function buildSystemRules(prefixes: readonly string[]): readonly
159
159
  * (api.request) ou la politique du canal (subscribe/inbound).
160
160
  *
161
161
  * Trois surfaces gardées :
162
- * - `api.request {path}` (pont API souverain) : re-match de zone HTTP → zone
162
+ * - `api.request {path}` (pont socket) : re-match de zone HTTP → zone
163
163
  * protégée + anonyme = refus (autorisation de ZONE, identique à `GET {path}` ;
164
164
  * la méthode/action est gardée en aval par le router + `@IsGranted`).
165
165
  * - `subscribe {channel}` : politique de canal (système plancher + déclaration
@@ -1,3 +1,4 @@
1
+ import type { IChannelPolicy, ISubscriberIdentity, TokenStatus } from "nodefony";
1
2
  /**
2
3
  * Miroirs STRUCTURELS des contrats `@nodefony/realtime` — **zéro import** du
3
4
  * module realtime (ni runtime ni type-only).
@@ -32,12 +33,27 @@ export interface IRealtimeToken {
32
33
  * (absent = valide). Appelé par le pont `api.request` avant l'action data plane.
33
34
  */
34
35
  isValid?(nowMs?: number): boolean | Promise<boolean>;
36
+ /**
37
+ * La CAUSE de l'état de l'identité, quand elle est connaissable : `active`,
38
+ * `expired` (jeton porteur arrivé à `exp`) ou `revoked` (révoqué l'emporte sur
39
+ * expiré). Le hub en tire le code de fermeture d'une socket ouverte : `expired` →
40
+ * `TOKEN_EXPIRED_CLOSE_CODE` (4006), sinon `IDENTITY_DEAD_CLOSE_CODE` (4001) —
41
+ * ADR-0021 §S11. Optionnel : absent, `isValid` décide et son `false` vaut 4001
42
+ * (une session ne sait pas dire si elle a expiré ou été détruite).
43
+ */
44
+ status?(nowMs?: number): TokenStatus | Promise<TokenStatus>;
35
45
  /**
36
46
  * Le compte de la socket existe-t-il toujours, actif, sous le même `id` ?
37
47
  * (miroir de `IRealtimeToken.confirmAccount` realtime). Optionnel (absent =
38
48
  * confirmé). Relit l'annuaire : payé sur `api.request` seul, jamais au tick.
39
49
  */
40
50
  confirmAccount?(): Promise<boolean>;
51
+ /**
52
+ * Identité d'admission gelée de la connexion (miroir de
53
+ * `IRealtimeToken.getIdentity` realtime) — ce que reçoit la règle `allow`
54
+ * d'un canal. Absente, la connexion n'entre sur aucun canal à `allow`.
55
+ */
56
+ getIdentity?(): ISubscriberIdentity;
41
57
  }
42
58
  /**
43
59
  * Données de handshake WS (miroir de `IRealtimeHandshake`) — DTO neutre passé à
@@ -76,25 +92,17 @@ export interface IRealtimeAuthenticatorMatcher {
76
92
  */
77
93
  export type FrameAuthorizer = (frame: unknown, token: IRealtimeToken) => boolean;
78
94
  /**
79
- * Politique d'autorisation d'un **canal** (miroir de `ChannelPolicy` realtime) —
80
- * exigences à satisfaire pour `subscribe`/inbound. Toutes les contraintes posées
81
- * sont cumulatives (ET) ; un champ absent = pas de contrainte sur cet axe. Une
82
- * politique entièrement vide = canal libre (équivaut à `null`).
95
+ * Politique d'autorisation d'un canal — déclaration UNIQUE au cœur (`nodefony`), que le
96
+ * hub realtime transporte et que ce module juge. Plus de miroir structurel : la
97
+ * copie dérivait en silence, chacune passant ses propres tests.
83
98
  *
84
- * Résolue 1× au `subscribe` (cold path : un client s'abonne rarement), comparée
85
- * au token déjà chargé au handshake → 0 lecture base. Deux origines : déclaration
86
- * **métier** (`@RealtimeChannel(name, opts)`, portée par le hub realtime) et
87
- * **plateforme** (`defineSecurityConfig().realtimeChannels` + namespaces système
88
- * réservés, portée par security — cf {@link buildFrameAuthorizer}).
99
+ * Résolue au `subscribe`/inbound (chemin froid), comparée au jeton déjà chargé
100
+ * à la poignée de main → 0 lecture base. Deux origines : déclaration MÉTIER
101
+ * (`@RealtimeChannel(nom, politique)`, portée par le hub) et PLATEFORME
102
+ * (`defineSecurityConfig().realtimeChannels` + namespaces système réservés —
103
+ * cf {@link buildFrameAuthorizer}).
89
104
  */
90
- export interface IChannelPolicy {
91
- /** Exige une connexion authentifiée (token non anonyme). */
92
- readonly authenticated?: boolean | undefined;
93
- /** Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles du firewall). */
94
- readonly roles?: readonly string[] | undefined;
95
- /** Un de ces scopes suffit (axe API : JWT/clé API ; session BFF n'en porte pas). */
96
- readonly scopes?: readonly string[] | undefined;
97
- }
105
+ export type { IChannelPolicy };
98
106
  /**
99
107
  * Surface consommée du `realtimeService` (miroir partiel de `RealtimeService`) —
100
108
  * seulement les seams que security câble/lit au boot et au dispatch. Résolu par
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Bornes temporelles qu'un jeton d'accès JWT DOIT porter : `exp` et `iat`
3
+ * (RFC 9068 §2.2, l'un et l'autre REQUIRED).
4
+ *
5
+ * UNE règle pour les deux chemins de vérification — jeton de l'application
6
+ * (`JwtAuthenticator`) et jeton d'un émetteur tiers (`RemoteJwtVerifier`). Sans
7
+ * `exp`, une connexion ouverte avec le jeton n'a plus de borne propre : le
8
+ * revalidateur d'une socket ne sait alors la fermer que par le store de
9
+ * révocation, et une clé volée suffit à signer un accès éternel.
10
+ */
11
+ export declare const REQUIRED_TIME_CLAIMS: readonly string[];
12
+ /**
13
+ * Tolérance d'horloge entre l'émetteur et ce serveur, en secondes : appliquée à
14
+ * `exp`/`nbf` par jose, et à `iat` par {@link isIssuedInFuture}.
15
+ */
16
+ export declare const CLOCK_TOLERANCE_S = 5;
17
+ /**
18
+ * Le jeton se dit-il émis dans le futur, au-delà de la tolérance d'horloge ?
19
+ *
20
+ * jose ne contrôle `iat` que lorsqu'un âge maximal est imposé (`maxTokenAge`),
21
+ * ce que Nodefony ne fait pas. Or un `iat` daté du futur rend inopérante la
22
+ * révocation en masse (`invalidBefore`), qui compare `iat` à un seuil passé :
23
+ * un jeton forgé à `iat = maintenant + 10 ans` ne serait jamais « émis avant ».
24
+ *
25
+ * @param payload - claims VÉRIFIÉS du jeton (signature déjà contrôlée)
26
+ * @param nowS - instant présent, en secondes epoch
27
+ * @param toleranceS - écart d'horloge admis, en secondes
28
+ * @returns `true` si `iat` dépasse `nowS + toleranceS`
29
+ */
30
+ export declare function isIssuedInFuture(payload: {
31
+ readonly iat?: unknown;
32
+ }, nowS: number, toleranceS: number): boolean;
@@ -2,8 +2,36 @@ import type { IAuditEvent } from "../../contracts/IAuditEvent.js";
2
2
  import type { IWebhookEndpoint } from "../../contracts/IWebhookEndpoint.js";
3
3
  import type { IWebhookDeliveryPolicy } from "../../service/webhooks.js";
4
4
  import type { IDeliveryResult } from "./webhookDelivery.js";
5
- /** Une souscription matche-t-elle une action d'audit ? `*` = toutes, `x.*` = préfixe. */
5
+ /**
6
+ * Rend les motifs compilés d'une liste d'abonnement, compilés à sa première lecture puis relus du cache.
7
+ *
8
+ * Deux appels sur le MÊME tableau rendent la même liste : c'est ce qui garantit
9
+ * qu'un abonnement n'est jamais recompilé par événement. Un motif trop large
10
+ * (refusé par `compileChannelPattern`) n'y figure pas.
11
+ *
12
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
13
+ * @returns les expressions compilées, une par motif valide.
14
+ */
15
+ export declare function compiledPatterns(patterns: readonly string[]): readonly RegExp[];
16
+ /**
17
+ * Dit si un abonnement webhook désigne une action d'audit, selon la grammaire commune aux canaux et aux webhooks (ADR-0023 W1).
18
+ *
19
+ * `*` vaut n'importe quelle suite de caractères : `*` désigne tout, `login.*` les
20
+ * actions qui commencent par `login.`, `commande:*` aussi bien que `*:créée`
21
+ * (`compileChannelPattern`, cœur). Chaque liste n'est compilée qu'une fois.
22
+ *
23
+ * @param patterns - les motifs de l'abonnement (`IWebhookEndpoint.events`).
24
+ * @param action - le nom de l'action d'audit.
25
+ * @returns `true` si un motif au moins désigne l'action.
26
+ */
6
27
  export declare function matchesSubscription(patterns: readonly string[], action: string): boolean;
28
+ /**
29
+ * Refuse une liste d'abonnement dont un motif porte trop de `*`, en le nommant.
30
+ *
31
+ * @param patterns - les motifs proposés à l'enregistrement ou à la mise à jour.
32
+ * @throws Error si un motif dépasse `MAX_CHANNEL_PATTERN_WILDCARDS` étoiles.
33
+ */
34
+ export declare function assertSubscriptionPatterns(patterns: readonly string[]): void;
7
35
  /**
8
36
  * Classe le résultat d'une livraison : 2xx = succès ; réseau/timeout/429/408/5xx =
9
37
  * réessayable ; 3xx/4xx (config cliente erronée) = échec définitif (pas de retry).
@@ -29,6 +57,13 @@ export interface IWebhookDispatcherDeps {
29
57
  endpointCount(): number;
30
58
  /** Snapshot des endpoints (n'est lu que si `endpointCount() > 0`). */
31
59
  getSnapshot(): IWebhookEndpoint[];
60
+ /**
61
+ * L'abonnement tel qu'il est MAINTENANT, ou `null` s'il a été supprimé — relu à
62
+ * chaque tentative. Un réessai part jusqu'à plusieurs minutes après le premier
63
+ * envoi : sans cette relecture, il partirait vers une URL retirée, signé d'un
64
+ * secret révoqué, pour un abonnement supprimé ou désactivé.
65
+ */
66
+ currentEndpoint(id: string): IWebhookEndpoint | null;
32
67
  /** Secret de signature en clair (`whsec_…`) d'un endpoint. */
33
68
  secretOf(endpoint: IWebhookEndpoint): string;
34
69
  /** Politique de livraison (tolérance/retries/timeout/concurrence/file). */
@@ -0,0 +1,57 @@
1
+ import { type IChannelPolicyResolver } from "../src/realtime/frameAuthorizer.js";
2
+ import type { FrameAuthorizer, IRealtimeToken } from "../src/realtime/realtimeContracts.js";
3
+ /** L'identité d'essai : qui se connecte, avec quels droits. */
4
+ export interface ITestIdentityOptions {
5
+ /** Identifiant de l'utilisateur (défaut `"alice"`). */
6
+ readonly identifier?: string;
7
+ /**
8
+ * Identifiant IMMUABLE du compte (défaut : `identifier`) — l'`id` que lit la
9
+ * règle `allow` d'un canal, distinct du login comme au runtime. Le poser quand
10
+ * l'appartenance se relit par la clé primaire de l'entité.
11
+ */
12
+ readonly id?: string;
13
+ /** Rôles portés (défaut `["ROLE_USER"]`) — ceux qu'aurait résolus le pare-feu. */
14
+ readonly roles?: readonly string[];
15
+ /** Scopes portés (défaut aucun) — l'axe des jetons d'API, pas de la session. */
16
+ readonly scopes?: readonly string[];
17
+ /**
18
+ * L'utilisateur rendu par `@CurrentUser()` et `RequestContext.getUser()`
19
+ * (défaut `{ identifier, roles }`). Passer l'entité de l'application quand
20
+ * l'action en lit d'autres champs.
21
+ */
22
+ readonly user?: unknown;
23
+ }
24
+ /**
25
+ * Une identité de connexion AUTHENTIFIÉE, telle que le pare-feu l'aurait posée
26
+ * au handshake.
27
+ *
28
+ * Pour éprouver un visiteur anonyme, ne rien passer au harnais : il pose alors
29
+ * le jeton anonyme, comme le repli Zero Trust du runtime.
30
+ *
31
+ * @param options - identifiant, rôles, scopes, utilisateur.
32
+ * @returns un jeton à passer en `identity` au harnais temps réel.
33
+ */
34
+ export declare function createTestIdentity(options?: ITestIdentityOptions): IRealtimeToken;
35
+ /** Écarts au pare-feu d'essai. */
36
+ export interface ITestFrameAuthorizerOptions {
37
+ /**
38
+ * Hiérarchie de rôles, à la forme de `roleHierarchy` de la configuration de
39
+ * sécurité (défaut : aucune). La reprendre de l'application quand un rôle en
40
+ * couvre un autre — `{ ROLE_ADMIN: ["ROLE_USER"] }`.
41
+ */
42
+ readonly roleHierarchy?: Record<string, readonly string[]>;
43
+ }
44
+ /**
45
+ * Le VRAI verrou de frame du pare-feu, pour le harnais temps réel.
46
+ *
47
+ * C'est lui qui applique `@RealtimeChannel(nom, { roles })`,
48
+ * `@RealtimeAction(nom, { roles })` et les canaux système : sans lui, une
49
+ * politique déclarée n'est appliquée par personne — en test comme au runtime.
50
+ *
51
+ * @param channels - ce qui connaît les politiques déclarées : le hub temps réel
52
+ * (`getRealtimeHub()`), lu à CHAQUE frame — les politiques que le controller
53
+ * enregistre à sa construction y sont donc vues.
54
+ * @param options - hiérarchie de rôles de l'application.
55
+ * @returns le verrou à passer en `frameAuthorizer` au harnais.
56
+ */
57
+ export declare function createTestFrameAuthorizer(channels: IChannelPolicyResolver, options?: ITestFrameAuthorizerOptions): FrameAuthorizer;
package/docs/api-keys.md CHANGED
@@ -279,7 +279,9 @@ jamais par le préfixe.
279
279
 
280
280
  ### Émission — `ApiKeyService.createForSubject()`
281
281
 
282
- `ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Dans l'ordre :
282
+ `ApiKeyService.createForSubject()` (`apiKeys.ts#ApiKeyService.createForSubject`) est le seul chemin d'émission. Un `tenantId` rempli est refusé d'entrée (`ApiKeyError` 400) : le
283
+ multi-tenant n'existe pas, aucun lecteur ne cloisonne sur ce champ, et une clé « rattachée à un
284
+ locataire » verrait tout. Il sera accepté le jour où il sera appliqué. Ensuite, dans l'ordre :
283
285
 
284
286
  1. **Validation du nom** — non vide, ≤ 100 caractères ; sinon `ApiKeyError` 400 (`#normalizeName()`,
285
287
  `apiKeys.ts#ApiKeyService.#normalizeName`).
@@ -270,8 +270,9 @@ Credential = l'**identifiant** posé dans le blob de session, jamais un secret.
270
270
  partagés avec `AuthFlow.me()` : `isLocked()`/`isActive()` → rejet (`sessionIdentity.ts#resolveSessionIdentity`).
271
271
  - `onSuccess()` pose l'identifiant sur le contexte — la persistance de session lie le blob au
272
272
  principal courant (`SessionAuthenticator.ts#SessionAuthenticator.onSuccess`).
273
- - **Pas de `challenge()`** : session absente = 401 nu → le front redirige vers son écran de login,
274
- jamais une popup Basic (`SessionAuthenticator.ts#SessionAuthenticator`).
273
+ - **Pas de `challenge()`** : session absente = 401 portant le défi de repli du pare-feu,
274
+ `Session realm="nodefony"` (RFC 9110 §15.5.2) — schéma inconnu des navigateurs, jamais une popup ;
275
+ le front redirige vers son écran de login (`SessionAuthenticator.ts#SessionAuthenticator`).
275
276
 
276
277
  ### `jwt` — Bearer signé pour les API (RFC 6750 + BCP RFC 8725)
277
278
 
@@ -348,8 +349,9 @@ l'ALS. `FirewallRealtimeAuthenticator.supports()` ne fait que le constater
348
349
  ouverte (logout, changement de compte, `jti` denylisté). En mode session,
349
350
  `buildSessionRevalidator()` re-lit `storage.read(id)` et compare l'identifiant ; toute erreur de
350
351
  lecture invalide, fail-closed (`FirewallRealtimeAuthenticator.ts#SessionLike.storage`). En mode jeton porteur, la
351
- preuve est autre : `exp`, `jti` denylisté, `invalidBefore`
352
- (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.#buildBearerRevalidator`).
352
+ preuve est autre : `jti` denylisté, `invalidBefore`, puis `exp` — dans cet ordre, car révoqué
353
+ l'emporte sur expiré — et la cause est rendue : un jeton expiré ferme la socket en `4006`, un jeton
354
+ révoqué en `4001` (`FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator.#buildBearerStatus`).
353
355
 
354
356
  > [!NOTE]
355
357
  > **Asymétrie de révocation HTTP↔WS (assumée)** : le jeton realtime est figé au handshake (les