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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
package/README.md CHANGED
@@ -25,7 +25,7 @@ Consomme [`@nodefony/user`](https://www.npmjs.com/package/@nodefony/user).
25
25
  (cookie opaque BFF, révocable, `HttpOnly; Secure; SameSite`) ; les **API/agents** portent leur
26
26
  preuve à chaque requête (**JWT** signé / clé API). Jamais « full stateless » : la session reste
27
27
  la fondation web (révocation immédiate), le JWT est réservé au sans-état machine-à-machine.
28
- - **Config type-safe** : schéma **Zod** (18 sections, tout `enabled`), **introspectable** (Studio
28
+ - **Config type-safe** : schéma **Zod** (19 sections, la plupart désactivables par `enabled`), **introspectable** (Studio
29
29
  génère son formulaire d'édition). L'app configure via `use("@nodefony/security", { … })`.
30
30
  - **En-têtes natifs** (sans la lib `helmet`) — 0 dépendance, nonce CSP par requête.
31
31
 
@@ -67,7 +67,8 @@ export default defineConfig((ctx) => ({
67
67
 
68
68
  > Une zone se déclare idéalement **par module** (override `module-security`, dans la config du
69
69
  > module) pour vivre au plus près de ses routes. Authenticators fournis : `anonymous`, `session`,
70
- > `userpassword`, `jwt`, `apikey`, `webauthn`, plus les providers OAuth2. La chaîne est validée
70
+ > `userpassword`, `jwt`, `external-jwt`, `apikey`. WebAuthn, OAuth2 et TOTP n'en sont pas : ils
71
+ > ouvrent une session BFF, que l'authenticator `session` identifie ensuite. La chaîne est validée
71
72
  > au boot (`mode: "first"` par défaut = le premier qui reconnaît authentifie ; `"all"` = tous
72
73
  > requis, ex. mTLS + JWT). Config invalide → firewall **fail-closed** (tout rejeté).
73
74
 
@@ -117,7 +118,7 @@ csrf: {
117
118
  > ouvrir la lecture CORS des réponses au JavaScript tiers. Une origine listée dans `cors.origins` est
118
119
  > toutefois aussi acceptée (ce que CORS autorise explicitement n'est pas du CSRF).
119
120
 
120
- Le token synchronizer renforcé (`@CsrfProtect` / `@CsrfExempt`) arrive à l'étape suivante.
121
+ Le token synchronizer renforcé s'active par route avec `@CsrfProtect` ; `@CsrfExempt` en exclut une route.
121
122
 
122
123
  ### CORS — l'inverse du CSRF
123
124
 
@@ -141,8 +142,9 @@ const schema = securityConfigJsonSchema(); // JSON Schema → formulaire d'édit
141
142
  ## Journal d'audit (événements de sécurité)
142
143
 
143
144
  Le journal trace les **transitions d'état** de sécurité (login, refus, jeton émis/révoqué, verrou
144
- WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible ; le
145
- **chemin de succès reste muet** (le volume n'est pas un signal), seul l'échec/refus émet (cold-path)
145
+ WS) — distinct du log de trafic (1 PDU/requête). Émission **explicite** par point sensible, sur les
146
+ chemins froids (login, logout, émission ou révocation de jeton, refus) ; jamais par requête nominale :
147
+ le trafic courant ne passe pas par l'audit (cold-path)
146
148
  → aucun coût ajouté au hot-path nominal. Activé par défaut (`audit.enabled`, OWASP A09), coût nul si
147
149
  désactivé. Lecture : `GET /nodefony/security/api/audit/events` (RBAC `ROLE_NODEFONY_ADMIN`).
148
150
 
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorate.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorate.js
2
2
  function __decorate(decorators, target, key, desc) {
3
3
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
4
4
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -1,4 +1,4 @@
1
- //#region \0@oxc-project+runtime@0.151.0/helpers/esm/decorateMetadata.js
1
+ //#region \0@oxc-project+runtime@0.153.0/helpers/esm/decorateMetadata.js
2
2
  function __decorateMetadata(k, v) {
3
3
  if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
4
4
  }
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
+ import { JwtKeystore } from "./nodefony/src/token/JwtKeystore.js";
1
2
  import config from "./nodefony/config/config.js";
2
3
  import { SecuredArea } from "./nodefony/src/SecuredArea.js";
3
4
  import { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
5
+ import { InsecureTransportError } from "./nodefony/errors/InsecureTransportError.js";
6
+ import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "./nodefony/src/transport/CredentialTransportPolicy.js";
4
7
  import { RoleHierarchyWalker } from "./nodefony/src/RoleHierarchyWalker.js";
5
8
  import { CsrfTokenManager } from "./nodefony/src/csrfToken.js";
6
9
  import { CsrfError } from "./nodefony/errors/CsrfError.js";
@@ -26,13 +29,13 @@ import { getAuthenticatorFactory, listAuthenticatorFactories, registerAuthentica
26
29
  import { recordAudit } from "./nodefony/src/audit/recordAudit.js";
27
30
  import { readAuditContext } from "./nodefony/src/audit/readAuditContext.js";
28
31
  import { Firewall } from "./nodefony/service/firewall.js";
32
+ import { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
29
33
  import { AuthFlow } from "./nodefony/service/authFlow.js";
30
34
  import { InvalidTargetError } from "./nodefony/errors/InvalidTargetError.js";
31
35
  import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS } from "./nodefony/src/token/tokenSort.js";
32
36
  import { matchesTokenStatus, tokenStatusOf } from "./nodefony/src/token/tokenStatus.js";
33
37
  import { MemoryTokenStore } from "./nodefony/src/token/MemoryTokenStore.js";
34
38
  import { getTokenStoreFactory, listTokenStores, registerTokenStore } from "./nodefony/src/token/tokenStoreRegistry.js";
35
- import { JwtKeystore } from "./nodefony/src/token/JwtKeystore.js";
36
39
  import { TokenService } from "./nodefony/service/tokenService.js";
37
40
  import { RemoteJwtVerifier } from "./nodefony/src/token/RemoteJwtVerifier.js";
38
41
  import { AccessTokenVerifierService } from "./nodefony/service/accessTokenVerifier.js";
@@ -44,11 +47,13 @@ import { Authorization } from "./nodefony/service/authorization.js";
44
47
  import { MemoryWebAuthnCredentialStore } from "./nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js";
45
48
  import { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore } from "./nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js";
46
49
  import { WebAuthnService } from "./nodefony/service/webAuthn.js";
50
+ import { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme } from "./nodefony/keycloak/keycloakAdmin.js";
47
51
  import { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState } from "./nodefony/src/oauth/oauth2Client.js";
48
52
  import { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
49
- import { createDiscoveredOidcProvider, createOidcProvider } from "./nodefony/src/oauth/providers/oidc.js";
53
+ import { createDiscoveredOidcProvider, createOidcProvider, readLogoutTokenClaims } from "./nodefony/src/oauth/providers/oidc.js";
50
54
  import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
51
- import { getOAuthProviderFactory, listOAuthProviders, registerOAuthProvider } from "./nodefony/src/oauth/oauthProviderRegistry.js";
55
+ import { getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider } from "./nodefony/src/oauth/oauthProviderRegistry.js";
56
+ import { diagnoseOAuthProvider } from "./nodefony/src/oauth/providerDiagnosis.js";
52
57
  import { OAuth2Service } from "./nodefony/service/oauth2.js";
53
58
  import { TOKEN_FACETS } from "./nodefony/src/token/tokenFilters.js";
54
59
  import { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
@@ -69,17 +74,22 @@ import { getWebhookStoreFactory, listWebhookStores, registerWebhookStore } from
69
74
  import { SsrfError } from "./nodefony/errors/SsrfError.js";
70
75
  import { assertPublicUrl, isBlockedAddress } from "./nodefony/src/net/ssrfGuard.js";
71
76
  import { WebhookService } from "./nodefony/service/webhooks.js";
77
+ import { KeycloakService } from "./nodefony/service/keycloak.js";
72
78
  import SecuritySecrets from "./nodefony/command/security-secrets.js";
73
79
  import SecurityUserAdd from "./nodefony/command/security-user-add.js";
74
80
  import SecurityUserList from "./nodefony/command/security-user-list.js";
75
81
  import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
76
82
  import SecurityUserPassword from "./nodefony/command/security-user-password.js";
83
+ import { publicRoutesNotice } from "./nodefony/src/boot/publicRoutesNotice.js";
77
84
  import SecurityToken from "./nodefony/command/security-token.js";
85
+ import SecurityKeycloakRealm from "./nodefony/keycloak/security-keycloak-realm.js";
86
+ import SecurityOAuthDoctor from "./nodefony/command/security-oauth-doctor.js";
78
87
  import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
79
- import { registerUserRevocationCascade } from "./nodefony/src/admin/userRevocationCascade.js";
80
- import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.js";
81
- import __decorate from "./_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.js";
88
+ import { registerUserRevocationCascade, usersEventBusNotice } from "./nodefony/src/admin/userRevocationCascade.js";
89
+ import __decorateMetadata from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
90
+ import __decorate from "./_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
82
91
  import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
92
+ import { requestClientCredentialsToken } from "./nodefony/src/oauth/clientCredentials.js";
83
93
  import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
84
94
  import "./nodefony/errors/index.js";
85
95
  import "./nodefony/contracts/index.js";
@@ -96,7 +106,7 @@ import { registerUserAdminApi } from "@nodefony/user";
96
106
  * + Zod validée au boot (fail-closed si invalide). Consomme `@nodefony/user`
97
107
  * (IUser/IUserProvider/IPasswordVerifier) — jamais l'inverse.
98
108
  *
99
- * Livré : firewall (zones, mode first|all, challenge RFC 7235), authenticators
109
+ * Livré : firewall (zones, mode first|all, challenge RFC 9110 §11), authenticators
100
110
  * `anonymous`/`userpassword` (Basic RFC 7617)/`session` (BFF, J3), flux
101
111
  * login/logout/me (`AuthFlow`, anti-fixation + throttling partagé).
102
112
  * Jwt/oauth2/mtls/apikey, CORS, CSRF, autorisation par décorateurs et data
@@ -111,6 +121,8 @@ let Security = class Security extends Module {
111
121
  this.addCommand(SecurityUserDelete);
112
122
  this.addCommand(SecurityUserPassword);
113
123
  this.addCommand(SecurityToken);
124
+ this.addCommand(SecurityKeycloakRealm);
125
+ this.addCommand(SecurityOAuthDoctor);
114
126
  }
115
127
  /**
116
128
  * JSON Schema de la config security → data plane admin (config riche Studio).
@@ -135,7 +147,28 @@ let Security = class Security extends Module {
135
147
  registerSecurityAdminApi(registry, container);
136
148
  registerUserAdminApi(registry, container);
137
149
  }
138
- if (this.kernel && container) registerUserRevocationCascade(this.kernel, container);
150
+ const kernel = this.kernel;
151
+ if (kernel && container) {
152
+ registerUserRevocationCascade(kernel, container);
153
+ kernel.once("onPostReady", () => {
154
+ const notice = usersEventBusNotice(container, kernel);
155
+ if (notice) kernel.reportBootNotice(notice);
156
+ });
157
+ }
158
+ return this;
159
+ }
160
+ /**
161
+ * Déclare au bilan de démarrage les routes métier laissées PUBLIQUES.
162
+ *
163
+ * `onReady` et pas `onBoot` : les zones sont compilées par le pare-feu à son
164
+ * propre `onBoot`, posé APRÈS celui du module — à `onBoot` la liste est encore
165
+ * vide. `onReady` précède l'écriture du bilan (`var/last-boot.json`).
166
+ */
167
+ async onKernelReady() {
168
+ const firewall = this.kernel?.container?.get("firewall");
169
+ if (!firewall) return this;
170
+ const notice = publicRoutesNotice(firewall.describe().zones);
171
+ if (notice) this.kernel?.reportBootNotice(notice);
139
172
  return this;
140
173
  }
141
174
  };
@@ -150,8 +183,9 @@ Security = __decorate([services([
150
183
  OAuth2Service,
151
184
  AuditService,
152
185
  TotpService,
153
- WebhookService
186
+ WebhookService,
187
+ KeycloakService
154
188
  ]), __decorateMetadata("design:paramtypes", [typeof Kernel === "undefined" ? Object : Kernel])], Security);
155
189
  var security_default = Security;
156
190
  //#endregion
157
- export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InvalidTargetError, JwtAuthenticator, JwtKeystore, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, resolveJwtRuntime, securityConfigJsonSchema, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
191
+ export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, CredentialTransportPolicy, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InsecureTransportError, InvalidTargetError, JwtAuthenticator, JwtKeystore, KeycloakAdminClient, KeycloakAdminError, KeycloakService, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, amrForFactor, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, diagnoseOAuthProvider, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, keycloakAdminTarget, keycloakLoginThemeCheck, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, oauthProviderRequiresIssuer, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, readKeycloakLoginTheme, readLogoutTokenClaims, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, requestClientCredentialsToken, resolveJwtRuntime, securityConfigJsonSchema, shouldEnforceCredentialTransport, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
@@ -0,0 +1,66 @@
1
+ import { Command } from "nodefony";
2
+ //#region nodefony/command/security-oauth-doctor.ts
3
+ const options = {
4
+ helpGroup: "COMPTES ET SECRETS",
5
+ showBanner: false,
6
+ kernelEvent: "onReady",
7
+ quietBoot: true
8
+ };
9
+ const GREEN = "\x1B[32m";
10
+ const RED = "\x1B[31m";
11
+ const YELLOW = "\x1B[33m";
12
+ const DIM = "\x1B[2m";
13
+ const RESET = "\x1B[0m";
14
+ /** Codes de sortie (sysexits). */
15
+ const EXIT_FAILED = 1;
16
+ const EXIT_CONFIG = 78;
17
+ const LABELS = {
18
+ discovery: "découverte",
19
+ authorization: "URL de retour",
20
+ token: "secret",
21
+ theme: "thème"
22
+ };
23
+ function mark(check) {
24
+ switch (check.status) {
25
+ case "ok": return `${GREEN}✓${RESET}`;
26
+ case "failed": return `${RED}✗${RESET}`;
27
+ case "inconclusive": return `${YELLOW}?${RESET}`;
28
+ case "skipped": return `${DIM}–${RESET}`;
29
+ }
30
+ }
31
+ /**
32
+ * `nodefony security:oauth:doctor` — dit POURQUOI un fournisseur OAuth refuse
33
+ * l'application, sans qu'un humain ait à se connecter.
34
+ *
35
+ * Trois verdicts distincts là où le journal ne montre qu'un 401 : l'émetteur
36
+ * se déclare autrement que la configuration ne l'écrit, l'URL de retour n'est
37
+ * pas enregistrée, le secret est faux. La même analyse alimente
38
+ * `nodefony doctor --live` (famille « Fournisseurs OAuth ») et l'endpoint
39
+ * d'administration `security/oauth/diagnosis` : une seule implémentation, celle
40
+ * du service `oauth2`.
41
+ *
42
+ * Valable pour tout fournisseur : les sondes passent par le fournisseur que le
43
+ * login construit, Keycloak n'y a rien de particulier.
44
+ */
45
+ var SecurityOAuthDoctor = class extends Command {
46
+ constructor(cli) {
47
+ super("security:oauth:doctor", "diagnostique le branchement des fournisseurs OAuth", cli, options);
48
+ this.addOption("-p, --provider <nom>", "un seul fournisseur de oauth2.providers");
49
+ this.addOption("-j, --json", "sortie JSON (scripts/CI)");
50
+ }
51
+ async generate(opts) {
52
+ const oauth = this.kernel?.container?.get("oauth2");
53
+ const diagnoses = oauth?.isEnabled() === true ? await oauth.diagnose(opts.provider) : [];
54
+ if (opts.json) process.stdout.write(`${JSON.stringify(diagnoses, null, 2)}\n`);
55
+ else if (diagnoses.length === 0) process.stderr.write("aucun fournisseur OAuth à diagnostiquer — `security.oauth2` est désactivé ou ne déclare aucun fournisseur\n");
56
+ else for (const diagnosis of diagnoses) {
57
+ process.stdout.write(`${diagnosis.ok ? GREEN : RED}${diagnosis.provider}${RESET}\n`);
58
+ for (const check of diagnosis.checks) process.stdout.write(` ${mark(check)} ${LABELS[check.name].padEnd(14)}${check.message}\n`);
59
+ }
60
+ if (diagnoses.length === 0) process.exitCode = EXIT_CONFIG;
61
+ else if (diagnoses.some((d) => !d.ok)) process.exitCode = EXIT_FAILED;
62
+ return this;
63
+ }
64
+ };
65
+ //#endregion
66
+ export { SecurityOAuthDoctor as default };
@@ -1,8 +1,9 @@
1
1
  import { readIfPresentSync } from "../src/token/secretFile.js";
2
- import { Command, diskManifestReader, manifestFileWith, readManifestCode } from "nodefony";
3
- import { randomBytes } from "node:crypto";
2
+ import { generateKeySet } from "../src/token/JwtKeystore.js";
3
+ import { Command, ENV_FILE, diskManifestReader, manifestFileWith, readManifestCode } from "nodefony";
4
4
  import path from "node:path";
5
5
  import { appendFileSync } from "node:fs";
6
+ import { randomBytes } from "node:crypto";
6
7
  import { spawnSync } from "node:child_process";
7
8
  //#region nodefony/command/security-secrets.ts
8
9
  const options = {
@@ -45,11 +46,11 @@ const ROLES = {
45
46
  },
46
47
  NF_CSRF_SECRET: {
47
48
  protected: "signe les jetons anti-rejeu des mutations (`@CsrfProtect`)",
48
- without: "en cluster, le jeton émis par un pod est rejeté par les autres"
49
+ without: "démarrage REFUSÉ en production (code 78) — une valeur tirée au hasard différerait d'un exemplaire à l'autre"
49
50
  },
50
- "jwt.keystore": {
51
- protected: "signe les JWT (clé Ed25519, rotation gérée par le keystore)",
52
- without: "chaque process signe avec la sienne : un jeton émis par la CLI est refusé par le serveur"
51
+ NF_JWT_KEYSET: {
52
+ protected: "signe les jetons JWT (paire Ed25519) — la MÊME pour tous les pods et workers",
53
+ without: "démarrage REFUSÉ en production dès que l'application émet des jetons — sinon 401 au hasard derrière plusieurs exemplaires, et tous les jetons perdus au redémarrage"
53
54
  }
54
55
  };
55
56
  /**
@@ -71,22 +72,24 @@ var SecuritySecrets = class extends Command {
71
72
  super("security:secrets", "engendre les clés de chiffrement du module security", cli, options);
72
73
  this.addOption("-j, --json", "sortie JSON (scripts/CI)");
73
74
  this.addOption("-w, --write", "écrit les clés manquantes dans le .env du projet (jamais de remplacement)");
75
+ this.addOption("-e, --env", "les 4 secrets en lignes CLÉ=valeur (docker run --env-file, gestionnaire de secrets)");
76
+ this.addOption("-k, --jwt-keyset", "génère la valeur de NF_JWT_KEYSET (une ligne JSON, à ranger dans le gestionnaire de secrets)");
74
77
  }
75
78
  /** Racine du projet (kernel booté) — repli cwd. */
76
79
  #root() {
77
80
  return this.kernel?.path ?? process.cwd();
78
81
  }
79
82
  /**
80
- * `true` si `.env.local` est SUIVI par git — y écrire des secrets les mènerait
81
- * au commit (convention B : `*.local` doit être gitignoré). Best-effort : git
82
- * absent / hors repo → `false` (on écrit).
83
+ * `true` si `.env` est SUIVI par git — y écrire des secrets les mènerait au
84
+ * commit (`.env` doit être gitignoré). Best-effort : git absent / hors repo →
85
+ * `false` (on écrit).
83
86
  */
84
87
  #dotenvTracked() {
85
88
  try {
86
89
  return spawnSync("git", [
87
90
  "ls-files",
88
91
  "--error-unmatch",
89
- ".env.local"
92
+ ENV_FILE
90
93
  ], {
91
94
  cwd: this.#root(),
92
95
  stdio: "ignore"
@@ -104,15 +107,24 @@ var SecuritySecrets = class extends Command {
104
107
  }
105
108
  }
106
109
  async generate(opts) {
110
+ if (opts.jwtKeyset) {
111
+ process.stdout.write(await generateKeySet() + "\n");
112
+ return this;
113
+ }
107
114
  const gen = () => randomBytes(32).toString("base64");
108
115
  const secrets = {};
109
116
  for (const k of KEYS) secrets[k] = gen();
110
117
  if (opts.json) {
118
+ secrets["NF_JWT_KEYSET"] = await generateKeySet();
111
119
  process.stdout.write(JSON.stringify(secrets, null, 2) + "\n");
112
120
  return this;
113
121
  }
114
- const dotenvLocal = this.#read(".env.local");
115
- const dotenv = this.#read(".env") + "\n" + dotenvLocal;
122
+ if (opts.env) {
123
+ secrets["NF_JWT_KEYSET"] = await generateKeySet();
124
+ process.stdout.write(Object.entries(secrets).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
125
+ return this;
126
+ }
127
+ const dotenv = this.#read(ENV_FILE);
116
128
  const envTs = this.#read("env.ts");
117
129
  const cfgTs = readManifestCode(this.#root(), diskManifestReader);
118
130
  const cfgFile = manifestFileWith(this.#root(), diskManifestReader, /satisfies\s+ISecurityConfigInput/);
@@ -130,18 +142,18 @@ var SecuritySecrets = class extends Command {
130
142
  process.stdout.write(s);
131
143
  };
132
144
  w(`\n${BOLD}🔐 Secrets du module security${RESET} ${DIM}— 4 secrets, 3 fichiers${RESET}\n\n`);
133
- for (const [name, role] of Object.entries(ROLES)) w(` ${(name === "jwt.keystore" ? /keystore\s*:/u.test(cfgTs) : new RegExp(`^\\s*${name}\\s*=`, "m").test(dotenv)) ? "\x1B[32m✓" : "\x1B[33m○"}${RESET} ${BOLD}${name.padEnd(16)}${RESET}${DIM}${role.protected}${RESET}\n ${DIM}sans → ${role.without}${RESET}\n`);
134
- const tokenStillThere = /^\s*NF_MCP_TOKEN\s*=/m.test(dotenvLocal);
145
+ for (const [name, role] of Object.entries(ROLES)) w(` ${(name === "NF_JWT_KEYSET" ? cfgTs.includes(name) : new RegExp(`^\\s*${name}\\s*=`, "m").test(dotenv)) ? "\x1B[32m✓" : "\x1B[33m○"}${RESET} ${BOLD}${name.padEnd(16)}${RESET}${DIM}${role.protected}${RESET}\n ${DIM}sans → ${role.without}${RESET}\n`);
146
+ const tokenStillThere = /^\s*NF_MCP_TOKEN\s*=/m.test(dotenv);
135
147
  w(`\n${DIM} · NF_MCP_TOKEN n'est PAS un secret de cette application, et n'a rien à\n faire dans cette liste : c'est un jeton qu'elle ÉMET, présenté par un\n agent pour entrer. Aucun code d'ici ne le lit. Il se pose chez l'agent\n qui le porte — nodefony security:token --write.${RESET}\n`);
136
- if (tokenStillThere) w(`${YELLOW} ⚠ une ligne NF_MCP_TOKEN traîne encore dans .env.local — rien ne la lit,\n tu peux la retirer.${RESET}\n`);
148
+ if (tokenStillThere) w(`${YELLOW} ⚠ une ligne NF_MCP_TOKEN traîne encore dans .env — rien ne la lit,\n tu peux la retirer.${RESET}\n`);
137
149
  w(`\n${YELLOW}⚠ rien ne se tape dans le terminal : chaque bloc se colle dans le fichier indiqué.${RESET}\n\n`);
138
- w(`${BOLD}1. Fichier ${CYAN}.env.local${RESET}${BOLD} — les valeurs${RESET} ${DIM}(gitignoré ; .env commité = défauts NON-secrets)${RESET}\n`);
150
+ w(`${BOLD}1. Fichier ${CYAN}.env${RESET}${BOLD} — les valeurs${RESET} ${DIM}(valeurs du poste, gitignoré ; la notice est .env.example)${RESET}\n`);
139
151
  if (missingInDotenv.length === 0) w(` ${GREEN}✓ les 3 clés y sont déjà${RESET} ${DIM}(rien à faire — rotation = remplacer la valeur à la main)${RESET}\n\n`);
140
- else if (opts.write && this.#dotenvTracked()) w(` ${YELLOW}⚠ .env.local est suivi par git — je n'y écris PAS de secrets.${RESET}\n ${DIM}Ajoute \`*.local\` au .gitignore (et \`git rm --cached .env.local\`), puis relance --write ;\n ou colle les lignes ci-dessous à la main :${RESET}\n\n` + missingInDotenv.map((k) => ` ${k}=${secrets[k]}`).join("\n") + `\n\n`);
152
+ else if (opts.write && this.#dotenvTracked()) w(` ${YELLOW}⚠ .env est suivi par git — je n'y écris PAS de secrets.${RESET}\n ${DIM}Ajoute \`.env\` au .gitignore (et \`git rm --cached .env\`), puis relance --write ;\n ou colle les lignes ci-dessous à la main :${RESET}\n\n` + missingInDotenv.map((k) => ` ${k}=${secrets[k]}`).join("\n") + `\n\n`);
141
153
  else if (opts.write) {
142
- const block = (dotenvLocal && !dotenvLocal.endsWith("\n") ? "\n" : "") + `# clés security — générées par \`nodefony security:secrets\`\n` + missingInDotenv.map((k) => `${k}=${secrets[k]}`).join("\n") + "\n";
143
- appendFileSync(path.resolve(this.#root(), ".env.local"), block);
144
- w(` ${GREEN}✓ écrit dans .env.local${RESET} ${DIM}(${missingInDotenv.join(", ")} — les clés déjà présentes n'ont pas été touchées)${RESET}\n\n`);
154
+ const block = (dotenv && !dotenv.endsWith("\n") ? "\n" : "") + `# clés security — générées par \`nodefony security:secrets\`\n` + missingInDotenv.map((k) => `${k}=${secrets[k]}`).join("\n") + "\n";
155
+ appendFileSync(path.resolve(this.#root(), ENV_FILE), block);
156
+ w(` ${GREEN}✓ écrit dans .env${RESET} ${DIM}(${missingInDotenv.join(", ")} — les clés déjà présentes n'ont pas été touchées)${RESET}\n\n`);
145
157
  } else w(` colle ces lignes ${DIM}(ou relance avec ${RESET}${CYAN}--write${RESET}${DIM} pour que je les écrive)${RESET} :\n\n` + missingInDotenv.map((k) => ` ${k}=${secrets[k]}`).join("\n") + `\n ${DIM}(en prod : Secret k8s / vault — jamais en git)${RESET}\n\n`);
146
158
  w(`${BOLD}2. Fichier ${CYAN}env.ts${RESET}${BOLD} — la déclaration typée${RESET} ${DIM}(env.ts est le seul lecteur de process.env)${RESET}\n`);
147
159
  if (missingInEnvTs.length === 0) w(` ${GREEN}✓ déjà déclarées${RESET}\n\n`);
@@ -150,11 +162,16 @@ var SecuritySecrets = class extends Command {
150
162
  if (missingInCfg.length === 0) w(` ${GREEN}✓ déjà câblées${RESET}\n\n`);
151
163
  else if (cfgIsFragment) w(` complète le descripteur ${CYAN}securityConfig${RESET} :\n\n` + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n\n`);
152
164
  else w(" complète l'entrée security du manifeste modules :\n\n use(\"@nodefony/security\", {\n" + missingInCfg.map((k) => WIRING[k]).join("\n") + `\n }),\n\n`);
153
- const jwtCable = /keystore\s*:/u.test(cfgTs);
154
- w(`${BOLD}4. Fichier ${CYAN}nodefony.config.ts${RESET}${BOLD} — les clés de SIGNATURE des jetons${RESET} ${DIM}(jwt.keystore)${RESET}\n`);
155
- if (jwtCable) w(` ${GREEN}✓ déjà câblées${RESET}\n\n`);
156
- else w(` ${YELLOW}⚠ absentes : chaque process signe avec une clé ÉPHÉMÈRE.${RESET}\n ${DIM}Un jeton émis par la CLI porte alors un \`kid\` que le serveur ne\n connaît pas, et il est refusé — et un redémarrage invalide les jetons\n en vol. Ce n'est pas une valeur à coller : c'est une SOURCE à déclarer.${RESET}\n\n use("@nodefony/security", {\n jwt: { keystore: ctx.isProd ? {} : { dir: "var/keys" } },\n }),\n\n ${DIM}En production, le dossier n'a pas de sens (pods jetables) : la clé vient\n de l'environnement — jwt.keystore.keySetJson, injecté par ton gestionnaire\n de secrets et partagé par tous les pods.${RESET}\n\n`);
157
- w(`${DIM}Pourquoi 3 fichiers ? .env.local porte la VALEUR (secret machine, gitignoré —\nle .env commité ne porte que des défauts non-secrets) ; env.ts la DÉCLARE\n(catalogue typé, validé au boot) ; nodefony.config.ts la CÂBLE au module.\nLes étapes 2 et 3 ne se font qu'une fois — ensuite seule l'étape 1 vit.\nL'étape 4 sort de ce schéma, et c'est voulu : un keyset n'est pas une valeur\nqu'on colle, mais une source que le keystore gère (rotation, permissions).${RESET}\n\nRelance le serveur : plus aucun warning « clé ÉPHÉMÈRE » au boot.\n\n`);
165
+ const jwtDeclared = envTs.includes("NF_JWT_KEYSET");
166
+ const jwtWired = cfgTs.includes("NF_JWT_KEYSET");
167
+ w(`${BOLD}4. La clé de SIGNATURE des jetons${RESET} ${DIM}(NF_JWT_KEYSET — production seulement)${RESET}\n`);
168
+ if (jwtDeclared && jwtWired) w(` ${GREEN}✓ déclarée dans env.ts et câblée dans ${cfgRel}${RESET}\n`);
169
+ else {
170
+ if (!jwtDeclared) w(` ${CYAN}env.ts${RESET}, dans le defineEnv({ … }) :\n\n NF_JWT_KEYSET: envString({ optional: true }),\n\n`);
171
+ if (!jwtWired) w(` ${CYAN}${cfgRel}${RESET}, entrée security :\n\n jwt: {\n keystore: {\n keySetJson: ctx.env.NF_JWT_KEYSET,\n dir: ctx.isProd ? undefined : "var/keys",\n },\n },\n\n`);
172
+ }
173
+ w(` ${DIM}Développement : rien à poser, la clé vit dans var/keys/.\n Production : UNE valeur pour tous les pods et workers, générée une fois —\n ${RESET}${CYAN}npx nodefony security:secrets --jwt-keyset${RESET}${DIM} — puis rangée dans le\n gestionnaire de secrets et injectée en NF_JWT_KEYSET. Jamais dans .env,\n jamais dans git. STABLE : la changer refuse les jetons en vol ; pour une\n rotation, ajouter la nouvelle clé au jeu, la rendre « active », garder l'ancienne.${RESET}\n\n`);
174
+ w(`${DIM}Pourquoi 3 fichiers ? .env porte la VALEUR (secret du poste, gitignoré ;\nen production, le gestionnaire de secrets) ; env.ts la DÉCLARE\n(catalogue typé, validé au boot) ; ${cfgRel} la CÂBLE au module.\nLes étapes 2 et 3 ne se font qu'une fois — ensuite seule l'étape 1 vit.${RESET}\n\nRelance le serveur : plus aucun warning « clé ÉPHÉMÈRE » au boot.\n\n`);
158
175
  return this;
159
176
  }
160
177
  };
@@ -38,6 +38,36 @@ function ttlSeconds(raw) {
38
38
  if (minutes > TTL_MAX_MINUTES) return /* @__PURE__ */ new Error(`--ttl est borné à ${TTL_MAX_MINUTES} minutes (30 jours) — un jeton posé dans un fichier est une clé, et une clé se remplace`);
39
39
  return minutes * 60;
40
40
  }
41
+ /**
42
+ * Durée d'un jeton POSÉ dans un agent : 8 h, une journée de travail.
43
+ *
44
+ * L'en-tête d'un agent est figé, rien ne le rafraîchit : le défaut de la
45
+ * configuration (15 min, taillé pour un client d'API qui renouvelle son jeton)
46
+ * y produisait un 401 au bout d'un quart d'heure, qui n'accusait pas
47
+ * l'expiration. Huit heures évitent la relance dans la journée et bornent une
48
+ * fuite à quelques heures. Le défaut global (`jwt.accessTtlS`) ne bouge pas :
49
+ * le relever allongerait TOUS les jetons de l'application.
50
+ */
51
+ const AGENT_TTL_MINUTES = 480;
52
+ /**
53
+ * Durée demandée à l'émetteur : `--ttl` explicite, sinon 8 h quand le jeton
54
+ * part chez les agents, sinon rien — la configuration décide.
55
+ *
56
+ * @param raw - la valeur de `--ttl` telle que tapée, ou rien
57
+ * @param write - le jeton sera posé dans la configuration des agents
58
+ * @returns les secondes, `undefined` pour le défaut de configuration, une `Error` sinon
59
+ */
60
+ function tokenTtlSeconds(raw, write) {
61
+ if (raw !== void 0) return ttlSeconds(raw);
62
+ return write ? AGENT_TTL_MINUTES * 60 : void 0;
63
+ }
64
+ /** « 8 h », « 1 h 30 », « 15 min » — la durée telle qu'on la dit. */
65
+ function humanDuration(seconds) {
66
+ const minutes = Math.round(seconds / 60);
67
+ if (minutes < 60) return `${minutes} min`;
68
+ const rest = minutes % 60;
69
+ return rest === 0 ? `${minutes / 60} h` : `${Math.floor(minutes / 60)} h ${rest}`;
70
+ }
41
71
  const GREEN = "\x1B[32m";
42
72
  const YELLOW = "\x1B[33m";
43
73
  const DIM = "\x1B[2m";
@@ -69,7 +99,7 @@ var SecurityToken = class extends Command {
69
99
  this.addOption("-s, --scope <scopes>", `scopes demandés, séparés par des espaces (défaut : « ${ADMIN_SCOPE_READ} » ; ajouter « ${ADMIN_SCOPE_WRITE} » pour les mutations)`);
70
100
  this.addOption("-r, --resource <uri>", "audience visée (défaut : la porte MCP de cette application)");
71
101
  this.addOption("-a, --agent <noms>", "agents à servir, séparés par des virgules (défaut : ceux détectés ; « none » pour aucun)");
72
- this.addOption("-t, --ttl <duree>", "durée de validité, en minutes (défaut : celle de la config, 15 min)");
102
+ this.addOption("-t, --ttl <duree>", "durée de validité, en minutes (défaut : 8 h avec --write, sinon celle de la config, 15 min)");
73
103
  this.addOption("-w, --write", `pose ${MCP_TOKEN_ENV} dans la configuration des agents présents`);
74
104
  this.addOption("-j, --json", "sortie JSON (scripts/CI)");
75
105
  }
@@ -239,7 +269,15 @@ var SecurityToken = class extends Command {
239
269
  const resource = opts.resource ?? this.#defaultResource();
240
270
  const requestedScopes = (opts.scope ?? "").split(/\s+/u).filter(Boolean);
241
271
  const scopes = requestedScopes.length > 0 ? requestedScopes : [ADMIN_SCOPE_READ];
242
- const ttlS = ttlSeconds(opts.ttl);
272
+ let write = opts.write === true;
273
+ if (!write && !opts.json && process.stdin.isTTY) {
274
+ await this.loadPrompts();
275
+ write = await this.prompts.confirm({
276
+ message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
277
+ default: true
278
+ });
279
+ }
280
+ const ttlS = tokenTtlSeconds(opts.ttl, write);
243
281
  if (ttlS instanceof Error) {
244
282
  this.log(ttlS.message, "ERROR");
245
283
  process.exitCode = 1;
@@ -271,30 +309,23 @@ var SecurityToken = class extends Command {
271
309
  const token = issued.access_token;
272
310
  const accordes = issued.scope.split(/\s+/u).filter(Boolean);
273
311
  const nonAccordes = scopes.filter((s) => !accordes.includes(s));
312
+ const expiresAt = new Date(Date.now() + issued.expires_in * 1e3);
274
313
  if (opts.json) {
275
314
  process.stdout.write(`${JSON.stringify({
276
315
  access_token: token,
277
316
  resource,
278
317
  scopes: accordes,
279
318
  requested: scopes,
280
- expires_in: issued.expires_in
319
+ expires_in: issued.expires_in,
320
+ expires_at: expiresAt.toISOString()
281
321
  }, null, 2)}\n`);
282
322
  return this;
283
323
  }
284
324
  const w = (s) => {
285
325
  process.stdout.write(s);
286
326
  };
287
- let write = opts.write === true;
288
- if (!write && process.stdin.isTTY) {
289
- await this.loadPrompts();
290
- write = await this.prompts.confirm({
291
- message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
292
- default: true
293
- });
294
- }
295
- if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement.${RESET}\n`);
296
- const minutes = Math.round(issued.expires_in / 60);
297
- w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${minutes} min${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
327
+ if (this.#ephemeralKey()) w(`\n${YELLOW}⚠ Clé de signature ÉPHÉMÈRE — ce jeton sera REFUSÉ.${RESET}\n${DIM} Cette application n'a pas de clé persistante : chaque process en génère\n une au démarrage. Le jeton ci-dessous n'est vérifiable que par le process\n qui vient de le signer — pas par le serveur en marche, qui a la sienne.\n → déclare une source de clés dans nodefony.config.ts :\n use("@nodefony/security", { jwt: { keystore: { dir: "var/keys" } } })\n ou, en production, keySetJson depuis l'environnement (NF_JWT_KEYSET —\n npx nodefony security:secrets --jwt-keyset).${RESET}\n`);
328
+ w(`\n${BOLD}🔑 Jeton d'accès${RESET} ${DIM}— compte ${identifier}, audience ${resource}${RESET}\n${DIM} valable ${humanDuration(issued.expires_in)}, expire le ${expiresAt.toLocaleString()}${accordes.length ? `, scopes : ${accordes.join(" ")}` : ", aucun scope"}${RESET}\n\n`);
298
329
  if (nonAccordes.length > 0) w(`${DIM} ⚠️ non accordé(s) : ${nonAccordes.join(" ")} — réservé(s) au rôle d'administration, que le compte « ${identifier} » ne porte pas.${RESET}\n\n`);
299
330
  if (write) {
300
331
  const requested = requestedAgents(opts.agent);
@@ -333,4 +364,4 @@ var SecurityToken = class extends Command {
333
364
  }
334
365
  };
335
366
  //#endregion
336
- export { SecurityToken as default, ttlSeconds };
367
+ export { SecurityToken as default, tokenTtlSeconds, ttlSeconds };
@@ -30,9 +30,10 @@ const ADMIN_ROLE = "ROLE_NODEFONY_ADMIN";
30
30
  * seul recours est une écriture directe en base. Le même garde-fou existe côté
31
31
  * data plane (`UserAdminApi`) — ici il est constaté, pas supposé.
32
32
  *
33
- * La révocation des sessions et jetons du compte suit d'elle-même : la
34
- * suppression émet `onUserRevoked`, auquel `@nodefony/security` réagit en
35
- * éjectant sessions et jetons. Rien à faire de plus ici.
33
+ * La suppression passe par `users.delete`, qui émet `onUserRevoked`
34
+ * (`reason: "deleted"`) et ATTEND la cascade de `@nodefony/security` : sessions
35
+ * et jetons éjectés, secret TOTP et passkeys retirés. La commande n'émet rien
36
+ * elle-même — le service est le seul émetteur du cycle de vie d'un compte.
36
37
  */
37
38
  var SecurityUserDelete = class extends Command {
38
39
  constructor(cli) {
@@ -95,7 +96,7 @@ var SecurityUserDelete = class extends Command {
95
96
  process.exitCode = 1;
96
97
  return this;
97
98
  }
98
- w(`${GREEN}✓ compte supprimé${RESET} — ${BOLD}${identifier}${RESET}\n${DIM} ses sessions et ses jetons sont révoqués (event onUserRevoked).${RESET}\n\n`);
99
+ w(`${GREEN}✓ compte supprimé${RESET} — ${BOLD}${identifier}${RESET}\n${DIM} ses sessions, ses jetons et ses facteurs (2FA, passkeys) sont retirés.${RESET}\n\n`);
99
100
  return this;
100
101
  }
101
102
  };
@@ -1,5 +1,4 @@
1
1
  import { AUTH_LOGIN_PATH, CONSOLE_DATA_RUN_PROFILE, Command, askPasswordMasked } from "nodefony";
2
- import { USER_REVOKED_EVENT } from "@nodefony/user";
3
2
  //#region nodefony/command/security-user-password.ts
4
3
  const options = {
5
4
  runProfile: CONSOLE_DATA_RUN_PROFILE,
@@ -34,10 +33,10 @@ 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) {
@@ -100,12 +99,6 @@ var SecurityUserPassword = class extends Command {
100
99
  process.exitCode = 1;
101
100
  return this;
102
101
  }
103
- this.kernel?.fire(USER_REVOKED_EVENT, {
104
- id: user.id,
105
- identifier: user.identifier,
106
- tenantId: null,
107
- reason: "password_changed"
108
- });
109
102
  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
103
  return this;
111
104
  }