@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
@@ -155,6 +155,9 @@ function toCredentialView(c) {
155
155
  * - `GET /nodefony/security/api/firewall` — introspection du firewall (zones,
156
156
  * authenticators montés, défenses) — état RUNTIME, secrets exclus.
157
157
  * - `GET /nodefony/security/api/roleHierarchy` — hiérarchie de rôles + résolution.
158
+ * - `GET /nodefony/security/api/csp-reports` — violations CSP reçues (ADR-0024
159
+ * §C7), dédoublonnées, la plus récente d’abord ; champs écrits par le
160
+ * navigateur, donc à afficher comme du texte.
158
161
  * - `GET /nodefony/security/api/apikeys` — toutes les clés API (gouvernance),
159
162
  * `GET …/apikeys/status` — backend du token store (« où on écrit »),
160
163
  * `POST …/apikeys/{id}/revoke` — révocation par id (réponse à incident).
@@ -222,6 +225,22 @@ function createSecurityAdminApi(container) {
222
225
  };
223
226
  }
224
227
  },
228
+ {
229
+ path: "csp-reports",
230
+ method: "GET",
231
+ role: "ROLE_NODEFONY_ADMIN",
232
+ summary: "Violations de la politique CSP rapportées par les navigateurs (dédoublonnées, URL expurgées, extrait jamais gardé). Champs écrits par le navigateur — à afficher comme du texte.",
233
+ handler: () => {
234
+ return container.get("cspReports")?.snapshot() ?? {
235
+ enabled: false,
236
+ endpoint: null,
237
+ capacity: 0,
238
+ received: 0,
239
+ rejected: 0,
240
+ items: []
241
+ };
242
+ }
243
+ },
225
244
  {
226
245
  path: "roleHierarchy",
227
246
  method: "GET",
@@ -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,8 +1,8 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
2
  import { isBoundToAccount } from "../accountBinding.js";
3
3
  import { UserToken } from "../token/UserToken.js";
4
- import { bearerToken } from "./bearer.js";
5
4
  import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
5
+ import { bearerToken } from "./bearer.js";
6
6
  // nodefony/src/authenticator/ApiKeyAuthenticator.ts
7
7
  const INVALID_TOKEN = "Invalid token";
8
8
  /**
@@ -1,7 +1,7 @@
1
- import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
2
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
2
  import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
4
3
  import { localIdentifierFor } from "./externalSubject.js";
4
+ import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
5
5
  import { canonicalIssuer } from "nodefony";
6
6
  import { BaseUser } from "@nodefony/user";
7
7
  // nodefony/src/authenticator/ExternalSubjectResolver.ts
@@ -11,6 +11,25 @@ import { BaseUser } from "@nodefony/user";
11
11
  */
12
12
  const INVALID_TOKEN = "Invalid token";
13
13
  /**
14
+ * Les utilisateurs FABRIQUÉS en mode `ephemeral` — aucun compte local derrière.
15
+ * Faible : l'entrée meurt avec l'utilisateur, à la fin de sa requête ou de sa
16
+ * socket ; rien n'est à nettoyer.
17
+ */
18
+ const EPHEMERAL_SUBJECTS = /* @__PURE__ */ new WeakSet();
19
+ /**
20
+ * Cet utilisateur a-t-il été fabriqué pour un appelant machine SANS compte local (mode `ephemeral`) ?
21
+ *
22
+ * L'identité d'admission temps réel en tire `account: false` : rien ne
23
+ * pourrait relire cet appelant par son `id` (`<émetteur>#<sujet>`), et un
24
+ * abonnement durable ne doit pas s'y adosser (ADR-0021 §S10 point 6). Lu sur
25
+ * le FAIT (l'objet vient du résolveur), jamais déduit de la forme de `id`.
26
+ *
27
+ * @param user - utilisateur résolu par le pare-feu.
28
+ */
29
+ function isEphemeralSubject(user) {
30
+ return EPHEMERAL_SUBJECTS.has(user);
31
+ }
32
+ /**
14
33
  * Les options de rattachement des sujets externes, lues dans la configuration
15
34
  * de sécurité — UNE lecture, pour la zone `external-jwt` comme pour le
16
35
  * résolveur de compte d'une ressource protégée.
@@ -105,12 +124,17 @@ var ExternalSubjectResolver = class {
105
124
  async resolve(issuer, subject, claims, area) {
106
125
  const mapping = this.#issuers.get(issuer);
107
126
  if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
127
+ if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") {
128
+ const identifier = localIdentifierFor(issuer, subject, "prefixed");
129
+ const ephemeral = new BaseUser({
130
+ id: identifier,
131
+ identifier,
132
+ roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
133
+ });
134
+ EPHEMERAL_SUBJECTS.add(ephemeral);
135
+ return ephemeral;
136
+ }
108
137
  const identifier = localIdentifierFor(issuer, subject, mapping);
109
- if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
110
- id: identifier,
111
- identifier,
112
- roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
113
- });
114
138
  const provider = this.#resolveUserProvider();
115
139
  let user = null;
116
140
  const linked = this.#linkedProviders?.get(issuer);
@@ -177,4 +201,4 @@ function linkProviders(trusted, providers) {
177
201
  for (const issuer of ambiguous) links.delete(issuer);
178
202
  return links.size > 0 ? links : null;
179
203
  }
180
- export { ExternalSubjectResolver, externalSubjectOptions };
204
+ export { ExternalSubjectResolver, externalSubjectOptions, isEphemeralSubject };
@@ -1,9 +1,15 @@
1
- import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
1
  import { isBoundToAccount } from "../accountBinding.js";
3
- import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
2
+ import { AuthenticationError } from "../../errors/AuthenticationError.js";
3
+ import { LOCAL_ACCOUNT, UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
4
+ import { isEphemeralSubject } from "./ExternalSubjectResolver.js";
5
+ import { tokenStatusOf } from "../token/tokenStatus.js";
4
6
  import { Nodefony, RequestContext } from "nodefony";
5
7
  import { authenticatedUser } from "@nodefony/user";
6
8
  // nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
9
+ /** Le verdict booléen d'un état : seul `active` fait vivre l'identité. */
10
+ function toValidity(status) {
11
+ return async (nowMs) => await status(nowMs) === "active";
12
+ }
7
13
  /**
8
14
  * Authenticator realtime des identités résolues par le **firewall** — équivalent
9
15
  * WS de tout ce que le pipeline HTTP sait authentifier.
@@ -90,9 +96,10 @@ var FirewallRealtimeAuthenticator = class {
90
96
  if (user === null) throw new AuthenticationError("Invalid realtime session");
91
97
  const issued = readFirewallToken();
92
98
  const type = issuedType(issued);
93
- const revalidate = this.#revalidatorFor(user, issued, type);
99
+ const status = type === "session" ? null : this.#buildBearerStatus(issued, user.identifier, type);
100
+ const revalidate = status === null ? buildSessionRevalidator(user.identifier) : toValidity(status);
94
101
  const scopes = issued ? issued.getScopes() : void 0;
95
- return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
102
+ return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type), status, admissionSourceOf(issued, user));
96
103
  }
97
104
  /**
98
105
  * Le revalidateur de l'identité que le firewall a posée sur la requête
@@ -140,46 +147,58 @@ var FirewallRealtimeAuthenticator = class {
140
147
  };
141
148
  }
142
149
  #revalidatorFor(user, issued, type) {
143
- return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
150
+ return type === "session" ? buildSessionRevalidator(user.identifier) : toValidity(this.#buildBearerStatus(issued, user.identifier, type));
144
151
  }
145
152
  /**
146
- * Re-validation d'une identité portée par un **jeton** (JWT, clé API, OAuth) :
147
- * rejoue les trois conditions qui tuent un jeton, sans jamais parler de session.
153
+ * État d'une identité portée par un **jeton** (JWT, clé API, OAuth) : rejoue les
154
+ * trois conditions qui tuent un jeton, sans jamais parler de session, et en rend
155
+ * la CAUSE — `expired` ferme la socket en 4006, `revoked` en 4001 (ADR-0021 §S11).
156
+ *
157
+ * **Révoqué l'emporte sur expiré** : la liste de refus est interrogée AVANT de
158
+ * conclure à l'expiration, et l'ordre est celui de `tokenStatusOf`, appelé
159
+ * plutôt que recopié. Un jeton révoqué puis arrivé à `exp` ferme donc en 4001.
148
160
  *
149
161
  * `exp` est vérifié **sans personne** (la borne voyage dans le jeton) ; le store
150
162
  * n'ajoute que la révocation *avant terme*. C'est pourquoi un store en panne ne
151
163
  * coupe pas les sockets d'agents : ce serait une panne de disponibilité déguisée
152
164
  * en mesure de sécurité, alors que la borne du jeton tient toujours.
153
165
  */
154
- #buildBearerRevalidator(issued, identifier, type) {
155
- const claims = issued.getAttribute("claims") ?? EMPTY_CLAIMS;
166
+ #buildBearerStatus(issued, identifier, type) {
167
+ const claims = issued?.getAttribute("claims") ?? EMPTY_CLAIMS;
156
168
  const expMs = secondsToMs(claims.exp);
157
169
  const iatMs = secondsToMs(claims.iat);
158
- const jti = issued.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
170
+ const jti = issued?.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
159
171
  const resolveStore = this.#resolveStore;
160
172
  if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
161
173
  Nodefony.getKernel()?.log(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
162
- return () => Promise.resolve(false);
174
+ return () => Promise.resolve("revoked");
163
175
  }
164
- return async (nowMs = Date.now()) => {
165
- if (expMs !== null && nowMs >= expMs) return false;
166
- if (!resolveStore) return true;
176
+ /** `true` révoqué, `false` non, `null` = le store n'a pas pu répondre. */
177
+ const isRevoked = async () => {
178
+ if (!resolveStore) return false;
167
179
  let store;
168
180
  try {
169
181
  store = resolveStore();
170
182
  } catch {
171
- return expMs !== null;
183
+ return null;
172
184
  }
173
- if (!store) return expMs !== null;
185
+ if (!store) return null;
174
186
  try {
175
- if (jti !== null && await store.isJtiDenied(jti)) return false;
187
+ if (jti !== null && await store.isJtiDenied(jti)) return true;
176
188
  const invalidBefore = await store.getInvalidBefore(identifier);
177
- if (invalidBefore !== null && iatMs !== null && iatMs < invalidBefore) return false;
178
- return true;
189
+ return invalidBefore !== null && iatMs !== null && iatMs < invalidBefore;
179
190
  } catch {
180
- return expMs !== null;
191
+ return null;
181
192
  }
182
193
  };
194
+ return async (nowMs = Date.now()) => {
195
+ const revoked = await isRevoked();
196
+ if (revoked === null && expMs === null) return "revoked";
197
+ return tokenStatusOf({
198
+ revokedAt: revoked === true ? nowMs : null,
199
+ expiresAt: expMs
200
+ }, nowMs);
201
+ };
183
202
  }
184
203
  };
