@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.3

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 (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. package/package.json +17 -15
@@ -1,4 +1,4 @@
1
- import { messageNonRestreint, modeNonRestreintAsync, readIfPresent, writeSecret } from "./secretFile.js";
1
+ import { createSecretExclusive, messageNonRestreint, modeNonRestreintAsync, readIfPresent } from "./secretFile.js";
2
2
  import { join } from "node:path";
3
3
  //#region nodefony/src/token/JwtKeystore.ts
4
4
  /**
@@ -36,6 +36,58 @@ function keystoreLeaksIntoImage(dir) {
36
36
  return `JWT keystore: « ${dir} » n'est PAS sous ${CLEANED_DIRS.map((d) => `\`${d}/\``).join(" ni ")}, les seuls dossiers que le Dockerfile généré efface avant de fabriquer l'image. La clé privée de signature partira donc dans ton image de conteneur, où elle reste lisible par quiconque la télécharge — même effacée par une couche suivante. Deux issues : déplacer le dossier sous \`var/\` (ex. \`var/keys\`), ou injecter la clé par jwt.keystore.keySetJson depuis l'environnement, ce qui est la voie de production.`;
37
37
  }
38
38
  /**
39
+ * Lit un jeu de clés sérialisé et en vérifie la FORME — la seule lecture du
40
+ * dépôt, appelée par le keystore ET par la validation de configuration.
41
+ *
42
+ * Ne vérifie que la structure (`keys` non vide, un `kid` par clé) : importer
43
+ * les clés exige `jose`, chargé paresseusement au premier jeton. Les messages
44
+ * ne recopient JAMAIS la valeur reçue — elle porte une clé privée.
45
+ *
46
+ * @param json - le jeu de clés, tel que `NF_JWT_KEYSET` ou `keyset.json` le porte.
47
+ * @returns le jeu de clés lu.
48
+ * @throws Error si le JSON est illisible ou la structure incomplète.
49
+ */
50
+ function parseKeySet(json) {
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(json);
54
+ } catch {
55
+ throw new Error("JwtKeystore: keyset JSON invalide");
56
+ }
57
+ const keyset = parsed;
58
+ if (!keyset || !Array.isArray(keyset.keys) || keyset.keys.length === 0 || keyset.keys.some((k) => typeof k.kid !== "string")) throw new Error("JwtKeystore: keyset malformé (champ `keys` non vide avec `kid` requis)");
59
+ return keyset;
60
+ }
61
+ /** Génère une nouvelle paire Ed25519 (extractable pour persistance JWK). */
62
+ async function buildKeySet(jose) {
63
+ const { publicKey, privateKey } = await jose.generateKeyPair("Ed25519", { extractable: true });
64
+ const publicJwk = await jose.exportJWK(publicKey);
65
+ const kid = await jose.calculateJwkThumbprint(publicJwk, "sha256");
66
+ return {
67
+ active: kid,
68
+ keys: [{
69
+ ...await jose.exportJWK(privateKey),
70
+ kid,
71
+ alg: "EdDSA",
72
+ use: "sig",
73
+ createdAt: Date.now()
74
+ }]
75
+ };
76
+ }
77
+ /**
78
+ * Génère un jeu de clés de signature à partager entre tous les process d'une
79
+ * application — la valeur de `NF_JWT_KEYSET`.
80
+ *
81
+ * Même générateur que celui du keystore (une seule implémentation), sérialisé
82
+ * en JSON COMPACT : une ligne, sans quote simple, qu'un `.env` porte entre
83
+ * quotes simples et qu'un gestionnaire de secrets stocke tel quel.
84
+ *
85
+ * @returns le jeu de clés sérialisé — il contient la clé PRIVÉE.
86
+ */
87
+ async function generateKeySet() {
88
+ return JSON.stringify(await buildKeySet(await import("jose")));
89
+ }
90
+ /**
39
91
  * Keystore Ed25519 — implémentation de référence d'{@link IJwtKeystore}.
40
92
  *
41
93
  * Résout la clé de signature selon une **priorité** (jamais d'auto-génération en
@@ -55,10 +107,15 @@ function keystoreLeaksIntoImage(dir) {
55
107
  * usage ; le boot ne paie rien si le JWT n'est jamais sollicité. Le chargement
56
108
  * est mémoïsé (une seule résolution concurrente).
57
109
  *
58
- * @remarks Race au 1ᵉʳ boot d'un **cluster** sans clé pré-provisionnée : deux
59
- * workers peuvent générer puis écrire des clés différentes (le dernier `rename`
60
- * gagne). En prod, provisionner la clé hors-bande (`keySetJson`/SecretProvider
61
- * P16) élimine ce cas — c'est précisément la source recommandée.
110
+ * @remarks Plusieurs process qui servent la MÊME application doivent signer
111
+ * avec la même clé, sinon un jeton signé par l'un est refusé par l'autre :
112
+ * - **pods** (aucun disque partagé) → source `env` obligatoire, la même valeur
113
+ * injectée partout (`NF_JWT_KEYSET`, générée par `security:secrets`) ;
114
+ * - **workers de `nodefony cluster`** → la source `env` est héritée au fork ; à
115
+ * défaut, la source `fichier` est sûre : la création de `keyset.json` est
116
+ * EXCLUSIVE ({@link createSecretExclusive}), un seul worker la génère et les
117
+ * autres relisent la sienne ;
118
+ * - source `mémoire` → une clé par process, jamais cohérente.
62
119
  */
