@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,244 @@
1
+ import { assertPublicUrl } from "./ssrfGuard.js";
2
+ import { SsrfError } from "../../errors/SsrfError.js";
3
+ import { FetchPublicError } from "../../errors/FetchPublicError.js";
4
+ import { request } from "node:http";
5
+ import { isIP } from "node:net";
6
+ import { request as request$1 } from "node:https";
7
+ // nodefony/src/net/fetchPublic.ts
8
+ const DEFAULT_MAX_BYTES = 1048576;
9
+ const DEFAULT_TIMEOUT_MS = 1e4;
10
+ /** Statuts de redirection que l'on suit (RFC 9110 §15.4). */
11
+ function isRedirectStatus(status) {
12
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
13
+ }
14
+ /** En-têtes qui ne doivent jamais suivre une redirection. */
15
+ const REDIRECT_STRIPPED = /* @__PURE__ */ new Set([
16
+ "cookie",
17
+ "authorization",
18
+ "proxy-authorization",
19
+ "host"
20
+ ]);
21
+ /** En-têtes qui décrivent le corps — retirés quand la redirection l'abandonne. */
22
+ const BODY_HEADERS = /* @__PURE__ */ new Set([
23
+ "content-type",
24
+ "content-length",
25
+ "content-encoding"
26
+ ]);
27
+ /** `lookup` qui n'offre que les IP vérifiées — le nom ne sert plus qu'au SNI et à `Host`. */
28
+ function pinnedLookup(addresses) {
29
+ return (_hostname, options, callback) => {
30
+ const entries = addresses.map((address) => ({
31
+ address,
32
+ family: isIP(address) === 6 ? 6 : 4
33
+ }));
34
+ const first = entries[0];
35
+ if (first === void 0) callback(Object.assign(/* @__PURE__ */ new Error("aucune adresse vérifiée"), {
36
+ code: "ENOTFOUND",
37
+ errno: -3008
38
+ }), "", 4);
39
+ else if (options.all === true) callback(null, entries, first.family);
40
+ else callback(null, first.address, first.family);
41
+ };
42
+ }
43
+ /** Concatène les morceaux reçus UNE fois dans un `Uint8Array`. */
44
+ function concat(chunks, total) {
45
+ const out = new Uint8Array(total);
46
+ let offset = 0;
47
+ for (const chunk of chunks) {
48
+ out.set(chunk, offset);
49
+ offset += chunk.length;
50
+ }
51
+ return out;
52
+ }
53
+ /**
54
+ * Un saut HTTP(S) vers l'IP épinglée. Le corps de réponse est lu jusqu'à
55
+ * `maxBytes` puis la connexion est DÉTRUITE (jamais drainée).
56
+ */
57
+ function hop(url, method, headers, payload, addresses, follow, maxBytes, state) {
58
+ return new Promise((resolve, reject) => {
59
+ let settled = false;
60
+ const finish = (outcome) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ state.current = null;
64
+ resolve(outcome);
65
+ };
66
+ /** Un échec réseau ; `code` est celui d'une erreur système (`ECONNRESET`…). */
67
+ const fail = (error, code) => {
68
+ if (settled) return;
69
+ settled = true;
70
+ state.current = null;
71
+ reject(state.expired ?? new FetchPublicError("network", typeof code === "string" ? `${error.message} (${code})` : error.message));
72
+ };
73
+ const req = (url.protocol === "https:" ? request$1 : request)(url, {
74
+ method,
75
+ headers,
76
+ agent: false,
77
+ ...addresses.length > 0 ? { lookup: pinnedLookup(addresses) } : {}
78
+ });
79
+ state.current = req;
80
+ const onError = (error) => fail(error, error.code);
81
+ req.on("error", onError);
82
+ req.on("response", (res) => {
83
+ const status = res.statusCode ?? 0;
84
+ const location = res.headers.location;
85
+ if (follow && isRedirectStatus(status) && typeof location === "string") {
86
+ res.destroy();
87
+ finish({
88
+ kind: "redirect",
89
+ status,
90
+ location
91
+ });
92
+ return;
93
+ }
94
+ const chunks = [];
95
+ let total = 0;
96
+ res.on("data", (chunk) => {
97
+ if (settled) return;
98
+ const room = maxBytes - total;
99
+ if (chunk.length > room) {
100
+ if (room > 0) chunks.push(chunk.subarray(0, room));
101
+ total = maxBytes;
102
+ finish({
103
+ kind: "final",
104
+ status,
105
+ headers: res.headers,
106
+ body: concat(chunks, total),
107
+ truncated: true
108
+ });
109
+ res.destroy();
110
+ req.destroy();
111
+ return;
112
+ }
113
+ chunks.push(chunk);
114
+ total += chunk.length;
115
+ });
116
+ res.on("end", () => finish({
117
+ kind: "final",
118
+ status,
119
+ headers: res.headers,
120
+ body: concat(chunks, total),
121
+ truncated: false
122
+ }));
123
+ res.on("error", onError);
124
+ res.on("close", () => {
125
+ if (!settled) fail(/* @__PURE__ */ new Error("connexion fermée avant la fin du corps"));
126
+ });
127
+ });
128
+ req.end(payload);
129
+ });
130
+ }
131
+ /**
132
+ * Lit une URL fournie par un humain sans jamais atteindre le réseau interne.
133
+ *
134
+ * À CHAQUE saut (adresse de départ comme redirections) : protocole, absence
135
+ * d'identifiants et IP sont jugés par `assertPublicUrl`, puis la connexion est
136
+ * ÉPINGLÉE sur l'IP vérifiée (aucune seconde résolution : un rebinding DNS ne
137
+ * mène nulle part). Le délai est TOTAL — un serveur qui goutte un octet par
138
+ * seconde est coupé — et la lecture s'arrête à `maxBytes` en détruisant la
139
+ * connexion. Aucun cookie, aucun `Authorization` n'est ajouté ni conservé à
140
+ * travers une redirection, et le corps n'est jamais décodé (pas de bombe de
141
+ * décompression).
142
+ *
143
+ * @param rawUrl - adresse à lire.
144
+ * @param options - méthode, corps, bornes et politique de cible.
145
+ * @returns la réponse finale, corps borné et non décodé.
146
+ * @throws SsrfError si la cible, ou une redirection, est interdite.
147
+ * @throws FetchPublicError si le délai total, le nombre de redirections ou le réseau échoue.
148
+ */
149
+ async function fetchPublic(rawUrl, options = {}) {
150
+ const maxRedirects = options.maxRedirects ?? 0;
151
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
152
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
153
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) throw new RangeError("maxBytes doit être un entier positif ou nul");
154
+ if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) throw new RangeError("maxRedirects doit être un entier positif ou nul");
155
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new RangeError("timeoutMs doit être strictement positif");
156
+ let payload = options.body === void 0 ? null : typeof options.body === "string" ? new TextEncoder().encode(options.body) : options.body;
157
+ let method = (options.method ?? (payload === null ? "GET" : "POST")).toUpperCase();
158
+ let headers = { "accept-encoding": "identity" };
159
+ if (options.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) headers[name.toLowerCase()] = value;
160
+ if (payload !== null) headers["content-length"] = String(payload.length);
161
+ const allowHttp = options.allowHttp === true;
162
+ const state = {
163
+ current: null,
164
+ expired: null
165
+ };
166
+ let expire;
167
+ const deadline = new Promise((_, rejectDeadline) => {
168
+ expire = rejectDeadline;
169
+ });
170
+ deadline.catch(() => void 0);
171
+ const timer = setTimeout(() => {
172
+ const error = new FetchPublicError("timeout", `délai total de ${timeoutMs} ms dépassé`);
173
+ state.expired = error;
174
+ expire(error);
175
+ state.current?.destroy();
176
+ }, timeoutMs);
177
+ try {
178
+ let first = true;
179
+ let target = rawUrl;
180
+ let pinnedHost = null;
181
+ let pinnedAddresses = [];
182
+ let redirects = 0;
183
+ for (;;) {
184
+ let url;
185
+ let addresses;
186
+ if (first && options.vettedAddresses !== void 0 && options.vettedAddresses.length > 0) {
187
+ ({url} = await Promise.race([assertPublicUrl(target, {
188
+ allowPrivate: true,
189
+ allowHttp
190
+ }), deadline]));
191
+ addresses = options.vettedAddresses;
192
+ } else if (!first && new URL(target).hostname === pinnedHost) {
193
+ ({url} = await Promise.race([assertPublicUrl(target, {
194
+ allowPrivate: true,
195
+ allowHttp
196
+ }), deadline]));
197
+ addresses = pinnedAddresses;
198
+ } else {
199
+ const checked = await Promise.race([assertPublicUrl(target, {
200
+ allowPrivate: options.allowPrivate === true,
201
+ allowHttp,
202
+ ...options.resolver !== void 0 ? { resolver: options.resolver } : {}
203
+ }), deadline]);
204
+ url = checked.url;
205
+ addresses = checked.addresses;
206
+ }
207
+ first = false;
208
+ pinnedHost = url.hostname;
209
+ pinnedAddresses = addresses;
210
+ const outcome = await Promise.race([hop(url, method, headers, payload, addresses, maxRedirects > 0, maxBytes, state), deadline]);
211
+ if (outcome.kind === "final") return {
212
+ status: outcome.status,
213
+ headers: outcome.headers,
214
+ body: outcome.body,
215
+ truncated: outcome.truncated,
216
+ url,
217
+ redirects
218
+ };
219
+ if (redirects >= maxRedirects) throw new FetchPublicError("too-many-redirects", `plus de ${maxRedirects} redirection(s)`);
220
+ redirects++;
221
+ try {
222
+ target = new URL(outcome.location, url).href;
223
+ } catch {
224
+ throw new SsrfError("en-tête Location invalide");
225
+ }
226
+ const toGet = outcome.status === 303 && method !== "GET" && method !== "HEAD" || (outcome.status === 301 || outcome.status === 302) && method === "POST";
227
+ const next = {};
228
+ for (const [name, value] of Object.entries(headers)) {
229
+ if (REDIRECT_STRIPPED.has(name)) continue;
230
+ if (toGet && BODY_HEADERS.has(name)) continue;
231
+ next[name] = value;
232
+ }
233
+ headers = next;
234
+ if (toGet) {
235
+ payload = null;
236
+ method = "GET";
237
+ }
238
+ }
239
+ } finally {
240
+ clearTimeout(timer);
241
+ state.current?.destroy();
242
+ }
243
+ }
244
+ export { fetchPublic };
@@ -116,9 +116,9 @@ async function assertPublicUrl(rawUrl, options = {}) {
116
116
  try {
117
117
  addresses = await resolver(host);
118
118
  } catch {
119
- throw new SsrfError(`hôte non résolvable : « ${host} »`);
119
+ throw new SsrfError(`hôte non résolvable : « ${host} »`, "unresolvable");
120
120
  }
121
- if (addresses.length === 0) throw new SsrfError(`hôte sans adresse : « ${host} »`);
121
+ if (addresses.length === 0) throw new SsrfError(`hôte sans adresse : « ${host} »`, "unresolvable");
122
122
  }
123
123
  for (const ip of addresses) if (isBlockedAddress(ip)) throw new SsrfError(`cible non publique interdite : ${host} → ${ip}`);
124
124
  return {
@@ -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 };
@@ -130,40 +130,104 @@ function buildSystemRules(prefixes) {
130
130
  return rules;
131
131
  }
132
132
  /**
133
- * `s` contient-il `needle`, insensible à la casse et sans allocation ? Pour les
134
- * conventions transverses `:health`/`:stats` (après le namespace de module —
135
- * `mymod:health` — éventuellement suffixées d'une cadence `nodefony:orm:health:5000`).
133
+ * `word` (en minuscules) figure-t-il à la position `at` de `s`, insensible à la casse, sans allocation ?
136
134
  */
137
- function containsCI(s, needle) {
138
- const last = s.length - needle.length;
139
- for (let i = 0; i <= last; i++) {
140
- let ok = true;
141
- for (let j = 0; j < needle.length; j++) {
142
- let a = s.charCodeAt(i + j);
143
- if (a >= 65 && a <= 90) a += 32;
144
- let b = needle.charCodeAt(j);
145
- if (b >= 65 && b <= 90) b += 32;
146
- if (a !== b) {
147
- ok = false;
148
- break;
149
- }
150
- }
151
- if (ok) return true;
135
+ function wordAtCI(s, at, word) {
136
+ if (at + word.length > s.length) return false;
137
+ for (let j = 0; j < word.length; j++) {
138
+ let c = s.charCodeAt(at + j);
139
+ if (c >= 65 && c <= 90) c += 32;
140
+ if (c !== word.charCodeAt(j)) return false;
141
+ }
142
+ return true;
143
+ }
144
+ /**
145
+ * Convention transverse `:health` ou `:stats` portée par le nom, insensible à la casse et sans allocation — le suffixe trouvé (en minuscules), ou `null`. Après le namespace de module (`mymod:health`), éventuellement suffixée d'une cadence (`nodefony:orm:health:5000`).
146
+ *
147
+ * Exécuté à CHAQUE trame jugée — une action RPC n'est jamais retenue. Les deux
148
+ * motifs commencent par `:`, qui n'a pas de casse : on saute de `:` en `:` par
149
+ * `indexOf` (natif) et on ne compare les deux mots qu'à ces positions, en une
150
+ * passe. Le balayage naïf (chaque position, deux passes, casse abaissée à
151
+ * chaque caractère) pesait plus que tout le reste du verrou sur un canal libre
152
+ * (micro-frame-authorizer, verrou seul : 125 → 55 ns). Même verdict, prouvé
153
+ * contre le balayage naïf par `realtimeFrameLock.test.ts`. Les retours sont des
154
+ * littéraux : rien n'est alloué, et le refus expliqué
155
+ * ({@link explainReservedSuffix}) nomme le suffixe par la MÊME fonction.
156
+ */
157
+ function reservedConventionAt(s) {
158
+ let i = s.indexOf(":");
159
+ while (i !== -1) {
160
+ if (wordAtCI(s, i + 1, "health")) return ":health";
161
+ if (wordAtCI(s, i + 1, "stats")) return ":stats";
162
+ i = s.indexOf(":", i + 1);
152
163
  }
153
- return false;
164
+ return null;
165
+ }
166
+ /**
167
+ * Première règle système dont le préfixe couvre le canal, ou `null`. Premier
168
+ * préfixe qui matche gagne (la config est placée AVANT les défauts par le
169
+ * firewall → elle peut surcharger). Match INSENSIBLE À LA CASSE (cf
170
+ * {@link startsWithCI}) : le plancher ne se contourne pas en altérant la casse
171
+ * du namespace.
172
+ */
173
+ function matchSystemRule(channel, rules) {
174
+ for (const rule of rules) if (startsWithCI(channel, rule.prefix)) return rule;
175
+ return null;
154
176
  }
155
177
  /**
156
178
  * Politique système applicable à un canal, ou `null` si le canal n'est pas
157
- * réservé. Premier préfixe qui matche gagne (la config est placée AVANT les
158
- * défauts par le firewall → elle peut surcharger). Les conventions transverses
159
- * `:health`/`:stats` retombent sur la politique système par défaut. Le match est
160
- * INSENSIBLE À LA CASSE (cf {@link startsWithCI}) : le plancher ne se contourne
161
- * pas en altérant la casse du namespace.
179
+ * réservé. Une règle de préfixe ({@link matchSystemRule}) passe avant tout ; à
180
+ * défaut, les conventions transverses `:health`/`:stats` retombent sur la
181
+ * politique système par défaut.
162
182
  */
163
183
  function matchSystemPolicy(channel, rules) {
164
- for (const rule of rules) if (startsWithCI(channel, rule.prefix)) return floorReserved(channel, rule.policy);
165
- if (containsCI(channel, ":health") || containsCI(channel, ":stats")) return SYSTEM_CHANNEL_POLICY;
166
- return null;
184
+ const rule = matchSystemRule(channel, rules);
185
+ if (rule !== null) return floorReserved(channel, rule.policy);
186
+ return reservedConventionAt(channel) !== null ? SYSTEM_CHANNEL_POLICY : null;
187
+ }
188
+ /**
189
+ * Explique un refus dû à la convention transverse `:health` / `:stats` — la phrase qui nomme le suffixe réservé, le rôle exigé et la surcharge, ou `null` si le refus a une autre cause.
190
+ *
191
+ * Le cas mérite sa phrase parce qu'il SURPREND : le hub ne réserve que l'espace
192
+ * `nodefony:`, et une application qui nomme son canal `net:health` se le voit
193
+ * refuser sans avoir rien déclaré — elle finissait par le renommer à l'aveugle.
194
+ * Les autres refus (namespace de plateforme, politique déclarée) gardent le
195
+ * détail générique du hub : la règle y est écrite par l'application ou nommée
196
+ * par l'espace lui-même.
197
+ *
198
+ * Chemin FROID : appelé seulement après un refus. Ce n'est pas un oracle de
199
+ * plus — la convention est un défaut public du framework, pas une politique de
200
+ * l'application —, et le hub ne la laisse de toute façon partir au client
201
+ * qu'hors production (`deniedDetail`).
202
+ *
203
+ * @param channel - canal refusé.
204
+ * @param token - jeton de la connexion.
205
+ * @param firewall - checker de rôle (hiérarchie comprise).
206
+ * @param rules - règles système effectives (config puis défauts).
207
+ * @returns la phrase, ou `null`.
208
+ */
209
+ function explainReservedSuffix(channel, token, firewall, rules) {
210
+ if (matchSystemRule(channel, rules) !== null) return null;
211
+ const suffix = reservedConventionAt(channel);
212
+ if (suffix === null) return null;
213
+ if (satisfies(SYSTEM_CHANNEL_POLICY, token, firewall)) return null;
214
+ const colon = channel.indexOf(":");
215
+ const prefix = colon > 0 ? channel.slice(0, colon + 1) : channel;
216
+ return `« ${channel} » porte le suffixe « ${suffix} », que les politiques par défaut de la sécurité réservent à la plateforme dans TOUT espace de noms (${(SYSTEM_CHANNEL_POLICY.roles ?? []).join(" ou ")} exigé). Pour l'ouvrir à l'application, déclare une règle de préfixe dans \`realtimeChannels\` de la configuration de sécurité — par exemple \`{ pattern: "${prefix}", authenticated: true }\` : placée avant les défauts, elle gagne`;
217
+ }
218
+ /**
219
+ * Canal visé par une trame — celui d'un `subscribe`, ou le nom d'un canal
220
+ * entrant — ; `null` pour un `api.request` ou une trame sans nom lisible.
221
+ */
222
+ function frameChannel(frame) {
223
+ if (typeof frame !== "object" || frame === null || !("method" in frame)) return null;
224
+ const method = frame.method;
225
+ if (method === "subscribe") {
226
+ const params = "params" in frame ? frame.params : void 0;
227
+ const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
228
+ return typeof channel === "string" ? channel : null;
229
+ }
230
+ return typeof method === "string" && method !== "api.request" ? method : null;
167
231
  }
168
232
  /**
169
233
  * F2 (revue 0.6) — applique le PLANCHER irréductible : si `channel` est dans un
@@ -224,7 +288,7 @@ function satisfies(policy, token, firewall) {
224
288
  * data plane. Le verrou n'accorde donc que le plancher de ZONE, pas l'action.
225
289
  */
226
290
  function authorizeApiRequest(firewall, params, token, onDeny) {
227
- const path = params?.path;
291
+ const path = typeof params === "object" && params !== null && "path" in params ? params.path : void 0;
228
292
  if (typeof path !== "string") return true;
229
293
  const qi = path.indexOf("?");
230
294
  const pathname = qi === -1 ? path : path.slice(0, qi);
@@ -236,16 +300,35 @@ function authorizeApiRequest(firewall, params, token, onDeny) {
236
300
  return true;
237
301
  }
238
302
  /**
239
- * Verrou `subscribe {channel}` (et canaux inbound full-duplex) — applique la
240
- * politique du canal. PLANCHER système prioritaire (namespace réservé) ; sinon
241
- * politique métier déclarée (`@RealtimeChannel`) ; sinon canal libre.
303
+ * Verrou `subscribe {channel}`, canaux entrants et actions — applique la
304
+ * politique du nom. PLANCHER système prioritaire (namespace réservé) ; sinon
305
+ * politique métier déclarée ; sinon canal libre.
306
+ *
307
+ * Deux sortes (ADR-0022 §6, décision 1 quater) : `subscribe` exige la LECTURE ;
308
+ * une trame entrante (`writing`) exige la lecture ET l'écriture distincte quand
309
+ * le nom en déclare une. L'écriture n'est jamais moins stricte que la lecture.
242
310
  */
243
- function authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny) {
244
- const policy = matchSystemPolicy(channel, systemRules) ?? resolver?.resolveChannelPolicy?.(channel) ?? null;
245
- if (policy === null) return true;
246
- if (satisfies(policy, token, firewall)) return true;
247
- onDeny?.("channel", channel, "channel_policy", token);
248
- return false;
311
+ function authorizeChannel(channel, writing, token, firewall, resolver, systemRules, onDeny) {
312
+ let read = matchSystemPolicy(channel, systemRules);
313
+ let write = null;
314
+ if (read === null && resolver !== null) {
315
+ if (resolver.resolveChannelPolicies !== void 0) {
316
+ const declared = resolver.resolveChannelPolicies(channel);
317
+ if (declared === null) return true;
318
+ read = declared.policy;
319
+ if (writing) write = declared.write;
320
+ } else read = resolver.resolveChannelPolicy?.(channel) ?? null;
321
+ }
322
+ if (read === null) return true;
323
+ if (!satisfies(read, token, firewall)) {
324
+ onDeny?.("channel", channel, "channel_policy", token);
325
+ return false;
326
+ }
327
+ if (write !== null && !satisfies(write, token, firewall)) {
328
+ onDeny?.("channel", channel, "channel_write_policy", token);
329
+ return false;
330
+ }
331
+ return true;
249
332
  }
250
333
  /**
251
334
  * Construit le verrou de frame WS branché sur le hub realtime par le firewall au
@@ -276,17 +359,26 @@ function buildFrameAuthorizer(firewall, options) {
276
359
  const resolver = options?.channelResolver ?? null;
277
360
  const systemRules = options?.systemRules ?? DEFAULT_SYSTEM_RULES;
278
361
  const onDeny = options?.onDeny;
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);
362
+ const authorizer = (frame, token) => {
363
+ if (typeof frame !== "object" || frame === null || !("method" in frame)) return true;
364
+ const method = frame.method;
365
+ if (method === "api.request") return authorizeApiRequest(firewall, "params" in frame ? frame.params : void 0, token, onDeny);
283
366
  if (method === "subscribe") {
284
- const channel = (f?.params)?.channel;
367
+ const params = "params" in frame ? frame.params : void 0;
368
+ const channel = typeof params === "object" && params !== null && "channel" in params ? params.channel : void 0;
285
369
  if (typeof channel !== "string") return true;
286
- return authorizeChannel(channel, token, firewall, resolver, systemRules, onDeny);
370
+ return authorizeChannel(channel, false, token, firewall, resolver, systemRules, onDeny);
287
371
  }
288
- if (typeof method === "string") return authorizeChannel(method, token, firewall, resolver, systemRules, onDeny);
372
+ if (typeof method === "string") return authorizeChannel(method, true, token, firewall, resolver, systemRules, onDeny);
289
373
  return true;
290
374
  };
375
+ const explain = (frame, token) => {
376
+ const channel = frameChannel(frame);
377
+ return channel === null ? null : explainReservedSuffix(channel, token, firewall, systemRules);
378
+ };
379
+ return Object.assign(authorizer, {
380
+ judgesWrite: true,
381
+ explain
382
+ });
291
383
  }
292
384
  export { DEFAULT_SYSTEM_PREFIXES, DEFAULT_SYSTEM_RULES, RESERVED_FLOOR_PREFIXES, SECURITY_CHANNEL_POLICY, SYSTEM_CHANNEL_POLICY, UPLINK_CHANNEL_POLICY, buildFrameAuthorizer, buildFrameAuthorizer as default, buildSystemRules };
@@ -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