@nodefony/security 10.0.0-beta.1 → 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 (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
@@ -1,4 +1,6 @@
1
1
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
2
+ import { recordAudit } from "../src/audit/recordAudit.js";
3
+ import { readAuditContext } from "../src/audit/readAuditContext.js";
2
4
  import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
3
5
  import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
4
6
  import { deriveTotpKey } from "../src/totp/totpCipher.js";
@@ -157,17 +159,60 @@ var TotpService = class extends Service {
157
159
  beginEnrollment(userId, account) {
158
160
  return beginTotpEnrollment(this.#ensureReady(), userId, account);
159
161
  }
160
- /** Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs. */
161
- confirmEnrollment(userId, code) {
162
- return confirmTotpEnrollment(this.#ensureReady(), userId, code);
162
+ /**
163
+ * Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs.
164
+ *
165
+ * Activer un second facteur est un geste de SÉCURITÉ : il est audité ici
166
+ * (`user.totp_enabled`), avec la requête qui l'a porté — sans trace, un facteur
167
+ * posé à l'insu du titulaire ne se retrouve pas.
168
+ *
169
+ * @param userId - compte dont on active le 2FA (le porteur de la session).
170
+ * @param code - premier code de l'application d'authentification.
171
+ * @param context - contexte de la requête (IP, navigateur, identifiant de
172
+ * requête) recopié dans l'événement d'audit ; absent, l'événement part sans.
173
+ * @returns les codes de récupération, affichés une seule fois.
174
+ * @throws quand le code est faux, l'enrôlement absent ou déjà confirmé.
175
+ */
176
+ async confirmEnrollment(userId, code, context) {
177
+ const activation = await confirmTotpEnrollment(this.#ensureReady(), userId, code);
178
+ recordAudit(this.container, {
179
+ category: "auth",
180
+ action: "user.totp_enabled",
181
+ outcome: "success",
182
+ actor: userId,
183
+ resource: userId,
184
+ ...readAuditContext(context)
185
+ });
186
+ return activation;
163
187
  }
164
188
  /** Vérifie un second facteur au login (code TOTP ou code de récupération). */
165
189
  verifyLogin(userId, code) {
166
190
  return verifyTotpLogin(this.#ensureReady(), userId, code);
167
191
  }
168
- /** Désactive le 2FA d'un utilisateur. */
169
- disable(userId) {
170
- return disableTotp(this.#ensureReady(), userId);
192
+ /**
193
+ * Désactive le 2FA d'un utilisateur.
194
+ *
195
+ * Avec un `context`, c'est le TITULAIRE qui retire son facteur (porte
196
+ * libre-service) : le geste est audité ici (`user.totp_disabled`). Sans
197
+ * contexte, l'appelant porte lui-même l'audit — le plan d'administration le
198
+ * fait avec l'identité de l'administrateur (`viaAdmin`), qu'ici on ignore.
199
+ * Un seul événement par geste, jamais deux.
200
+ *
201
+ * @param userId - compte dont on retire le 2FA.
202
+ * @param context - contexte de la requête du titulaire (audit libre-service).
203
+ */
204
+ async disable(userId, context) {
205
+ await disableTotp(this.#ensureReady(), userId);
206
+ if (context === void 0) return;
207
+ recordAudit(this.container, {
208
+ category: "auth",
209
+ action: "user.totp_disabled",
210
+ outcome: "success",
211
+ actor: userId,
212
+ resource: userId,
213
+ metadata: { viaAdmin: false },
214
+ ...readAuditContext(context)
215
+ });
171
216
  }
172
217
  /** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
173
218
  status(userId) {
@@ -3,12 +3,30 @@ import { defineSecurityConfig } from "../config/defineModuleConfig.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, runNeedsExternalServices } from "nodefony";
6
+ import { createHash } from "node:crypto";
6
7
  import { Buffer } from "node:buffer";
7
8
  //#region nodefony/service/webAuthn.ts
8
9
  const serviceName = "webauthn";
9
10
  function isFlushable(s) {
10
11
  return s !== null && typeof s.flushNow === "function";
11
12
  }
13
+ /** Taille maximale d'un `userHandle` (WebAuthn L3 §4, « User Handle »). */
14
+ const USER_HANDLE_MAX_BYTES = 64;
15
+ /**
16
+ * Octets du `userHandle` d'une cérémonie d'enregistrement — opaques, bornés, sans
17
+ * donnée personnelle (cf {@link IWebAuthnUser.handle}).
18
+ *
19
+ * @param user - sujet de la cérémonie.
20
+ * @returns `handle` encodé s'il est fourni et tient en 64 octets, sinon le
21
+ * condensat SHA-256 de l'`id`.
22
+ */
23
+ function userHandleBytes(user) {
24
+ if (user.handle !== void 0 && user.handle.length > 0) {
25
+ const bytes = Buffer.from(user.handle, "utf8");
26
+ if (bytes.length <= USER_HANDLE_MAX_BYTES) return new Uint8Array(bytes);
27
+ }
28
+ return new Uint8Array(createHash("sha256").update(user.id, "utf8").digest());
29
+ }
12
30
  /**
13
31
  * **WebAuthn / passkeys** (P6 J9) — orchestrateur des deux cérémonies FIDO2
14
32
  * (WebAuthn L3 §7.1 enregistrement, §7.2 authentification).
@@ -143,7 +161,7 @@ var WebAuthnService = class extends Service {
143
161
  rpName: this.#rpName,
144
162
  rpID: this.#rpID,
145
163
  userName: user.name,
146
- userID: new Uint8Array(Buffer.from(user.id, "utf8")),
164
+ userID: userHandleBytes(user),
147
165
  userDisplayName: user.displayName ?? user.name,
148
166
  attestationType: pk.attestation,
149
167
  timeout: pk.timeoutMs,
@@ -348,4 +366,4 @@ var WebAuthnService = class extends Service {
348
366
  }
349
367
  };
350
368
  //#endregion
351
- export { WebAuthnService, WebAuthnService as default };
369
+ export { WebAuthnService, WebAuthnService as default, userHandleBytes };
@@ -1,3 +1,4 @@
1
+ import { stripTrailingSlashes } from "nodefony";
1
2
  //#region nodefony/src/SecuredArea.ts
2
3
  /**
3
4
  * Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
@@ -16,22 +17,30 @@ var SecuredArea = class {
16
17
  host;
17
18
  realtime;
18
19
  resource;
20
+ entryPoint;
21
+ subjectPolicy;
22
+ ephemeralRoles;
19
23
  /**
20
24
  * Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
21
25
  * aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
22
26
  * et aucune zone ne porte de tableau qu'elle n'utilise pas.
23
27
  */
24
28
  roles;
29
+ #hostKey;
25
30
  constructor(name, config) {
26
31
  this.name = name;
27
- this.pattern = new RegExp(config.pattern, "u");
32
+ this.pattern = new RegExp(config.pattern, "iu");
28
33
  this.security = config.security;
29
34
  this.stateless = config.stateless;
30
35
  this.mode = config.mode;
31
36
  this.authenticators = config.authenticators;
32
37
  this.host = config.host;
38
+ this.#hostKey = config.host ? config.host.toLowerCase() : null;
33
39
  this.realtime = config.realtime;
34
40
  this.resource = config.resource;
41
+ this.entryPoint = config.entryPoint;
42
+ this.subjectPolicy = config.subjectPolicy;
43
+ this.ephemeralRoles = config.subjectPolicy === "ephemeral" ? config.ephemeralRoles ?? [] : void 0;
35
44
  this.roles = config.roles.length > 0 ? config.roles : null;
36
45
  }
37
46
  /**
@@ -40,8 +49,10 @@ var SecuredArea = class {
40
49
  * UNIQUE de la décision de zone (invariant `api.request {path}` ≤ `GET {path}`).
41
50
  */
42
51
  matchPath(pathname, host) {
43
- if (this.host && this.host !== host) return false;
44
- return this.pattern.test(pathname);
52
+ if (this.#hostKey !== null && this.#hostKey !== host?.toLowerCase()) return false;
53
+ if (this.pattern.test(pathname)) return true;
54
+ const bare = stripTrailingSlashes(pathname);
55
+ return bare !== pathname && this.pattern.test(bare);
45
56
  }
46
57
  /** La requête tombe-t-elle dans cette zone ? (host éventuel + pathname). */
47
58
  match(context) {
@@ -99,6 +99,39 @@ function parseAuditQuery(query) {
99
99
  if (page.cursor !== void 0) filter.cursor = page.cursor;
100
100
  return filter;
101
101
  }
102
+ /**
103
+ * Traduit l'`id` d'un compte (paramètre `{id}` d'une route admin) en sa **clé de
104
+ * sujet** `IUser.identifier` — la clé sous laquelle vivent ses facteurs (TOTP,
105
+ * passkeys), sa session et ses jetons.
106
+ *
107
+ * Viser un store avec l'`id` brut agit sur une clé qui n'existe pas et rend un
108
+ * succès : l'administrateur croit avoir débloqué un compte resté bloqué. D'où
109
+ * le 503 quand le service `users` manque — sans lui, l'`id` ne se traduit pas.
110
+ *
111
+ * @param container - container du kernel (service `users`).
112
+ * @param id - paramètre `{id}` de la route.
113
+ * @returns le compte (`id` + `identifier`), ou la réponse d'erreur à rendre (404/503).
114
+ */
115
+ async function resolveSubject(container, id) {
116
+ if (typeof id !== "string" || id.length === 0) return {
117
+ status: 404,
118
+ body: { error: "not found" }
119
+ };
120
+ const users = container.get("users");
121
+ if (!users) return {
122
+ status: 503,
123
+ body: { error: "users unavailable" }
124
+ };
125
+ const user = await users.findById(id);
126
+ if (!user || typeof user.identifier !== "string") return {
127
+ status: 404,
128
+ body: { error: "not found" }
129
+ };
130
+ return {
131
+ id,
132
+ identifier: user.identifier
133
+ };
134
+ }
102
135
  /** Projette un credential serveur vers sa vue admin (sans `publicKey`/`userId`). */
103
136
  function toCredentialView(c) {
104
137
  return {
@@ -170,6 +203,25 @@ function createSecurityAdminApi(container) {
170
203
  return firewall.describe();
171
204
  }
172
205
  },
206
+ {
207
+ path: "oauth/diagnosis",
208
+ method: "GET",
209
+ role: "ROLE_NODEFONY_ADMIN",
210
+ summary: "Diagnostic des fournisseurs OAuth sans connexion humaine : émetteur, URL de retour, secret (?provider=<nom>). Interroge les fournisseurs à chaque appel ; aucun secret rendu.",
211
+ handler: async (request) => {
212
+ const oauth = container.get("oauth2");
213
+ if (!oauth || !oauth.isEnabled()) return {
214
+ enabled: false,
215
+ providers: []
216
+ };
217
+ const raw = request.query.provider;
218
+ const provider = typeof raw === "string" && raw !== "" ? raw : void 0;
219
+ return {
220
+ enabled: true,
221
+ providers: await oauth.diagnose(provider)
222
+ };
223
+ }
224
+ },
173
225
  {
174
226
  path: "roleHierarchy",
175
227
  method: "GET",
@@ -324,17 +376,9 @@ function createSecurityAdminApi(container) {
324
376
  status: 503,
325
377
  body: { error: "webauthn unavailable" }
326
378
  };
327
- const id = request.params.id;
328
- if (typeof id !== "string" || id.length === 0) return {
329
- status: 404,
330
- body: { error: "not found" }
331
- };
332
- const users = container.get("users");
333
- if (users && !await users.findById(id)) return {
334
- status: 404,
335
- body: { error: "not found" }
336
- };
337
- return { credentials: (await svc.listUserCredentials(id)).map(toCredentialView) };
379
+ const account = await resolveSubject(container, request.params.id);
380
+ if (!("identifier" in account)) return account;
381
+ return { credentials: (await svc.listUserCredentials(account.identifier)).map(toCredentialView) };
338
382
  }
339
383
  },
340
384
  {
@@ -348,13 +392,14 @@ function createSecurityAdminApi(container) {
348
392
  status: 503,
349
393
  body: { error: "webauthn unavailable" }
350
394
  };
351
- const id = request.params.id;
352
395
  const credentialId = request.params.credentialId;
353
- if (typeof id !== "string" || id.length === 0 || typeof credentialId !== "string" || credentialId.length === 0) return {
396
+ if (typeof credentialId !== "string" || credentialId.length === 0) return {
354
397
  status: 404,
355
398
  body: { error: "not found" }
356
399
  };
357
- if (!await svc.removeUserCredential(id, credentialId)) return {
400
+ const account = await resolveSubject(container, request.params.id);
401
+ if (!("identifier" in account)) return account;
402
+ if (!await svc.removeUserCredential(account.identifier, credentialId)) return {
358
403
  status: 404,
359
404
  body: { error: "not found" }
360
405
  };
@@ -363,7 +408,7 @@ function createSecurityAdminApi(container) {
363
408
  action: "user.passkey_revoked",
364
409
  outcome: "success",
365
410
  actor: adminActor(request.user),
366
- resource: id,
411
+ resource: account.id,
367
412
  metadata: {
368
413
  credentialId,
369
414
  viaAdmin: true
@@ -381,7 +426,8 @@ function createSecurityAdminApi(container) {
381
426
  const svc = container.get("totp");
382
427
  const pageQuery = parsePageQuery(request.query, {
383
428
  defaultLimit: KEYS_DEFAULT_LIMIT,
384
- maxLimit: KEYS_MAX_LIMIT
429
+ maxLimit: KEYS_MAX_LIMIT,
430
+ searchable: true
385
431
  });
386
432
  const limit = pageQuery.limit;
387
433
  const offset = pageQuery.offset ?? 0;
@@ -393,7 +439,7 @@ function createSecurityAdminApi(container) {
393
439
  offset
394
440
  };
395
441
  const confirmed = one(request.query, "confirmed");
396
- const q = one(request.query, "q");
442
+ const q = pageQuery.q;
397
443
  const page = await svc.listPage({
398
444
  limit,
399
445
  offset,
@@ -420,17 +466,9 @@ function createSecurityAdminApi(container) {
420
466
  status: 503,
421
467
  body: { error: "totp unavailable" }
422
468
  };
423
- const id = request.params.id;
424
- if (typeof id !== "string" || id.length === 0) return {
425
- status: 404,
426
- body: { error: "not found" }
427
- };
428
- const users = container.get("users");
429
- if (users && !await users.findById(id)) return {
430
- status: 404,
431
- body: { error: "not found" }
432
- };
433
- return svc.status(id);
469
+ const account = await resolveSubject(container, request.params.id);
470
+ if (!("identifier" in account)) return account;
471
+ return svc.status(account.identifier);
434
472
  }
435
473
  },
436
474
  {
@@ -444,23 +482,15 @@ function createSecurityAdminApi(container) {
444
482
  status: 503,
445
483
  body: { error: "totp unavailable" }
446
484
  };
447
- const id = request.params.id;
448
- if (typeof id !== "string" || id.length === 0) return {
449
- status: 404,
450
- body: { error: "not found" }
451
- };
452
- const users = container.get("users");
453
- if (users && !await users.findById(id)) return {
454
- status: 404,
455
- body: { error: "not found" }
456
- };
457
- await svc.disable(id);
485
+ const account = await resolveSubject(container, request.params.id);
486
+ if (!("identifier" in account)) return account;
487
+ await svc.disable(account.identifier);
458
488
  auditAdmin(container, {
459
489
  category: "auth",
460
490
  action: "user.totp_disabled",
461
491
  outcome: "success",
462
492
  actor: adminActor(request.user),
463
- resource: id,
493
+ resource: account.id,
464
494
  metadata: { viaAdmin: true }
465
495
  });
466
496
  return { ok: true };
@@ -16,7 +16,6 @@
16
16
  function adminActor(user) {
17
17
  if (user && typeof user === "object") {
18
18
  const u = user;
19
- if (typeof u.username === "string" && u.username) return u.username;
20
19
  if (typeof u.identifier === "string" && u.identifier) return u.identifier;
21
20
  }
22
21
  return "admin";
@@ -1,15 +1,22 @@
1
- import { USER_REVOKED_EVENT } from "@nodefony/user";
1
+ import { USER_CREATED_EVENT, USER_REVOKED_EVENT } from "@nodefony/user";
2
2
  //#region nodefony/src/admin/userRevocationCascade.ts
3
3
  /**
4
4
  * Cascade de révocation déclenchée par {@link USER_REVOKED_EVENT} : éjecte
5
5
  * **immédiatement** les artefacts d'accès du porteur — ses **sessions** (http,
6
6
  * `destroyByUser`) et ses **jetons/PAT** (`tokenStore.revokeAllForSubject`,
7
- * seuil `invalidBefore`). Best-effort par brique (une indispo n'empêche pas
8
- * l'autre) — l'accès était DÉJÀ neutralisé par le re-fetch des authenticators,
9
- * cette cascade est de la **propreté + défense en profondeur**, jamais l'unique
10
- * rempart. `tenantId` du payload est réservé (scoping non câblé en mono-tenant).
7
+ * seuil `invalidBefore`, qui couvre aussi les clés d'API). Best-effort par
8
+ * brique (une indispo n'empêche pas l'autre) — l'accès était DÉJÀ neutralisé par
9
+ * le re-fetch des authenticators. `tenantId` du payload est réservé (scoping non
10
+ * câblé en mono-tenant).
11
11
  *
12
- * @param container - container du kernel (résolution lazy de `sessions`/`tokenStore`).
12
+ * **Suppression du compte** (`reason: "deleted"`) : retire AUSSI ses facteurs —
13
+ * secret TOTP (codes de récupération compris) et passkeys. Ils sont rangés sous
14
+ * l'`identifier`, qu'un compte recréé peut reprendre : laissés en place, le
15
+ * nouveau titulaire hériterait du second facteur de l'ancien. Un compte
16
+ * seulement désactivé ou verrouillé les GARDE — il les retrouve à sa
17
+ * réactivation.
18
+ *
19
+ * @param container - container du kernel (résolution lazy de `sessions`, `tokenStore`, `totp`, `webauthn`).
13
20
  * @param event - charge utile de l'événement (porteur + raison).
14
21
  * @param now - horloge (epoch ms) injectable pour les tests.
15
22
  */
@@ -21,20 +28,77 @@ async function cascadeUserRevocation(container, event, now = Date.now()) {
21
28
  try {
22
29
  await container.get("tokenStore")?.revokeAllForSubject(identifier, now);
23
30
  } catch {}
31
+ if (event.reason === "deleted") await removeAccountFactors(container, identifier);
24
32
  }
25
33
  /**
26
- * Abonne la cascade au bus kernel. À appeler au `onKernelBoot` d'un module
27
- * bootable (ici `@nodefony/security`). **Extensible** : tout autre module
28
- * (webhooks…) peut s'abonner au MÊME `USER_REVOKED_EVENT` pour ses propres
29
- * artefacts, sans toucher à ce fichier.
34
+ * Retire les facteurs rangés sous un identifiant — secret TOTP (codes de
35
+ * récupération compris) et passkeys. SEULE implémentation du geste, appelée à
36
+ * la suppression d'un compte ET à la création d'un compte sous cet identifiant.
37
+ * Best-effort par brique : un TOTP indisponible n'empêche pas le retrait des
38
+ * passkeys.
39
+ *
40
+ * @param container - container du kernel (services `totp`, `webauthn`).
41
+ * @param identifier - `IUser.identifier` du compte.
42
+ */
43
+ async function removeAccountFactors(container, identifier) {
44
+ try {
45
+ const totp = container.get("totp");
46
+ if (totp?.isEnabled?.() !== false) await totp?.disable?.(identifier);
47
+ } catch {}
48
+ try {
49
+ const webauthn = container.get("webauthn");
50
+ if (webauthn?.isEnabled?.() !== false) {
51
+ const creds = await webauthn?.listUserCredentials?.(identifier) ?? [];
52
+ for (const cred of creds) await webauthn?.removeUserCredential?.(identifier, cred.id);
53
+ }
54
+ } catch {}
55
+ }
56
+ /** Garde : charge utile de {@link USER_CREATED_EVENT} (identifiant non vide). */
57
+ function isUserCreatedEvent(value) {
58
+ return typeof value === "object" && value !== null && "identifier" in value && typeof value.identifier === "string" && value.identifier.length > 0;
59
+ }
60
+ /**
61
+ * Abonne la cascade au bus kernel, UNE fois, au `onKernelBoot` de
62
+ * `@nodefony/security` — avant qu'aucun compte ne puisse être créé ou révoqué
63
+ * (les comptes semés au démarrage compris). Deux abonnements :
64
+ * - {@link USER_REVOKED_EVENT} → {@link cascadeUserRevocation} ;
65
+ * - {@link USER_CREATED_EVENT} → purge des facteurs HÉRITÉS : un compte qui
66
+ * vient de naître n'a encore rien enrôlé, donc tout facteur déjà rangé sous
67
+ * son identifiant appartient à un ancien titulaire. Règle exacte, aucune
68
+ * date à comparer. ⚠️ Un import qui chargerait les facteurs AVANT les
69
+ * comptes les verrait retirés : importer les comptes d'abord.
70
+ *
71
+ * Les deux sont émis par `UserService` seul, et attendus : chaque handler rend
72
+ * sa promesse. **Extensible** : tout autre module (webhooks…) s'abonne aux
73
+ * MÊMES événements pour ses propres artefacts, sans toucher à ce fichier.
30
74
  *
31
75
  * @param kernel - bus d'événements (kernel) exposant `on`.
32
- * @param container - container capturé par le handler.
76
+ * @param container - container capturé par les handlers.
33
77
  */
34
78
  function registerUserRevocationCascade(kernel, container) {
35
- kernel.on(USER_REVOKED_EVENT, (event) => {
36
- cascadeUserRevocation(container, event);
37
- });
79
+ kernel.on(USER_REVOKED_EVENT, (event) => cascadeUserRevocation(container, event));
80
+ kernel.on(USER_CREATED_EVENT, (event) => isUserCreatedEvent(event) ? removeAccountFactors(container, event.identifier) : void 0);
81
+ }
82
+ /**
83
+ * Avis de démarrage quand le service `users` n'émet pas sur le bus du kernel —
84
+ * construit sans container (`new UserService(repo, encoder)`). Ses événements
85
+ * partent alors sur son propre bus, où personne n'écoute : supprimer un compte
86
+ * laisserait ses sessions, ses jetons et ses facteurs en place, SANS UN MOT.
87
+ *
88
+ * @param container - container du kernel.
89
+ * @param kernel - le kernel attendu comme bus des événements.
90
+ * @returns l'avis à déclarer, ou `null` si `users` est absent ou bien câblé.
91
+ */
92
+ function usersEventBusNotice(container, kernel) {
93
+ const users = container.get("users");
94
+ if (typeof users !== "object" || users === null) return null;
95
+ if (!("kernel" in users) || users.kernel === kernel) return null;
96
+ return {
97
+ code: "USERS_SERVICE_WITHOUT_KERNEL",
98
+ level: "warning",
99
+ message: "le service « users » n'atteint pas le kernel : supprimer, désactiver un compte ou changer son mot de passe ne révoquera ni ses sessions, ni ses jetons, ni ses facteurs.",
100
+ fix: "new UserService(repository, encoder, module.container)"
101
+ };
38
102
  }
39
103
  //#endregion
40
- export { cascadeUserRevocation, registerUserRevocationCascade };
104
+ export { cascadeUserRevocation, registerUserRevocationCascade, removeAccountFactors, usersEventBusNotice };
@@ -1,3 +1,4 @@
1
+ import { mapProviderRoles } from "../oauth/providerRoles.js";
1
2
  import { AuthenticationError } from "../../errors/AuthenticationError.js";
2
3
  import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
3
4
  import { UserToken } from "../token/UserToken.js";
@@ -16,6 +17,11 @@ const INVALID_TOKEN = "Invalid token";
16
17
  /** Clé de transport de l'audience entre `createToken` et `authenticate`. */
17
18
  const AUDIENCE = "audience";
18
19
  /**
20
+ * Clé de transport de la zone qui SURCHARGE la politique de rattachement.
21
+ * Posée seulement par ces zones-là : les autres ne paient rien de plus.
22
+ */
23
+ const SUBJECT_AREA = "subjectArea";
24
+ /**
19
25
  * Authentification par **jeton d'accès émis par un serveur d'autorisation
20
26
  * TIERS** (Keycloak, Auth0, Entra, ou l'émetteur d'une flotte d'agents).
21
27
  *
@@ -58,6 +64,10 @@ var ExternalJwtAuthenticator = class {
58
64
  #issuers;
59
65
  #policy;
60
66
  #ephemeralRoles;
67
+ /** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
68
+ #linkedProviders;
69
+ /** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
70
+ #roleMappings;
61
71
  #userProvider = null;
62
72
  /**
63
73
  * @param container - container DI (résolution lazy du vérificateur et de `users`)
@@ -74,6 +84,8 @@ var ExternalJwtAuthenticator = class {
74
84
  this.#issuers = issuers;
75
85
  this.#policy = options.subjectPolicy;
76
86
  this.#ephemeralRoles = options.ephemeralRoles;
87
+ this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
88
+ this.#roleMappings = collectRoleMappings(options.oauthProviders);
77
89
  }
78
90
  /**
79
91
  * La requête porte-t-elle un jeton se réclamant d'un émetteur de confiance ?
@@ -108,6 +120,7 @@ var ExternalJwtAuthenticator = class {
108
120
  const token = new UserToken("external-jwt", bearerToken(auth) ?? "");
109
121
  const area = context.security;
110
122
  if (area?.resource) token.setAttribute(AUDIENCE, area.resource);
123
+ if (area?.subjectPolicy !== void 0) token.setAttribute(SUBJECT_AREA, area);
111
124
  return Promise.resolve(token);
112
125
  }
113
126
  /**
@@ -136,7 +149,7 @@ var ExternalJwtAuthenticator = class {
136
149
  if (!subject) throw new AuthenticationError(INVALID_TOKEN);
137
150
  const mapping = this.#issuers.get(principal.issuer);
138
151
  if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${principal.issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
139
- const user = await this.#resolveUser(principal.issuer, subject, mapping);
152
+ const user = await this.#resolveUser(principal.issuer, subject, mapping, principal.claims, token.getAttribute(SUBJECT_AREA));
140
153
  const ut = token;
141
154
  ut.promote(user);
142
155
  ut.setAttribute("scopes", [...principal.scopes]);
@@ -194,16 +207,21 @@ var ExternalJwtAuthenticator = class {
194
207
  * nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
195
208
  * que l'annuaire a authentifiée ? ».
196
209
  */
197
- async #resolveUser(issuer, subject, mapping) {
210
+ async #resolveUser(issuer, subject, mapping, claims, area) {
198
211
  const identifier = localIdentifierFor(issuer, subject, mapping);
199
- if (this.#policy === "ephemeral") return new BaseUser({
212
+ if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
200
213
  id: identifier,
201
214
  identifier,
202
- roles: [...this.#ephemeralRoles]
215
+ roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
203
216
  });
204
217
  const provider = this.#resolveUserProvider();
205
- let user;
206
- try {
218
+ let user = null;
219
+ const linked = this.#linkedProviders?.get(issuer);
220
+ if (linked !== void 0) try {
221
+ const roleMapping = this.#roleMappings?.get(linked);
222
+ user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
223
+ } catch {}
224
+ if (user === null) try {
207
225
  user = await provider.loadUserByIdentifier(identifier);
208
226
  } catch {
209
227
  throw new AuthenticationError(INVALID_TOKEN);
@@ -220,5 +238,47 @@ var ExternalJwtAuthenticator = class {
220
238
  return this.#userProvider;
221
239
  }
222
240
  };
241
+ /** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
242
+ function canSyncRoles(provider) {
243
+ return typeof provider.syncOAuthRoles === "function";
244
+ }
245
+ /** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
246
+ function collectRoleMappings(providers) {
247
+ if (!providers) return null;
248
+ let mappings = null;
249
+ for (const { name, roleMapping } of providers) {
250
+ if (roleMapping == null) continue;
251
+ mappings ??= /* @__PURE__ */ new Map();
252
+ mappings.set(name, roleMapping);
253
+ }
254
+ return mappings;
255
+ }
256
+ /**
257
+ * Associe chaque émetteur de confiance au fournisseur de connexion qui partage
258
+ * son émetteur canonique.
259
+ *
260
+ * Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
261
+ * des deux ferait dépendre l'identité de l'ordre de la configuration.
262
+ *
263
+ * @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
264
+ */
265
+ function linkProviders(trusted, providers) {
266
+ if (!providers || providers.length === 0) return null;
267
+ const links = /* @__PURE__ */ new Map();
268
+ const ambiguous = /* @__PURE__ */ new Set();
269
+ for (const { name, issuer } of providers) {
270
+ let canonical;
271
+ try {
272
+ canonical = canonicalIssuer(issuer);
273
+ } catch {
274
+ continue;
275
+ }
276
+ if (!trusted.has(canonical)) continue;
277
+ if (links.has(canonical)) ambiguous.add(canonical);
278
+ links.set(canonical, name);
279
+ }
280
+ for (const issuer of ambiguous) links.delete(issuer);
281
+ return links.size > 0 ? links : null;
282
+ }
223
283
  //#endregion
224
284
  export { ExternalJwtAuthenticator, ExternalJwtAuthenticator as default };