185
204
  const EMPTY_CLAIMS = Object.freeze({});
@@ -200,6 +219,29 @@ function secondsToMs(value) {
200
219
  function issuedType(issued) {
201
220
  return typeof issued?.type === "string" && issued.type ? issued.type : "session";
202
221
  }
222
+ /**
223
+ * Émetteur et compte local de l'identité, pour l'identité d'admission temps réel (ADR-0021 §S10).
224
+ *
225
+ * L'émetteur est celui que l'authentificateur a VÉRIFIÉ et posé sur le jeton
226
+ * (attribut `issuer` : JWT de l'application, JWT tiers) ; `null` sans jeton
227
+ * porteur (session, clé d'API). Le compte est absent pour le seul appelant
228
+ * machine fabriqué en mode `ephemeral` — constaté sur l'objet, jamais déduit
229
+ * de la forme de son `id`.
230
+ */
231
+ function admissionSourceOf(issued, user) {
232
+ const issuer = issued?.getAttribute("issuer");
233
+ const account = !isEphemeralSubject(user);
234
+ if (typeof issuer !== "string" || issuer === "") return account ? LOCAL_ACCOUNT : EPHEMERAL_WITHOUT_ISSUER;
235
+ return {
236
+ issuer,
237
+ account
238
+ };
239
+ }
240
+ /** Filet : un sujet éphémère sans émetteur posé (ne devrait pas exister). */
241
+ const EPHEMERAL_WITHOUT_ISSUER = Object.freeze({
242
+ issuer: null,
243
+ account: false
244
+ });
203
245
  /** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
204
246
  function readFirewallToken() {
205
247
  return RequestContext.get()?.token;
@@ -1,12 +1,15 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
- import { loadBoundAccount } from "../accountBinding.js";
3
2
  import { UserToken } from "../token/UserToken.js";
3
+ import { defaultAudience } from "../token/jwtRuntime.js";
4
+ import { REQUIRED_TIME_CLAIMS, isIssuedInFuture } from "../token/tokenTimeClaims.js";
4
5
  import { bearerToken } from "./bearer.js";
5
6
  import peekIssuer from "./peekIssuer.js";
7
+ import { loadBoundAccount } from "../accountBinding.js";
6
8
  import { canonicalIssuer } from "nodefony";
7
9
  // nodefony/src/authenticator/JwtAuthenticator.ts
8
10
  const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
9
11
  const INVALID_TOKEN = "Invalid token";
12
+ const AUDIENCE = "audience";
10
13
  /**
11
14
  * Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
12
15
  * agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
@@ -18,7 +21,8 @@ const INVALID_TOKEN = "Invalid token";
18
21
  * - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
19
22
  * `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
20
23
  * - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
21
- * 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`.
22
26
  * - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
23
27
  * est auto-porté et non révocable sans état serveur).
24
28
  * - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
@@ -67,15 +71,31 @@ var JwtAuthenticator = class {
67
71
  const issuer = peekIssuer(token);
68
72
  return issuer === null || issuer === this.#runtime.issuer;
69
73
  }
70
- /** 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
+ */
71
82
  createToken(context) {
72
83
  const auth = context.request?.headers.authorization;
73
- 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);
74
88
  }
