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

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 (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,231 @@
1
+ import { buildKeycloakRealm, renderKeycloakRealm } from "./keycloakRealm.js";
2
+ import { fileURLToPath } from "node:url";
3
+ import path from "node:path";
4
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
6
+ // nodefony/keycloak/scaffold.ts
7
+ /**
8
+ * Le décor Keycloak de développement que `@nodefony/security` livre à une
9
+ * application : realm d'import et thème aux couleurs du framework.
10
+ *
11
+ * Appelé par le point de contribution du paquet (`scaffold/contribute.ts`,
12
+ * déclaré dans son `package.json` sous `nodefony.contribute`), que le cœur
13
+ * joue sans en rien connaître : `create app` après l'installation, puis
14
+ * `nodefony scaffold:sync` à la demande. Un fichier déjà présent n'est jamais
15
+ * remplacé — il appartient à l'application, qui a pu le retoucher.
16
+ *
17
+ * @module
18
+ */
19
+ /** Le nom du paquet — sert à retrouver sa racine, en source comme en `dist`. */
20
+ const PACKAGE_NAME = "@nodefony/security";
21
+ /** Le nom du thème, tel que le realm le déclare et que Keycloak le monte. */
22
+ const KEYCLOAK_THEME_NAME = "nodefony";
23
+ /** Où l'application reçoit le décor Keycloak (chemins qui voyagent : `/`). */
24
+ const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.json";
25
+ const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
26
+ const APP_KEYCLOAK_THEME_DIR = `${APP_KEYCLOAK_THEMES_DIR}/${KEYCLOAK_THEME_NAME}`;
27
+ /** Fichier de configuration de sécurité d'une application générée. */
28
+ const APP_SECURITY_CONFIG_FILE = "nodefony/config/security.ts";
29
+ /**
30
+ * Ressource de l'API partenaire d'une application neuve en développement :
31
+ * ce que rend `partnerResource()` du gabarit `complete` sans `NF_JWT_ISSUER`
32
+ * ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
33
+ * realm. Deux paquets, une valeur : le test de contribution compare le
34
+ * gabarit rendu à cette constante.
35
+ */
36
+ const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/api/partner";
37
+ /** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
38
+ const THEME_TYPES = [
39
+ "login",
40
+ "account",
41
+ "email",
42
+ "admin"
43
+ ];
44
+ /**
45
+ * La racine du paquet, trouvée en remontant depuis ce fichier : il vit à une
46
+ * profondeur différente en source (`nodefony/scaffold/`) et une fois bâti
47
+ * (`dist/nodefony/scaffold/`).
48
+ *
49
+ * @returns le dossier qui porte le `package.json` du paquet
50
+ * @throws Si aucun ancêtre n'est le paquet (installation corrompue)
51
+ */
52
+ function packageRoot() {
53
+ let dir = path.dirname(fileURLToPath(import.meta.url));
54
+ for (;;) {
55
+ const manifest = path.join(dir, "package.json");
56
+ if (existsSync(manifest)) {
57
+ const parsed = JSON.parse(readFileSync(manifest, "utf8"));
58
+ if (typeof parsed === "object" && parsed !== null && "name" in parsed && parsed.name === PACKAGE_NAME) return dir;
59
+ }
60
+ const parent = path.dirname(dir);
61
+ if (parent === dir) throw new Error(`racine du paquet ${PACKAGE_NAME} introuvable`);
62
+ dir = parent;
63
+ }
64
+ }
65
+ /**
66
+ * Le dossier du thème Keycloak livré par le paquet.
67
+ *
68
+ * @returns chemin natif absolu (`<paquet>/keycloak/themes/nodefony`)
69
+ */
70
+ function keycloakThemeSource() {
71
+ return path.join(keycloakThemesSource(), KEYCLOAK_THEME_NAME);
72
+ }
73
+ /**
74
+ * Le dossier de TOUS les thèmes Keycloak livrés : `nodefony` et ses thèmes
75
+ * enfants `nodefony-<habillage>`, un par habillage de la page `/login`.
76
+ * Keycloak les monte côte à côte (`/opt/keycloak/themes/`).
77
+ *
78
+ * @returns chemin natif absolu (`<paquet>/keycloak/themes`)
79
+ */
80
+ function keycloakThemesSource() {
81
+ return path.join(packageRoot(), "keycloak", "themes");
82
+ }
83
+ /**
84
+ * Les types que le thème livré couvre — un dossier par type. Le realm ne
85
+ * déclare QUE ceux-là : déclarer un type absent ferait retomber Keycloak sur
86
+ * son thème par défaut, avec un avertissement à chaque page.
87
+ *
88
+ * @param themeDir - dossier du thème
89
+ * @returns les types présents, dans l'ordre de {@link THEME_TYPES}
90
+ */
91
+ function deliveredThemeTypes(themeDir) {
92
+ const present = new Set(readdirSync(themeDir));
93
+ return THEME_TYPES.filter((type) => present.has(type));
94
+ }
95
+ /**
96
+ * Le secret du client navigateur dans le décor de développement d'une app.
97
+ *
98
+ * ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.clientSecret` dans
99
+ * `engine.ts`) : la frontière de paquets empêche de l'appeler, un test de ce
100
+ * paquet compare donc les deux sorties.
101
+ *
102
+ * @param appName - nom de l'application
103
+ * @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
104
+ */
105
+ function devClientSecret(appName) {
106
+ return `${appName}-dev-keycloak-secret`;
107
+ }
108
+ /**
109
+ * Le secret de l'appelant machine (`<app>-machine`) dans le décor de
110
+ * développement d'une app.
111
+ *
112
+ * ⚠️ Même règle que le gabarit `.env` du cœur (`keycloak.machineSecret` dans
113
+ * `engine.ts`), comparée par le même test que {@link devClientSecret}.
114
+ *
115
+ * @param appName - nom de l'application
116
+ * @returns le secret, PUBLIC par nature (écrit dans le realm versionné)
117
+ */
118
+ function devMachineSecret(appName) {
119
+ return `${appName}-machine-dev-secret`;
120
+ }
121
+ /**
122
+ * Ce que dit la configuration rendue par le gabarit `security.ts` d'une app
123
+ * neuve : client `<app>`, URL de retour sur les deux ports de développement,
124
+ * rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
125
+ * audiences des zones `external-jwt` que la configuration rendue déclare.
126
+ *
127
+ * @param appName - nom de l'application (= realm = identifiant de client)
128
+ * @param themes - types de thème à déclarer
129
+ * @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
130
+ * laissés à Keycloak, ils changent à chaque réimport et la connexion
131
+ * suivante est refusée (identifiant déjà lié à un autre `sub`)
132
+ * @param audiences - ressources que les jetons du realm doivent porter
133
+ * (mapper sur les deux clients) ; vide = aucune zone ne les accepte
134
+ * @returns l'entrée du constructeur de realm
135
+ */
136
+ function appDevRealmInput(appName, themes, ids, audiences = []) {
137
+ const callback = "/nodefony/security/api/oauth2/keycloak/callback";
138
+ return {
139
+ realm: appName,
140
+ clientId: appName,
141
+ clientSecret: devClientSecret(appName),
142
+ clientName: `${appName} (dev)`,
143
+ clientDescription: "Client confidentiel de l'application en développement — secret PUBLIC, jamais en production.",
144
+ redirectUris: [`https://localhost:5152${callback}`, `http://localhost:5151${callback}`],
145
+ postLogoutRedirectUris: ["https://localhost:5152/*", "http://localhost:5151/*"],
146
+ backchannelLogoutUrl: "http://host.docker.internal:5151/nodefony/security/api/oauth2/keycloak/backchannel-logout",
147
+ audiences,
148
+ clientRoles: [{
149
+ name: "admin",
150
+ description: "Administrateur de l'application — traduit en ROLE_ADMIN par la table roleMapping du fournisseur keycloak."
151
+ }],
152
+ realmRoles: [],
153
+ machine: {
154
+ clientId: `${appName}-machine`,
155
+ secret: devMachineSecret(appName),
156
+ name: `${appName} — appelant machine (compte de service)`,
157
+ description: "Appelant machine : obtient un jeton d'accès par client_credentials, sans utilisateur. Secret PUBLIC, jamais en production."
158
+ },
159
+ themes: Object.fromEntries(themes.map((type) => [type, KEYCLOAK_THEME_NAME])),
160
+ users: [{
161
+ id: ids.user,
162
+ username: "alice",
163
+ email: `alice@${appName}.test`,
164
+ firstName: "Alice",
165
+ lastName: "Keycloak",
166
+ password: "alice-dev"
167
+ }, {
168
+ id: ids.admin,
169
+ username: "bob",
170
+ email: `bob@${appName}.test`,
171
+ firstName: "Bob",
172
+ lastName: "Keycloak",
173
+ password: "bob-dev",
174
+ clientRoles: ["admin"]
175
+ }]
176
+ };
177
+ }
178
+ /**
179
+ * L'application monte-t-elle un décor Keycloak ? Seul son compose le dit : on
180
+ * ne pose que ce que quelqu'un lit — une application sans profil `keycloak`
181
+ * (preset minimal, sécurité ajoutée plus tard) ne reçoit rien.
182
+ *
183
+ * @param context - contexte de contribution
184
+ * @returns `true` si le compose monte `./docker/keycloak/…`
185
+ */
186
+ function mountsKeycloak(context) {
187
+ return [
188
+ "compose.yaml",
189
+ "compose.yml",
190
+ "docker-compose.yml"
191
+ ].some((file) => context.read(file)?.includes("./docker/keycloak/") === true);
192
+ }
193
+ /**
194
+ * Les audiences que la configuration RENDUE de l'application déclare. Le
195
+ * gabarit `complete` ouvre la zone `partner` (`jwt` + `external-jwt`) : sans
196
+ * son audience dans le realm, le jeton du client machine y est refusé dès le
197
+ * premier essai, et `security:keycloak:realm --check` trouve le realm né en
198
+ * désaccord avec la configuration.
199
+ *
200
+ * @param context - contexte de contribution
201
+ * @returns les ressources à inscrire dans les jetons du realm
202
+ */
203
+ function renderedAudiences(context) {
204
+ return context.read(APP_SECURITY_CONFIG_FILE)?.includes("partnerResource(") === true ? [APP_PARTNER_DEV_RESOURCE] : [];
205
+ }
206
+ /**
207
+ * Recopie les thèmes livrés dans l'application — `nodefony` et ses thèmes
208
+ * d'habillage —, fichier par fichier, sans jamais remplacer un fichier présent.
209
+ *
210
+ * @param context - contexte de contribution
211
+ */
212
+ function copyKeycloakTheme(context) {
213
+ context.copyTree(keycloakThemesSource(), APP_KEYCLOAK_THEMES_DIR);
214
+ }
215
+ /**
216
+ * Pose le realm d'import et le thème Keycloak, si l'application monte un
217
+ * décor Keycloak.
218
+ *
219
+ * @param context - contexte fourni par le cœur (`runAppContributions`)
220
+ */
221
+ function contributeKeycloak(context) {
222
+ if (!mountsKeycloak(context)) return;
223
+ const themes = deliveredThemeTypes(keycloakThemeSource());
224
+ const realm = buildKeycloakRealm(appDevRealmInput(context.appName, themes, {
225
+ user: randomUUID(),
226
+ admin: randomUUID()
227
+ }, renderedAudiences(context)));
228
+ context.write(APP_KEYCLOAK_REALM_FILE, renderKeycloakRealm(realm));
229
+ copyKeycloakTheme(context);
230
+ }
231
+ export { APP_KEYCLOAK_REALM_FILE, APP_KEYCLOAK_THEMES_DIR, APP_KEYCLOAK_THEME_DIR, APP_PARTNER_DEV_RESOURCE, KEYCLOAK_THEME_NAME, appDevRealmInput, contributeKeycloak, copyKeycloakTheme, deliveredThemeTypes, devClientSecret, devMachineSecret, keycloakThemeSource, keycloakThemesSource };
@@ -0,0 +1,130 @@
1
+ import { KeycloakRealmError } from "./keycloakRealmInput.js";
2
+ import { KeycloakAdminError } from "./keycloakAdmin.js";
3
+ import { Command } from "nodefony";
4
+ import path from "node:path";
5
+ // nodefony/keycloak/security-keycloak-realm.ts
6
+ const options = {
7
+ helpGroup: "COMPTES ET SECRETS",
8
+ showBanner: false,
9
+ kernelEvent: "onReady",
10
+ quietBoot: true
11
+ };
12
+ const GREEN = "\x1B[32m";
13
+ const YELLOW = "\x1B[33m";
14
+ const DIM = "\x1B[2m";
15
+ const RESET = "\x1B[0m";
16
+ /** Codes de sortie (sysexits). */
17
+ const EXIT_DIFFERS = 1;
18
+ const EXIT_CONFIG = 78;
19
+ /**
20
+ * `nodefony security:keycloak:realm` — le realm Keycloak que la configuration
21
+ * de l'application implique.
22
+ *
23
+ * Le realm d'import répète ce que la config dit déjà : identifiant du client,
24
+ * URL de retour, audience de chaque zone, rôles de `roleMapping`. Écrit à la
25
+ * main, il diverge — et le symptôme est un 401 ou une URL de retour refusée,
26
+ * jamais un message qui nomme la cause. Cette commande le DÉRIVE, par le
27
+ * service `keycloak` — qui porte toutes les actions Keycloak ; elle n'en
28
+ * garde que l'affichage et les codes de sortie.
29
+ *
30
+ * Elle FUSIONNE dans le fichier existant au lieu de l'écraser : comptes de
31
+ * démonstration, titres, secret machine, mappers posés dans la console
32
+ * survivent (`mergeKeycloakRealm`, cœur — le même constructeur que
33
+ * `create app`).
34
+ *
35
+ * - sans option : affiche le realm fusionné ;
36
+ * - `--write` : l'écrit (le fichier par défaut est l'unique `.json` de
37
+ * `docker/keycloak/import/`) ;
38
+ * - `--check` : sort en 1 si le fichier n'est plus à jour — la garde de CI ;
39
+ * - `--apply` : pose sur le realm EXISTANT le thème de connexion qui suit
40
+ * `loginPage.skin`, par l'API d'administration (service `keycloak`) —
41
+ * développement seulement, compte `oauth2.providers.<nom>.admin` requis.
42
+ *
43
+ * ⚠️ Keycloak n'importe un realm qu'à sa CRÉATION : un realm déjà présent
44
+ * n'est pas relu. Après `--write`, le porter par un import PARTIEL (clients en
45
+ * « Overwrite » : comptes et seconds facteurs survivent) ; retirer le volume
46
+ * ne se fait que sur un realm qui ne porte rien à garder.
47
+ */
48
+ var SecurityKeycloakRealm = class extends Command {
49
+ constructor(cli) {
50
+ super("security:keycloak:realm", "écrit le realm Keycloak déduit de la configuration", cli, {
51
+ ...options,
52
+ examples: ["nodefony security:keycloak:realm --check", "nodefony security:keycloak:realm --write"]
53
+ });
54
+ this.addOption("-w, --write [fichier]", "écrit le realm fusionné dans le fichier d'import");
55
+ this.addOption("-c, --check [fichier]", "sort en 1 si le fichier d'import n'est plus à jour");
56
+ this.addOption("-a, --apply", "pose le thème de connexion sur le realm existant (développement)");
57
+ this.addOption("-p, --provider <nom>", "fournisseur de oauth2.providers (défaut keycloak)");
58
+ this.addOption("-m, --machine <clientId>", "client machine dont l'audience suit la config");
59
+ this.addOption("--backchannel-origin <url>", "origine par laquelle Keycloak joint l'application (canal arrière)");
60
+ }
61
+ /** Le service `keycloak`, ou `null` (code de sortie posé). */
62
+ #service() {
63
+ const service = this.kernel?.container?.get("keycloak");
64
+ if (service) return service;
65
+ this.log("service keycloak absent — module security non chargé ?", "ERROR");
66
+ process.exitCode = EXIT_CONFIG;
67
+ return null;
68
+ }
69
+ async generate(opts) {
70
+ const service = this.#service();
71
+ if (service === null) return this;
72
+ if (opts.apply === true) {
73
+ if (!await this.#apply(service, opts.provider) || opts.write === void 0 && opts.check === void 0) return this;
74
+ }
75
+ let plan;
76
+ try {
77
+ plan = service.realmPlan({
78
+ provider: opts.provider,
79
+ machine: opts.machine,
80
+ backchannelOrigin: opts.backchannelOrigin,
81
+ file: typeof opts.write === "string" ? opts.write : typeof opts.check === "string" ? opts.check : void 0
82
+ });
83
+ } catch (error) {
84
+ if (!(error instanceof KeycloakRealmError)) throw error;
85
+ this.log(error.message, "ERROR");
86
+ process.exitCode = EXIT_CONFIG;
87
+ return this;
88
+ }
89
+ for (const warning of plan.warnings) process.stderr.write(`${YELLOW}⚠ ${warning}${RESET}\n`);
90
+ const relative = path.relative(this.kernel?.path ?? process.cwd(), plan.file);
91
+ if (opts.check !== void 0) {
92
+ if (plan.upToDate) {
93
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} est à jour\n`);
94
+ return this;
95
+ }
96
+ process.stderr.write(`${relative} ne correspond plus à la configuration — nodefony security:keycloak:realm --write\n`);
97
+ process.exitCode = EXIT_DIFFERS;
98
+ return this;
99
+ }
100
+ if (opts.write !== void 0) {
101
+ if (!service.writeRealm(plan)) {
102
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} déjà à jour, rien d'écrit\n`);
103
+ return this;
104
+ }
105
+ process.stdout.write(`${GREEN}✓${RESET} ${relative} écrit\n${DIM} Keycloak n'importe un realm qu'à sa CRÉATION — un realm déjà là ne relit pas ce fichier :\n · thème de connexion : nodefony security:keycloak:realm --apply ;\n · le reste : console › Realm settings › Action › Partial import, clients en « Overwrite » (comptes et seconds facteurs gardés) ;\n · ou recréer le realm (retirer le volume Keycloak) s'il ne porte rien à garder.${RESET}\n`);
106
+ return this;
107
+ }
108
+ process.stdout.write(plan.text);
109
+ return this;
110
+ }
111
+ /**
112
+ * `--apply` : le thème que l'habillage demande, posé sur le realm vivant.
113
+ *
114
+ * @returns `false` si le geste a échoué (code de sortie posé)
115
+ */
116
+ async #apply(service, provider) {
117
+ try {
118
+ const result = await service.applyLoginTheme(provider === void 0 ? {} : { provider });
119
+ const line = result.outcome === "applied" ? `${GREEN}✓${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "—"} » → « ${result.theme} »` : result.outcome === "unchanged" ? `${GREEN}✓${RESET} realm « ${result.realm} » : déjà sur « ${result.theme} »` : `${YELLOW}⚠${RESET} realm « ${result.realm} » : thème « ${result.previous ?? "aucun"} » propre à l'application, laissé tel quel`;
120
+ process.stdout.write(`${line}\n`);
121
+ return true;
122
+ } catch (error) {
123
+ if (!(error instanceof KeycloakAdminError)) throw error;
124
+ this.log(error.message, "ERROR");
125
+ process.exitCode = EXIT_CONFIG;
126
+ return false;
127
+ }
128
+ }
129
+ };
130
+ export { SecurityKeycloakRealm as default };
@@ -0,0 +1,19 @@
1
+ import { contributeKeycloak } from "../keycloak/scaffold.js";
2
+ // nodefony/scaffold/contribute.ts
3
+ /**
4
+ * Le point de contribution de `@nodefony/security` : ce que le paquet livre à
5
+ * une application, déclaré dans son `package.json` (`nodefony.contribute`) et
6
+ * joué par le cœur, qui n'en connaît rien.
7
+ *
8
+ * @module
9
+ */
10
+ /**
11
+ * La contribution du paquet — aujourd'hui le seul décor Keycloak de
12
+ * développement (`nodefony/keycloak/`).
13
+ *
14
+ * @param context - contexte fourni par le cœur (`runAppContributions`)
15
+ */
16
+ function contribute(context) {
17
+ contributeKeycloak(context);
18
+ }
19
+ export { contribute };
@@ -1,25 +1,52 @@
1
+ import { AuthenticationError } from "../errors/AuthenticationError.js";
1
2
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
3
+ import { UserToken } from "../src/token/UserToken.js";
4
+ import peekIssuer from "../src/authenticator/peekIssuer.js";
5
+ import { JwtAuthenticator } from "../src/authenticator/JwtAuthenticator.js";
6
+ import { ExternalSubjectResolver, externalSubjectOptions } from "../src/authenticator/ExternalSubjectResolver.js";
7
+ import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
2
8
  import { RemoteJwtVerifier } from "../src/token/RemoteJwtVerifier.js";