63
120
  var JwtKeystore = class {
64
121
  #source;
@@ -91,7 +148,7 @@ var JwtKeystore = class {
91
148
  async #load() {
92
149
  const jose = await import("jose");
93
150
  if (this.#source.keySetJson) {
94
- await this.#importKeyset(jose, this.#parseKeyset(this.#source.keySetJson));
151
+ await this.#importKeyset(jose, parseKeySet(this.#source.keySetJson));
95
152
  return;
96
153
  }
97
154
  if (this.#source.dir) {
@@ -101,32 +158,24 @@ var JwtKeystore = class {
101
158
  const existing = await this.#readFile(file);
102
159
  if (existing) {
103
160
  await this.#checkRestricted(file);
104
- await this.#importKeyset(jose, this.#parseKeyset(existing));
161
+ await this.#importKeyset(jose, parseKeySet(existing));
105
162
  return;
106
163
  }
107
- const keyset = await this.#generate(jose);
108
- await this.#writeAtomic(file, JSON.stringify(keyset));
109
- await this.#importKeyset(jose, keyset);
164
+ const keyset = await buildKeySet(jose);
165
+ if (await createSecretExclusive(file, JSON.stringify(keyset))) {
166
+ this.#log(`JWT keystore: clé Ed25519 générée et persistée (${file}).`, "INFO");
167
+ await this.#checkRestricted(file);
168
+ await this.#importKeyset(jose, keyset);
169
+ return;
170
+ }
171
+ const winner = await this.#readFile(file);
172
+ if (winner === null) throw new Error(`JwtKeystore: ${file} a disparu juste après sa création`);
173
+ await this.#checkRestricted(file);
174
+ await this.#importKeyset(jose, parseKeySet(winner));
110
175
  return;
111
176
  }
112
- this.#log("JWT keystore: clé de signature ÉPHÉMÈRE en mémoire (perdue au redémarrage → refresh tokens invalidés, incohérente en cluster). Configurez jwt.keystore.dir (dev/VPS) ou jwt.keystore.keySetJson depuis l'env (prod).", "WARNING");
113
- await this.#importKeyset(jose, await this.#generate(jose));
114
- }
115
- /** Génère une nouvelle paire Ed25519 (extractable pour persistance JWK). */
116
- async #generate(jose) {
117
- const { publicKey, privateKey } = await jose.generateKeyPair("Ed25519", { extractable: true });
118
- const publicJwk = await jose.exportJWK(publicKey);
119
- const kid = await jose.calculateJwkThumbprint(publicJwk, "sha256");
120
- return {
121
- active: kid,
122
- keys: [{
123
- ...await jose.exportJWK(privateKey),
124
- kid,
125
- alg: "EdDSA",
126
- use: "sig",
127
- createdAt: Date.now()
128
- }]
129
- };
177
+ this.#log("JWT keystore: clé de signature ÉPHÉMÈRE en mémoire — perdue au redémarrage (jetons en vol invalidés) et propre à CE process : derrière plusieurs pods ou workers, un jeton signé par l'un est refusé par l'autre (401 au hasard). Production : poser jwt.keystore.keySetJson depuis l'environnement (NF_JWT_KEYSET dans une application générée — `npx nodefony security:secrets --jwt-keyset`). Développement : jwt.keystore.dir.", "WARNING");
178
+ await this.#importKeyset(jose, await buildKeySet(jose));
130
179
  }
131
180
  /** Importe un keyset persisté : privée → `CryptoKey`, public → JWK sans `d`. */
132
181
  async #importKeyset(jose, keyset) {
@@ -153,27 +202,10 @@ var JwtKeystore = class {
153
202
  this.#keys = loaded;
154
203
  this.#activeKid = keyset.active && loaded.some((k) => k.kid === keyset.active) ? keyset.active : firstKey.kid;
155
204
  }
156
- #parseKeyset(json) {
157
- let parsed;
158
- try {
159
- parsed = JSON.parse(json);
160
- } catch {
161
- throw new Error("JwtKeystore: keyset JSON invalide");
162
- }
163
- const keyset = parsed;
164
- if (!keyset || !Array.isArray(keyset.keys) || keyset.keys.length === 0 || keyset.keys.some((k) => typeof k.kid !== "string")) throw new Error("JwtKeystore: keyset malformé (champ `keys` non vide avec `kid` requis)");
165
- return keyset;
166
- }
167
205
  /** Lit un fichier ; `null` si absent (ENOENT), relance toute autre erreur. */
