@nodefony/security 10.0.0-beta.6 → 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 (51) hide show
  1. package/README.md +24 -0
  2. package/dist/_virtual/{_@oxc-project_runtime@0.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/index.js +4 -2
  4. package/dist/nodefony/command/security-mcp-token.js +43 -5
  5. package/dist/nodefony/config/config.js +17 -6
  6. package/dist/nodefony/config/defineModuleConfig.js +52 -1
  7. package/dist/nodefony/errors/FetchPublicError.js +18 -0
  8. package/dist/nodefony/errors/SsrfError.js +9 -1
  9. package/dist/nodefony/errors/StaleSessionError.js +20 -0
  10. package/dist/nodefony/errors/index.js +3 -1
  11. package/dist/nodefony/keycloak/scaffold.js +31 -14
  12. package/dist/nodefony/service/accessTokenVerifier.js +1 -1
  13. package/dist/nodefony/service/auditService.js +48 -3
  14. package/dist/nodefony/service/authFlow.js +6 -3
  15. package/dist/nodefony/service/firewall.js +48 -3
  16. package/dist/nodefony/service/tokenService.js +8 -3
  17. package/dist/nodefony/service/totp.js +7 -2
  18. package/dist/nodefony/service/webAuthn.js +45 -6
  19. package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
  20. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
  21. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
  22. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
  23. package/dist/nodefony/src/net/fetchPublic.js +244 -0
  24. package/dist/nodefony/src/net/ssrfGuard.js +2 -2
  25. package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
  26. package/dist/nodefony/src/token/jwtRuntime.js +22 -2
  27. package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
  28. package/dist/types/index.d.ts +4 -1
  29. package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
  30. package/dist/types/nodefony/config/config.d.ts +6 -0
  31. package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
  32. package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
  33. package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
  34. package/dist/types/nodefony/errors/index.d.ts +3 -0
  35. package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
  36. package/dist/types/nodefony/service/authFlow.d.ts +2 -2
  37. package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
  38. package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
  39. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
  40. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
  41. package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
  42. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
  43. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
  44. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
  45. package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
  46. package/docs/audit.md +112 -28
  47. package/docs/keycloak.md +4 -4
  48. package/docs/obtenir-un-jeton.md +17 -10
  49. package/docs/tokens.md +1 -1
  50. package/docs/webhooks.md +15 -7
  51. package/package.json +13 -13
@@ -11,6 +11,7 @@ import { Csrf } from "./csrf.js";
11
11
  import { Cors } from "./cors.js";
12
12
  import { SecurityHeaders, responseKindOf } from "./securityHeaders.js";
13
13
  import { AuthenticationError } from "../errors/AuthenticationError.js";
14
+ import { StaleSessionError } from "../errors/StaleSessionError.js";
14
15
  import { ThrottledError } from "../errors/ThrottledError.js";
15
16
  import { UnverifiableTokenError } from "../errors/UnverifiableTokenError.js";
16
17
  import { InsecureTransportError } from "../errors/InsecureTransportError.js";
@@ -26,6 +27,7 @@ import { findZonesOpenToSignup } from "../src/openToSignup.js";
26
27
  import { compileProviderRoleMapping, isPlatformRole } from "../src/oauth/providerRoles.js";
27
28
  import { CSP_REPORT_GROUP, CSP_REPORT_PATH, ROLES_WITHIN, RequestContext, SESSION_AUTH_CHALLENGE, Service, logColor } from "nodefony";
28
29
  import { randomBytes } from "node:crypto";
30
+ import { rpcCorsLists } from "@nodefony/http";
29
31
  import { encoderFromConfig } from "@nodefony/user";
30
32
  // nodefony/service/firewall.ts
31
33
  const serviceName = "firewall";
@@ -102,6 +104,29 @@ var Firewall = class extends Service {
102
104
  super(serviceName, module.container, module.notificationsCenter, module.options);
103
105
  this.module = module;
104
106
  this.kernel?.once("onBoot", () => this.#build());
107
+ this.kernel?.once("onReady", () => this.#widenCorsForRpc());
108
+ }
109
+ /**
110
+ * Ajoute à la politique CORS les méthodes et en-têtes que demandent les
111
+ * protocoles RPC — dès qu'un service gRPC est déclaré et que le CORS est
112
+ * actif (ADR-0026 D9 point 2). Le préflight part AVANT le routage et ne
113
+ * connaît pas la route : l'union est la seule voie. Une seule politique,
114
+ * celle de l'application ; calculée une fois, annoncée au démarrage.
115
+ */
116
+ #widenCorsForRpc() {
117
+ const config = this.#config;
118
+ const rpc = rpcCorsLists();
119
+ if (this.#cors === null || config === null || rpc === null) return;
120
+ const methods = withMissing(config.cors.methods, rpc.allowedMethods);
121
+ const allowedHeaders = withMissing(config.cors.allowedHeaders, rpc.allowedHeaders);
122
+ const added = [...methods.added, ...allowedHeaders.added];
123
+ if (added.length === 0) return;
124
+ this.#cors = new Cors({
125
+ ...config.cors,
126
+ methods: methods.list,
127
+ allowedHeaders: allowedHeaders.list
128
+ });
129
+ this.log(`CORS : service gRPC déclaré — le préflight autorise aussi ${added.join(", ")} (ADR-0026 D9)`, "INFO");
105
130
  }
106
131
  #build() {
107
132
  try {
@@ -552,8 +577,7 @@ var Firewall = class extends Service {
552
577
  * @returns la trace fraîche (posée sur le context), ou `null` hors profiling.
553
578
  */
554
579
  #startTrace(context) {
555
- const ctx = context;
556
- if (!ctx.profiling) return null;
580
+ if (!context.profiling) return null;
557
581
  const trace = {
558
582
  authenticator: null,
559
583
  outcome: "denied",
@@ -561,7 +585,7 @@ var Firewall = class extends Service {
561
585
  user: null,
562
586
  roles: null
563
587
  };
564
- ctx.securityTrace = trace;
588
+ context.securityTrace = trace;
565
589
  return trace;
566
590
  }
567
591
  #recordAuth(context, area, action, outcome, reason, actor) {
@@ -923,6 +947,10 @@ var Firewall = class extends Service {
923
947
  authenticated = await authenticator.authenticate(created);
924
948
  } catch (error) {
925
949
  await authenticator.onFailure(context, error);
950
+ if (error instanceof StaleSessionError && area.mode === "first") {
951
+ this.log(`session périmée purgée (area "${area.name}", authenticator "${name}") — la requête continue sans elle`, "WARNING");
952
+ continue;
953
+ }
926
954
  if (error instanceof AuthenticationError) {
927
955
  this.log(`authentication failed (area "${area.name}", authenticator "${name}")`, "WARNING");
928
956
  throw error;
@@ -991,4 +1019,21 @@ var Firewall = class extends Service {
991
1019
  return super.log(pci, severity, msgid, msg);
992
1020
  }
993
1021
  };
1022
+ /**
1023
+ * La liste `base` complétée des entrées de `extra` qu'elle ne porte pas encore
1024
+ * — comparaison sans casse (les noms d'en-têtes et de méthodes HTTP ne
1025
+ * distinguent pas la casse) ; l'ordre et l'écriture de `base` sont gardés.
1026
+ *
1027
+ * @param base - la liste de la configuration.
1028
+ * @param extra - les entrées à garantir.
1029
+ * @returns la liste complétée et les entrées ajoutées.
1030
+ */
1031
+ function withMissing(base, extra) {
1032
+ const known = new Set(base.map((entry) => entry.toLowerCase()));
1033
+ const added = extra.filter((entry) => !known.has(entry.toLowerCase()));
1034
+ return {
1035
+ list: [...base, ...added],
1036
+ added
1037
+ };
1038
+ }
994
1039
  export { Firewall, Firewall as default, NONCED_DOCUMENT_CACHE_CONTROL };
@@ -47,9 +47,14 @@ var TokenService = class extends Service {
47
47
  constructor(module) {
48
48
  super(serviceName, module.container, module.notificationsCenter, module.options);
49
49
  this.module = module;
50
- const tokenServiceBoot = () => this.#build();
50
+ const tokenServiceBoot = () => {
51
+ try {
52
+ this.#build();
53
+ } finally {
54
+ this.kernel?.once("onTerminate", () => this.#shutdown());
55
+ }
56
+ };
51
57
  this.kernel?.once("onBoot", tokenServiceBoot);
52
- this.kernel?.once("onTerminate", () => this.#shutdown());
53
58
  }
54
59
  /**
55
60
  * Refuse de SERVIR en production sans clé de signature partagée.
@@ -130,7 +135,7 @@ var TokenService = class extends Service {
130
135
  this.container?.set("tokenStore", this.#store);
131
136
  if (jwtEnabled) {
132
137
  this.#requireSharedSigningKey(config.jwt.keystore);
133
- this.#runtime = resolveJwtRuntime(config.jwt);
138
+ this.#runtime = resolveJwtRuntime(config.jwt, config.areas);
134
139
  this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
135
140
  this.container?.set("jwtKeystore", this.#keystore);
136
141
  this.#resolvePublication(config.jwt.jwks, this.#runtime.issuer);
@@ -36,8 +36,13 @@ var TotpService = class extends Service {
36
36
  constructor(module) {
37
37
  super(serviceName, module.container, module.notificationsCenter, module.options);
38
38
  this.module = module;
39
- this.kernel?.once("onBoot", () => this.#build());
40
- this.kernel?.once("onTerminate", () => void this.#shutdown());
39
+ this.kernel?.once("onBoot", () => {
40
+ try {
41
+ this.#build();
42
+ } finally {
43
+ this.kernel?.once("onTerminate", () => this.#shutdown());
44
+ }
45
+ });
41
46
  }
42
47
  #build() {
43
48
  let config;
@@ -3,6 +3,7 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
3
3
  import { WebAuthnError } from "../errors/WebAuthnError.js";
4
4
  import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
5
5
  import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
6
+ import { domainToASCII } from "node:url";
6
7
  import { createHash } from "node:crypto";
7
8
  import { Buffer } from "node:buffer";
8
9
  // nodefony/service/webAuthn.ts
@@ -59,12 +60,19 @@ var WebAuthnService = class extends Service {
59
60
  #store = null;
60
61
  #rpID = "localhost";
61
62
  #rpName = "Nodefony";
63
+ /** Origines de la config, en ASCII (punycode) — résolues au boot. */
64
+ #origins = [];
62
65
  #ready = false;
63
66
  constructor(module) {
64
67
  super(serviceName, module.container, module.notificationsCenter, module.options);
65
68
  this.module = module;
66
- this.kernel?.once("onBoot", () => this.#build());
67
- this.kernel?.once("onTerminate", () => void this.#shutdown());
69
+ this.kernel?.once("onBoot", () => {
70
+ try {
71
+ this.#build();
72
+ } finally {
73
+ this.kernel?.once("onTerminate", () => this.#shutdown());
74
+ }
75
+ });
68
76
  }
69
77
  #build() {
70
78
  let config;
@@ -79,7 +87,11 @@ var WebAuthnService = class extends Service {
79
87
  }
80
88
  this.#config = config;
81
89
  const rawDomain = config.passkeys.rpId ?? (this.kernel?.domain || "localhost");
82
- this.#rpID = /^(\d{1,3}\.){3}\d{1,3}$|:/.test(rawDomain) ? "localhost" : rawDomain;
90
+ const isIp = /^(\d{1,3}\.){3}\d{1,3}$|:/.test(rawDomain);
91
+ const asciiDomain = isIp ? "" : domainToASCII(rawDomain);
92
+ if (!isIp && asciiDomain === "") this.log(`passkeys : domaine « ${rawDomain} » invalide comme RP ID — repli sur « localhost »`, "ERROR");
93
+ this.#rpID = asciiDomain === "" ? "localhost" : asciiDomain;
94
+ this.#origins = asciiOrigins(config.passkeys.origins, (bad) => this.log(`passkeys.origins : « ${bad} » n'est pas une origine (scheme://hôte[:port]) — écartée`, "ERROR"));
83
95
  this.#rpName = config.passkeys.rpName ?? "Nodefony";
84
96
  const existing = this.get("webAuthnCredentialStore");
85
97
  let resolved;
@@ -195,7 +207,7 @@ var WebAuthnService = class extends Service {
195
207
  verification = await lib.verifyRegistrationResponse({
196
208
  response,
197
209
  expectedChallenge,
198
- expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
210
+ expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
199
211
  expectedRPID: this.#rpID,
200
212
  requireUserVerification: pk.userVerification === "required"
201
213
  });
@@ -268,7 +280,7 @@ var WebAuthnService = class extends Service {
268
280
  verification = await lib.verifyAuthenticationResponse({
269
281
  response,
270
282
  expectedChallenge,
271
- expectedOrigin: this.#expectedOrigin(pk.origins, requestOrigin),
283
+ expectedOrigin: this.#expectedOrigin(this.#origins, requestOrigin),
272
284
  expectedRPID: this.#rpID,
273
285
  credential: {
274
286
  id: stored.id,
@@ -365,4 +377,31 @@ var WebAuthnService = class extends Service {
365
377
  };
366
378
  }
367
379
  };
368
- export { WebAuthnService, WebAuthnService as default, userHandleBytes };
380
+ /**
381
+ * Origines WebAuthn rendues en ASCII (punycode), forme sous laquelle le
382
+ * navigateur les présente — `https://café.example` → `https://xn--caf-dma.example`.
383
+ *
384
+ * Une entrée qui n'est pas une origine (`scheme://hôte[:port]`) est écartée et
385
+ * signalée : la garder ferait échouer CHAQUE cérémonie, sans que personne ne
386
+ * sache pourquoi.
387
+ *
388
+ * @param origins - origines telles qu'écrites dans la config.
389
+ * @param onInvalid - appelé pour chaque entrée écartée.
390
+ * @returns les origines valides, sérialisées en ASCII, dans l'ordre reçu.
391
+ */
392
+ function asciiOrigins(origins, onInvalid) {
393
+ const out = [];
394
+ for (const origin of origins) {
395
+ let ascii;
396
+ try {
397
+ const serialized = new URL(origin).origin;
398
+ ascii = serialized === "null" ? null : serialized;
399
+ } catch {
400
+ ascii = null;
401
+ }
402
+ if (ascii === null) onInvalid(origin);
403
+ else out.push(ascii);
404
+ }
405
+ return out;
406
+ }
407
+ export { WebAuthnService, WebAuthnService as default, asciiOrigins, userHandleBytes };
@@ -0,0 +1,139 @@
1
+ // nodefony/src/audit/AuditRefusalAggregator.ts
2
+ /** IP de la clé de repli, quand `maxKeys` est atteint. */
3
+ const OVERFLOW_IP = "*";
4
+ const SEP = "";
5
+ /**
6
+ * Une clé suivie : le gabarit de la ligne de synthèse et les compteurs de la
7
+ * fenêtre en cours. Champs déclarés au constructeur (forme stable).
8
+ */
9
+ var RefusalBucket = class {
10
+ start;
11
+ count = 0;
12
+ suppressed = 0;
13
+ firstSuppressed = 0;
14
+ lastSuppressed = 0;
15
+ category;
16
+ action;
17
+ outcome;
18
+ resource;
19
+ reason;
20
+ ip;
21
+ constructor(draft, ip, now) {
22
+ this.start = now;
23
+ this.category = draft.category;
24
+ this.action = draft.action;
25
+ this.outcome = draft.outcome;
26
+ this.resource = draft.resource ?? null;
27
+ this.reason = draft.reason ?? null;
28
+ this.ip = ip;
29
+ }
30
+ };
31
+ /**
32
+ * Agrège les REFUS du journal d'audit par source, pour qu'un flot ne se paie pas
33
+ * d'une ligne par requête (OWASP Logging Cheat Sheet : la journalisation ne doit
34
+ * pas épuiser les ressources ni le journal de la base).
35
+ *
36
+ * Clé : catégorie, action, motif, ressource et IP — l'IP résolue par le contexte
37
+ * (`trustProxy`), donc non forgeable par le client. L'acteur n'entre PAS dans la
38
+ * clé : un bourrage d'identifiants change d'acteur à chaque essai, c'est sa
39
+ * source qui le trahit.
40
+ *
41
+ * Par clé et par fenêtre (ouverte au premier refus) : les `burst` premiers refus
42
+ * sont écrits tels quels — l'enquêteur voit les premiers essais, acteur compris ;
43
+ * les suivants sont seulement comptés, et la fenêtre close émet UNE ligne de
44
+ * synthèse (`metadata.aggregated`). Mémoire bornée : au-delà de `maxKeys` clés,
45
+ * les refus se regroupent sous l'IP `*`.
46
+ *
47
+ * Pur : l'horloge est passée à chaque appel, la synthèse part par `emit`. Les
48
+ * succès n'y passent jamais (l'appelant les écarte).
49
+ */
50
+ var AuditRefusalAggregator = class {
51
+ #buckets = /* @__PURE__ */ new Map();
52
+ #options;
53
+ #emit;
54
+ /**
55
+ * @param options - fenêtre, rafale écrite telle quelle, borne de clés.
56
+ * @param emit - écrit une ligne de synthèse dans le journal.
57
+ */
58
+ constructor(options, emit) {
59
+ this.#options = options;
60
+ this.#emit = emit;
61
+ }
62
+ /** Nombre de clés suivies (fenêtres ouvertes). */
63
+ get size() {
64
+ return this.#buckets.size;
65
+ }
66
+ /**
67
+ * Compte un refus.
68
+ *
69
+ * @param draft - l'événement de refus.
70
+ * @param now - horloge, en ms.
71
+ * @returns `true` : l'écrire tel quel ; `false` : il est compté dans la synthèse.
72
+ */
73
+ admit(draft, now) {
74
+ const prefix = draft.category + SEP + draft.action + SEP + draft.outcome + SEP + (draft.reason ?? "") + SEP + (draft.resource ?? "") + SEP;
75
+ const ip = draft.ip ?? null;
76
+ let key = prefix + (ip ?? "");
77
+ let bucket = this.#buckets.get(key);
78
+ if (bucket === void 0) {
79
+ let bucketIp = ip;
80
+ if (this.#buckets.size >= this.#options.maxKeys) {
81
+ key = prefix + OVERFLOW_IP;
82
+ bucketIp = OVERFLOW_IP;
83
+ bucket = this.#buckets.get(key);
84
+ }
85
+ if (bucket === void 0) {
86
+ bucket = new RefusalBucket(draft, bucketIp, now);
87
+ this.#buckets.set(key, bucket);
88
+ }
89
+ }
90
+ if (now - bucket.start >= this.#options.windowMs) {
91
+ this.#close(bucket);
92
+ bucket.start = now;
93
+ bucket.count = 0;
94
+ bucket.suppressed = 0;
95
+ }
96
+ bucket.count += 1;
97
+ if (bucket.count <= this.#options.burst) return true;
98
+ if (bucket.suppressed === 0) bucket.firstSuppressed = now;
99
+ bucket.suppressed += 1;
100
+ bucket.lastSuppressed = now;
101
+ return false;
102
+ }
103
+ /**
104
+ * Ferme les fenêtres échues : émet leur synthèse et libère leurs clés.
105
+ *
106
+ * @param now - horloge, en ms.
107
+ */
108
+ sweep(now) {
109
+ for (const [key, bucket] of this.#buckets) {
110
+ if (now - bucket.start < this.#options.windowMs) continue;
111
+ this.#close(bucket);
112
+ this.#buckets.delete(key);
113
+ }
114
+ }
115
+ /** Émet la synthèse de toutes les fenêtres ouvertes (arrêt) et vide la table. */
116
+ flush() {
117
+ for (const bucket of this.#buckets.values()) this.#close(bucket);
118
+ this.#buckets.clear();
119
+ }
120
+ #close(bucket) {
121
+ if (bucket.suppressed === 0) return;
122
+ this.#emit({
123
+ category: bucket.category,
124
+ action: bucket.action,
125
+ outcome: bucket.outcome,
126
+ actor: null,
127
+ resource: bucket.resource,
128
+ reason: bucket.reason,
129
+ ip: bucket.ip,
130
+ metadata: { aggregated: {
131
+ count: bucket.suppressed,
132
+ firstTs: bucket.firstSuppressed,
133
+ lastTs: bucket.lastSuppressed,
134
+ windowMs: this.#options.windowMs
135
+ } }
136
+ });
137
+ }
138
+ };
139
+ export { AuditRefusalAggregator };
@@ -1,5 +1,6 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
2
  import { UserToken } from "../token/UserToken.js";
3
+ import { defaultAudience } from "../token/jwtRuntime.js";
3
4
  import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
4
5
  import { bearerToken } from "./bearer.js";
5
6
  import peekIssuer from "./peekIssuer.js";
@@ -8,6 +9,7 @@ import { canonicalIssuer } from "nodefony";
8
9
  // nodefony/src/authenticator/JwtAuthenticator.ts
9
10
  const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
10
11
  const INVALID_TOKEN = "Invalid token";
12
+ const AUDIENCE = "audience";
11
13
  /**
12
14
  * Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
13
15
  * agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
@@ -19,7 +21,8 @@ const INVALID_TOKEN = "Invalid token";
19
21
  * - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
20
22
  * `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
21
23
  * - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
22
- * access et refresh) + `exp`/`nbf` (jose).
24
+ * access et refresh) + `exp`/`nbf` (jose). L'audience est celle de la ZONE
25
+ * (`resource`, ou l'audience par défaut), jamais toute la liste `jwt.audiences`.
23
26
  * - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
24
27
  * est auto-porté et non révocable sans état serveur).
25
28
  * - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
@@ -68,15 +71,31 @@ var JwtAuthenticator = class {
68
71
  const issuer = peekIssuer(token);
69
72
  return issuer === null || issuer === this.#runtime.issuer;
70
73
  }
71
- /** Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`. */
74
+ /**
75
+ * Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`,
76
+ * avec la ressource de la zone quand elle en déclare une.
77
+ *
78
+ * L'audience transite par le token parce que `authenticate()` ne reçoit pas
79
+ * le contexte (même patron que `ExternalJwtAuthenticator`). Rien n'est posé
80
+ * pour une zone sans ressource : l'audience par défaut s'y applique.
81
+ */
72
82
  createToken(context) {
73
83
  const auth = context.request?.headers.authorization;
74
- return Promise.resolve(new UserToken("jwt", bearerToken(auth) ?? ""));
84
+ const token = new UserToken("jwt", bearerToken(auth) ?? "");
85
+ const zone = context.security;
86
+ if (zone && "resource" in zone && typeof zone.resource === "string" && zone.resource.length > 0) token.setAttribute(AUDIENCE, zone.resource);
87
+ return Promise.resolve(token);
75
88
  }
76
89
  /**
77
90
  * Vérifie la signature + les claims du JWT, applique la révocation et résout le
78
91
  * sujet — ou lève un 401 au message uniforme.
79
92
  *
93
+ * L'audience exigée est celle de la ZONE, jamais la liste blanche d'émission
94
+ * entière (RFC 8707, RFC 9068 §4) : la `resource` de la zone si elle en
95
+ * déclare une, sinon l'audience par défaut — l'application elle-même. Un
96
+ * jeton émis pour la porte MCP, ou pour la ressource d'une autre zone, figure
97
+ * dans `jwt.audiences` sans pour autant ouvrir celle-ci.
98
+ *
80
99
  * @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
81
100
  * sujet disparu/banni.
82
101
  * @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
@@ -85,7 +104,8 @@ var JwtAuthenticator = class {
85
104
  async authenticate(token) {
86
105
  const raw = token.getCredentials();
87
106
  if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
88
- const { user, payload } = await this.#verify(raw, this.#runtime.audiences);
107
+ const audience = token.getAttribute(AUDIENCE) ?? defaultAudience(this.#runtime);
108
+ const { user, payload } = await this.#verify(raw, audience);
89
109
  return this.#promote(token, user, payload);
90
110
  }
91
111
  /**
@@ -1,4 +1,5 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
+ import { StaleSessionError } from "../../errors/StaleSessionError.js";
2
3
  import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
3
4
  import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
4
5
  import { UserToken } from "../token/UserToken.js";
@@ -72,15 +73,22 @@ var SessionAuthenticator = class {
72
73
  * token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
73
74
  * {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
74
75
  *
75
- * @throws AuthenticationError (401, message uniforme) — session orpheline,
76
- * compte verrouillé ou désactivé.
76
+ * @throws StaleSessionError (401, message uniforme) — session orpheline,
77
+ * compte verrouillé ou désactivé, liaison au compte rompue : un état à
78
+ * purger ({@link onFailure}). Une PANNE de la source d'identité remonte
79
+ * telle quelle, et ne purge rien.
77
80
  */
78
81
  async authenticate(token) {
79
82
  const credentials = token.getCredentials();
80
- if (!credentials?.identifier) throw new AuthenticationError("Invalid session");
83
+ if (!credentials?.identifier) throw new StaleSessionError();
81
84
  const provider = this.#provider ??= this.#resolveProvider();
82
- const user = await resolveBoundSessionIdentity(provider, credentials.identifier, credentials.accountId, credentials.credentialsVersion);
83
- return token.promote(user);
85
+ try {
86
+ const user = await resolveBoundSessionIdentity(provider, credentials.identifier, credentials.accountId, credentials.credentialsVersion);
87
+ return token.promote(user);
88
+ } catch (error) {
89
+ if (error instanceof AuthenticationError) throw new StaleSessionError(error.message);
90
+ throw error;
91
+ }
84
92
  }
85
93
  /**
86
94
  * Pose l'identifiant sur le contexte : la persistance de session du pipeline
@@ -90,9 +98,14 @@ var SessionAuthenticator = class {
90
98
  context.user = token.getUser().identifier;
91
99
  return Promise.resolve();
92
100
  }
93
- /** Slot audit (P6.14). Le 401 est posé par le firewall. */
94
- onFailure(_context, _error) {
95
- return Promise.resolve();
101
+ /**
102
+ * Une session périmée est DÉTRUITE, côté serveur (entrée du stockage) et
103
+ * côté navigateur (cookie expiré, RFC 6265 §5.3) — sans quoi le navigateur la
104
+ * renvoie à chaque requête et chaque page lui répond 401, sans issue pour
105
+ * l'utilisateur. Le firewall poursuit ensuite la chaîne de la zone.
106
+ */
107
+ async onFailure(context, error) {
108
+ if (error instanceof StaleSessionError) await context.session?.destroy(true);
96
109
  }
97
110
  };
98
111
  export { SessionAuthenticator, SessionAuthenticator as default };
@@ -42,7 +42,7 @@ registerAuthenticatorFactory("session", ({ container }) => {
42
42
  });
43
43
  });
44
44
  registerAuthenticatorFactory("jwt", ({ container, config }) => {
45
- return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
45
+ return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt, config.areas));
46
46
  });
47
47
  registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
48
48
  return new ExternalJwtAuthenticator(container, externalSubjectOptions(config));