3
- import { ACCESS_TOKEN_VERIFIER, Service, canonicalIssuer } from "nodefony";
4
- //#region nodefony/service/accessTokenVerifier.ts
9
+ import { ACCESS_ACCOUNT_RESOLVER, ACCESS_TOKEN_VERIFIER, Service } from "nodefony";
10
+ // nodefony/service/accessTokenVerifier.ts
5
11
  const serviceName = ACCESS_TOKEN_VERIFIER;
6
12
  /**
7
- * Pose (ou non) le vérificateur de jetons d'accès TIERS dans le conteneur.
13
+ * Pose (ou non) le vérificateur de jetons d'accès dans le conteneur — celui
14
+ * qu'une ressource protégée (porte MCP, zone `external-jwt`) interroge.
8
15
  *
9
- * Ce service est une **décision de câblage**, pas de la cryptographie : toute la
10
- * mécanique vit dans {@link RemoteJwtVerifier}, et la doctrine de refus dans le
11
- * cœur (`nodefony/src/oauth/`). Ici, on lit la configuration et on tranche une
12
- * seule question — cette application accepte-t-elle des jetons émis ailleurs ?
16
+ * Ce service est une **décision de câblage**, pas de la cryptographie : la
17
+ * vérification d'un jeton TIERS vit dans {@link RemoteJwtVerifier}, celle d'un
18
+ * jeton de CETTE application dans {@link JwtAuthenticator}, et la doctrine de
19
+ * refus dans le cœur (`nodefony/src/oauth/`). Ici, on tranche deux questions :
20
+ * l'application signe-t-elle ses jetons, et en accepte-t-elle d'ailleurs ?
13
21
  *
14
- * **Aucun émetteur déclaré = rien n'est posé**, et c'est le comportement voulu :
15
- * une porte protégée qui ne trouve pas de vérificateur refuse de servir en le
16
- * disant (503 + CRITIC), là où un vérificateur présent mais vide refuserait
22
+ * ⭐ **Un jeton signé par cette application est TOUJOURS vérifiable**, dès que
23
+ * sa capacité JWT est active (`security.jwt.enabled`, le défaut) — sans rien
24
+ * déclarer dans `resourceServer.issuers`. Une application qui émet un jeton
25
+ * pour sa propre porte (`nodefony security:mcp:token --resource …`) doit pouvoir
26
+ * le lire : sans cette règle, la porte refusait un jeton parfaitement valide,
27
+ * et l'application était un serveur d'autorisation dont aucune ressource ne
28
+ * reconnaissait la signature. Rien de nouveau ne devient valide : l'audience
29
+ * reste exigée, et seules celles de `jwt.audiences` sont émises.
30
+ *
31
+ * 🔴 **Il est vérifié par les règles de l'authentificateur `jwt`, pas par son
32
+ * jeu de clés seul** : révocation ciblée et en masse, compte encore actif. Lu
33
+ * comme un jeton tiers, un jeton RÉVOQUÉ — ou celui d'un compte désactivé —
34
+ * ouvrait encore la porte jusqu'à son expiration.
35
+ *
36
+ * L'aiguillage se fait sur l'émetteur revendiqué (`peekIssuer`), comme dans le
37
+ * pare-feu : l'émetteur de l'application va à la vérification locale, tout
38
+ * autre à la liste fermée des émetteurs tiers. Un émetteur inconnu est refusé
39
+ * sans la moindre requête sortante.
40
+ *
41
+ * **Rien à vérifier = rien n'est posé** (capacité JWT coupée, aucun émetteur
42
+ * tiers) : une porte protégée qui ne trouve pas de vérificateur refuse alors de
43
+ * servir en le disant (503 + CRITIC), là où un vérificateur vide refuserait
17
44
  * chaque jeton un par un — même résultat pour l'appelant, diagnostic beaucoup
18
45
  * plus difficile pour l'exploitant.
19
46
  *
20
- * Le coût est nul quand la capacité n'est pas utilisée : rien n'est instancié,
21
- * aucune requête n'est faite au démarrage. La découverte des clés n'a lieu qu'au
22
- * PREMIER jeton réellement présenté, et une seule fois par émetteur.
47
+ * Le coût est nul tant qu'aucun jeton n'est présenté : aucune requête au
48
+ * démarrage, clés locales lues au premier jeton, découverte d'un émetteur tiers
49
+ * au premier jeton qui s'en réclame.
23
50
  */