168
206
  async #readFile(file) {
169
207
  return readIfPresent(file);
170
208
  }
171
- /** Écriture atomique (tmp + rename) en mode 600 — pas de fichier partiel lu. */
172
- async #writeAtomic(file, data) {
173
- await writeSecret(file, data);
174
- this.#log(`JWT keystore: clé Ed25519 générée et persistée (${file}).`, "INFO");
175
- await this.#checkRestricted(file);
176
- }
177
209
  /**
178
210
  * Constate le mode EFFECTIF du fichier de clés et avertit s'il n'est pas 0600.
179
211
  *
@@ -194,4 +226,4 @@ var JwtKeystore = class {
194
226
  }
195
227
  };
196
228
  //#endregion
197
- export { JwtKeystore, JwtKeystore as default, keystoreLeaksIntoImage };
229
+ export { JwtKeystore, JwtKeystore as default, generateKeySet, keystoreLeaksIntoImage, parseKeySet };
@@ -1,4 +1,4 @@
1
- import { canonicalIssuer, extractScopes, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
1
+ import { canonicalIssuer, extractScopes, metadataCandidateUrls, parseDiscoveryUrl, validateIssuerMetadata } from "nodefony";
2
2
  //#region nodefony/src/token/RemoteJwtVerifier.ts
3
3
  /**
4
4
  * Codes d'erreur `jose` qui désignent un **jeton fautif** — la liste est
@@ -65,8 +65,8 @@ var RemoteJwtVerifier = class {
65
65
  #jose = null;
66
66
  /**
67
67
  * @param options - émetteurs de confiance et réglages réseau
68
- * @throws Error si un émetteur est invalide, dupliqué, ou déclare un
69
- * algorithme à secret partagé
68
+ * @throws Error si un émetteur est invalide, dupliqué, déclare un
69
+ * algorithme à secret partagé ou une `discoveryUrl` invalide
70
70
  */
71
71
  constructor(options) {
72
72
  this.#options = options;
@@ -75,6 +75,7 @@ var RemoteJwtVerifier = class {
75
75
  const issuer = canonicalIssuer(trusted.issuer);
76
76
  if (this.#issuers.has(issuer)) throw new Error(`émetteur « ${issuer} » déclaré deux fois — deux politiques pour un même émetteur ne peuvent pas coexister : la seconde serait ignorée en silence.`);
77
77
  assertAsymmetric(issuer, trusted.algorithms);
78
+ if (trusted.discoveryUrl !== void 0) parseDiscoveryUrl(trusted.discoveryUrl);
78
79
  this.#issuers.set(issuer, {
79
80
  ...trusted,
80
81
  issuer
@@ -101,6 +102,36 @@ var RemoteJwtVerifier = class {
101
102
  * « jeton invalide »
102
103
  */
103
104
  async verify(token, audience) {
105
+ const verified = await this.verifyClaims(token, audience);
106
+ if (verified === null) return null;
107
+ const { issuer, payload } = verified;
108
+ return {
109
+ issuer,
110
+ subject: typeof payload.sub === "string" ? payload.sub : void 0,
111
+ scopes: extractScopes(payload),
112
+ expiresAt: typeof payload.exp === "number" ? payload.exp : void 0,
113
+ issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
114
+ tokenId: typeof payload.jti === "string" ? payload.jti : void 0,
115
+ claims: payload
116
+ };
117
+ }
118
+ /**
119
+ * Vérifie un jeton et rend ses claims BRUTS — pour un jeton qui n'est pas un
120
+ * jeton d'accès (jeton de déconnexion OpenID Connect, par exemple), dont le
121
+ * sens se lit dans des claims que {@link verify} ne remonte pas.
122
+ *
123
+ * Mêmes garanties et même contrat d'erreur que {@link verify} : signature,
124
+ * émetteur de la liste fermée, algorithme imposé, audience obligatoire,
125
+ * claims exigés par l'émetteur ; un refus est un `null`, une panne lève.
126
+ *
127
+ * @param token - le jeton brut, tel que présenté
128
+ * @param audience - audience que le jeton DOIT porter dans `aud`
129
+ * @returns l'émetteur canonique et les claims vérifiés, ou `null` si le jeton
130
+ * est refusé
131
+ * @throws Error si l'émetteur ne peut pas être joint ou publie un jeu de clés
132
+ * inutilisable
133
+ */
134
+ async verifyClaims(token, audience) {
104
135
  if (typeof token !== "string" || token.length === 0) return null;
105
136
  const jose = this.#jose ??= await import("jose");
106
137
  let claimedIssuer;
@@ -126,14 +157,9 @@ var RemoteJwtVerifier = class {
126
157
  ...trusted.typ ? { typ: trusted.typ } : {},
127
158
  ...trusted.requiredClaims?.length ? { requiredClaims: [...trusted.requiredClaims] } : {}
128
159
  });
129
- const subject = typeof payload.sub === "string" ? payload.sub : void 0;
130
160
  return {
131
161
  issuer: claimedIssuer,
132
- subject,
133
- scopes: extractScopes(payload),
134
- expiresAt: typeof payload.exp === "number" ? payload.exp : void 0,
135
- issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
136
- tokenId: typeof payload.jti === "string" ? payload.jti : void 0
162
+ payload
137
163
  };
138
164
  } catch (error) {
139
165
  const code = error.code;
@@ -167,7 +193,7 @@ var RemoteJwtVerifier = class {
167
193
  trusted,
168
194
  getKey: jose.createLocalJWKSet(await trusted.localJwks())
169
195
  };
170
- const jwksUri = trusted.jwksUri ?? await this.#discover(trusted.issuer);
196
+ const jwksUri = trusted.jwksUri ?? await this.#discover(trusted);
171
197
  const options = {
172
198
  timeoutDuration: this.#options.timeoutMs ?? 5e3,
173
199
  cooldownDuration: this.#options.cooldownMs ?? 3e4,
@@ -188,11 +214,12 @@ var RemoteJwtVerifier = class {
188
214
  * « meilleure réponse que rien » : il est écarté comme s'il n'avait pas
189
215
  * répondu, et la recherche continue.
190
216
  */
191
- async #discover(issuer) {
217
+ async #discover(trusted) {
218
+ const { issuer, discoveryUrl } = trusted;
192
219
  const doFetch = this.#options.fetch ?? globalThis.fetch;
193
220
  const timeout = this.#options.timeoutMs ?? 5e3;
194
221
  const failures = [];
195
- for (const url of issuerMetadataUrls(issuer)) try {
222
+ for (const url of metadataCandidateUrls(issuer, discoveryUrl)) try {
196
223
  const response = await doFetch(url, {
197
224
  signal: AbortSignal.timeout(timeout),
198
225
  redirect: "manual",
@@ -202,7 +229,7 @@ var RemoteJwtVerifier = class {
202
229
  failures.push(`${url} → HTTP ${response.status}`);
203
230
  continue;
204
231
  }
205
- const metadata = validateIssuerMetadata(await response.json(), issuer);
232
+ const metadata = validateIssuerMetadata(await response.json(), issuer, discoveryUrl);
206
233
  this.#audit(`émetteur « ${issuer} » découvert : clés sur ${metadata.jwksUri}.`);
207
234
  return metadata.jwksUri;
208
235
  } catch (error) {
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
3
- import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { link, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
4
5
  import process from "node:process";
5
6
  //#region nodefony/src/token/secretFile.ts
6
7
  /**
@@ -113,6 +114,62 @@ async function writeSecret(file, content) {
113
114
  throw e;
114
115
  }
115
116
  }
117
+ /**
118
+ * Codes d'un système de fichiers qui ne sait pas poser de lien dur (FAT, exFAT,
119
+ * certains montages réseau) — et non d'une cible déjà présente.
120
+ */
121
+ const NO_HARD_LINK = /* @__PURE__ */ new Set([
122
+ "EPERM",
123
+ "ENOTSUP",
124
+ "ENOSYS",
125
+ "EOPNOTSUPP"
126
+ ]);
127
+ /**
128
+ * Crée un secret SEULEMENT s'il n'existe pas encore : 0600, atomique, et un
129
+ * seul gagnant quand plusieurs process le tentent en même temps.
130
+ *
131
+ * ⭐ **Pourquoi pas {@link writeSecret}.** `rename` REMPLACE la cible : deux
132
+ * workers de `nodefony cluster` qui génèrent chacun une clé au premier boot
133
+ * l'écrivent tous les deux, le dernier gagne sur le disque — et l'autre garde
134
+ * en mémoire une clé que plus personne ne connaît. Ici, le temporaire est
135
+ * publié par un LIEN DUR, que le système refuse (`EEXIST`) si le nom existe :
136
+ * le perdant le sait, et relit la clé du gagnant. Le lecteur ne voit jamais un
137
+ * fichier à demi écrit, puisque le lien n'apparaît qu'une fois le contenu complet.
138
+ *
139
+ * ⚠️ Un système de fichiers sans liens durs (FAT, exFAT) retombe sur une
140
+ * création exclusive directe (`wx`) : toujours un seul gagnant, mais un lecteur
141
+ * concurrent peut lire le fichier pendant son écriture — il lève alors, au lieu
142
+ * de signer avec une clé divergente.
143
+ *
144
+ * @param file - chemin du secret.
145
+ * @param content - contenu à écrire.
146
+ * @returns `true` si ce process l'a créé, `false` s'il existait déjà.
147
+ */
148
+ async function createSecretExclusive(file, content) {
149
+ await mkdir(path.dirname(file), { recursive: true });
150
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
151
+ await writeFile(tmp, content, { mode: 384 });
152
+ try {
153
+ await link(tmp, file);
154
+ return true;
155
+ } catch (e) {
156
+ const code = e.code ?? "";
157
+ if (code === "EEXIST") return false;
158
+ if (!NO_HARD_LINK.has(code)) throw e;
159
+ try {
160
+ await writeFile(file, content, {
161
+ mode: 384,
162
+ flag: "wx"
163
+ });
164
+ return true;
165
+ } catch (fallback) {
166
+ if (fallback.code === "EEXIST") return false;
167
+ throw fallback;
168
+ }
169
+ } finally {
170
+ await rm(tmp, { force: true }).catch(() => {});
171
+ }
172
+ }
116
173
  /** Forme synchrone de {@link writeSecret}. */
117
174
  function writeSecretSync(file, content) {
118
175
  mkdirSync(path.dirname(file), { recursive: true });
@@ -131,4 +188,4 @@ function writeSecretSync(file, content) {
131
188
  } catch {}
132
189
  }
133
190
  //#endregion
134
- export { MODE_SECRET, messageNonRestreint, modeNonRestreint, modeNonRestreintAsync, readIfPresent, readIfPresentSync, writeSecret, writeSecretSync };
191
+ export { MODE_SECRET, createSecretExclusive, messageNonRestreint, modeNonRestreint, modeNonRestreintAsync, readIfPresent, readIfPresentSync, writeSecret, writeSecretSync };
@@ -0,0 +1,51 @@
1
+ import { InsecureTransportError } from "../../errors/InsecureTransportError.js";
2
+ //#region nodefony/src/transport/CredentialTransportPolicy.ts
3
+ /**
4
+ * Dit si un secret de connexion se refuse : en production, sans échappement
5
+ * explicite. Seule source de la décision — le firewall la compose au boot.
6
+ *
7
+ * @param environment - environnement normalisé du kernel.
8
+ * @param allowInsecureCredentials - échappement `security.allowInsecureCredentials`.
9
+ */
10
+ function shouldEnforceCredentialTransport(environment, allowInsecureCredentials) {
11
+ return environment === "production" && !allowInsecureCredentials;
12
+ }
13
+ /**
14
+ * Refuse, avant toute vérification, un mot de passe ou un code de second
15
+ * facteur reçu en clair. Une instance partagée au conteneur (`credentialTransport`)
16
+ * sert TOUTES les portes qui reçoivent un secret : formulaire de session, second
17
+ * facteur, Basic, émission de jeton — une porte oubliée serait le canal clair.
18
+ *
19
+ * La décision se prend sur le scheme EFFECTIF (`context.scheme`) : derrière un
20
+ * proxy qui termine TLS et déclaré de confiance, il vaut `https` alors que le
21
+ * pod écoute en HTTP. C'est le cas de la plupart des déploiements ; c'est donc
22
+ * `trustProxy` que nomme le journal, pas « passez en HTTPS ».
23
+ */
24
+ var CredentialTransportPolicy = class {
25
+ enforce;
26
+ #log;
27
+ #warned = false;
28
+ constructor(options) {
29
+ this.enforce = options.enforce;
30
+ this.#log = options.log ?? null;
31
+ }
32
+ /**
33
+ * Laisse passer un secret reçu sur TLS, ou n'importe lequel hors production.
34
+ *
35
+ * @param context - contexte de la requête qui porte le secret.
36
+ * @param door - porte qui le reçoit, nommée dans le journal.
37
+ * @throws InsecureTransportError (403) — secret reçu en clair en production.
38
+ */
39
+ assert(context, door) {
40
+ if (!this.enforce) return;
41
+ const scheme = context.scheme;
42
+ if (scheme === "https" || scheme === "wss") return;
43
+ if (!this.#warned && this.#log !== null) {
44
+ this.#warned = true;
45
+ this.#log(`Secret de connexion REFUSÉ (${door}) : la requête est arrivée en ${scheme ?? "scheme inconnu"} en production. Derrière un proxy qui termine TLS, déclarez-le de confiance (\`trustProxy\`, config @nodefony/http) pour que \`X-Forwarded-Proto\` soit lu ; sinon servez l'application en HTTPS. Échappement explicite : \`security.allowInsecureCredentials\`. Les refus suivants ne sont plus journalisés ici (voir l'audit).`);
46
+ }
47
+ throw new InsecureTransportError();
48
+ }
49
+ };
50
+ //#endregion
51
+ export { CredentialTransportPolicy, shouldEnforceCredentialTransport };
@@ -10,6 +10,7 @@ import ApiKeyService from "./nodefony/service/apiKeys.js";
10
10
  import AuditService from "./nodefony/service/auditService.js";
11
11
  import TotpService from "./nodefony/service/totp.js";
12
12
  import WebhookService from "./nodefony/service/webhooks.js";
13
+ import KeycloakService from "./nodefony/service/keycloak.js";
13
14
  import type { ISecurityConfigInput } from "./nodefony/config/defineModuleConfig.js";
14
15
  declare module "nodefony" {
15
16
  interface NodefonyModuleConfig {
@@ -25,7 +26,7 @@ declare module "nodefony" {
25
26
  * + Zod validée au boot (fail-closed si invalide). Consomme `@nodefony/user`
26
27
  * (IUser/IUserProvider/IPasswordVerifier) — jamais l'inverse.
27
28
  *
28
- * Livré : firewall (zones, mode first|all, challenge RFC 7235), authenticators
29
+ * Livré : firewall (zones, mode first|all, challenge RFC 9110 §11), authenticators
29
30
  * `anonymous`/`userpassword` (Basic RFC 7617)/`session` (BFF, J3), flux
30
31
  * login/logout/me (`AuthFlow`, anti-fixation + throttling partagé).
31
32
  * Jwt/oauth2/mtls/apikey, CORS, CSRF, autorisation par décorateurs et data
@@ -48,6 +49,14 @@ declare class Security extends Module {
48
49
  * No-op si le broker est absent (Studio non chargé) ou déjà enregistré.
49
50
  */
50
51
  onKernelBoot(): Promise<this>;
52
+ /**
53
+ * Déclare au bilan de démarrage les routes métier laissées PUBLIQUES.
54
+ *
55
+ * `onReady` et pas `onBoot` : les zones sont compilées par le pare-feu à son
56
+ * propre `onBoot`, posé APRÈS celui du module — à `onBoot` la liste est encore
57
+ * vide. `onReady` précède l'écriture du bilan (`var/last-boot.json`).
58
+ */
59
+ onKernelReady(): Promise<this>;
51
60
  }
52
61
  export default Security;
53
62
  export { Firewall };
@@ -63,8 +72,11 @@ export { OAuth2Service };
63
72
  export { AuditService };
64
73
  export { TotpService };
65
74
  export { WebhookService };
75
+ export { KeycloakService };
76
+ export type { IKeycloakRealmPlan, IKeycloakRealmRequest, } from "./nodefony/service/keycloak.js";
66
77
  export type { IWebhookRegisterInput, IWebhookSecretReveal, IWebhookDeliveryPolicy, } from "./nodefony/service/webhooks.js";
67
78
  export type { ISafeUser, ILoginOutcome } from "./nodefony/service/authFlow.js";
79
+ export { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
68
80
  export type { ITokenResponse } from "./nodefony/service/tokenService.js";
69
81
  export type { IApiKeyView, IApiKeyCreated, IApiKeyCapabilities, ICreateApiKeyOptions, } from "./nodefony/contracts/IApiKey.js";
70
82
  export { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
@@ -114,14 +126,20 @@ export type { IWebAuthnUser, IWebAuthnAssertionResult, } from "./nodefony/servic
114
126
  export type { IWebAuthnCredential } from "./nodefony/contracts/IWebAuthnCredential.js";
115
127
  export type { IWebAuthnCredentialStore, IWebAuthnCredentialSummary, IWebAuthnListQuery, WebAuthnAuthUpdate, } from "./nodefony/contracts/IWebAuthnCredentialStore.js";
116
128
  export type { IOAuthAuthorization } from "./nodefony/service/oauth2.js";
117
- export type { IOAuthProvider, IIssuerPolicy, } from "./nodefony/contracts/IOAuthProvider.js";
118
- export { registerOAuthProvider, getOAuthProviderFactory, listOAuthProviders, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
119
- export type { OAuthProviderFactory, IOAuthProviderContext, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
129
+ export type { IOAuthProvider, IIssuerPolicy, ILogoutRequest, ILogoutTokenClaims, } from "./nodefony/contracts/IOAuthProvider.js";
130
+ export { registerOAuthProvider, getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
131
+ export type { OAuthProviderFactory, IOAuthProviderContext, IOAuthProviderRegistration, } from "./nodefony/src/oauth/oauthProviderRegistry.js";
120
132
  export { OAuth2Client, OAuth2Tokens, OAuth2RequestError, generateState, generateCodeVerifier, createCodeChallenge, } from "./nodefony/src/oauth/oauth2Client.js";
121
133
  export type { IOAuth2ClientOptions } from "./nodefony/src/oauth/oauth2Client.js";
122
134
  export { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
135
+ export { requestClientCredentialsToken } from "./nodefony/src/oauth/clientCredentials.js";
136
+ export type { IClientCredentialsRequest } from "./nodefony/src/oauth/clientCredentials.js";
137
+ export { diagnoseOAuthProvider } from "./nodefony/src/oauth/providerDiagnosis.js";
138
+ export type { IOAuthDiagnosis, IOAuthDiagnosisInput, IOAuthCheck, OAuthCheckName, OAuthCheckStatus, OAuthFailureKind, } from "./nodefony/src/oauth/providerDiagnosis.js";
139
+ export { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme, } from "./nodefony/keycloak/keycloakAdmin.js";
140
+ export type { IKeycloakAdminClientOptions, IKeycloakAdminCredentials, IKeycloakAdminTarget, IKeycloakLoginThemeResult, KeycloakLoginThemeOutcome, } from "./nodefony/keycloak/keycloakAdmin.js";
123
141
  export type { IDiscoveredAuthorizationServer, IDiscoveryOptions, } from "./nodefony/src/oauth/metadata.js";
124
- export { createOidcProvider, createDiscoveredOidcProvider, } from "./nodefony/src/oauth/providers/oidc.js";
142
+ export { createOidcProvider, createDiscoveredOidcProvider, readLogoutTokenClaims, } from "./nodefony/src/oauth/providers/oidc.js";
125
143
  export type { IOidcPkceClient, IOidcProviderOptions, } from "./nodefony/src/oauth/providers/oidc.js";
126
144
  export { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
127
145
  export { MemoryAuditStore } from "./nodefony/src/audit/MemoryAuditStore.js";
@@ -159,6 +177,8 @@ export { defineSecurityConfig, securityConfigJsonSchema, } from "./nodefony/conf
159
177
  export type { ISecurityConfig, ISecurityConfigInput, ISecurityAreaConfig, } from "./nodefony/config/defineModuleConfig.js";
160
178
  export { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
161
179
  export type { ILoginThrottleOptions } from "./nodefony/src/throttle/LoginThrottler.js";
162
- export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, } from "./nodefony/errors/index.js";
180
+ export { CredentialTransportPolicy, shouldEnforceCredentialTransport, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
181
+ export type { ICredentialTransportContext, ICredentialTransportPolicyOptions, } from "./nodefony/src/transport/CredentialTransportPolicy.js";
182
+ export { AuthenticationError, AccessDeniedError, ThrottledError, UnverifiableTokenError, InvalidTargetError, CsrfError, SsrfError, InsecureTransportError, } from "./nodefony/errors/index.js";
163
183
  export { VoterVote } from "./nodefony/contracts/index.js";
164
184
  export type { IToken, IAuthenticator, ISecuredArea, IFirewall, IAccessVoter, IAuthorizationService, IAccessTokenRecord, ITokenStore, ITokenListQuery, ITokenUsage, IResourcePermission, TokenRevokeReason, TokenStatus, IJwtKeystore, IJwtSigningKey, } from "./nodefony/contracts/index.js";
@@ -0,0 +1,23 @@
1
+ import { CliKernel, Command } from "nodefony";
2
+ /**
3
+ * `nodefony security:oauth:doctor` — dit POURQUOI un fournisseur OAuth refuse
4
+ * l'application, sans qu'un humain ait à se connecter.
5
+ *
6
+ * Trois verdicts distincts là où le journal ne montre qu'un 401 : l'émetteur
7
+ * se déclare autrement que la configuration ne l'écrit, l'URL de retour n'est
8
+ * pas enregistrée, le secret est faux. La même analyse alimente
9
+ * `nodefony doctor --live` (famille « Fournisseurs OAuth ») et l'endpoint
10
+ * d'administration `security/oauth/diagnosis` : une seule implémentation, celle
11
+ * du service `oauth2`.
12
+ *
13
+ * Valable pour tout fournisseur : les sondes passent par le fournisseur que le
14
+ * login construit, Keycloak n'y a rien de particulier.
15
+ */
16
+ declare class SecurityOAuthDoctor extends Command {
17
+ constructor(cli: CliKernel);
18
+ generate(opts: {
19
+ provider?: string;
20
+ json?: boolean;
21
+ }): Promise<this>;
22
+ }
23
+ export default SecurityOAuthDoctor;
@@ -19,6 +19,8 @@ declare class SecuritySecrets extends Command {
19
19
  generate(opts: {
20
20
  json?: boolean;
21
21
  write?: boolean;
22
+ jwtKeyset?: boolean;
23
+ env?: boolean;
22
24
  }): Promise<this>;
23
25
  }
24
26
  export default SecuritySecrets;
@@ -10,6 +10,15 @@ import { CliKernel, Command } from "nodefony";
10
10
  * @returns les secondes, `undefined` si rien n'est demandé, une `Error` sinon
11
11
  */
12
12
  export declare function ttlSeconds(raw: string | undefined): number | undefined | Error;
13
+ /**
14
+ * Durée demandée à l'émetteur : `--ttl` explicite, sinon 8 h quand le jeton
15
+ * part chez les agents, sinon rien — la configuration décide.
16
+ *
17
+ * @param raw - la valeur de `--ttl` telle que tapée, ou rien
18
+ * @param write - le jeton sera posé dans la configuration des agents
19
+ * @returns les secondes, `undefined` pour le défaut de configuration, une `Error` sinon
20
+ */
21
+ export declare function tokenTtlSeconds(raw: string | undefined, write: boolean): number | undefined | Error;
13
22
  /**
14
23
  * `nodefony security:token` — émet un jeton d'accès pour une porte de cette
15
24
  * application (la porte MCP par défaut).
@@ -12,9 +12,10 @@ import { CliKernel, Command } from "nodefony";
12
12
  * seul recours est une écriture directe en base. Le même garde-fou existe côté
13
13
  * data plane (`UserAdminApi`) — ici il est constaté, pas supposé.
14
14
  *
15
- * La révocation des sessions et jetons du compte suit d'elle-même : la
16
- * suppression émet `onUserRevoked`, auquel `@nodefony/security` réagit en
17
- * éjectant sessions et jetons. Rien à faire de plus ici.
15
+ * La suppression passe par `users.delete`, qui émet `onUserRevoked`
16
+ * (`reason: "deleted"`) et ATTEND la cascade de `@nodefony/security` : sessions
17
+ * et jetons éjectés, secret TOTP et passkeys retirés. La commande n'émet rien
18
+ * elle-même — le service est le seul émetteur du cycle de vie d'un compte.
18
19
  */
19
20
  declare class SecurityUserDelete extends Command {
20
21
  constructor(cli: CliKernel);
@@ -20,10 +20,10 @@ import { CliKernel, Command } from "nodefony";
20
20
  *
21
21
  * 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
22
22
  * option : on change un mot de passe parce qu'il est compromis ou perdu, et
23
- * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
24
- * révocation passe par l'événement que la suppression emploie déjà — une
25
- * cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
26
- * rien changer ici.
23
+ * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé.
24
+ * `UserService.changePassword` émet la révocation lui-même — la même que pour
25
+ * le plan d'administration et la page « mon mot de passe » : un seul émetteur,
26
+ * aucun transport ne peut l'oublier.
27
27
  */
28
28
  declare class SecurityUserPassword extends Command {
29
29
  constructor(cli: CliKernel);