75
89
  /**
76
90
  * Vérifie la signature + les claims du JWT, applique la révocation et résout le
77
91
  * sujet — ou lève un 401 au message uniforme.
78
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
+ *
79
99
  * @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
80
100
  * sujet disparu/banni.
81
101
  * @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
@@ -84,7 +104,8 @@ var JwtAuthenticator = class {
84
104
  async authenticate(token) {
85
105
  const raw = token.getCredentials();
86
106
  if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
87
- 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);
88
109
  return this.#promote(token, user, payload);
89
110
  }
90
111
  /**
@@ -143,11 +164,13 @@ var JwtAuthenticator = class {
143
164
  algorithms: ["EdDSA"],
144
165
  issuer: this.#runtime.issuer,
145
166
  audience,
146
- typ: "at+jwt"
167
+ typ: "at+jwt",
168
+ requiredClaims: [...REQUIRED_TIME_CLAIMS]
147
169
  })).payload;
148
170
  } catch {
149
171
  throw new AuthenticationError(INVALID_TOKEN);
150
172
  }
173
+ if (isIssuedInFuture(payload, Math.floor(Date.now() / 1e3), 5)) throw new AuthenticationError(INVALID_TOKEN);
151
174
  const sub = payload.sub;
152
175
  const jti = payload.jti;
153
176
  if (typeof sub !== "string" || typeof jti !== "string") throw new AuthenticationError(INVALID_TOKEN);
@@ -1,6 +1,7 @@
1
1
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
- import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
2
+ import { StaleSessionError } from "../../errors/StaleSessionError.js";
3
3
  import { resolveBoundSessionIdentity } from "../sessionIdentity.js";
4
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY } from "../accountBinding.js";
4
5
  import { UserToken } from "../token/UserToken.js";
5
6
  // nodefony/src/authenticator/SessionAuthenticator.ts
6
7
  /**
@@ -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 };
@@ -3,8 +3,8 @@ import { SessionAuthenticator } from "./SessionAuthenticator.js";
3
3
  import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
4
4
  import { JwtAuthenticator } from "./JwtAuthenticator.js";
5
5
  import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
6
- import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
7
6
  import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
7
+ import { externalSubjectOptions } from "./ExternalSubjectResolver.js";
8
8
  import { resolveJwtRuntime } from "../token/jwtRuntime.js";
9
9
  // nodefony/src/authenticator/authenticatorRegistry.ts
10
10
  const factories = /* @__PURE__ */ new Map();
@@ -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));