24
51
  var AccessTokenVerifierService = class extends Service {
25
52
  module;
@@ -29,7 +56,7 @@ var AccessTokenVerifierService = class extends Service {
29
56
  this.module = module;
30
57
  this.kernel?.once("onBoot", () => this.#build());
31
58
  }
32
- /** Le vérificateur, ou `null` si aucun émetteur n'est déclaré. */
59
+ /** Le vérificateur posé, ou `null` si rien n'est vérifiable. */
33
60
  get verifier() {
34
61
  return this.#verifier;
35
62
  }
@@ -40,23 +67,13 @@ var AccessTokenVerifierService = class extends Service {
40
67
  } catch {
41
68
  return;
42
69
  }
70
+ const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt) : null;
71
+ const local = runtime === null ? null : new JwtAuthenticator(this.container, runtime);
43
72
  const { issuers, ...tuning } = config.resourceServer;
44
- if (issuers.length === 0) {
45
- this.log("aucun émetteur de confiance déclaré (security.resourceServer.issuers) — les jetons émis par un serveur d'autorisation tiers ne sont pas vérifiables ; une porte protégée refusera de servir.", "DEBUG");
46
- return;
47
- }
48
- const localIssuer = config.jwt.issuer;
49
- const armed = issuers.map((trusted) => localIssuer && canonicalIssuer(trusted.issuer) === canonicalIssuer(localIssuer) ? {
50
- ...trusted,
51
- localJwks: async () => {
52
- const keystore = this.container?.get("jwtKeystore");
53
- if (!keystore) throw new Error(`l'émetteur « ${trusted.issuer} » est cette application, mais sa capacité JWT n'est pas armée (security.jwt) — aucune clé locale à présenter`);
54
- return keystore.getPublicJWKS();
55
- }
56
- } : trusted);
57
- try {
58
- this.#verifier = new RemoteJwtVerifier({
59
- issuers: armed,
73
+ let remote = null;
74
+ if (issuers.length > 0) try {
75
+ remote = new RemoteJwtVerifier({
76
+ issuers,
60
77
  timeoutMs: tuning.timeoutMs,
61
78
  cooldownMs: tuning.cooldownMs,
62
79
  cacheMaxAgeMs: tuning.cacheMaxAgeMs,
@@ -67,11 +84,61 @@ var AccessTokenVerifierService = class extends Service {
67
84
  this.log(`vérificateur de jetons non armé — ${error.message}`, "CRITIC");
68
85
  return;
69
86
  }
70
- const verify = (token, audience) => this.#verifier.verify(token, audience);
87
+ if (local === null && remote === null) {
88
+ this.log("rien à vérifier — capacité JWT coupée (security.jwt.enabled) et aucun émetteur tiers (security.resourceServer.issuers) : une porte protégée refusera de servir.", "DEBUG");
89
+ return;
90
+ }
91
+ const localIssuer = runtime?.issuer ?? null;
92
+ const verify = (token, audience) => {
93
+ if (local !== null && peekIssuer(token) === localIssuer) return local.verifyForResource(token, audience);
94
+ return remote === null ? Promise.resolve(null) : remote.verify(token, audience);
95
+ };
96
+ this.#verifier = verify;
71
97
  this.container?.set(serviceName, verify);
72
- const locaux = armed.filter((i) => "localJwks" in i).length;
73
- this.log(`vérificateur de jetons armé — ${issuers.length} émetteur(s) de confiance : ` + issuers.map((i) => i.issuer).join(", ") + (locaux > 0 ? ` (dont ${locaux} servi par les clés LOCALES, sans requête)` : ""), "INFO");
98
+ this.container?.set(ACCESS_ACCOUNT_RESOLVER, accountResolver(local, issuers.length > 0 ? new ExternalSubjectResolver(this.container, externalSubjectOptions(config)) : null));
99
+ const trusted = [...localIssuer === null ? [] : [`${localIssuer} (cette application)`], ...issuers.map((i) => i.issuer)];
100
+ this.log(`vérificateur de jetons armé — ${trusted.length} émetteur(s) de confiance : ` + trusted.join(", "), "INFO");
74
101
  }
75
102
  };
76
- //#endregion
103
+ /**
104
+ * Le résolveur de compte posé à côté du vérificateur : il retrouve, pour un
105
+ * principal que celui-ci vient d'établir, le compte de l'application qui agit.
106
+ *
107
+ * Aiguillé par l'émetteur, comme le vérificateur : un jeton maison rejoue la
108
+ * règle de la zone `jwt` (`JwtAuthenticator.resolveAccount`), un jeton tiers
109
+ * celle de la zone `external-jwt` (`ExternalSubjectResolver`). Aucune n'est
110
+ * recopiée ici. Un refus rend `null` ; une panne (annuaire non câblé,
111
+ * émetteur absent de la table de rattachement) traverse.
112
+ *
113
+ * Le jeton rendu est un `UserToken` de type `"mcp"` — hors des types humains
114
+ * que le voteur de scopes laisse passer : les scopes du jeton bornent les
115
+ * attributs `api:action`, et les rôles restent ceux du compte, comme dans une
116
+ * zone `jwt`.
117
+ */
118
+ function accountResolver(local, external) {
119
+ return async (principal) => {
120
+ let user = null;
121
+ if (local !== null) user = await local.resolveAccount(principal);
122
+ if (user === null && external !== null && principal.subject) {
123
+ if (!external.recognizes(principal.issuer)) return null;
124
+ try {
125
+ user = await external.resolve(principal.issuer, principal.subject, principal.claims);
126
+ } catch (error) {
127
+ if (error instanceof AuthenticationError) return null;
128
+ throw error;
129
+ }
130
+ }
131
+ if (user === null) return null;
132
+ const token = new UserToken("mcp").promote(user);
133
+ token.setAttribute("scopes", [...principal.scopes]);
134
+ token.setAttribute("issuer", principal.issuer);
135
+ token.setAttribute("subject", principal.subject);
136
+ if (principal.tokenId !== void 0) token.setAttribute("jti", principal.tokenId);
137
+ if (principal.claims !== void 0) token.setAttribute("claims", principal.claims);
138
+ return {
139
+ token,
140
+ user
141
+ };
142
+ };
143
+ }
77
144
  export { AccessTokenVerifierService, AccessTokenVerifierService as default };
@@ -1,11 +1,13 @@
1
+ import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
1
2
  import { defineSecurityConfig } from "../config/defineModuleConfig.js";
3
+ import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf } from "../src/accountBinding.js";
2
4
  import { generateApiKey } from "../src/apikey/apiKeyFormat.js";
3
5
  import { recordAudit } from "../src/audit/recordAudit.js";
4
6
  import { TOKEN_FACETS } from "../src/token/tokenFilters.js";
5
7
  import { ApiKeyError } from "../errors/ApiKeyError.js";
6
8
  import { Service, countFacets } from "nodefony";
7
9
  import { randomUUID } from "node:crypto";
8
- //#region nodefony/service/apiKeys.ts
10
+ // nodefony/service/apiKeys.ts
9
11
  const serviceName = "apiKeys";
10
12
  const MAX_NAME_LEN = 100;
11
13
  const MS_PER_DAY = 864e5;
@@ -32,6 +34,8 @@ var ApiKeyService = class extends Service {
32
34
  #defaultExpiryDays = 90;
33
35
  #maxPerSubject = 100;
34
36
  #allowedScopes = null;
37
+ /** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
38
+ #scopeGrants = null;
35
39
  constructor(module) {
36
40
  super(serviceName, module.container, module.notificationsCenter, module.options);
37
41
  this.module = module;
@@ -54,6 +58,7 @@ var ApiKeyService = class extends Service {
54
58
  this.#defaultExpiryDays = ak.defaultExpiryDays;
55
59
  this.#maxPerSubject = ak.maxPerSubject;
56
60
  this.#allowedScopes = ak.allowedScopes;
61
+ this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
57
62
  this.log(`api keys ready — prefix "${ak.prefix}_", max ${ak.maxPerSubject}/porteur`, "DEBUG");
58
63
  }
59
64
  /** `true` si les clés API sont activées en config. */
@@ -83,17 +88,31 @@ var ApiKeyService = class extends Service {
83
88
  * Émet une nouvelle clé API pour un porteur — renvoie sa vue publique **+ le
84
89
  * token en clair** (affiché une seule fois).
85
90
  *
91
+ * Un scope que le porteur ne peut pas OBTENIR — réservé par
92
+ * `grantableScopes` à des rôles qu'il n'a pas, ou `admin:*` sans le rôle
93
+ * d'administration — refuse la création entière : une clé émise avec moins
94
+ * que demandé surprendrait sans qu'aucune réponse ne le dise. Même juge que
95
+ * l'émission des jetons ({@link refusedScopes}).
96
+ *
97
+ * @param roles - rôles du porteur tels que son compte les porte ; omis =
98
+ * aucun rôle, donc tout scope réservé est refusé (fail-closed).
86
99
  * @throws ApiKeyError 400 — nom vide/trop long, scope hors catalogue, expiry invalide.
100
+ * @throws ApiKeyError 403 — scope réservé à des rôles que le porteur n'a pas.
87
101
  * @throws ApiKeyError 409 — plafond `maxPerSubject` atteint.
88
- * @throws ApiKeyError 503 — store indisponible.
102
+ * @throws ApiKeyError 403 — aucun compte sous cet identifiant (la clé est liée
103
+ * à l'`id` immuable du compte, lu dans l'annuaire).
104
+ * @throws ApiKeyError 503 — store ou annuaire `users` indisponible.
89
105
  */
90
- async createForSubject(subjectId, subjectType, opts) {
106
+ async createForSubject(subjectId, subjectType, opts, roles = []) {
91
107
  const store = this.#resolveStore();
92
108
  const name = this.#normalizeName(opts.name);
93
109
  const scopes = this.#normalizeScopes(opts.scopes);
110
+ const refusals = refusedScopes(scopes, roles, this.#scopeGrants ?? new ScopeGrantPolicy(), this.container?.get("roleHierarchy") ?? null);
111
+ if (refusals.length > 0) throw new ApiKeyError(`scope not grantable to this account: ${refusals.map((r) => r.scope).join(", ")}`, 403);
94
112
  const now = Date.now();
95
113
  const expiresAt = this.#resolveExpiry(opts.expiresInDays, now);
96
114
  if ((await store.findBySubject(subjectId)).filter((r) => r.kind === "pat" && this.#isActive(r, now)).length >= this.#maxPerSubject) throw new ApiKeyError(`API key limit reached (${this.#maxPerSubject})`, 409);
115
+ const holder = await this.#resolveHolder(subjectId);
97
116
  const generated = generateApiKey(this.#prefix);
98
117
  const record = {
99
118
  id: randomUUID(),
@@ -119,7 +138,10 @@ var ApiKeyService = class extends Service {
119
138
  lastUsedUserAgent: null,
120
139
  revokedAt: null,
121
140
  revokedReason: null,
122
- metadata: {}
141
+ metadata: {
142
+ [ACCOUNT_ID_KEY]: holder.id,
143
+ [CREDENTIALS_VERSION_KEY]: credentialsVersionOf(holder)
144
+ }
123
145
  };
124
146
  await store.put(record);
125
147
  this.log(`api key created — id=${record.id} subject=${subjectId} scopes=[${scopes.join(",")}]`, "INFO");
@@ -253,6 +275,25 @@ var ApiKeyService = class extends Service {
253
275
  });
254
276
  return true;
255
277
  }
278
+ /**
279
+ * Le compte qui reçoit la clé — son identifiant IMMUABLE et sa version
280
+ * d'identifiants sont la liaison qu'elle portera, contrôlée à chaque
281
+ * présentation (`isBoundToAccount`). Lu dans
282
+ * l'annuaire, jamais reçu de l'appelant : la clé appartient au compte qui
283
+ * existe MAINTENANT sous cet identifiant.
284
+ *
285
+ * @throws ApiKeyError 503 — annuaire `users` absent (câblage).
286
+ * @throws ApiKeyError 403 — aucun compte sous cet identifiant.
287
+ */
288
+ async #resolveHolder(subjectId) {
289
+ const users = this.get("users");
290
+ if (!users) throw new ApiKeyError("user directory unavailable", 503);
291
+ try {
292
+ return await users.loadUserByIdentifier(subjectId);
293
+ } catch {
294
+ throw new ApiKeyError("API key holder unknown", 403);
295
+ }
296
+ }
256
297
  #resolveStore() {
257
298
  if (this.#store === null) {
258
299
  const store = this.get("tokenStore");
@@ -306,5 +347,4 @@ var ApiKeyService = class extends Service {
306
347
  };
307
348
  }
308
349
  };
309
- //#endregion
310
350
  export { ApiKeyService, ApiKeyService as default };