@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
@@ -1,5 +1,5 @@
1
1
  import { CONSOLE_DATA_RUN_PROFILE, Command } from "nodefony";
2
- //#region nodefony/command/security-user-list.ts
2
+ // nodefony/command/security-user-list.ts
3
3
  const options = {
4
4
  runProfile: CONSOLE_DATA_RUN_PROFILE,
5
5
  helpGroup: "COMPTES ET SECRETS",
@@ -31,7 +31,10 @@ const DEFAULT_LIMIT = 50;
31
31
  */
32
32
  var SecurityUserList = class extends Command {
33
33
  constructor(cli) {
34
- super("security:user:list", "liste les comptes : identifiant, rôles, état", cli, options);
34
+ super("security:user:list", "liste les comptes : identifiant, rôles, état", cli, {
35
+ ...options,
36
+ examples: ["nodefony security:user:list", "nodefony security:user:list --role ROLE_ADMIN --json"]
37
+ });
35
38
  this.addOption("-q, --query <texte>", "filtre sur l'identifiant (sous-chaîne, insensible à la casse)");
36
39
  this.addOption("-r, --role <role>", "n'affiche que les porteurs d'un rôle");
37
40
  this.addOption("-l, --limit <n>", `nombre maximum de comptes (défaut ${DEFAULT_LIMIT})`);
@@ -74,5 +77,4 @@ var SecurityUserList = class extends Command {
74
77
  return this;
75
78
  }
76
79
  };
77
- //#endregion
78
80
  export { SecurityUserList as default };
@@ -1,6 +1,5 @@
1
1
  import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
- import { USER_REVOKED_EVENT } from "@nodefony/user";
3
- //#region nodefony/command/security-user-password.ts
2
+ // nodefony/command/security-user-password.ts
4
3
  const options = {
5
4
  runProfile: CONSOLE_DATA_RUN_PROFILE,
6
5
  helpGroup: "COMPTES ET SECRETS",
@@ -34,14 +33,17 @@ const RESET = "\x1B[0m";
34
33
  *
35
34
  * 🔒 **Les sessions et les jetons du compte sont RÉVOQUÉS**, et ce n'est pas une
36
35
  * option : on change un mot de passe parce qu'il est compromis ou perdu, et
37
- * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé. La
38
- * révocation passe par l'événement que la suppression emploie déjà — une
39
- * cascade, un seul canal, et les abonnés futurs (webhooks) en profitent sans
40
- * rien changer ici.
36
+ * laisser vivre les sessions ouvertes laisserait l'accès à qui l'a volé.
37
+ * `UserService.changePassword` émet la révocation lui-même — la même que pour
38
+ * le plan d'administration et la page « mon mot de passe » : un seul émetteur,
39
+ * aucun transport ne peut l'oublier.
41
40
  */
42
41
  var SecurityUserPassword = class extends Command {
43
42
  constructor(cli) {
44
- super("security:user:password", "change le mot de passe d'un compte", cli, options);
43
+ super("security:user:password", "change le mot de passe d'un compte", cli, {
44
+ ...options,
45
+ examples: ["nodefony security:user:password alice"]
46
+ });
45
47
  this.addArgument("[identifier]", "identifiant (login) du compte");
46
48
  this.addOption("-p, --password <password>", "nouveau mot de passe (sinon : prompt masqué en TTY)");
47
49
  }
@@ -100,15 +102,8 @@ var SecurityUserPassword = class extends Command {
100
102
  process.exitCode = 1;
101
103
  return this;
102
104
  }
103
- this.kernel?.fire(USER_REVOKED_EVENT, {
104
- id: user.id,
105
- identifier: user.identifier,
106
- tenantId: null,
107
- reason: "password_changed"
108
- });
109
105
  process.stdout.write(`\n${GREEN}✓ mot de passe changé${RESET} — ${BOLD}${user.identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués : il devra se reconnecter.${RESET}\n${DIM} connexion : POST ${AUTH_LOGIN_PATH}${RESET}\n` + (opts.password ? ` ${YELLOW}⚠ mot de passe passé en argument — pense à purger l'historique shell${RESET}\n` : "") + "\n");
110
106
  return this;
111
107
  }
112
108
  };
113
- //#endregion
114
109
  export { SecurityUserPassword as default };
@@ -1,5 +1,9 @@
1
+ import { parseKeySet } from "../src/token/JwtKeystore.js";
2
+ import { scopeGrantKeyProblem } from "../src/token/scopeGrants.js";
3
+ import { PLATFORM_ROLE_PREFIX, isPlatformRole } from "../src/oauth/providerRoles.js";
4
+ import { DEFAULT_LOGIN_PAGE_SKIN, LOGIN_PAGE_LAYOUTS, LOGIN_PAGE_PATH, LOGIN_PAGE_SKINS, parseDiscoveryUrl, safeRedirectPath } from "nodefony";
1
5
  import { z } from "zod";
2
- //#region nodefony/config/config.ts
6
+ // nodefony/config/config.ts
3
7
  /**
4
8
  * ════════════════════════════════════════════════════════════════════════════
5
9
  * @nodefony/security — CONFIGURATION DU MODULE (schéma Zod = source unique)
@@ -45,10 +49,18 @@ const areaSchema = z.strictObject({
45
49
  mode: z.enum(["first", "all"]).default("first").describe("Chaîne d'authenticators : 'first' = le premier qui reconnaît la requête authentifie (ex. cookie OU bearer) ; 'all' = tous doivent passer (ex. mtls+jwt zone admin)."),
46
50
  authenticators: z.array(z.string()).default([]).describe("Authenticators de la zone (sémantique selon `mode`). Validés au boot contre le registre."),
47
51
  roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
48
- entryPoint: z.string().optional().describe("Route de login/redirect si non authentifié."),
52
+ entryPoint: z.string().refine((value) => safeRedirectPath(value, "") === value, { message: "entryPoint doit être un chemin de CETTE origine (`/login`), jamais une adresse externe" }).optional().describe("Page de connexion vers laquelle conduire un visiteur anonyme qui NAVIGUE (GET ou HEAD, `Accept: text/html`, `Sec-Fetch-Mode: navigate` s'il est présent) : 302 vers `<entryPoint>?from=<chemin demandé>`, et la page de connexion ramène au chemin demandé. Tout le reste — appel d'API, `fetch`, POST, WebSocket — garde son 401 et son `WWW-Authenticate`. Le refus reste journalisé (`auth.denied`). Absent (défaut) : 401 pour tous."),
49
53
  host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
50
54
  resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
51
- realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`.")
55
+ realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`."),
56
+ subjectPolicy: z.enum(["require", "ephemeral"]).optional().describe("Surcharge, pour CETTE zone, de `resourceServer.subjectPolicy` — comment le sujet d'un jeton TIERS devient un utilisateur. Omis (défaut) = la politique globale. Sert à faire cohabiter une zone d'appelants machine (« ephemeral » : aucun compte local exigé) et une zone de personnes (« require ») dans la même application ; une politique globale ne sait pas l'exprimer. N'a de sens que si la zone liste `external-jwt` : déclarée ailleurs, le boot la refuse."),
57
+ ephemeralRoles: z.array(z.string().min(1)).optional().describe("Rôles accordés à l'appelant accepté par CETTE zone en mode « ephemeral ». Exige `subjectPolicy: \"ephemeral\"` écrit dans la zone même : un pouvoir local ne s'hérite pas d'un réglage écrit ailleurs. Une zone qui déclare « ephemeral » sans ses rôles n'en accorde AUCUN — jamais ceux de `resourceServer.ephemeralRoles`, qui protègent une autre surface.")
58
+ }).refine((area) => area.subjectPolicy === void 0 || area.authenticators.includes("external-jwt"), {
59
+ message: "subjectPolicy ne s'applique qu'aux jetons tiers : la zone doit lister l'authenticator « external-jwt »",
60
+ path: ["subjectPolicy"]
61
+ }).refine((area) => area.ephemeralRoles === void 0 || area.subjectPolicy === "ephemeral", {
62
+ message: "ephemeralRoles exige subjectPolicy: \"ephemeral\" écrit dans la MÊME zone — un pouvoir local ne s'hérite pas",
63
+ path: ["ephemeralRoles"]
52
64
  });
53
65
  const corsSchema = z.strictObject({
54
66
  enabled: z.boolean().default(true).describe("Active la gestion CORS."),
@@ -152,7 +164,14 @@ const jwtSchema = z.strictObject({
152
164
  audiences: z.array(z.string()).default([]).describe("Audiences acceptées à la vérification ET **demandables à l'émission** (claim `aud`, RFC 8707) — une liste BLANCHE de ressources, pas une simple valeur par défaut. Un client nomme sa cible par le paramètre `resource` du grant ; absente de cette liste, la demande est REFUSÉE (`invalid_target`, RFC 8707 §2) au lieu d'être servie avec l'audience par défaut — sans quoi tout porteur d'un identifiant valide obtiendrait un jeton pour la ressource de son choix. La PREMIÈRE entrée est l'audience par défaut (jeton demandé sans `resource`). Vide = l'audience de l'app (= `issuer`). Déclarer ici l'URI canonique d'une ressource protégée (porte MCP, API interne) est ce qui la rend joignable avec un jeton émis par cette application. La validation d'audience est OBLIGATOIRE côté resource (RFC 9700)."),
153
165
  issuer: z.string().optional().describe("Émetteur (claim `iss`, RFC 7519). Omis = `\"nodefony\"` — suffisant tant que l'app émet ET vérifie ses propres jetons, mais PAS publiable (RFC 8414 §2 exige une URL https) : sans valeur explicite, aucune découverte n'est offerte. Ne se DEVINE pas — derrière un relais, `Host`/`X-Forwarded-*` viennent du client. STABLE : gravé dans chaque jeton déjà émis, le changer les invalide."),
154
166
  keystore: z.strictObject({
155
- keySetJson: z.string().optional().describe("JWK Set (clé(s) privée(s) Ed25519) injecté par l'app depuis son env — SECRET, jamais loggé. Présent = source `env` (prod cloud)."),
167
+ keySetJson: z.string().refine((json) => {
168
+ try {
169
+ parseKeySet(json);
170
+ return true;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }, { message: "jeu de clés illisible — attendu le JSON produit par `npx nodefony security:secrets --jwt-keyset` ({ active, keys: [JWK privé avec kid] }), sur une ligne" }).optional().describe("JWK Set (clé(s) privée(s) Ed25519) PARTAGÉ par tous les process de l'app (pods, workers de cluster) — variable `NF_JWT_KEYSET` d'une application générée. SECRET, jamais loggé. Présent = source `env`, prioritaire sur `dir`."),
156
175
  dir: z.string().optional().describe("Dossier de persistance `keyset.json` (chmod 600), généré si absent — opt-in dev/VPS. Sans `keySetJson` ni `dir` = clé ÉPHÉMÈRE en mémoire + warning (refresh non durables).")
157
176
  }).default(() => ({})).describe("Source du matériel de signature JWT (priorité) : env (`keySetJson`) → fichier (`dir`) → mémoire+warn. SecretProvider (Vault/KMS) = P16.")
158
177
  }).describe("JWT — réservé API service↔service / agents (le web/navigateur utilise la session BFF).");
@@ -223,8 +242,17 @@ const remoteJwtAlgSchema = z.enum([
223
242
  "EdDSA",
224
243
  "Ed25519"
225
244
  ]);
245
+ const discoveryUrlSchema = z.string().refine((value) => {
246
+ try {
247
+ parseDiscoveryUrl(value);
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }, "adresse http(s) complète du document attendue, sans identifiants ni fragment").optional().describe("Adresse COMPLÈTE où lire le document de métadonnées, quand l'application ne joint pas l'émetteur par son adresse publique — en Kubernetes, le service interne : « http://keycloak.sso:8080/realms/app/.well-known/openid-configuration ». Seul le lieu de lecture change : le document doit toujours se déclarer de `issuer` (RFC 8414 §3.3), sinon refus. Les points appelés de serveur à serveur (jeton, clés) peuvent alors être en http sur l'origine exacte de cette adresse ; le point d'autorisation, où le navigateur est envoyé, reste en https. Keycloak publie ses points internes avec `--hostname-backchannel-dynamic=true`. Omis = découverte sur l'émetteur.");
226
253
  const trustedIssuerSchema = z.strictObject({
227
254
  issuer: z.string().min(1).describe("Identifiant de l'émetteur (claim `iss`), en https, sans requête ni fragment (RFC 8414 §2) — ex. « https://auth.example.com/realms/mon-royaume ». C'est une ALLOWLIST : un jeton dont l'`iss` n'est pas ici est refusé avant toute requête sortante."),
255
+ discoveryUrl: discoveryUrlSchema,
228
256
  jwksUri: z.string().optional().describe("Jeu de clés publiques de l'émetteur. Omis = découvert par les points bien connus RFC 8414/OpenID (3 URL essayées dans l'ordre normatif). Déclaré = aucune découverte (émetteur sans métadonnées, ou démarrage à froid sans requête)."),
229
257
  algorithms: z.array(remoteJwtAlgSchema).min(1).default([
230
258
  "RS256",
@@ -236,7 +264,7 @@ const trustedIssuerSchema = z.strictObject({
236
264
  subjectMapping: z.enum(["prefixed", "subject"]).default("prefixed").describe("Comment le sujet (`sub`) de CET émetteur devient un identifiant dans l'espace de noms local. « prefixed » (défaut) : l'identifiant cherché est « <issuer>#<sub> » — deux émetteurs ne peuvent pas se disputer un compte, et surtout aucun sujet étranger ne peut tomber par hasard sur un identifiant local existant. « subject » : le `sub` est cherché TEL QUEL, l'ancien comportement — à ne déclarer que si l'on maîtrise l'espace de noms de cet émetteur, typiquement parce qu'il EST cette application (jetons maison) ou parce que ses sujets sont déjà des identifiants locaux. 🔴 Le défaut n'est pas un réglage de confort : un annuaire où l'utilisateur choisit son identifiant permet de présenter `sub: \"admin\"`, et « subject » rattache alors le porteur au compte local du même nom. `sub` n'est unique que dans l'espace de son émetteur (OIDC Core §2) — l'identité est la paire, jamais le sujet seul.")
237
265
  });
238
266
  const resourceServerSchema = z.strictObject({
239
- issuers: z.array(trustedIssuerSchema).default([]).describe("Émetteurs dont l'application accepte les jetons. VIDE (défaut) = aucun jeton tiers n'est vérifiable, et le service `accessTokenVerifier` n'est pas posé — une porte protégée refusera alors de servir plutôt que d'accepter des porteurs qu'elle ne sait pas lire. Un seul réglage commande le rôle : pas de drapeau `enabled` qui permettrait « activé sans émetteur »."),
267
+ issuers: z.array(trustedIssuerSchema).default([]).describe("Émetteurs TIERS dont l'application accepte les jetons. Les jetons signés par l'application elle-même n'ont pas à y figurer : dès que `jwt.enabled`, le service `accessTokenVerifier` les reconnaît, et les juge par les règles de l'authentificateur `jwt` (révocation, compte actif). VIDE (défaut) = aucun jeton tiers n'est vérifiable ; avec `jwt.enabled: false`, le service n'est pas posé et une porte protégée refusera de servir plutôt que d'accepter des porteurs qu'elle ne sait pas lire. Une liste INVALIDE n'en pose aucun, jetons locaux compris."),
240
268
  subjectPolicy: z.enum(["require", "ephemeral"]).default("require").describe("Comment le sujet d'un jeton tiers (`sub`) devient un utilisateur de CETTE application. « require » (défaut) : le sujet doit correspondre à un compte local (`loadUserByIdentifier`) — absent, désactivé ou verrouillé, l'accès est refusé. « ephemeral » : aucun compte local n'est exigé ni créé, l'appelant vit le temps de la requête avec les rôles de `ephemeralRoles` — c'est le mode de l'appelant PUREMENT machine (agent, service). Le défaut est « require » parce qu'un jeton d'un annuaire d'entreprise vaut pour des milliers de comptes : l'accepter d'office ferait de cet annuaire l'unique autorité d'accès à l'application, et supprimerait la seconde décision — locale — qui est la raison d'être du pare-feu."),
241
269
  ephemeralRoles: z.array(z.string().min(1)).default([]).describe("Rôles accordés à un appelant accepté en mode « ephemeral ». VIDE par défaut, et ce n'est pas une omission : sans rôle, l'appelant ne passe aucun `@IsGranted` et n'est autorisé que par ses SCOPES (`@RequireScope`), qui viennent du jeton et sont bornés par le serveur d'autorisation. Y écrire un rôle revient à accorder un pouvoir local à quiconque détient un jeton valide pour cette ressource — à faire sciemment, jamais par confort."),
242
270
  timeoutMs: z.number().int().min(100).default(5e3).describe("Délai maximal d'une requête vers l'émetteur (découverte + clés)."),
@@ -301,28 +329,89 @@ const studioSchema = z.strictObject({
301
329
  description: "INERTE — l'audit des mutations admin est posé point par point par `SecurityAdminApi` (certaines mutations émettent, pas toutes) ; ce drapeau ne pilote aucune couverture globale."
302
330
  })
303
331
  }).describe("Sécurité de la console Studio — durcissement exposition publique.");
332
+ const loginPageSchema = z.strictObject({
333
+ enabled: z.boolean().default(true).describe("Sert la page de connexion par défaut. false = l'application sert la sienne au même chemin (`useNodefonyLogin`) : les redirections continuent d'y mener."),
334
+ path: z.string().default(LOGIN_PAGE_PATH).refine((p) => p.length > 0 && safeRedirectPath(p, "") === p && !/[?#]/.test(p), "chemin local attendu, sans requête ni fragment (`/login`)").describe("Chemin de la page de connexion. La redirection d'échec d'un fournisseur et le retour après déconnexion fédérée le suivent, sauf s'ils sont réglés."),
335
+ title: z.string().min(1).optional().describe("Titre de la page. Omis = titre par défaut."),
336
+ logo: z.string().min(1).optional().describe("Adresse du logo affiché (chemin servi par l'application ou URL). Omis = logo Nodefony."),
337
+ template: z.string().min(1).optional().describe("Chemin d'un gabarit `.eta` de l'application qui REMPLACE celui du framework. Dernier recours : `stylesheet` (variables `--nf-login-*`), `layout` et les textes (`heading`, `subtitle`, `hero`) suffisent à l'habillage, et survivent aux évolutions du balisage — un gabarit copié, non."),
338
+ stylesheet: z.string().min(1).optional().describe("Adresse d'une feuille de l'application chargée APRÈS celle du framework : elle redéfinit les variables `--nf-login-*` (couleurs, rayons, `--nf-login-hero-image` pour la photo du panneau). Chemin servi par l'application de préférence — une URL d'un autre site doit être autorisée par la CSP (`style-src`)."),
339
+ heading: z.string().min(1).optional().describe("Titre de la carte et de l'onglet. Omis = « Se connecter ». Le nom de l'application, lui, vient de `title`."),
340
+ subtitle: z.string().min(1).optional().describe("Ligne sous le titre. Omis = phrase déduite des moyens proposés (identifiant local, compte de l'organisation, ou les deux)."),
341
+ providersFirst: z.boolean().default(true).describe("Les boutons des fournisseurs passent AVANT le formulaire identifiant et mot de passe. false = le formulaire d'abord. Sans fournisseur proposé, sans effet."),
342
+ hero: z.union([z.literal(false), z.strictObject({
343
+ heading: z.string().min(1).describe("Accroche du panneau."),
344
+ text: z.string().min(1).optional().describe("Phrase sous l'accroche.")
345
+ })]).optional().describe("Panneau d'illustration de la mise en page `split`. Omis = vitrine Nodefony. `{ heading, text }` = votre texte à la place. false = la marque seule, sur l'image du panneau. L'image se règle dans `stylesheet` (`--nf-login-hero-image`), jamais ici."),
346
+ footer: z.boolean().default(true).describe("Pied de page : protections de la session (cookie HttpOnly, anti-CSRF, CSP) et mention « Propulsé par Nodefony ». false = retiré."),
347
+ password: z.boolean().default(true).describe("Propose le formulaire identifiant et mot de passe. false = connexion par fournisseur seulement (SSO)."),
348
+ skin: z.enum(LOGIN_PAGE_SKINS).default(DEFAULT_LOGIN_PAGE_SKIN).describe("Habillage : `frontispiece` (défaut), `ledger`, `horizon`, `console`, `blueprint`, `dots`, `minimal`, `enterprise`, `photo-card`. Une feuille du framework chargée avant `stylesheet` ; aucune ne fournit de photo — elle vient de `stylesheet` (`--nf-login-hero-image`). En développement, `?skin=` et `?layout=` en donnent un aperçu."),
349
+ layout: z.enum(LOGIN_PAGE_LAYOUTS).optional().describe("Mise en page : `split` (illustration et formulaire côte à côte), `card` (panneau centré), `bare` (formulaire seul). Omis = celle pour laquelle l'habillage est dessiné."),
350
+ previewPicker: z.boolean().default(false).describe("En DÉVELOPPEMENT seulement : un sélecteur d'habillage et de mise en page sur la page, qui recharge avec `?skin=` et `?layout=`. Sans effet en production, où ces paramètres sont ignorés. Pour choisir son habillage à l'écran, pas pour le livrer."),
351
+ previewHook: z.string().regex(/^\/(?![/\\])/u, "chemin de l'application attendu (« /… »), jamais une autre origine").optional().describe("Avec `previewPicker`, en DÉVELOPPEMENT : chemin de l'application appelé en POST (`{ skin, layout }`, même origine) AVANT chaque rechargement de l'aperçu. Pour qu'un tiers suive l'habillage choisi — le thème d'un Keycloak de développement (`applyKeycloakLoginTheme`). Un échec n'empêche pas le rechargement.")
352
+ }).describe("Page de connexion servie par le framework, sans front (ADR-0015).");
304
353
  const oauthProviderSchema = z.strictObject({
305
354
  clientId: z.string().min(1).describe("Identifiant client OAuth délivré par le fournisseur."),
306
355
  clientSecret: z.string().min(1).describe("Secret client OAuth — SECRET, fourni par env, jamais loggé."),
307
356
  clientAuthMethod: z.enum(["client_secret_basic", "client_secret_post"]).optional().describe("Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). OMIS = `client_secret_basic`, ce que la RFC demande de préférer et ce que la plupart des serveurs annoncent par défaut. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans ses métadonnées (`token_endpoint_auth_methods_supported`). La méthode `none` (client public) n'est pas offerte ici : ce schéma exige un secret non vide."),
308
357
  redirectUri: z.string().min(1).describe("URL de callback EXACTE (RFC 9700 : exact string matching) enregistrée chez le fournisseur — doit pointer sur .../oauth2/<provider>/callback."),
309
358
  issuer: z.string().optional().describe("Émetteur OIDC self-hosted (Keycloak : URL du realm, ex. https://kc.example/realms/app). REQUIS pour keycloak ; ignoré par les fournisseurs à endpoints fixes (Google/GitHub)."),
359
+ discoveryUrl: discoveryUrlSchema,
310
360
  scopes: z.array(z.string()).default([]).describe("Scopes demandés. Vide = défauts du fournisseur (Google: openid/profile/email ; GitHub: read:user/user:email)."),
311
361
  successRedirect: z.string().optional().describe("Redirection succès — surcharge le global pour CE fournisseur."),
312
362
  failureRedirect: z.string().optional().describe("Redirection échec — surcharge le global pour CE fournisseur."),
363
+ postLogoutRedirectUri: z.url().optional().describe("Adresse ABSOLUE où le fournisseur renvoie le navigateur après la déconnexion (`post_logout_redirect_uri`, OpenID Connect RP-Initiated Logout) — doit être enregistrée chez lui. OMIS = l'écran d'échec (`failureRedirect`, la page de connexion) résolu contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion."),
313
364
  defaultRoles: z.array(z.string()).optional().describe("Rôles du Shadow User à la création — surcharge le global pour CE fournisseur."),
365
+ roleMapping: z.record(z.string().min(1), z.string().min(1)).optional().describe("Rôles du FOURNISSEUR traduits en rôles de l'application, recalculés à chaque connexion ET à chaque jeton présenté à l'API : `{ \"app-admin\": \"ROLE_ADMIN\" }`. Un rôle du fournisseur absent de la table est ignoré, jamais recopié. Les rôles donnés à la main sont conservés. OMIS = les rôles restent entièrement locaux (posés à la création, puis la base fait foi). Un rôle `ROLE_NODEFONY_*` refuse le démarrage, sauf `allowPlatformRoles`."),
366
+ audiences: z.array(z.string().min(1)).optional().describe("Ressources pour lesquelles CE fournisseur émet des jetons à l'application — la mention « valable pour » (`aud`) qu'il doit imprimer. Chaque entrée est la `resource` d'une zone `external-jwt`. Lue par `security:keycloak:realm`, qui en écrit les mappers d'audience ; sans effet sur la vérification, que la `resource` de la zone porte seule. OMIS = la `resource` de TOUTES les zones `external-jwt`. À écrire dès qu'une zone existe pour REFUSER les jetons de ce fournisseur : la déduire ferait imprimer son adresse."),
367
+ allowPlatformRoles: z.boolean().default(false).describe("Autorise `roleMapping` à accorder des rôles de PLATEFORME (`ROLE_NODEFONY_*` : console d'administration, génération de code, secrets). OMIS = refusé au démarrage. L'ouvrir fait de l'administrateur de l'annuaire un administrateur de l'instance, et d'un annuaire compromis une prise de contrôle complète — rappelé à chaque démarrage, en avertissement en production, en information ailleurs."),
368
+ rolesSource: z.array(z.enum([
369
+ "client",
370
+ "realm",
371
+ "groups"
372
+ ])).min(1).optional().describe("Où lire les rôles du fournisseur pour `roleMapping`. `client` (défaut) : les rôles du client de CETTE application (`resource_access.<clientId>.roles`, Keycloak). `realm` : les rôles du realm (`realm_access.roles`). `groups` : le claim `groups` (mapper de groupes à poser chez le fournisseur). Sans `roleMapping`, sans effet."),
314
373
  label: z.string().min(1).optional().describe("Libellé du bouton sur l'écran de connexion. OMIS = dérivé du nom du fournisseur (`keycloak` → « Keycloak », `oidc` → « OIDC », `mon-idp` → « Mon Idp »). À poser quand la marque ne se devine pas du nom de la clé (« Connexion agent », « Annuaire interne »)."),
374
+ icon: z.string().min(1).optional().describe("Image du bouton sur l'écran de connexion (chemin servi par l'application, ou URL autorisée par la CSP `img-src`). OMIS = icône du framework : la marque pour `github`, une clé sinon."),
375
+ admin: z.strictObject({
376
+ username: z.string().min(1).describe("Identifiant administrateur."),
377
+ password: z.string().min(1).describe("Mot de passe administrateur — SECRET, jamais loggé.")
378
+ }).optional().describe("DÉVELOPPEMENT seulement — compte d'administration du serveur Keycloak (realm `master`). Sert à poser le thème de connexion qui suit `loginPage.skin` sur un realm EXISTANT (`security:keycloak:realm --apply`, aperçu de `/login`), ce que le fichier d'import ne fait pas. Refusé hors développement : ne le passer qu'en `ctx.isDev`."),
315
379
  hidden: z.boolean().default(false).describe("Retire le bouton de l'écran de connexion SANS désactiver le fournisseur : le flux `/authorize` reste ouvert et fonctionnel. C'est la seule différence avec le fait de ne pas le configurer. Deux usages : une FIXTURE de développement qui pointe vers un serveur fictif (bouton mort), et un fournisseur réservé à un point d'entrée particulier (lien direct, sous-domaine) plutôt qu'offert à tout visiteur.")
380
+ }).superRefine((provider, ctx) => {
381
+ if (provider.allowPlatformRoles) return;
382
+ for (const [from, to] of Object.entries(provider.roleMapping ?? {})) {
383
+ if (!isPlatformRole(to)) continue;
384
+ ctx.addIssue({
385
+ code: "custom",
386
+ path: ["roleMapping", from],
387
+ message: `« ${to} » est un rôle de PLATEFORME (${PLATFORM_ROLE_PREFIX}*) : un annuaire externe ne l'accorde pas sans \`allowPlatformRoles: true\` écrit sur ce fournisseur. Sinon, le donner à la main (\`security:user:add --admin\`, console d'administration) — il survit alors à chaque connexion.`
388
+ });
389
+ }
316
390
  }).describe("Fournisseur OAuth/OIDC (secrets via env).");
317
391
  const oauth2Schema = z.strictObject({
318
392
  enabled: z.boolean().default(true).describe("Active le social login (les routes ne montent que si ≥1 provider)."),
319
- defaultRoles: z.array(z.string()).default(["ROLE_USER"]).describe("Rôles du Shadow User à la CRÉATION uniquement (OAuth = authentification, pas autorisation : les rôles ne sont jamais réécrits ensuite, la base fait foi)."),
393
+ defaultRoles: z.array(z.string()).default(["ROLE_USER"]).describe("Rôles du Shadow User à la CRÉATION uniquement. Les rôles ne sont jamais réécrits ensuite, la base fait foi — sauf ceux qu'un fournisseur gère par sa `roleMapping`."),
320
394
  allowSignup: z.boolean().default(true).describe("true = crée une ligne locale au 1er login (JIT, Shadow User). false = un compte préexistant lié est requis (fail-closed)."),
321
395
  successRedirect: z.string().default("/").describe("Redirection après login réussi."),
322
- failureRedirect: z.string().default("/login").describe("Redirection après échec (state invalide, refus utilisateur...)."),
396
+ failureRedirect: z.string().optional().describe("Redirection après échec (state invalide, refus utilisateur...). Omis = la page de connexion (`loginPage.path`)."),
323
397
  providers: z.record(z.string(), oauthProviderSchema).default({}).describe("Fournisseurs activés par nom (doit correspondre au registre : builtins google/keycloak/oidc/github ; tout autre serveur OpenID Connect en enregistrant une fabrique, décrit par son seul émetteur).")
324
398
  }).describe("Social login OAuth 2.0 — Authorization Code + PKCE, session BFF.");
325
399
  /**
400
+ * Qui peut OBTENIR un scope d'application — appliqué par CHAQUE émetteur de
401
+ * l'application (jeton d'accès, clé API), jamais par une zone : un jeton ne
402
+ * sait pas quelle zone le lira, et `security:mcp:token` signe sans requête.
403
+ */
404
+ const grantableScopesSchema = z.record(z.string(), z.array(z.string().min(1)).min(1, "au moins un rôle — pour n'accorder ce scope à personne, ne pas l'exposer")).superRefine((grants, ctx) => {
405
+ for (const key of Object.keys(grants)) {
406
+ const problem = scopeGrantKeyProblem(key);
407
+ if (problem !== null) ctx.addIssue({
408
+ code: "custom",
409
+ path: [key],
410
+ message: `« ${key} » ${problem}`
411
+ });
412
+ }
413
+ }).default({}).describe("Qui peut OBTENIR un scope d'application. Clé : un scope exact (`vitrine:write`) ou une famille (`shop:*`) ; valeur : les rôles dont UN suffit, hiérarchie de rôles comprise. Une clé exacte l'emporte sur une famille, la famille la plus longue sur une plus courte. Un scope qu'aucune clé ne couvre reste accordé à tout compte connecté. Appliqué par chaque émetteur de CETTE application : un jeton d'accès est émis sans les scopes refusés (RFC 6749 §3.3, nommés au journal), une clé API demandée avec l'un d'eux est refusée (403). Ne borne PAS les jetons d'un émetteur tiers (Keycloak…), qui signe selon sa propre politique. `admin:read`, `admin:write` et `admin:*` sont refusés au démarrage : réservés au framework. Un module peut compléter cette section par `module-security` ; un préfixe = un propriétaire, car la fusion de configuration fusionne les listes case par case (`[\"A\",\"B\"]` surchargé par `[\"C\"]` donne `[\"C\",\"B\"]`).");
414
+ /**
326
415
  * Règle d'autorisation d'un **namespace de canaux WebSocket** (subscribe/inbound)
327
416
  * par préfixe. Contraintes cumulatives ; un champ absent = pas de contrainte sur
328
417
  * cet axe. Étend OU surcharge les défauts système (placée AVANT eux à l'évaluation).
@@ -336,6 +425,7 @@ const realtimeChannelRuleSchema = z.strictObject({
336
425
  const securityConfigSchema = z.strictObject({
337
426
  encoders: z.record(z.string(), encoderSchema).default(() => ({ default: encoderSchema.parse({ type: "argon2id" }) })).describe("Chaîne d'encodeurs de mot de passe. 1re entrée = principal (hash à la création), suivantes = legacy en lecture seule (migration au login). Défaut : un argon2id sûr — le firewall en dérive le service `passwordEncoder`."),
338
427
  roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
428
+ grantableScopes: grantableScopesSchema,
339
429
  areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
340
430
  realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (syslog:/orm:/node:/dashboard:/debugbar:/realtime:/cluster: + :health/:stats = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
341
431
  cors: corsSchema.default(() => corsSchema.parse({})),
@@ -352,6 +442,8 @@ const securityConfigSchema = z.strictObject({
352
442
  description: "RÉSERVÉ — Token Exchange RFC 8693 (délégation agents/MCP). Slot P12, aucun champ encore lu par le runtime."
353
443
  }),
354
444
  oauth2: oauth2Schema.default(() => oauth2Schema.parse({})),
445
+ loginPage: loginPageSchema.default(() => loginPageSchema.parse({})),
446
+ allowInsecureCredentials: z.boolean().default(false).describe("Laisse passer, en production, un mot de passe ou un code de second facteur reçu en clair (HTTP). false (défaut) : refus 403 AVANT toute vérification, sur toutes les portes — formulaire de session, second facteur, Basic, émission de jeton. Le refus se décide sur le scheme EFFECTIF : derrière un proxy qui termine TLS, déclarer ce proxy de confiance (`trustProxy` de @nodefony/http) suffit, et c'est la bonne réponse — pas cette clé. Ne la passer à true que pour un réseau où le clair est un choix assumé (maillage chiffré au niveau inférieur). Hors production, sans effet."),
355
447
  apiKeys: apiKeysSchema.default(() => apiKeysSchema.parse({})),
356
448
  webhooks: webhooksSchema.default(() => webhooksSchema.parse({})),
357
449
  audit: auditSchema.default(() => auditSchema.parse({})),
@@ -366,5 +458,4 @@ const securityConfigSchema = z.strictObject({
366
458
  * construction ; passés au `super(..., config)` du Module class.
367
459
  */
368
460
  const config = securityConfigSchema.parse({});
369
- //#endregion
370
461
  export { config as default, securityConfigSchema };
@@ -1,7 +1,7 @@
1
1
  import { securityConfigSchema } from "./config.js";
2
2
  import { BootConfigurationError, parseModuleConfig } from "nodefony";
3
3
  import { z } from "zod";
4
- //#region nodefony/config/defineModuleConfig.ts
4
+ // nodefony/config/defineModuleConfig.ts
5
5
  /**
6
6
  * Valide + normalise + gèle la configuration de sécurité.
7
7
  *
@@ -31,5 +31,4 @@ function detectConflicts(areas) {
31
31
  seen.set(area.pattern, name);
32
32
  }
33
33
  }
34
- //#endregion
35
34
  export { defineSecurityConfig, securityConfigJsonSchema };
@@ -1,4 +1,4 @@
1
- //#region nodefony/contracts/IAccessVoter.ts
1
+ // nodefony/contracts/IAccessVoter.ts
2
2
  /**
3
3
  * Vote d'un {@link IAccessVoter}. Stratégie firewall = affirmative + DENY veto :
4
4
  * un seul DENY bloque ; sinon un GRANT suffit ; tous ABSTAIN → DENY (Zero Trust).
@@ -9,5 +9,4 @@ let VoterVote = /* @__PURE__ */ function(VoterVote) {
9
9
  VoterVote["ABSTAIN"] = "ABSTAIN";
10
10
  return VoterVote;
11
11
  }({});
12
- //#endregion
13
12
  export { VoterVote };
@@ -1,2 +1 @@
1
- import { VoterVote } from "./IAccessVoter.js";
2
- export { VoterVote };
1
+ export { VoterVote } from "./IAccessVoter.js";
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/AccessDeniedError.ts
2
+ // nodefony/errors/AccessDeniedError.ts
3
3
  /**
4
4
  * Accès refusé — `code = 403`. Levée par l'autorisation (un `@IsGranted` non
5
5
  * satisfait, un voter DENY) : l'utilisateur EST authentifié mais n'a pas le droit.
@@ -10,5 +10,4 @@ var AccessDeniedError = class extends nodefonyError {
10
10
  super(message, 403);
11
11
  }
12
12
  };
13
- //#endregion
14
13
  export { AccessDeniedError, AccessDeniedError as default };
@@ -1,11 +1,13 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/ApiKeyError.ts
2
+ // nodefony/errors/ApiKeyError.ts
3
3
  /**
4
4
  * Erreur de **gestion** d'une clé API (création/révocation) — porte un `code`
5
5
  * HTTP que l'adaptateur framework mappe par duck-typing (il n'importe jamais les
6
6
  * classes de `@nodefony/security`).
7
7
  *
8
8
  * - `400` — entrée invalide (nom vide, scope hors catalogue) ;
9
+ * - `403` — scope demandé que le porteur ne peut pas obtenir (`grantableScopes`,
10
+ * ou `admin:*` sans le rôle d'administration) ;
9
11
  * - `409` — plafond `apiKeys.maxPerSubject` atteint ;
10
12
  * - `503` — store de jetons indisponible (clés activées mais non provisionnées).
11
13
  *
@@ -17,5 +19,4 @@ var ApiKeyError = class extends nodefonyError {
17
19
  super(message, code);
18
20
  }
19
21
  };
20
- //#endregion
21
22
  export { ApiKeyError, ApiKeyError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/AuthenticationError.ts
2
+ // nodefony/errors/AuthenticationError.ts
3
3
  /**
4
4
  * Échec d'authentification — `code = 401`. Levée par un {@link IAuthenticator}
5
5
  * quand le credential est absent/invalide, ou par le firewall en Zero Trust
@@ -10,5 +10,4 @@ var AuthenticationError = class extends nodefonyError {
10
10
  super(message, 401);
11
11
  }
12
12
  };
13
- //#endregion
14
13
  export { AuthenticationError, AuthenticationError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/CsrfError.ts
2
+ // nodefony/errors/CsrfError.ts
3
3
  /**
4
4
  * Mutation cross-site bloquée — `code = 403` (RFC 9110 §15.5.4 : le serveur a
5
5
  * compris la requête mais refuse de l'honorer).
@@ -19,5 +19,4 @@ var CsrfError = class extends nodefonyError {
19
19
  super(message, 403);
20
20
  }
21
21
  };
22
- //#endregion
23
22
  export { CsrfError, CsrfError as default };
@@ -0,0 +1,23 @@
1
+ import { INSECURE_TRANSPORT_MESSAGE, nodefonyError } from "nodefony";
2
+ // nodefony/errors/InsecureTransportError.ts
3
+ /**
4
+ * Secret refusé parce qu'il arrive en clair — `code = 403` (RFC 9110 §15.5.4 :
5
+ * le serveur a compris la requête mais refuse de l'honorer).
6
+ *
7
+ * Message constant ({@link INSECURE_TRANSPORT_MESSAGE}, cœur) : le navigateur
8
+ * le reconnaît, et il ne dit rien de la topologie.
9
+ *
10
+ * Levée AVANT toute vérification, quand un mot de passe ou un code de second
11
+ * facteur arrive en production sur une requête dont le scheme effectif n'est
12
+ * pas `https` (cf {@link CredentialTransportPolicy}). Ce n'est pas un 401 :
13
+ * aucune preuve n'a été jugée, et rien de ce que le client corrigera dans son
14
+ * secret ne changera la réponse — c'est le canal qu'il doit changer. Pas de
15
+ * `WWW-Authenticate` non plus : un défi Basic ferait rouvrir au navigateur la
16
+ * fenêtre qui renverrait le mot de passe par le même canal.
17
+ */
18
+ var InsecureTransportError = class extends nodefonyError {
19
+ constructor() {
20
+ super(INSECURE_TRANSPORT_MESSAGE, 403);
21
+ }
22
+ };
23
+ export { InsecureTransportError, InsecureTransportError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/InvalidTargetError.ts
2
+ // nodefony/errors/InvalidTargetError.ts
3
3
  /**
4
4
  * La ressource demandée à l'émission ne peut pas être servie — `code = 400`,
5
5
  * code d'erreur OAuth `invalid_target` (RFC 8707 §2).
@@ -35,5 +35,4 @@ var InvalidTargetError = class extends nodefonyError {
35
35
  this.requested = requested;
36
36
  }
37
37
  };
38
- //#endregion
39
38
  export { InvalidTargetError, InvalidTargetError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/SsrfError.ts
2
+ // nodefony/errors/SsrfError.ts
3
3
  /**
4
4
  * URL rejetée par la protection SSRF — `code = 422`. Levée quand une URL sortante
5
5
  * (endpoint webhook, fetch applicatif…) est syntaxiquement valide mais cible une
@@ -13,5 +13,4 @@ var SsrfError = class extends nodefonyError {
13
13
  super(message, 422);
14
14
  }
15
15
  };
16
- //#endregion
17
16
  export { SsrfError, SsrfError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/ThrottledError.ts
2
+ // nodefony/errors/ThrottledError.ts
3
3
  /**
4
4
  * Tentatives de login trop rapprochées — `code = 429` (RFC 6585 §4).
5
5
  *
@@ -17,5 +17,4 @@ var ThrottledError = class extends nodefonyError {
17
17
  this.retryAfterS = retryAfterS;
18
18
  }
19
19
  };
20
- //#endregion
21
20
  export { ThrottledError, ThrottledError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/UnverifiableTokenError.ts
2
+ // nodefony/errors/UnverifiableTokenError.ts
3
3
  /**
4
4
  * Le jeton n'a pas pu être VÉRIFIÉ — `code = 503`, et surtout **pas 401**.
5
5
  *
@@ -38,5 +38,4 @@ var UnverifiableTokenError = class extends nodefonyError {
38
38
  this.detail = detail;
39
39
  }
40
40
  };
41
- //#endregion
42
41
  export { UnverifiableTokenError, UnverifiableTokenError as default };
@@ -1,5 +1,5 @@
1
1
  import { nodefonyError } from "nodefony";
2
- //#region nodefony/errors/WebAuthnError.ts
2
+ // nodefony/errors/WebAuthnError.ts
3
3
  /**
4
4
  * Erreur de **gestion** d'un credential WebAuthn (enrôlement) — porte un `code`
5
5
  * HTTP que l'adaptateur framework mappe par duck-typing (il n'importe jamais les
@@ -17,5 +17,4 @@ var WebAuthnError = class extends nodefonyError {
17
17
  super(message, code);
18
18
  }
19
19
  };
20
- //#endregion
21
20
  export { WebAuthnError, WebAuthnError as default };
@@ -1,3 +1,4 @@
1
+ import { InsecureTransportError } from "./InsecureTransportError.js";
1
2
  import { CsrfError } from "./CsrfError.js";
2
3
  import { AuthenticationError } from "./AuthenticationError.js";
3
4
  import { ThrottledError } from "./ThrottledError.js";
@@ -6,4 +7,4 @@ import { InvalidTargetError } from "./InvalidTargetError.js";
6
7
  import { WebAuthnError } from "./WebAuthnError.js";
7
8
  import { SsrfError } from "./SsrfError.js";
8
9
  import { AccessDeniedError } from "./AccessDeniedError.js";
9
- export { AccessDeniedError, AuthenticationError, CsrfError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };
10
+ export { AccessDeniedError, AuthenticationError, CsrfError, InsecureTransportError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };