@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
@@ -0,0 +1,307 @@
1
+ import { MemoryRateLimitStore } from "@nodefony/http";
2
+ // nodefony/src/cspReports.ts
3
+ const MINUTE_MS = 6e4;
4
+ const MAX_URL_LENGTH = 512;
5
+ const MAX_USER_AGENT_LENGTH = 200;
6
+ /** Directive ou mot-clé de la norme : minuscules et tirets, court. */
7
+ const TOKEN = /^[a-z][a-z0-9-]{0,47}$/u;
8
+ /** Contrôles C0, DEL et C1 — jamais dans une ligne de journal ni un affichage. */
9
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f]/gu;
10
+ const MAX_INT = 1e7;
11
+ /**
12
+ * Reçoit, expurge, dédoublonne et garde EN MÉMOIRE les rapports de violation
13
+ * CSP (ADR-0024 §C7). La porte qui les livre est publique — le navigateur
14
+ * n'envoie aucun identifiant — donc tout ce qui entre est traité comme écrit
15
+ * par un attaquant :
16
+ *
17
+ * - **quota par adresse** (fenêtre d'une minute, le limiteur de `@nodefony/http`) ;
18
+ * - **deux types de contenu seulement** : `application/csp-report` (`report-uri`)
19
+ * et `application/reports+json` (`report-to`) ;
20
+ * - **URL expurgées** de leur requête, de leur fragment et de leurs identifiants
21
+ * (la norme ne retire pas la requête : elle peut porter un jeton) ; une URL
22
+ * qui n'est pas HTTP(S) se réduit à son schéma ;
23
+ * - **l'extrait de code n'est jamais gardé** — c'est une chaîne de l'attaquant ;
24
+ * - **mémoire bornée** : au plus `capacity` violations distinctes, la moins
25
+ * récemment vue évincée d'abord ; une répétition incrémente un compteur ;
26
+ * - **journal dédoublonné et borné** : une violation neuve y part une fois, et
27
+ * pas plus de `logPerMinute` lignes par minute.
28
+ *
29
+ * Rien n'est écrit sur disque : le journal centralisé voit déjà ce qui compte.
30
+ */
31
+ var CspReportCollector = class {
32
+ /** URL que la politique désigne. */
33
+ endpoint;
34
+ /** Violations distinctes gardées au plus. */
35
+ capacity;
36
+ #violations = /* @__PURE__ */ new Map();
37
+ #limiter;
38
+ #log;
39
+ #now;
40
+ #logPerMinute;
41
+ #logWindowStart = 0;
42
+ #loggedInWindow = 0;
43
+ #silenced = 0;
44
+ #received = 0;
45
+ #rejected = 0;
46
+ /**
47
+ * @param options - URL désignée, bornes, journal, horloge.
48
+ */
49
+ constructor(options) {
50
+ this.endpoint = options.endpoint;
51
+ this.capacity = options.capacity ?? 200;
52
+ this.#now = options.now ?? Date.now;
53
+ this.#log = options.log ?? null;
54
+ this.#logPerMinute = options.logPerMinute ?? 20;
55
+ this.#limiter = new MemoryRateLimitStore({
56
+ windowMs: MINUTE_MS,
57
+ max: options.perMinute ?? 120,
58
+ maxTracked: options.maxTrackedClients ?? 1e4
59
+ }, this.#now);
60
+ }
61
+ /**
62
+ * Traite un corps de rapport déjà lu (et déjà borné par l'appelant).
63
+ *
64
+ * @param contentType - type de contenu de la requête, paramètres compris ou non.
65
+ * @param body - le corps, octets ou texte.
66
+ * @param client - adresse cliente résolue (proxy de confiance compris) ;
67
+ * `null` = inconnue, non comptée (jamais une clé commune à tous).
68
+ * @param userAgent - en-tête `User-Agent`, gardé tronqué.
69
+ * @returns ce que le rapport est devenu — pour le journal et les tests ;
70
+ * l'émetteur reçoit 204 quoi qu'il arrive.
71
+ */
72
+ receive(contentType, body, client, userAgent) {
73
+ this.#received++;
74
+ if (client !== null && this.#limiter.hit(client).limited) {
75
+ this.#rejected++;
76
+ return "limited";
77
+ }
78
+ const type = mediaTypeOf(contentType);
79
+ if (type !== "application/csp-report" && type !== "application/reports+json") {
80
+ this.#rejected++;
81
+ return "unsupported";
82
+ }
83
+ let payload;
84
+ try {
85
+ payload = JSON.parse(typeof body === "string" ? body : Buffer.from(body).toString("utf8"));
86
+ } catch {
87
+ this.#rejected++;
88
+ return "malformed";
89
+ }
90
+ const agent = cleanText(userAgent, MAX_USER_AGENT_LENGTH);
91
+ const violations = violationsOf(payload);
92
+ if (violations.length === 0) {
93
+ this.#rejected++;
94
+ return "malformed";
95
+ }
96
+ let outcome = "duplicate";
97
+ for (const violation of violations) if (this.#store(violation, agent)) outcome = "stored";
98
+ return outcome;
99
+ }
100
+ /**
101
+ * Photo des violations gardées, la plus récemment vue d'abord — ce que rend
102
+ * le plan d'administration.
103
+ *
104
+ * @returns une copie : la modifier ne touche pas le collecteur.
105
+ */
106
+ snapshot() {
107
+ const items = [];
108
+ for (const v of this.#violations.values()) items.push({ ...v });
109
+ items.reverse();
110
+ return {
111
+ enabled: true,
112
+ endpoint: this.endpoint,
113
+ capacity: this.capacity,
114
+ received: this.#received,
115
+ rejected: this.#rejected,
116
+ items
117
+ };
118
+ }
119
+ /** Range une violation ; `true` si elle est neuve. */
120
+ #store(violation, userAgent) {
121
+ const now = this.#now();
122
+ const key = [
123
+ violation.disposition,
124
+ violation.effectiveDirective,
125
+ violation.documentURL,
126
+ violation.blockedURL ?? "",
127
+ violation.sourceFile ?? "",
128
+ violation.lineNumber ?? "",
129
+ violation.columnNumber ?? ""
130
+ ].join("\0");
131
+ const known = this.#violations.get(key);
132
+ if (known !== void 0) {
133
+ known.count++;
134
+ known.lastSeen = now;
135
+ if (userAgent !== null) known.userAgent = userAgent;
136
+ this.#violations.delete(key);
137
+ this.#violations.set(key, known);
138
+ return false;
139
+ }
140
+ if (this.#violations.size >= this.capacity) {
141
+ const oldest = this.#violations.keys().next();
142
+ if (oldest.done !== true) this.#violations.delete(oldest.value);
143
+ }
144
+ this.#violations.set(key, {
145
+ ...violation,
146
+ userAgent,
147
+ count: 1,
148
+ firstSeen: now,
149
+ lastSeen: now
150
+ });
151
+ this.#announce(violation, now);
152
+ return true;
153
+ }
154
+ /** Journalise une violation neuve, dans la limite du budget de la minute. */
155
+ #announce(v, now) {
156
+ if (this.#log === null) return;
157
+ if (now - this.#logWindowStart >= MINUTE_MS) {
158
+ if (this.#silenced > 0) this.#log(`CSP : ${this.#silenced} violation(s) neuve(s) non journalisée(s) la minute précédente — liste complète : GET /nodefony/security/api/csp-reports`, "NOTICE");
159
+ this.#logWindowStart = now;
160
+ this.#loggedInWindow = 0;
161
+ this.#silenced = 0;
162
+ }
163
+ if (this.#loggedInWindow >= this.#logPerMinute) {
164
+ this.#silenced++;
165
+ return;
166
+ }
167
+ this.#loggedInWindow++;
168
+ const where = v.sourceFile !== null ? ` (${v.sourceFile}${v.lineNumber !== null ? `:${v.lineNumber}` : ""})` : "";
169
+ this.#log(`CSP ${v.disposition === "report" ? "signalée" : "bloquée"} : ${v.effectiveDirective} refuse ${v.blockedURL ?? "?"} sur ${v.documentURL}${where}`, "WARNING");
170
+ }
171
+ };
172
+ /** Type de contenu sans paramètres, en minuscules. */
173
+ function mediaTypeOf(value) {
174
+ if (typeof value !== "string") return null;
175
+ const semi = value.indexOf(";");
176
+ return (semi === -1 ? value : value.slice(0, semi)).trim().toLowerCase();
177
+ }
178
+ /** Objet ordinaire, ou `null`. */
179
+ function recordOf(value) {
180
+ return isRecord(value) ? value : null;
181
+ }
182
+ function isRecord(value) {
183
+ return typeof value === "object" && value !== null && !Array.isArray(value);
184
+ }
185
+ /**
186
+ * Lit un corps de rapport selon sa FORME, quel que soit celui des deux types de
187
+ * contenu admis qui l'annonce.
188
+ *
189
+ * Le type ne suffit pas à décider : WebKit (Safari) poste son `report-to` en
190
+ * `application/csp-report` avec UN rapport de la Reporting API, ni enveloppé
191
+ * dans `csp-report` ni mis en tableau (corps relevé au navigateur). Lu sur le
192
+ * seul type, ce rapport était écarté comme malformé — et toutes les violations
193
+ * de Safari avec lui.
194
+ */
195
+ function violationsOf(payload) {
196
+ if (Array.isArray(payload)) return fromReportsJson(payload);
197
+ const record = recordOf(payload);
198
+ if (record === null) return [];
199
+ if ("csp-report" in record) return fromCspReport(record);
200
+ return fromReportsJson([record]);
201
+ }
202
+ /** Corps `{ "csp-report": { … } }` (CSP2, `report-uri`). */
203
+ function fromCspReport(payload) {
204
+ const report = recordOf(payload["csp-report"]);
205
+ if (report === null) return [];
206
+ const one = normalize({
207
+ documentURL: report["document-uri"],
208
+ blockedURL: report["blocked-uri"],
209
+ effectiveDirective: report["effective-directive"],
210
+ violatedDirective: report["violated-directive"],
211
+ disposition: report["disposition"],
212
+ sourceFile: report["source-file"],
213
+ lineNumber: report["line-number"],
214
+ columnNumber: report["column-number"],
215
+ statusCode: report["status-code"]
216
+ });
217
+ return one === null ? [] : [one];
218
+ }
219
+ /**
220
+ * Rapports de la Reporting API (`report-to`), dont seuls ceux de type
221
+ * `csp-violation` sont lus.
222
+ */
223
+ function fromReportsJson(payload) {
224
+ const out = [];
225
+ for (const item of payload) {
226
+ const report = recordOf(item);
227
+ if (report?.["type"] !== "csp-violation") continue;
228
+ const body = recordOf(report["body"]);
229
+ if (body === null) continue;
230
+ const one = normalize({
231
+ documentURL: body["documentURL"] ?? report["url"],
232
+ blockedURL: body["blockedURL"],
233
+ effectiveDirective: body["effectiveDirective"],
234
+ violatedDirective: void 0,
235
+ disposition: body["disposition"],
236
+ sourceFile: body["sourceFile"],
237
+ lineNumber: body["lineNumber"],
238
+ columnNumber: body["columnNumber"],
239
+ statusCode: body["statusCode"]
240
+ });
241
+ if (one !== null) out.push(one);
242
+ }
243
+ return out;
244
+ }
245
+ /**
246
+ * Expurge une violation ; `null` si elle ne désigne ni un document ni une
247
+ * directive — rien d'exploitable, donc rien à garder.
248
+ */
249
+ function normalize(raw) {
250
+ const documentURL = stripUrl(raw.documentURL);
251
+ if (documentURL === null) return null;
252
+ const effectiveDirective = tokenOf(raw.effectiveDirective) ?? firstTokenOf(raw.violatedDirective);
253
+ if (effectiveDirective === null) return null;
254
+ return {
255
+ documentURL,
256
+ blockedURL: stripUrl(raw.blockedURL),
257
+ effectiveDirective,
258
+ disposition: raw.disposition === "report" ? "report" : "enforce",
259
+ sourceFile: stripUrl(raw.sourceFile),
260
+ lineNumber: intOf(raw.lineNumber),
261
+ columnNumber: intOf(raw.columnNumber),
262
+ statusCode: intOf(raw.statusCode)
263
+ };
264
+ }
265
+ /**
266
+ * Une URL de rapport, expurgée : sans identifiants, requête ni fragment ; le
267
+ * seul schéma si elle n'est pas HTTP(S) (`w3c-reporting.bs`, « Strip URL for
268
+ * use in reports », qui garde la requête — pas nous : elle peut porter un
269
+ * jeton). Un mot-clé de la norme (`inline`, `eval`…) passe tel quel.
270
+ */
271
+ function stripUrl(value) {
272
+ if (typeof value !== "string" || value === "") return null;
273
+ if (TOKEN.test(value)) return value;
274
+ let url;
275
+ try {
276
+ url = new URL(value);
277
+ } catch {
278
+ return null;
279
+ }
280
+ if (url.protocol !== "http:" && url.protocol !== "https:") return url.protocol.slice(0, -1);
281
+ url.username = "";
282
+ url.password = "";
283
+ url.search = "";
284
+ url.hash = "";
285
+ const href = url.href;
286
+ return href.length > MAX_URL_LENGTH ? href.slice(0, MAX_URL_LENGTH) : href;
287
+ }
288
+ function tokenOf(value) {
289
+ return typeof value === "string" && TOKEN.test(value) ? value : null;
290
+ }
291
+ /** `violated-directive` (CSP2) porte la directive suivie de ses sources. */
292
+ function firstTokenOf(value) {
293
+ if (typeof value !== "string") return null;
294
+ const space = value.indexOf(" ");
295
+ return tokenOf(space === -1 ? value : value.slice(0, space));
296
+ }
297
+ function intOf(value) {
298
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= MAX_INT ? value : null;
299
+ }
300
+ /** Texte libre de l'émetteur : sans contrôles, tronqué. */
301
+ function cleanText(value, max) {
302
+ if (typeof value !== "string") return null;
303
+ const text = value.replace(CONTROL, "").trim();
304
+ if (text === "") return null;
305
+ return text.length > max ? text.slice(0, max) : text;
306
+ }
307
+ export { CspReportCollector };
@@ -1,5 +1,5 @@
1
- import { postTokenRequest } from "./oauth2Client.js";
2
1
  import { discoverAuthorizationServer } from "./metadata.js";
2
+ import { postTokenRequest } from "./oauth2Client.js";
3
3
  // nodefony/src/oauth/clientCredentials.ts
4
4
  /**
5
5
  * Obtient un jeton d'accès pour un programme, par le flux `client_credentials`
@@ -1,5 +1,5 @@
1
- import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
2
1
  import { readJsonObjectBounded } from "./httpJson.js";
2
+ import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
3
3
  import { canonicalIssuer, isBackChannelTransportAllowed, metadataCandidateUrls, validateIssuerMetadata } from "nodefony";
4
4
  // nodefony/src/oauth/metadata.ts
5
5
  /**
@@ -13,9 +13,22 @@ const factories = /* @__PURE__ */ new Map();
13
13
  function registerOAuthProvider(name, factory, registration = {}) {
14
14
  factories.set(name, {
15
15
  factory,
16
- requiresIssuer: registration.requiresIssuer === true
16
+ requiresIssuer: registration.requiresIssuer === true,
17
+ imageSources: registration.imageSources ?? []
17
18
  });
18
19
  }
20
+ /**
21
+ * Sources d'images (avatars) des fournisseurs nommés, sans doublon — ce que
22
+ * `img-src` doit autoriser pour afficher leurs photos de profil.
23
+ *
24
+ * @param names - fournisseurs configurés ; un nom inconnu ne rend rien
25
+ * @returns les sources déclarées par leurs enregistrements
26
+ */
27
+ function oauthProviderImageSources(names) {
28
+ const sources = /* @__PURE__ */ new Set();
29
+ for (const name of names) for (const source of factories.get(name)?.imageSources ?? []) sources.add(source);
30
+ return [...sources];
31
+ }
19
32
  /** Fabrique d'un fournisseur par nom, ou `undefined` si inconnu. */
20
33
  function getOAuthProviderFactory(name) {
21
34
  return factories.get(name)?.factory;
@@ -51,8 +64,8 @@ function withKeycloakThemeHint(provider) {
51
64
  };
52
65
  }
53
66
  const GOOGLE_ISSUER = "https://accounts.google.com";
54
- registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }));
67
+ registerOAuthProvider("google", (ctx) => createDiscoveredOidcProvider("google", ctx, { issuer: GOOGLE_ISSUER }), { imageSources: ["https://*.googleusercontent.com"] });
55
68
  registerOAuthProvider("keycloak", async (ctx) => withKeycloakThemeHint(await createDiscoveredOidcProvider("keycloak", ctx)), { requiresIssuer: true });
56
69
  registerOAuthProvider("oidc", (ctx) => createDiscoveredOidcProvider("oidc", ctx), { requiresIssuer: true });
57
- registerOAuthProvider("github", createGithubProvider);
58
- export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
70
+ registerOAuthProvider("github", createGithubProvider, { imageSources: ["https://avatars.githubusercontent.com"] });
71
+ export { KEYCLOAK_THEME_PARAMETER, getOAuthProviderFactory, listOAuthProviders, oauthProviderImageSources, oauthProviderRequiresIssuer, registerOAuthProvider, withKeycloakThemeHint };
@@ -1,5 +1,5 @@
1
- import { readJsonBounded, readJsonObjectBounded } from "../httpJson.js";
2
1
  import { OAuth2Client, OAuth2RequestError } from "../oauth2Client.js";
2
+ import { readJsonBounded, readJsonObjectBounded } from "../httpJson.js";
3
3
  import { isPlainObject } from "nodefony";
4
4
  // nodefony/src/oauth/providers/github.ts
5
5
  /** Scopes minimaux : profil public + emails (l'email primaire peut être privé). */
@@ -1,6 +1,11 @@
1
1
  // nodefony/src/realtime/UserRealtimeToken.ts
2
2
  /** Aucun scope — figé et partagé (0 allocation pour le cas humain, le plus courant). */
3
3
  const EMPTY_SCOPES = Object.freeze([]);
4
+ /** Une session ou une clé d'API : aucun émetteur, un compte local — figé et partagé. */
5
+ const LOCAL_ACCOUNT = Object.freeze({
6
+ issuer: null,
7
+ account: true
8
+ });
4
9
  /**
5
10
  * Adaptateur `IUser` → `IRealtimeToken` (jeton realtime).
6
11
  *
@@ -35,6 +40,8 @@ var UserRealtimeToken = class {
35
40
  * `null` = aucune re-validation câblée (best-effort).
36
41
  */
37
42
  #revalidate;
43
+ /** Cause de l'état d'un jeton porteur ; `null` pour une session. */
44
+ #status;
38
45
  /**
39
46
  * Scopes délégués repris du jeton du firewall (clé API / JWT / OAuth). Vide
40
47
  * pour une identité humaine, qui n'est pas downscopée.
@@ -46,15 +53,29 @@ var UserRealtimeToken = class {
46
53
  * émet elle-même l'artefact. `null` = rien à confirmer.
47
54
  */
48
55
  #confirmAccount;
56
+ /** Émetteur et compte, repris du jeton d'origine pour l'identité d'admission. */
57
+ #admission;
58
+ /**
59
+ * Identité d'admission, construite au PREMIER appel puis rendue telle quelle :
60
+ * `null` tant que personne ne l'a demandée (un jeton construit pour un test ou
61
+ * un pont qui ne la lit jamais ne la paie pas).
62
+ */
63
+ #identity = null;
49
64
  /**
50
65
  * @param user - identité authentifiée résolue par le firewall.
51
66
  * @param revalidate - preuve de vie de l'identité, adaptée à son mode.
52
67
  * @param type - mode d'authentification réel (défaut `"session"`).
53
68
  * @param scopes - scopes délégués du jeton d'origine (défaut aucun).
54
69
  * @param confirmAccount - relecture du compte, payée sur `api.request` seul.
70
+ * @param status - la CAUSE de l'état, pour un jeton porteur (expiré ou révoqué) ;
71
+ * `null` pour une session, qui n'a qu'un verdict.
72
+ * @param admission - émetteur et compte local du jeton d'origine (défaut :
73
+ * aucun émetteur, compte local — une session).
55
74
  */
56
- constructor(user, revalidate = null, type = "session", scopes = EMPTY_SCOPES, confirmAccount = null) {
75
+ constructor(user, revalidate = null, type = "session", scopes = EMPTY_SCOPES, confirmAccount = null, status = null, admission = LOCAL_ACCOUNT) {
76
+ this.#admission = admission;
57
77
  this.#confirmAccount = confirmAccount;
78
+ this.#status = status;
58
79
  this.#user = user;
59
80
  this.#revalidate = revalidate;
60
81
  this.type = type;
@@ -76,6 +97,17 @@ var UserRealtimeToken = class {
76
97
  return this.#revalidate ? this.#revalidate(nowMs) : Promise.resolve(true);
77
98
  }
78
99
  /**
100
+ * La cause de l'état de l'identité — `expired` ferme la socket en 4006, `revoked`
101
+ * en 4001 (ADR-0021 §S11). Sans cause câblée (session), dérivée de
102
+ * {@link isValid} : `false` vaut `revoked`.
103
+ *
104
+ * @param nowMs - horloge injectable (epoch ms) ; par défaut l'heure courante.
105
+ */
106
+ async status(nowMs) {
107
+ if (this.#status !== null) return this.#status(nowMs);
108
+ return await this.isValid(nowMs) ? "active" : "revoked";
109
+ }
110
+ /**
79
111
  * Le compte qui a ouvert la socket existe-t-il toujours, actif, et sous le
80
112
  * même `id` immuable ? `true` si aucune confirmation n'est câblée.
81
113
  */
@@ -101,8 +133,33 @@ var UserRealtimeToken = class {
101
133
  getScopes() {
102
134
  return [...this.#scopes];
103
135
  }
136
+ /**
137
+ * 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).
138
+ *
139
+ * Construite une fois, au premier appel, puis rendue par RÉFÉRENCE : les rôles
140
+ * y sont copiés et gelés une seule fois (là où {@link getRoles} recopie à
141
+ * chaque appel), si bien que ni le `welcome` ni l'admission ne recopient
142
+ * plus rien. Gelée : une règle `allow` ne peut pas s'ajouter un rôle.
143
+ * `id` est l'identifiant immuable du compte, jamais son login.
144
+ */
145
+ getIdentity() {
146
+ if (this.#identity !== null) return this.#identity;
147
+ const user = this.#user;
148
+ const scopes = this.#scopes;
149
+ this.#identity = Object.freeze({
150
+ type: this.type,
151
+ authenticated: true,
152
+ id: user.id,
153
+ userIdentifier: user.identifier,
154
+ roles: Object.freeze([...user.roles]),
155
+ scopes: Object.isFrozen(scopes) ? scopes : Object.freeze([...scopes]),
156
+ issuer: this.#admission.issuer,
157
+ account: this.#admission.account
158
+ });
159
+ return this.#identity;
160
+ }
104
161
  getAttribute(key) {
105
162
  return key === "user" ? this.#user : void 0;
106
163
  }
107
164
  };
108
- export { UserRealtimeToken, UserRealtimeToken as default };
165
+ export { LOCAL_ACCOUNT, UserRealtimeToken, UserRealtimeToken as default };
@@ -224,7 +224,7 @@ function satisfies(policy, token, firewall) {
224
224
  * data plane. Le verrou n'accorde donc que le plancher de ZONE, pas l'action.
225
225
  */
226
226
  function authorizeApiRequest(firewall, params, token, onDeny) {
227
- const path = params?.path;
227
+ const path = typeof params === "object" && params !== null && "path" in params ? params.path : void 0;
228
228
  if (typeof path !== "string") return true;
229
229
  const qi = path.indexOf("?");
230
230
  const pathname = qi === -1 ? path : path.slice(0, qi);
@@ -254,7 +254,7 @@ function authorizeChannel(channel, token, firewall, resolver, systemRules, onDen
254
254
  * (api.request) ou la politique du canal (subscribe/inbound).
255
255
  *
256
256
  * Trois surfaces gardées :
257
- * - `api.request {path}` (pont API souverain) : re-match de zone HTTP → zone
257
+ * - `api.request {path}` (pont socket) : re-match de zone HTTP → zone
258
258
  * protégée + anonyme = refus (autorisation de ZONE, identique à `GET {path}` ;
259
259
  * la méthode/action est gardée en aval par le router + `@IsGranted`).
260
260
  * - `subscribe {channel}` : politique de canal (système plancher + déclaration
@@ -277,11 +277,12 @@ function buildFrameAuthorizer(firewall, options) {
277
277
  const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
278
278
  const onDeny = options?.onDeny;
279
279
  return (frame, token) => {
280
- const f = frame;
281
- const method = f?.method;
282
- if (method === "api.request") return authorizeApiRequest(firewall, f?.params, token, onDeny);
280
+ if (typeof frame !== "object" || frame === null || !("method" in frame)) return true;
281
+ const method = frame.method;
282
+ if (method === "api.request") return authorizeApiRequest(firewall, "params" in frame ? frame.params : void 0, token, onDeny);
283
283
  if (method === "subscribe") {
284
- const channel = (f?.params)?.channel;
284
+ const params = "params" in frame ? frame.params : void 0;
285
+ const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
285
286
  if (typeof channel !== "string") return true;
286
287
  return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
287
288
  }
@@ -1,4 +1,5 @@
1
1
  import { describeTransportFailure, untrustedAuthorityHint } from "../net/transportFailure.js";
2
+ import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "./tokenTimeClaims.js";
2
3
  import { canonicalIssuer, extractScopes, metadataCandidateUrls, parseDiscoveryUrl, validateIssuerMetadata } from "nodefony";
3
4
  // nodefony/src/token/RemoteJwtVerifier.ts
4
5
  /**
@@ -149,15 +150,20 @@ var RemoteJwtVerifier = class {
149
150
  return null;
150
151
  }
151
152
  const { getKey } = await this.#resolve(trusted, jose);
153
+ const clockTolerance = this.#options.clockToleranceS ?? 5;
152
154
  try {
153
155
  const { payload } = await jose.jwtVerify(token, getKey, {
154
156
  algorithms: [...trusted.algorithms],
155
157
  issuer: trusted.issuer,
156
158
  audience,
157
- clockTolerance: this.#options.clockToleranceS ?? 5,
159
+ clockTolerance,
158
160
  ...trusted.typ ? { typ: trusted.typ } : {},
159
- ...trusted.requiredClaims?.length ? { requiredClaims: [...trusted.requiredClaims] } : {}
161
+ requiredClaims: [...REQUIRED_TIME_CLAIMS, ...trusted.requiredClaims ?? []]
160
162
  });
163
+ if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), clockTolerance)) {
164
+ this.#audit(`jeton refusé (${trusted.issuer}) : iat dans le futur`);
165
+ return null;
166
+ }
161
167
  return {
162
168
  issuer: claimedIssuer,
163
169
  payload
@@ -0,0 +1,34 @@
1
+ // nodefony/src/token/tokenTimeClaims.ts
2
+ /**
3
+ * Bornes temporelles qu'un jeton d'accès JWT DOIT porter : `exp` et `iat`
4
+ * (RFC 9068 §2.2, l'un et l'autre REQUIRED).
5
+ *
6
+ * UNE règle pour les deux chemins de vérification — jeton de l'application
7
+ * (`JwtAuthenticator`) et jeton d'un émetteur tiers (`RemoteJwtVerifier`). Sans
8
+ * `exp`, une connexion ouverte avec le jeton n'a plus de borne propre : le
9
+ * revalidateur d'une socket ne sait alors la fermer que par le store de
10
+ * révocation, et une clé volée suffit à signer un accès éternel.
11
+ */
12
+ const REQUIRED_TIME_CLAIMS = Object.freeze(["exp", "iat"]);
13
+ /**
14
+ * Tolérance d'horloge entre l'émetteur et ce serveur, en secondes : appliquée à
15
+ * `exp`/`nbf` par jose, et à `iat` par {@link isIssuedInFuture}.
16
+ */
17
+ const CLOCK_TOLERANCE_S = 5;
18
+ /**
19
+ * Le jeton se dit-il émis dans le futur, au-delà de la tolérance d'horloge ?
20
+ *
21
+ * jose ne contrôle `iat` que lorsqu'un âge maximal est imposé (`maxTokenAge`),
22
+ * ce que Nodefony ne fait pas. Or un `iat` daté du futur rend inopérante la
23
+ * révocation en masse (`invalidBefore`), qui compare `iat` à un seuil passé :
24
+ * un jeton forgé à `iat = maintenant + 10 ans` ne serait jamais « émis avant ».
25
+ *
26
+ * @param payload - claims VÉRIFIÉS du jeton (signature déjà contrôlée)
27
+ * @param nowS - instant présent, en secondes epoch
28
+ * @param toleranceS - écart d'horloge admis, en secondes
29
+ * @returns `true` si `iat` dépasse `nowS + toleranceS`
30
+ */
31
+ function isIssuedInFuture(payload, nowS, toleranceS) {
32
+ return typeof payload.iat === "number" && payload.iat > nowS + toleranceS;
33
+ }
34
+ export { CLOCK_TOLERANCE_S, REQUIRED_TIME_CLAIMS, isIssuedInFuture };
@@ -1,6 +1,6 @@
1
1
  import { decryptSecret, encryptSecret } from "../crypto/secretCipher.js";
2
- import "./totpCipher.js";
3
2
  import { base32Encode, buildOtpauthUri, generateRecoveryCodes, generateTotpSecret, hashRecoveryCode, matchRecoveryCode, verifyTotp } from "./totpCrypto.js";
3
+ import "./totpCipher.js";
4
4
  // nodefony/src/totp/totpOperations.ts
5
5
  /**
6
6
  * Démarre l'enrôlement : génère un secret aléatoire, le **chiffre** au repos et
@@ -1,5 +1,5 @@
1
- import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
2
1
  import "../../contracts/IAccessVoter.js";
2
+ import { RoleHierarchyWalker } from "../RoleHierarchyWalker.js";
3
3
  // nodefony/src/voter/RoleVoter.ts
4
4
  /**
5
5
  * Voter built-in **niveau A** — résout les attributs `ROLE_*` via la hiérarchie