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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +48 -16
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
  4. package/dist/nodefony/command/security-oauth-doctor.js +68 -0
  5. package/dist/nodefony/command/security-secrets.js +52 -29
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +10 -7
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +9 -14
  10. package/dist/nodefony/config/config.js +99 -8
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/contracts/index.js +1 -2
  14. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  15. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  16. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  17. package/dist/nodefony/errors/CsrfError.js +1 -2
  18. package/dist/nodefony/errors/InsecureTransportError.js +23 -0
  19. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  20. package/dist/nodefony/errors/SsrfError.js +1 -2
  21. package/dist/nodefony/errors/ThrottledError.js +1 -2
  22. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  23. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  24. package/dist/nodefony/errors/index.js +2 -1
  25. package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
  26. package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
  27. package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
  28. package/dist/nodefony/keycloak/scaffold.js +231 -0
  29. package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
  30. package/dist/nodefony/scaffold/contribute.js +19 -0
  31. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  32. package/dist/nodefony/service/apiKeys.js +45 -5
  33. package/dist/nodefony/service/auditService.js +4 -5
  34. package/dist/nodefony/service/authFlow.js +118 -16
  35. package/dist/nodefony/service/authorization.js +1 -2
  36. package/dist/nodefony/service/cors.js +1 -2
  37. package/dist/nodefony/service/csrf.js +1 -2
  38. package/dist/nodefony/service/firewall.js +116 -25
  39. package/dist/nodefony/service/keycloak.js +208 -0
  40. package/dist/nodefony/service/oauth2.js +403 -28
  41. package/dist/nodefony/service/securityHeaders.js +1 -2
  42. package/dist/nodefony/service/tokenService.js +94 -27
  43. package/dist/nodefony/service/totp.js +55 -11
  44. package/dist/nodefony/service/webAuthn.js +24 -7
  45. package/dist/nodefony/service/webhooks.js +4 -5
  46. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  47. package/dist/nodefony/src/SecuredArea.js +15 -5
  48. package/dist/nodefony/src/accountBinding.js +96 -0
  49. package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
  50. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  51. package/dist/nodefony/src/admin/adminAudit.js +1 -3
  52. package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
  53. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  54. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  55. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  56. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  57. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  58. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  59. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  60. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  61. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  62. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
  63. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  64. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
  65. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
  66. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  67. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
  68. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
  69. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  70. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  71. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  72. package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
  73. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  74. package/dist/nodefony/src/csp.js +1 -2
  75. package/dist/nodefony/src/csrfToken.js +1 -2
  76. package/dist/nodefony/src/entryPoint.js +38 -0
  77. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  78. package/dist/nodefony/src/net/transportFailure.js +57 -0
  79. package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
  80. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  81. package/dist/nodefony/src/oauth/metadata.js +28 -12
  82. package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
  83. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
  84. package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
  85. package/dist/nodefony/src/oauth/providerRoles.js +78 -0
  86. package/dist/nodefony/src/oauth/providers/github.js +48 -12
  87. package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
  88. package/dist/nodefony/src/openToSignup.js +46 -0
  89. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  90. package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
  91. package/dist/nodefony/src/rolesWithin.js +57 -0
  92. package/dist/nodefony/src/sessionAuthentication.js +25 -0
  93. package/dist/nodefony/src/sessionIdentity.js +29 -3
  94. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  95. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  96. package/dist/nodefony/src/token/JwtKeystore.js +89 -48
  97. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  98. package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
  99. package/dist/nodefony/src/token/UserToken.js +1 -2
  100. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  101. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  102. package/dist/nodefony/src/token/secretFile.js +60 -4
  103. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  104. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  105. package/dist/nodefony/src/token/tokenSort.js +1 -2
  106. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  107. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  108. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  109. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  110. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  111. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  112. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  113. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
  114. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  115. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  116. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  117. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  118. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  119. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  120. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  124. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  125. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  126. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  127. package/dist/types/index.d.ts +26 -6
  128. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
  129. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  130. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  131. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  132. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  133. package/dist/types/nodefony/config/config.d.ts +64 -1
  134. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  135. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  136. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  137. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  138. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  139. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  140. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  141. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  142. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  143. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  144. package/dist/types/nodefony/errors/index.d.ts +1 -0
  145. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  146. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  147. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  148. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  149. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  150. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  151. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  152. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  153. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  154. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  155. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  156. package/dist/types/nodefony/service/oauth2.d.ts +145 -9
  157. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  158. package/dist/types/nodefony/service/totp.d.ts +28 -4
  159. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  160. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  161. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  162. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
  163. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
  164. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  165. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
  166. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  167. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  168. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  169. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  170. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  171. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  172. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  173. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  174. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  175. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  176. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  177. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  178. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  179. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  180. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  181. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  182. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  183. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  184. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  185. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  186. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  187. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  188. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  189. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  190. package/docs/api-keys.md +182 -175
  191. package/docs/audit.md +141 -141
  192. package/docs/authenticators.md +129 -96
  193. package/docs/authorization.md +116 -69
  194. package/docs/cors.md +64 -64
  195. package/docs/csrf.md +77 -72
  196. package/docs/external-jwt.md +83 -11
  197. package/docs/firewall.md +119 -83
  198. package/docs/headers.md +82 -82
  199. package/docs/index.md +16 -8
  200. package/docs/keycloak.md +835 -0
  201. package/docs/login-page.md +361 -0
  202. package/docs/oauth2.md +291 -166
  203. package/docs/obtenir-un-jeton.md +131 -34
  204. package/docs/tokens.md +197 -140
  205. package/docs/totp.md +177 -136
  206. package/docs/webauthn.md +157 -154
  207. package/docs/webhooks.md +128 -125
  208. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  209. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  210. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  211. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  212. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  213. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  214. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  215. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  216. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  217. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  218. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  219. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  220. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  221. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  222. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  223. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  224. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  225. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  226. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  227. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  228. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  229. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  230. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  231. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  232. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  233. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  234. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  235. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  236. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  237. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  238. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  239. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  240. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  241. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  242. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  243. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  244. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  245. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  246. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  247. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  248. package/package.json +19 -17
  249. package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
@@ -0,0 +1,835 @@
1
+ ---
2
+ title: "Keycloak — brancher un annuaire, du realm au premier login"
3
+ navTitle: Keycloak
4
+ lang: fr
5
+ module: "@nodefony/security"
6
+ topic: keycloak
7
+ coverageModule: security
8
+ coverageFiles: "oauth2.ts,oauthProviderRegistry,oidc.ts,providerRoles"
9
+ section: "Sécurité"
10
+ audience: [developer, devops]
11
+ tags:
12
+ [
13
+ security,
14
+ keycloak,
15
+ oidc,
16
+ oauth2,
17
+ realm,
18
+ sso,
19
+ pkce,
20
+ bff,
21
+ resource-server,
22
+ roles,
23
+ roleMapping,
24
+ ]
25
+ version: "doc"
26
+ status: stable
27
+ updated: 2026-10-12
28
+ source: "src/packages/@nodefony/security/docs/keycloak.md"
29
+ ---
30
+
31
+ # Keycloak — brancher un annuaire, du realm au premier login
32
+
33
+ > Ton entreprise tient ses comptes dans un **Keycloak** et tu veux que tes utilisateurs se connectent
34
+ > à ton application avec ces comptes, sans que l'application voie jamais un mot de passe. Côté
35
+ > Nodefony, il n'y a **aucun code à écrire** : le fournisseur `keycloak` est fourni, et l'adresse
36
+ > du realm suffit à le décrire. Le travail se passe surtout dans Keycloak : créer le realm, déclarer
37
+ > un client **confidentiel**, enregistrer l'URL de retour au caractère près. Cette page fait le
38
+ > chemin entier, puis montre comment le jeton Keycloak de la même personne ouvre aussi ton API, sur
39
+ > le **même compte**. Ancré sur le fournisseur `createDiscoveredOidcProvider()` (`oidc.ts#createDiscoveredOidcProvider`) et
40
+ > sur `OAuth2Service` (`oauth2.ts#OAuth2Service`).
41
+
42
+ 📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Keycloak**
43
+
44
+ ## 🧠 Schéma général
45
+
46
+ ```mermaid
47
+ sequenceDiagram
48
+ autonumber
49
+ actor U as Navigateur
50
+ participant A as Application Nodefony
51
+ participant K as Keycloak (realm)
52
+ Note over A,K: Au démarrage : A lit les métadonnées du realm (.well-known)
53
+ U->>A: GET …/oauth2/keycloak/authorize
54
+ A-->>U: 302 vers K (state + code_challenge PKCE)
55
+ U->>K: page de connexion du realm
56
+ K-->>U: 302 vers …/oauth2/keycloak/callback?code=…&iss=…
57
+ U->>A: GET …/callback (code)
58
+ A->>K: échange code + code_verifier + secret du client (serveur à serveur)
59
+ K-->>A: ID token + jeton d'accès
60
+ A-->>U: 302 + cookie de session (aucun jeton dans le navigateur)
61
+ ```
62
+
63
+ Le navigateur ne voit passer qu'un `code` à usage unique. Le secret du client et les jetons restent
64
+ entre l'application et Keycloak, sur un canal TLS.
65
+
66
+ ## 📖 Lexique
67
+
68
+ | Terme | Développé et rôle |
69
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------- |
70
+ | Keycloak | Serveur d'identité libre, auto-hébergé : il tient les comptes, affiche la page de connexion et délivre les jetons. |
71
+ | Realm | Espace de comptes isolé dans Keycloak : ses utilisateurs, ses clients, ses clés. Son URL est l'**émetteur**. |
72
+ | Client | L'application telle que Keycloak la connaît : un identifiant, un secret, des URL de retour autorisées. |
73
+ | Client confidentiel | Client capable de garder un secret, parce qu'il tourne sur un serveur. Keycloak l'appelle « Client authentication ON ». |
74
+ | OIDC | _OpenID Connect_ : la couche d'identité au-dessus d'OAuth 2.0, qui ajoute l'ID token (qui est l'utilisateur). |
75
+ | Émetteur (`iss`) | L'identifiant du serveur qui signe les jetons. Pour Keycloak : `https://<hôte>/realms/<realm>`. |
76
+ | `sub` | _Subject_ : l'identifiant stable de l'utilisateur **dans son realm**. Unique chez son émetteur, nulle part ailleurs. |
77
+ | PKCE | _Proof Key for Code Exchange_ (RFC 7636) : rend inutilisable un `code` volé en route. |
78
+ | BFF | _Backend For Frontend_ : le serveur garde les jetons et ne donne au navigateur qu'un cookie de session. |
79
+ | Shadow User | Le compte local que l'application crée au premier login, lié à `(keycloak, sub)`. |
80
+ | Audience (`aud`) | La ressource à laquelle un jeton d'accès est destiné ; ton API refuse un jeton qui ne la nomme pas. |
81
+
82
+ ## Qu'est-ce que c'est ?
83
+
84
+ Keycloak est le **guichet d'accueil d'un immeuble**. Chaque société de l'immeuble (chaque
85
+ application) fait confiance au badge qu'il délivre, sans tenir son propre registre de visiteurs. Le
86
+ visiteur montre ses papiers une fois, au guichet ; les sociétés ne voient que le badge.
87
+
88
+ Pour ton application, cela bloque trois risques concrets :
89
+
90
+ - **Le mot de passe ne transite jamais par l'application** : une faille chez toi ne fait pas fuir
91
+ les identifiants de l'annuaire.
92
+ - **Un `code` intercepté ne sert à rien** : sans le `code_verifier` (PKCE) et sans le secret du
93
+ client, Keycloak refuse l'échange.
94
+ - **Un départ se gère à un seul endroit** : désactiver le compte dans le realm ferme la porte de
95
+ toutes les applications qui s'y fient, à leur prochaine connexion.
96
+
97
+ ## La vision Nodefony
98
+
99
+ **Aucun code propre à Keycloak.** Un serveur OpenID Connect publie lui-même ses points d'entrée ;
100
+ le fournisseur `keycloak` les **découvre** à partir de l'émetteur, et c'est tout ce qu'il sait faire
101
+ (`createDiscoveredOidcProvider()`, `oidc.ts#createDiscoveredOidcProvider`). Changer de realm, c'est changer une URL.
102
+
103
+ **Une configuration fausse arrête le démarrage.** Le fournisseur `keycloak` est enregistré avec
104
+ `requiresIssuer: true` (`oauthProviderRegistry.ts#withKeycloakThemeHint`). Au boot, un émetteur absent, ou qui n'est
105
+ pas une URL `https` sans requête ni fragment, lève une erreur qui nomme la clé
106
+ (`checkProviderIssuer()`, `oauth2.ts#checkProviderIssuer`). Un Keycloak **éteint**, lui, ne bloque rien : le bouton
107
+ disparaît de l'écran de connexion et revient tout seul quand le realm répond à nouveau
108
+ (`#isReachable()`, `oauth2.ts#OAuth2Service.#isReachable`).
109
+
110
+ **L'identité est la paire `(keycloak, sub)`, jamais l'email.** Au premier login, l'application crée
111
+ un compte local lié à cette paire (`UserService.provisionOAuthUser()`, `UserService.ts#UserService.provisionOAuthUser`). Un
112
+ compte local qui porte déjà le même identifiant sans ce lien n'est **jamais** rattaché en silence :
113
+ la connexion est refusée (`UserService.ts#UserService.changePassword`).
114
+
115
+ **Keycloak authentifie, l'application autorise.** Les rôles du realm ne sont pas lus : le compte
116
+ reçoit `defaultRoles` à sa création, puis la base locale fait foi. Une élévation se fait dans
117
+ l'application, pas dans Keycloak.
118
+
119
+ ## 🚀 Démarrage rapide
120
+
121
+ ### Essayer en deux minutes : le Keycloak du dépôt
122
+
123
+ Le dépôt fournit un Keycloak 26.8 **déjà configuré** : un realm `nodefony`, un client confidentiel
124
+ `nodefony-dev`, ses deux rôles client `admin` et `admin-nodefony`, et trois utilisateurs. Tout est
125
+ écrit dans `docker/keycloak/import/realm-nodefony.json`, importé au premier démarrage du conteneur.
126
+
127
+ | Utilisateur | Mot de passe | Rôles Keycloak (client `nodefony-dev`) | Rôles obtenus dans l'application |
128
+ | ----------- | ------------ | -------------------------------------- | ------------------------------------ |
129
+ | `alice` | `alice-dev` | aucun | `ROLE_USER` |
130
+ | `bob` | `bob-dev` | `admin` | `ROLE_USER`, `ROLE_ADMIN` |
131
+ | `cci` | `cci-dev` | `admin`, `admin-nodefony` | + `ROLE_NODEFONY_ADMIN` (plateforme) |
132
+
133
+ `cci` doit configurer un **code à usage unique** (TOTP) à sa première connexion
134
+ (`"requiredActions": ["CONFIGURE_TOTP"]`) : le compte porte le rôle de plateforme, il exige un second
135
+ facteur, et aucune graine n'est écrite dans le fichier.
136
+
137
+ 1. **Démarrer le conteneur** (en `https` sur le port 8444, avec le certificat de l'application) :
138
+
139
+ ```bash
140
+ docker compose -f docker/docker-compose.yml --profile keycloak up -d keycloak
141
+ ```
142
+
143
+ 2. **Brancher l'application** : poser dans `.env` (ignoré par git) les trois lignes
144
+ ci-dessous — valeurs publiques, écrites dans le realm importé.
145
+
146
+ ```bash
147
+ NF_KEYCLOAK_ISSUER=https://localhost:8444/realms/nodefony
148
+ NF_KEYCLOAK_CLIENT_ID=nodefony-dev
149
+ NF_KEYCLOAK_CLIENT_SECRET=nodefony-dev-keycloak-secret
150
+ ```
151
+
152
+ Ces valeurs sont **publiques** : elles n'existent que dans ce décor de développement.
153
+
154
+ 3. **Démarrer l'application**, ouvrir `https://localhost:5152/nodefony/login` : le bouton
155
+ **Keycloak** est là. Se connecter avec `alice` / `alice-dev`.
156
+
157
+ La console d'administration du realm est sur `https://localhost:8444/admin` (`admin` /
158
+ `nodefony-dev`). Les retouches qu'on y fait survivent aux redémarrages ;
159
+ `docker compose … down -v` repart du fichier.
160
+
161
+ ### Essayer dans une application générée
162
+
163
+ Une application créée par `nodefony create app` avec le préréglage `complete` porte déjà le
164
+ même décor, **à son nom** : un profil `keycloak` dans son `compose.yaml`, les variables
165
+ `NF_KEYCLOAK_*` déclarées dans `env.ts` et le fournisseur conditionnel dans
166
+ `nodefony/config/security.ts`. Tant qu'on ne l'allume pas, l'application démarre sans bouton ni
167
+ avertissement.
168
+
169
+ Le realm `docker/keycloak/import/realm.json` (realm et client nommés comme l'application,
170
+ utilisateur `alice`) et le thème `docker/keycloak/themes/nodefony/` ne viennent PAS du
171
+ générateur : ils sont **livrés par ce paquet** (`nodefony/keycloak/scaffold.ts`, appelé par le point
172
+ de contribution `nodefony/scaffold/contribute.ts` déclaré dans
173
+ `package.json` sous `nodefony.contribute`), et posés après l'installation. Ce sont de vrais
174
+ fichiers, versionnés avec l'application : le thème se retouche à ses couleurs. Les fichiers du thème
175
+ sont des **copies suivies** (empreintes dans `scaffold.lock.json`) ; le realm est une **semence**,
176
+ qui appartient à l'application dès qu'il existe (`security:keycloak:realm --write` le fait évoluer).
177
+
178
+ ```bash
179
+ npx nodefony scaffold:sync # pose ce qui manque (création en --no-install)
180
+ npx nodefony scaffold:sync --update # après un npm update : rafraîchit le thème resté intact
181
+ ```
182
+
183
+ Un fichier du thème retouché par l'application n'est jamais remplacé : il est signalé, et pour
184
+ reprendre la version du paquet on le supprime avant de relancer.
185
+
186
+ Rien ne prévient au démarrage qu'un `npm update` a laissé le thème en arrière : la page de
187
+ connexion de Keycloak garde simplement l'ancienne apparence. C'est `nodefony doctor --live` qui
188
+ le dit, famille « Fichiers livrés par les paquets » — une copie intacte en retard, ou un fichier
189
+ livré absent (un habillage ajouté par la nouvelle version), avec le geste
190
+ `nodefony scaffold:sync --update`. Une copie retouchée n'y est jamais signalée. Le diagnostic lit
191
+ le même plan que la console d'administration (`GET /nodefony/kernel/api/scaffold`), donc en
192
+ développement seulement ; ailleurs la famille est rendue « non contrôlée », avec sa raison.
193
+
194
+ Le thème couvre les **cinq types** de Keycloak, chacun hérité du thème de Keycloak qu'il habille
195
+ (seuls le logo, les couleurs et quelques textes sont posés — les pages restent celles de
196
+ Keycloak, donc justes à chaque mise à jour) :
197
+
198
+ | Type | Ce qu'il habille | Hérite de | Choisi par |
199
+ | --------- | -------------------------------------------------- | ------------- | ---------------------------------------------------- |
200
+ | `login` | connexion, mot de passe oublié, erreurs | `keycloak.v2` | le realm (`loginTheme`) |
201
+ | `account` | console du compte de l'utilisateur | `keycloak.v3` | le realm (`accountTheme`) |
202
+ | `admin` | console d'administration | `keycloak.v2` | le realm (`adminTheme`) |
203
+ | `email` | enveloppe HTML de tous les courriels | `keycloak` | le realm (`emailTheme`) |
204
+ | `welcome` | page d'accueil du serveur (avant le premier admin) | `keycloak` | le SERVEUR : `KC_SPI_THEME__WELCOME_THEME: nodefony` |
205
+
206
+ **L'habillage de la page `/login` suit jusqu'à Keycloak.** Chaque habillage de
207
+ `loginPage.skin` a son thème de connexion Keycloak : `nodefony` pour `frontispiece` (le défaut),
208
+ `nodefony-<habillage>` pour les huit autres (`nodefony-blueprint`, `nodefony-horizon`…). Ce sont
209
+ des thèmes ENFANTS de `nodefony` : même gabarit, mêmes textes, plus la feuille de l'habillage (copie
210
+ conforme de celle du framework) et sa mise en page. Toutes les pages du parcours en héritent —
211
+ connexion, code à usage unique, configuration du second facteur, mot de passe oublié. Le compose
212
+ monte le dossier `docker/keycloak/themes/` ENTIER, pour que Keycloak les voie tous ; la console les
213
+ propose aussi dans _Realm settings › Themes_. Limite : seule l'enveloppe de la page est à Nodefony,
214
+ les formulaires restent ceux de Keycloak — `ledger` (colonnes et étapes numérotées) et `horizon`
215
+ (bandeau photo dans la carte) n'y portent que leurs couleurs et leur mise en page.
216
+
217
+ Le compose pose aussi `KC_SPI_THEME__DEFAULT: nodefony` : le realm `master`, que l'import ne touche
218
+ pas, prend le même thème — sa connexion et sa console d'administration comprises. Clair et sombre
219
+ suivent le réglage du système (`prefers-color-scheme`) ; le réglage _Dark mode_ du realm le coupe.
220
+ Le realm `master` n'est **pas importé** (Keycloak le crée au premier démarrage, l'import ne le touche
221
+ pas) : il naît sans internationalisation, donc en anglais et sans sélecteur de langue. Le régler une
222
+ fois dans sa console — _Realm settings › Localization_ : internationalisation activée, `fr` et `en`,
223
+ `fr` par défaut. Le réglage vit dans le volume `keycloak-data` : il survit aux redémarrages, pas à
224
+ un `down -v`. Sur sa page de connexion, le bouton « Retour à … » n'apparaît pas : il annule la
225
+ connexion, et les consoles de Keycloak lui-même ne savent pas traiter cette annulation.
226
+
227
+ Les comptes du realm généré reçoivent le rôle `default-roles-<realm>` : un compte importé n'a QUE
228
+ les rôles qu'on lui liste, et sans celui-là la console du compte lui refuse son propre profil
229
+ (`401`).
230
+
231
+ Le realm généré ne déclare **aucun serveur d'envoi** de courriels : « mot de passe oublié » reste
232
+ coupé tant que l'application n'en branche pas un (console : _Realm settings › Email_). Le dépôt,
233
+ lui, en a un de test — Mailpit (`--profile keycloak`, interface `http://localhost:8025`).
234
+
235
+ 1. **Fabriquer le certificat de développement** (une fois — Keycloak le sert en `https`) :
236
+
237
+ ```bash
238
+ npx nodefony http:certificates
239
+ ```
240
+
241
+ 2. **Démarrer le conteneur** :
242
+
243
+ ```bash
244
+ docker compose --profile keycloak up -d keycloak
245
+ ```
246
+
247
+ Sous Linux natif, poser d'abord `export KEYCLOAK_UID=$(id -u)` : sinon la clé privée montée
248
+ est illisible par le conteneur.
249
+
250
+ 3. **Brancher l'application** : rien à faire. Le `.env` généré porte déjà les lignes
251
+ `NF_KEYCLOAK_*`, actives et alignées sur le realm généré. Keycloak éteint, l'application démarre
252
+ quand même : un avertissement, pas de bouton, et le bouton revient tout seul dès que Keycloak
253
+ répond. Pour t'en passer, commente ces lignes.
254
+
255
+ 4. **Démarrer l'application**, puis ouvrir `https://localhost:5152/nodefony/login` et se
256
+ connecter avec `alice` / `alice-dev` :
257
+
258
+ ```bash
259
+ npm run dev
260
+ ```
261
+
262
+ Rien d'autre à poser, sous Windows comme ailleurs. Le Keycloak du décor sert le certificat de
263
+ développement de l'application, et l'application **reconnaît d'office l'autorité qui l'a
264
+ signé** : en développement, le service de certificats l'ajoute aux autorités de confiance du
265
+ processus dès le démarrage, commandes en ligne comprises (`addDefaultCaCertificate()`,
266
+ `certificates.ts#addDefaultCaCertificate`). La variable `NODE_EXTRA_CA_CERTS` n'est plus nécessaire.
267
+
268
+ Le realm généré porte aussi, comme celui du dépôt, un client **machine** `<app>-machine` (compte de
269
+ service, `client_credentials`). Ses jetons portent l'audience de la zone `partner` que le gabarit
270
+ `complete` ouvre (`https://localhost:5152/api/partner`) : `npx nodefony app:partner:call --issuer
271
+ keycloak` obtient un jeton et appelle `GET /api/partner/whoami` dès le premier démarrage. Quand tu
272
+ ouvres une autre zone (section « Le jeton Keycloak sur ton API », plus bas),
273
+ `npx nodefony security:keycloak:realm --write` ajoute le mapper qui inscrit sa `resource` dans les
274
+ jetons.
275
+
276
+ Le port de Keycloak (8444) se change par `KEYCLOAK_PORT` au `up` — l'émetteur suit, donc
277
+ `NF_KEYCLOAK_ISSUER` aussi. En production, le décor ne sert plus : les trois variables pointent
278
+ ton Keycloak, et `NF_OAUTH_REDIRECT_BASE` porte l'URL publique de l'application, base de l'URL de
279
+ retour.
280
+
281
+ ### Le realm écrit depuis ta configuration
282
+
283
+ Les sections 1 et 2 ci-dessous déroulent les réglages à la main. Une fois le fournisseur branché
284
+ (section 4), Nodefony sait les écrire lui-même : **`nodefony security:keycloak:realm`** dérive le
285
+ realm de la configuration EFFECTIVE de l'application — client, URL de retour, déconnexion par canal
286
+ arrière, audience de chaque zone, rôles de `roleMapping`.
287
+
288
+ ```bash
289
+ npx nodefony security:keycloak:realm # affiche le realm
290
+ npx nodefony security:keycloak:realm --write # l'écrit dans docker/keycloak/import/
291
+ npx nodefony security:keycloak:realm --check # sort en 1 s'il ne suit plus la config (CI)
292
+ npx nodefony security:keycloak:realm --apply # pose le thème de connexion sur le realm VIVANT (dev)
293
+ ```
294
+
295
+ - **Il fusionne, il n'écrase pas.** Ce que la configuration dit est réécrit ; ce qu'un humain a
296
+ ajouté survit : comptes, titres, secret du client machine, rôles déjà déclarés, mappers qui ne sont
297
+ pas d'audience, autres clients (`mergeKeycloakRealm`, `nodefony/keycloak/keycloakRealm.ts`).
298
+ - **Hors développement, aucun secret n'est écrit** et seules les adresses de `redirectUri` sont
299
+ déclarées : Keycloak génère le secret, tu le copies dans ton gestionnaire.
300
+ - **Le thème de connexion suit `loginPage.skin`** (`loginTheme`, `keycloakLoginTheme`,
301
+ `nodefony/keycloak/keycloakRealmInput.ts`) — mais seulement sur un realm déjà habillé par
302
+ Nodefony : un thème propre à l'application n'est jamais remplacé, et l'habillage non appliqué
303
+ s'annonce en avertissement.
304
+ - **Keycloak n'importe un realm qu'à sa création.** Le thème d'un realm déjà là se pose par
305
+ `--apply` (ci-dessous), ou à la main dans **Realm settings** › **Themes** › _Login theme_. Le
306
+ reste d'un realm déjà là se met à jour par
307
+ **Realm settings** › **Action** › **Partial import**, clients en « Overwrite » : comptes et seconds
308
+ facteurs restent.
309
+
310
+ ### Le thème suit l'habillage de `/login` — `--apply`, et le service `keycloak`
311
+
312
+ Changer `loginPage.skin` ne change pas la page de Keycloak : le thème vit dans SA base (réglage
313
+ `loginTheme` du realm). `--apply` le pose par l'**API d'administration** de Keycloak, qui exige le
314
+ compte administrateur du SERVEUR (realm `master`) — celui du compose de développement :
315
+
316
+ ```typescript ignore
317
+ // nodefony/config/security.ts — le compte ne passe qu'en développement
318
+ keycloak: {
319
+ issuer: ctx.env.NF_KEYCLOAK_ISSUER,
320
+ // …
321
+ ...(ctx.isDev
322
+ ? { admin: { username: ctx.env.NF_KEYCLOAK_ADMIN_USERNAME, password: ctx.env.NF_KEYCLOAK_ADMIN_PASSWORD } }
323
+ : {}),
324
+ },
325
+ ```
326
+
327
+ - **Développement seulement** : refusé dans tout autre environnement, même compte posé ; le mot de
328
+ passe ne part qu'en https et n'est jamais journalisé.
329
+ - **Un thème de l'application n'est jamais remplacé** : seul un realm déjà sur `nodefony` ou
330
+ `nodefony-*` change d'habillage.
331
+ - **Toutes les actions Keycloak passent par le service `keycloak`** (`KeycloakService`) : realm
332
+ dérivé (`realmPlan`, `writeRealm`), client d'administration (`admin()`, jeton gardé jusqu'à son
333
+ expiration), thème (`applyLoginTheme`). La commande n'en garde que l'affichage ; une route de
334
+ développement l'appelle aussi — c'est ce que fait l'application de ce dépôt pour que le sélecteur
335
+ d'aperçu de `/login` (`loginPage.previewHook`) entraîne Keycloak.
336
+ - **L'écart se voit sans rien appliquer** : `nodefony doctor --live` et `security:oauth:doctor`
337
+ lisent le thème sur la page publique de Keycloak (aucun identifiant) et signalent
338
+ `login-theme-mismatch` quand il ne suit pas `loginPage.skin`.
339
+
340
+ Le client machine suivi est celui qu'on nomme (`--machine <clientId>`), sinon le compte de service
341
+ que le fichier déclare déjà. Le canal arrière vise `http://host.docker.internal:<port>` en
342
+ développement (Keycloak vit dans un conteneur) ; `--backchannel-origin` le déplace.
343
+
344
+ ### 1. Créer le realm
345
+
346
+ Dans la console d'administration de **ton** Keycloak :
347
+
348
+ 1. **Create realm** (à côté de _Current realm_), donner un nom — par exemple `mon-entreprise`.
349
+ 2. Le realm créé devient le realm courant. Ses utilisateurs s'y créent sous **Users**, ou s'y
350
+ importent depuis un annuaire LDAP (**User federation**).
351
+
352
+ Ne pas travailler dans le realm `master` : il administre Keycloak lui-même.
353
+
354
+ ### 2. Déclarer le client confidentiel
355
+
356
+ **Clients** › **Create client**, type **OpenID Connect**. Les réglages qui comptent :
357
+
358
+ | Réglage Keycloak | Valeur | Pourquoi |
359
+ | --------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
360
+ | **Client ID** | `mon-app` | Devient `NF_KEYCLOAK_CLIENT_ID`. |
361
+ | **Client authentication** | **ON** | Client **confidentiel** : l'application tourne sur un serveur, elle garde un secret. Nodefony en exige un. |
362
+ | **Standard flow** | coché | C'est le flux _Authorization Code_, le seul que Nodefony emploie. |
363
+ | **Direct access grants** | décoché | Ce flux fait transiter le mot de passe par l'application — exactement ce qu'on évite. |
364
+ | **Implicit flow** | décoché | Retiré par OAuth 2.1 : il livre le jeton dans l'URL. |
365
+ | **PKCE method** | `S256` | Keycloak **exige** alors PKCE pour ce client ; Nodefony l'envoie toujours. |
366
+ | **Valid redirect URIs** | `https://app.example.com/nodefony/security/api/oauth2/keycloak/callback` | Comparée **au caractère près** (RFC 9700). Pas de joker en production. |
367
+ | **Valid post logout redirect URIs** | `https://app.example.com/*` | Retour du navigateur après la déconnexion initiée par l'application. |
368
+ | **Front channel logout** | **OFF** | Il exige une page que l'application n'a pas ; activé, Keycloak n'appelle PAS le canal arrière. |
369
+ | **Backchannel logout URL** | `https://app.example.com/nodefony/security/api/oauth2/keycloak/backchannel-logout` | Keycloak y prévient l'application quand la session SSO se ferme ailleurs. Joignable **par Keycloak**. |
370
+ | **Backchannel logout session required** | **ON** | Le jeton porte le `sid` : seule LA session concernée est fermée, pas toutes celles du compte. |
371
+
372
+ Puis l'onglet **Credentials** : copier le **Client secret**. C'est `NF_KEYCLOAK_CLIENT_SECRET`.
373
+
374
+ > [!WARNING]
375
+ > L'URL de retour enregistrée chez Keycloak et celle que l'application envoie doivent être
376
+ > **identiques** : schéma, hôte, port, chemin. `localhost` et `127.0.0.1` sont deux hôtes
377
+ > différents. Une différence d'un caractère donne `Invalid parameter: redirect_uri` sur la page
378
+ > Keycloak, avant même le formulaire de connexion.
379
+
380
+ ### 3. Lire l'émetteur
381
+
382
+ **Realm settings** › onglet **General** › lien **OpenID Endpoint Configuration**. Le document qui
383
+ s'ouvre commence par la valeur à recopier :
384
+
385
+ ```json
386
+ { "issuer": "https://sso.example.com/realms/mon-entreprise", "…": "…" }
387
+ ```
388
+
389
+ C'est `NF_KEYCLOAK_ISSUER`, sans barre finale ni `/.well-known/…`. Elle doit être en `https`.
390
+
391
+ Keycloak calcule cette valeur à partir de son nom d'hôte. Derrière un proxy, ou quand le navigateur
392
+ et l'application ne joignent pas Keycloak par la même adresse, fixer `KC_HOSTNAME` à l'URL publique
393
+ complète : sinon l'émetteur change selon qui demande, et l'application refuse les jetons.
394
+
395
+ ### 4. Brancher l'application
396
+
397
+ Les trois valeurs entrent par `env.ts`, seul lecteur des variables d'environnement. Le fournisseur
398
+ n'est déclaré que si les trois sont présentes : pas de bouton mort quand une variable manque.
399
+
400
+ ```ts ignore
401
+ // nodefony/config/security.ts — le fragment du module, importé par le manifeste
402
+ import type { ConfigContext } from "nodefony";
403
+ import type { ISecurityConfigInput } from "@nodefony/security";
404
+ import type { env } from "../../env";
405
+
406
+ export const securityConfig = (ctx: ConfigContext<typeof env>) =>
407
+ ({
408
+ oauth2: {
409
+ // Rôles posés à la CRÉATION du compte local ; ceux du realm ne sont pas lus.
410
+ defaultRoles: ["ROLE_USER"],
411
+ providers: {
412
+ ...(ctx.env.NF_KEYCLOAK_ISSUER &&
413
+ ctx.env.NF_KEYCLOAK_CLIENT_ID &&
414
+ ctx.env.NF_KEYCLOAK_CLIENT_SECRET
415
+ ? {
416
+ keycloak: {
417
+ issuer: ctx.env.NF_KEYCLOAK_ISSUER,
418
+ clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
419
+ clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
420
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
421
+ // Libellé du bouton ; omis, il vaut « Keycloak ».
422
+ label: "Compte entreprise",
423
+ },
424
+ }
425
+ : {}),
426
+ },
427
+ },
428
+ }) satisfies ISecurityConfigInput;
429
+ ```
430
+
431
+ ```typescript ignore
432
+ // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
433
+ // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
434
+ import { defineConfig, defineEnv, envString, use } from "nodefony";
435
+ import { securityConfig } from "./nodefony/config/security";
436
+
437
+ export const env = defineEnv({
438
+ // Émetteur = URL du realm, en https (la découverte et le contrôle de `iss` en partent).
439
+ NF_KEYCLOAK_ISSUER: envString({ optional: true }),
440
+ NF_KEYCLOAK_CLIENT_ID: envString({ optional: true }),
441
+ NF_KEYCLOAK_CLIENT_SECRET: envString({ optional: true }),
442
+ // Base des URL de retour : celle que Keycloak a enregistrée, au caractère près.
443
+ NF_OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
444
+ });
445
+
446
+ export default defineConfig<typeof env>((ctx) => ({
447
+ modules: [
448
+ "@nodefony/http",
449
+ "@nodefony/framework",
450
+ use("@nodefony/security", securityConfig(ctx)),
451
+ ],
452
+ }));
453
+ ```
454
+
455
+ L'application doit faire **confiance au certificat** de Keycloak, puisqu'elle l'appelle en `https`.
456
+ C'est comme un badge présenté à l'accueil : l'application ne laisse entrer que les badges signés
457
+ par une autorité qu'elle connaît. Trois cas :
458
+
459
+ | Le certificat de Keycloak est signé par… | Ce qu'il faut faire |
460
+ | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- |
461
+ | une autorité publique (Let's Encrypt…) | rien |
462
+ | l'autorité de **développement** de Nodefony (le Keycloak du décor) | rien : reconnue d'office en développement |
463
+ | la même, mais l'application tourne en **production** sur le poste | la déclarer : `NF_TLS_CA_FILE=nodefony/config/certificates/ca/nodefony-root-ca.crt.pem` |
464
+ | l'autorité de **ton entreprise** (Keycloak interne) | la déclarer : `NF_TLS_CA_FILE=certs/autorite-entreprise.pem` dans `.env`, ou dans le Secret du pod |
465
+
466
+ `NF_TLS_CA_FILE` alimente la clé `certificates.extraCaFiles` du module `@nodefony/http` : un ou
467
+ plusieurs fichiers PEM, absolus ou relatifs à la racine de l'application, une chaîne entière
468
+ (racine + intermédiaires) pouvant tenir dans un seul fichier. Ils sont ajoutés aux autorités du
469
+ processus au démarrage, avant tout service (`trustCaFile()`, `certificates.ts#trustCaFile`), et servent à
470
+ **tous** ses appels sortants : découverte, point de jeton, jeu de clés. Un fichier introuvable, ou
471
+ sans certificat, **interrompt le démarrage** en nommant le chemin (code 78) — plutôt qu'un
472
+ `fetch failed` au premier clic d'un utilisateur. Seule exception : l'autorité de développement pas
473
+ encore engendrée est ignorée en mode `development` (le journal le dit) ; en production, elle doit
474
+ exister — `npx nodefony http:certificates` l'engendre. C'est l'équivalent, porté par la configuration, de
475
+ `NODE_EXTRA_CA_CERTS` : même effet, sans syntaxe de shell à traduire sous Windows.
476
+
477
+ Pourquoi la deuxième ligne du tableau : l'autorité de développement n'est reconnue d'office qu'en
478
+ mode `development` (`Certificate.trustDevelopmentAuthority()`, `certificates.ts#Certificate.trustDevelopmentAuthority`). Lancée
479
+ en production — pour essayer le build sur le poste, par exemple —, l'application ne lui fait plus
480
+ confiance sans qu'on le lui dise, et c'est voulu : une application de production qui adopterait en
481
+ silence une autorité de développement ouvrirait une porte qu'aucune configuration ne montre. Le
482
+ refus nomme la variable à poser (`certificat refusé (SELF_SIGNED_CERT_IN_CHAIN)` →
483
+ `NF_TLS_CA_FILE=…`), et le `.env` généré porte déjà la ligne exacte, en commentaire.
484
+
485
+ ### 5. Le premier login — ce qu'on observe
486
+
487
+ ```bash
488
+ # Le bouton est offert à l'écran de connexion :
489
+ curl -s https://localhost:5152/nodefony/security/api/oauth2/providers
490
+ # {"providers":[{"name":"keycloak","label":"Compte entreprise"}]}
491
+ ```
492
+
493
+ Au clic, l'utilisateur passe par la page de connexion du realm, puis revient connecté. Côté
494
+ application :
495
+
496
+ - un **compte local** est créé, identifiant = l'email du realm (ou `keycloak:<sub>` sans email),
497
+ rôles = `defaultRoles`, sans mot de passe local ;
498
+ - ce compte porte le lien `(keycloak, sub)` : les connexions suivantes le retrouvent par ce lien,
499
+ même si l'email change dans le realm ;
500
+ - la session est une **session BFF** ordinaire, la même qu'après un mot de passe.
501
+
502
+ Les écrans **Utilisateurs** et **Sessions** de Studio montrent le compte et sa session.
503
+
504
+ ## ⚙️ Configuration
505
+
506
+ Les clés de `security.oauth2.providers.keycloak` — le schéma complet vit sur la page
507
+ [OAuth2](oauth2.md).
508
+
509
+ | Clé | Requise | Effet |
510
+ | -------------------- | :-----: | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
511
+ | `issuer` | ✅ | URL `https` du realm. Absente ou mal formée : le démarrage est refusé. |
512
+ | `clientId` | ✅ | **Client ID** du client Keycloak. |
513
+ | `clientSecret` | ✅ | **Client secret** de l'onglet _Credentials_. Jamais journalisé. |
514
+ | `redirectUri` | ✅ | URL de retour, identique à l'une des **Valid redirect URIs** du client. |
515
+ | `discoveryUrl` | | Adresse COMPLÈTE où lire le document de découverte, quand l'application ne joint pas l'émetteur (service interne d'un cluster). Voir [En cluster](#-en-cluster--joindre-keycloak-par-son-adresse-interne). |
516
+ | `clientAuthMethod` | | `client_secret_basic` par défaut ; `client_secret_post` si le client Keycloak l'exige. |
517
+ | `scopes` | | Vide = `openid`, `profile`, `email`. |
518
+ | `defaultRoles` | | Rôles du compte à sa création, pour CE fournisseur (sinon la valeur globale `oauth2.defaultRoles`). |
519
+ | `roleMapping` | | Rôles Keycloak → rôles de l'application, recalculés à chaque connexion et à chaque jeton. Voir [Rôles](#-rôles--gérer-les-droits-dans-keycloak). |
520
+ | `rolesSource` | | Où lire les rôles : `client` (défaut), `realm`, `groups`. Sans `roleMapping`, sans effet. |
521
+ | `allowPlatformRoles` | | `true` autorise `roleMapping` à donner un `ROLE_NODEFONY_*`. Omis : démarrage refusé. |
522
+ | `audiences` | | Ressources pour lesquelles ce realm émet des jetons à l'application. Lue par `security:keycloak:realm` seulement. Omis : toutes les zones `external-jwt`. |
523
+ | `label` | | Libellé du bouton. Omis : « Keycloak ». |
524
+ | `hidden` | | Retire le bouton sans fermer le flux (lien direct, sous-domaine dédié). |
525
+
526
+ ## 🎭 Rôles — gérer les droits dans Keycloak
527
+
528
+ **L'idée.** Sans réglage, Keycloak dit seulement à l'application **qui** se connecte. Les droits
529
+ (`ROLE_ADMIN`…) sont posés à la création du compte, puis gérés dans l'application, à la main.
530
+ Une entreprise qui a Keycloak veut souvent l'inverse : promouvoir quelqu'un **une fois**, dans
531
+ Keycloak, et que l'application suive. C'est le rôle de la table `roleMapping` — un **dictionnaire
532
+ de traduction** : « quand Keycloak dit `admin`, chez moi ça s'appelle `ROLE_ADMIN` ».
533
+
534
+ **Côté Keycloak**, deux gestes dans la console du realm :
535
+
536
+ 1. **Clients** › ton client › **Roles** › _Create role_ : `admin`. Le rôle appartient au client,
537
+ c'est-à-dire à TON application — pas à tout le realm.
538
+ 2. **Users** › l'utilisateur › **Role mapping** › _Assign role_ › filtre _client roles_ › `admin`.
539
+
540
+ Dans un realm importé, c'est le bloc `roles.client` et la clé `clientRoles` de l'utilisateur :
541
+
542
+ ```json
543
+ "roles": { "client": { "mon-app": [{ "name": "admin" }] } },
544
+ "users": [{ "username": "bob", "clientRoles": { "mon-app": ["admin"] } }]
545
+ ```
546
+
547
+ **Côté Nodefony**, la table sur le fournisseur :
548
+
549
+ ```typescript ignore
550
+ keycloak: {
551
+ issuer: ctx.env.NF_KEYCLOAK_ISSUER,
552
+ clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
553
+ clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
554
+ redirectUri: "https://app.example.com/nodefony/security/api/oauth2/keycloak/callback",
555
+ roleMapping: { admin: "ROLE_ADMIN" },
556
+ },
557
+ ```
558
+
559
+ **Ce qui se passe à la connexion de bob** :
560
+
561
+ 1. Keycloak rend un jeton d'accès qui porte `"resource_access": { "mon-app": { "roles": ["admin"] } }`
562
+ — c'est là, et seulement là par défaut, que Keycloak met les rôles (ni dans l'ID token, ni
563
+ dans _userinfo_).
564
+ 2. `mapProviderRoles()` (`providerRoles.ts#mapProviderRoles`) traduit `admin` → `ROLE_ADMIN` par la table.
565
+ 3. Le compte de bob reçoit `ROLE_ADMIN` (`reconcileProviderRoles()`, `user/nodefony/src/providerRoles.ts#reconcileProviderRoles` côté
566
+ `@nodefony/user`).
567
+
568
+ **Les règles, à connaître avant de l'activer :**
569
+
570
+ - **Recalcul à chaque connexion ET à chaque jeton d'API.** Un rôle retiré dans Keycloak disparaît
571
+ au login suivant, et dès le jeton suivant sur l'API (`syncOAuthRoles()`, `UserService.ts#UserService.syncOAuthRoles`).
572
+ Une session déjà ouverte garde ses droits jusqu'à la reconnexion.
573
+ - **Un rôle Keycloak absent de la table est ignoré**, jamais recopié tel quel : l'annuaire ne
574
+ fabrique pas de droit que l'application n'a pas déclaré.
575
+ - **Les rôles donnés à la main survivent.** L'application retient, dans les métadonnées du compte
576
+ (`metadata.providerRoles.keycloak`), ce que Keycloak a accordé : elle ne retire que cela. Seule
577
+ ambiguïté : un rôle à la fois donné à la main ET accordé par Keycloak devient « géré » — le
578
+ retirer dans Keycloak le retire aussi.
579
+ - **Aucune écriture en base si rien ne change** : le recalcul tombe sur chaque requête porteuse
580
+ de jeton, il ne coûte une écriture que lorsqu'un rôle bouge.
581
+ - **Rôles du realm ou groupes** : `rolesSource: ["realm"]` lit `realm_access.roles`,
582
+ `["groups"]` le claim `groups` (à poser chez Keycloak par un mapper _Group Membership_).
583
+ Plusieurs sources se cumulent.
584
+ - **Rôles de plateforme (`ROLE_NODEFONY_*`) refusés par défaut.** Ils ouvrent la console
585
+ d'administration, la génération de code, les secrets : les faire venir de Keycloak fait de
586
+ l'administrateur du realm un administrateur de l'instance. La table qui en contient un refuse
587
+ le démarrage (`config.ts#oauthProviderSchema`) — sauf `allowPlatformRoles: true` ÉCRIT sur le fournisseur, que
588
+ chaque démarrage rappelle par un avertissement. Sinon, ce rôle se donne à la main
589
+ (`security:user:add --admin`, console d'administration).
590
+
591
+ ## 🧩 Le jeton Keycloak sur ton API — le même compte
592
+
593
+ Une fois les humains connectés par le navigateur, un script ou un service veut souvent appeler l'API
594
+ avec un **jeton d'accès** du même realm. La page [Jetons d'un émetteur tiers](external-jwt.md)
595
+ décrit cette brique en entier ; voici ce que Keycloak y ajoute.
596
+
597
+ **Côté Keycloak**, le jeton doit nommer ton API dans son audience (`aud`) — par défaut il porte
598
+ `account`, que toute zone refuse. `nodefony security:keycloak:realm --write` écrit le mapper pour
599
+ chaque ressource listée dans `audiences` du fournisseur, sinon pour la `resource` de chaque zone
600
+ `external-jwt`. À la main : dans le client, **Client scopes** › le scope dédié › **Add mapper** ›
601
+ _Audience_, avec **Included Custom Audience** = la `resource` de la zone et **Add to access token**
602
+ coché.
603
+
604
+ > [!TIP]
605
+ > Écris `audiences` dès qu'une zone `external-jwt` ne doit PAS recevoir les jetons de ce realm (une
606
+ > API d'un autre domaine de confiance, une zone de test) : sans la liste, la commande déduit
607
+ > l'audience de toutes les zones, et Keycloak imprimerait aussi l'adresse de celle-là.
608
+
609
+ **Côté Nodefony**, déclarer le realm comme émetteur de confiance et ouvrir une zone qui accepte ses
610
+ jetons :
611
+
612
+ ```typescript ignore
613
+ // nodefony/config/security.ts
614
+ import type { ISecurityConfigInput } from "@nodefony/security";
615
+
616
+ export const securityConfig = () =>
617
+ ({
618
+ resourceServer: {
619
+ issuers: [
620
+ // Les clés publiques du realm sont découvertes par l'émetteur.
621
+ {
622
+ issuer: "https://sso.example.com/realms/mon-entreprise",
623
+ algorithms: ["RS256"],
624
+ },
625
+ ],
626
+ },
627
+ areas: {
628
+ api: {
629
+ pattern: "^/api",
630
+ authenticators: ["external-jwt"],
631
+ stateless: true,
632
+ // L'audience exigée : celle que le mapper inscrit dans le jeton.
633
+ resource: "https://api.example.com",
634
+ },
635
+ },
636
+ }) satisfies ISecurityConfigInput;
637
+ ```
638
+
639
+ **Le même compte, pas un second.** Quand l'émetteur d'un jeton est celui d'un fournisseur de
640
+ connexion déclaré (`oauth2.providers.keycloak.issuer`), l'authenticator cherche d'abord le compte lié
641
+ à `(keycloak, sub)` — celui que le login par navigateur a créé (`ExternalSubjectResolver.ts#IExternalIssuerBinding.issuer`).
642
+ Sans ce lien, le jeton désignerait un compte distinct, sous l'identifiant `<émetteur>#<sub>`.
643
+
644
+ ## ☸️ En cluster — joindre Keycloak par son adresse interne
645
+
646
+ Dans un cluster Kubernetes, ton application et Keycloak tournent côte à côte. Pour se parler, ils
647
+ passent par le **service interne** (`http://keycloak.sso:8080`), sans ressortir sur Internet. Les
648
+ navigateurs, eux, ne connaissent que l'adresse **publique** (`https://sso.example.com`).
649
+
650
+ C'est comme le standard téléphonique d'une entreprise : entre collègues on compose le poste
651
+ interne, de l'extérieur le numéro public — mais la personne qui décroche se présente toujours
652
+ **sous le même nom**. Ce nom, c'est l'**émetteur** (`issuer`) : l'adresse publique du realm, celle
653
+ qu'on retrouve dans chaque jeton.
654
+
655
+ Sans réglage, l'application lit la « carte d'identité » de Keycloak (le document de découverte)
656
+ **à l'adresse de l'émetteur**, donc l'adresse publique — que bien des pods ne joignent pas, ou
657
+ seulement en ressortant par l'ingress. Et si on écrit l'adresse interne à la place de l'émetteur,
658
+ Keycloak répond « je m'appelle `https://sso.example.com/…` » : l'application refuse, et elle a
659
+ raison — c'est la garde qui empêche un serveur de parler au nom d'un autre (RFC 8414 §3.3).
660
+
661
+ `discoveryUrl` dit les deux à la fois : **lis la carte ici, mais exige que la personne se présente
662
+ sous ce nom-là.**
663
+
664
+ **Côté Keycloak**, deux réglages (guide Keycloak _Configuring the hostname_) :
665
+
666
+ ```bash
667
+ # Le nom public, en URL COMPLÈTE : c'est lui que porteront `iss` et les pages de connexion.
668
+ KC_HOSTNAME=https://sso.example.com
669
+ # Les points appelés de serveur à serveur suivent l'adresse par laquelle on interroge Keycloak.
670
+ KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true
671
+ ```
672
+
673
+ **Côté application**, une variable de plus dans le Secret du pod, à côté des trois `NF_KEYCLOAK_*` :
674
+
675
+ ```bash
676
+ NF_KEYCLOAK_DISCOVERY_URL=http://keycloak.sso:8080/realms/mon-entreprise/.well-known/openid-configuration
677
+ ```
678
+
679
+ Le gabarit `complete` la porte déjà vers les deux usages du realm — la connexion (le bouton) et
680
+ les jetons sur ton API :
681
+
682
+ ```typescript ignore
683
+ use("@nodefony/security", {
684
+ oauth2: {
685
+ providers: {
686
+ keycloak: {
687
+ issuer: ctx.env.NF_KEYCLOAK_ISSUER, // https://sso.example.com/realms/mon-entreprise
688
+ discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL, // le service interne
689
+ clientId: ctx.env.NF_KEYCLOAK_CLIENT_ID,
690
+ clientSecret: ctx.env.NF_KEYCLOAK_CLIENT_SECRET,
691
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/keycloak/callback`,
692
+ },
693
+ },
694
+ },
695
+ resourceServer: {
696
+ issuers: [
697
+ {
698
+ issuer: ctx.env.NF_KEYCLOAK_ISSUER,
699
+ discoveryUrl: ctx.env.NF_KEYCLOAK_DISCOVERY_URL,
700
+ algorithms: ["RS256"],
701
+ },
702
+ ],
703
+ },
704
+ });
705
+ ```
706
+
707
+ **Ce qu'on observe** — le document qu'un Keycloak 26.8 réglé ainsi rend quand on l'interroge par
708
+ son adresse interne (constaté, pas déduit) :
709
+
710
+ | Point d'entrée | Qui l'appelle | Adresse publiée |
711
+ | ------------------------ | ------------------------------------ | ---------------------------- |
712
+ | `issuer` | — | `https://sso.example.com/…` |
713
+ | `authorization_endpoint` | le **navigateur** | `https://sso.example.com/…` |
714
+ | `end_session_endpoint` | le **navigateur** | `https://sso.example.com/…` |
715
+ | `token_endpoint` | l'application (de serveur à serveur) | `http://keycloak.sso:8080/…` |
716
+ | `jwks_uri` | l'application (de serveur à serveur) | `http://keycloak.sso:8080/…` |
717
+
718
+ **Ce qui reste vérifié**, et ne se relâche jamais :
719
+
720
+ - **L'émetteur est comparé à l'identique.** Seul le lieu de lecture change
721
+ (`metadataCandidateUrls()`, `authorizationServer.ts#metadataCandidateUrls`) : un document lu à l'adresse interne
722
+ qui se déclare d'un autre émetteur est refusé.
723
+ - **Le `http` n'est admis que sur l'origine EXACTE de `discoveryUrl`** — même schéma, même hôte,
724
+ même port —, et seulement pour le point de jeton et le jeu de clés
725
+ (`isBackChannelTransportAllowed()`, `authorizationServer.ts#isBackChannelTransportAllowed`). L'opérateur a déjà choisi
726
+ cette adresse en l'écrivant ; un `http` vers une autre adresse est une adresse que personne n'a
727
+ écrite, refusée.
728
+ - **Les points du navigateur restent en `https`** (autorisation, déconnexion) : un navigateur
729
+ renvoyé vers une adresse interne n'aboutirait nulle part.
730
+ - **L'adresse est vérifiée au démarrage** : ni `http(s)`, ou porteuse d'identifiants ou d'un
731
+ fragment → démarrage refusé (`parseDiscoveryUrl()`, `authorizationServer.ts#parseDiscoveryUrl`).
732
+
733
+ > [!WARNING]
734
+ > Une adresse interne en `http` fait passer le **secret du client** et les jetons **en clair** sur
735
+ > le réseau du cluster. C'est le choix courant derrière un maillage de services (mTLS) ou dans un
736
+ > réseau isolé ; sinon, servir Keycloak en `https` sur l'adresse interne aussi, et déclarer son
737
+ > autorité par `NF_TLS_CA_FILE`.
738
+
739
+ ## 🔐 Sécurité
740
+
741
+ - **`https` partout, même en développement.** L'ID token est lu sans vérifier sa signature, ce
742
+ qu'OIDC Core §3.1.3.7 n'admet que parce qu'il arrive du point de jeton par TLS. Un émetteur en
743
+ `http` est refusé au démarrage (`canonicalIssuer()`, `authorizationServer.ts#canonicalIssuer`).
744
+ - **Client confidentiel obligatoire** : le schéma exige un secret non vide. Un client public ne
745
+ conviendrait qu'à une application sans serveur, ce que n'est pas une application Nodefony.
746
+ - **Anti-mix-up** : le paramètre `iss` du retour et le claim `iss` de l'ID token doivent égaler
747
+ l'émetteur découvert (RFC 9207). Un retour venu d'un autre realm est rejeté.
748
+ - **Rôles de plateforme hors de portée de l'annuaire**, sauf ouverture écrite
749
+ (`allowPlatformRoles`) : un realm compromis ne donne pas, par défaut, la console
750
+ d'administration. Un rôle Keycloak absent de `roleMapping` ne donne rien.
751
+ - **Aucune liaison par email** : un email, même vérifié par le realm, ne donne jamais accès à un
752
+ compte local existant. Le rattachement d'un compte local à un `sub` Keycloak se fait
753
+ explicitement.
754
+ - **Déconnexion, dans les deux sens.** Se déconnecter de l'application ferme aussi la session du
755
+ realm : la réponse porte `logoutUrl`, où le navigateur est envoyé avec l'ID token en
756
+ `id_token_hint` — sans page de confirmation, et le clic suivant redemande le mot de passe. À
757
+ l'inverse, une session fermée **chez Keycloak** (console d'administration, autre application du
758
+ SSO) ferme la session de l'application par le **canal arrière** : Keycloak appelle
759
+ `…/oauth2/keycloak/backchannel-logout` avec un jeton signé, que l'application vérifie sur les clés
760
+ du realm avant de détruire quoi que ce soit (détail : [OAuth2 § canal arrière](oauth2.md#déconnexion-demandée-par-le-fournisseur--le-canal-arrière)).
761
+
762
+ ## ⚠️ Pièges
763
+
764
+ Avant de lire le tableau, **demander le diagnostic** : il éprouve le branchement sans qu'un humain
765
+ se connecte, et nomme la cause là où le journal ne montre qu'un `401`.
766
+
767
+ ```bash
768
+ npx nodefony security:oauth:doctor # tous les fournisseurs configurés
769
+ npx nodefony security:oauth:doctor -p keycloak # un seul ; --json pour un script
770
+ ```
771
+
772
+ Trois sondes, chacune avec son verdict : **découverte** (émetteur annoncé ≠ émetteur configuré,
773
+ RFC 8414 §3.3), **URL de retour** (la requête du bouton, non suivie : Keycloak refuse en 400 une
774
+ URL qu'il ne connaît pas), **secret** (un code inventé présenté avec le vrai secret :
775
+ `invalid_grant` = secret accepté). Sortie en 1 si une sonde échoue. `nodefony doctor --live` rend
776
+ les mêmes échecs (famille « Fournisseurs OAuth »), l'administration les sert sur
777
+ `/nodefony/security/api/oauth/diagnosis`. Côté Keycloak, la sonde ouvre une session de connexion
778
+ vide, qui expire seule.
779
+
780
+ | Symptôme | Cause | Correction |
781
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
782
+ | Démarrage refusé : `security.oauth2.providers.keycloak.issuer est requis` | Les trois variables ne sont pas posées ensemble, ou `issuer` manque à la main | Poser `NF_KEYCLOAK_ISSUER` (`checkProviderIssuer()`, `oauth2.ts#checkProviderIssuer`) |
783
+ | Démarrage refusé : `émetteur invalide … https` | Émetteur en `http`, ou avec `?…`/`#…` | Recopier l'`issuer` du document _OpenID Endpoint Configuration_ |
784
+ | Pas de bouton + WARNING `oauth2 provider "keycloak" indisponible` | Keycloak injoignable, ou certificat non reconnu (`fetch failed (SELF_SIGNED_CERT_IN_CHAIN …)`) | Démarrer Keycloak ; déclarer l'autorité d'entreprise par `NF_TLS_CA_FILE`. Retour sous 30 s |
785
+ | Démarrage refusé (78) : `http.certificates.extraCaFiles : « … » est illisible` | Le fichier de `NF_TLS_CA_FILE` n'existe pas à ce chemin (relatif = à la racine de l'application) | Corriger le chemin, ou monter le fichier dans le pod |
786
+ | Avec `discoveryUrl` : `le document déclare « http://keycloak.sso:8080/… » alors qu'il a été demandé à « https://sso… »` | `KC_HOSTNAME` absent : Keycloak prend le nom de l'adresse par laquelle on l'interroge | `KC_HOSTNAME` = l'URL publique COMPLÈTE |
787
+ | Avec `discoveryUrl` : le bouton est là, mais le retour de connexion échoue en `fetch failed` vers l'adresse publique | `KC_HOSTNAME_BACKCHANNEL_DYNAMIC` absent : Keycloak publie son point de jeton sur l'adresse publique | `KC_HOSTNAME_BACKCHANNEL_DYNAMIC=true` |
788
+ | Avec `discoveryUrl` : `« token_endpoint » doit être en https (ou en http sur l'origine de discoveryUrl …)` | Keycloak publie une AUTRE origine que celle de `discoveryUrl` (hôte ou port réécrit par un intermédiaire) | Écrire dans `discoveryUrl` exactement l'origine que Keycloak publie |
789
+ | `Invalid parameter: redirect_uri` sur la page Keycloak | `redirectUri` ≠ les **Valid redirect URIs** du client | Aligner schéma, hôte (`localhost` ≠ `127.0.0.1`), port et chemin |
790
+ | Retour sur `failureRedirect` + WARNING `unauthorized_client` (secret faux) ou `invalid_client` (client inconnu) | Mauvais secret, client absent du realm, ou client public (_Client authentication OFF_) | Recopier le secret de l'onglet _Credentials_ ; passer le client en ON |
791
+ | Retour sur `failureRedirect` + WARNING `OAuth issuer mismatch` | Keycloak joint par deux adresses différentes : l'émetteur varie | Fixer `KC_HOSTNAME` à l'URL publique complète |
792
+ | Retour sur `failureRedirect` + WARNING `local account exists without a keycloak link` | Un compte local porte déjà cet identifiant (`UserService.ts#UserService.changePassword`) — typiquement un `sub` changé après réimport du realm | Rattacher le compte explicitement, ou fixer l'`id` des utilisateurs dans l'export du realm |
793
+ | API : `401` avec un jeton Keycloak valide | Audience absente du jeton : pas de mapper _Audience_ | `security:keycloak:realm --write` puis import partiel ; `aud` doit contenir la `resource` de la zone |
794
+ | Session de l'application vivante après une déconnexion chez Keycloak ; Keycloak journalise `Some clients have not been logged out` | _Front channel logout_ ON, ou **Backchannel logout URL** absente ou injoignable depuis Keycloak | _Front channel logout_ OFF ; une URL que Keycloak joint (en développement Linux : serveur à l'écoute au-delà de la boucle locale) |
795
+
796
+ | Démarrage refusé : `… est un rôle de PLATEFORME` | `roleMapping` traduit vers un `ROLE_NODEFONY_*` | Donner ce rôle à la main, ou écrire `allowPlatformRoles: true` en connaissance de cause |
797
+ | Le rôle Keycloak n'apparaît pas dans l'application | Rôle du REALM alors que la source est `client`, ou rôle d'un autre client, ou session ouverte avant le changement | Créer le rôle sur le client de l'application, ou `rolesSource: ["realm"]` ; se reconnecter |
798
+
799
+ La cause d'un retour sur `failureRedirect` n'est jamais montrée au navigateur ; elle est dans le
800
+ journal du serveur, préfixée `oauth2 callback "keycloak"` (`OAuth2Controller.ts#OAuth2Controller.authorize`).
801
+
802
+ ## 🧪 Tests & couverture
803
+
804
+ | Type | Fichier | Ce qu'il prouve |
805
+ | ----------- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
806
+ | Unitaire | `security/tests/unit/oauth2Service.test.ts` | Démarrage refusé sur émetteur absent ou mal formé ; bouton retiré puis rendu quand l'émetteur tombe. |
807
+ | Unitaire | `security/tests/unit/oauthProviders.test.ts` | Découverte, refus d'un serveur sans PKCE S256, contrôle des claims de l'ID token. |
808
+ | Unitaire | `security/tests/unit/providerRoles.test.ts` | Lecture des claims (client, realm, groupes), rôle inconnu ignoré, refus des rôles de plateforme sauf `allowPlatformRoles`, recalcul à la connexion et au jeton, aucune écriture sans changement. |
809
+ | Unitaire | `security/tests/unit/keycloakRealmInput.test.ts` | La configuration de l'application de dev, dérivée puis fusionnée, rend `realm-nodefony.json` octet pour octet ; audiences déduites ou écrites, production sans secret, rôles rangés selon `rolesSource`. |
810
+ | Unitaire | `security/tests/unit/providerDiagnosis.test.ts` | Diagnostic : trois verdicts distincts (émetteur, URL de retour, secret) ; un `401` n'accuse le secret que si le client est prouvé connu ; non concluant ≠ échec ; sondes passées par le client du login. |
811
+ | Unitaire | `security/tests/unit/keycloakRealm.test.ts` | Fusion : ce que la config dit est réécrit, ce qu'un humain a écrit survit ; mapper d'audience remplacé, jamais doublé. |
812
+ | Unitaire | `user/tests/unit/providerRoles.test.ts` | Rôles gérés contre rôles locaux : retrait, ajout, rôle à la main conservé, profil préservé. |
813
+ | Unitaire | `security/tests/unit/oauthMetadata.test.ts` | `discoveryUrl` : lu à cette adresse seule ; émetteur différent refusé ; point de jeton en `http` admis sur l'origine déclarée seulement ; point d'autorisation toujours en `https`. |
814
+ | Unitaire | `nodefony/src/tests/authorizationServer.test.ts` | La règle elle-même, au cœur : adresse validée, `jwks_uri` en `http` admis sur l'origine exacte (port compris), refusé ailleurs. |
815
+ | Unitaire | `http/nodefony/tests/unit/certificates.test.ts` | `NF_TLS_CA_FILE` : une chaîne en un fichier devient N autorités ; fichier absent, vide, ou Node trop ancien → démarrage refusé ; un **vrai** serveur https signé par l'autorité déclarée devient joignable. |
816
+ | Intégration | `http/nodefony/tests/integration/oauth2-keycloak.test.ts` | Contre un **vrai** Keycloak : flux complet, même compte entre session et jeton d'API, `invalid_grant` rendu par Keycloak, déconnexion dans les deux sens, jeton de déconnexion forgé refusé ; `bob` obtient puis perd `ROLE_ADMIN` (session et jeton) quand le rôle bouge dans le realm, un rôle local survit ; `cci` obtient `ROLE_NODEFONY_ADMIN` par `allowPlatformRoles` ; le diagnostic rend ses trois verdicts sur des branchements faussés (secret, URL de retour, émetteur `127.0.0.1` au lieu de `localhost`) ; le realm lu par `127.0.0.1` avec `discoveryUrl` rend un jeton machine, et refuse un émetteur réellement différent. |
817
+
818
+ Le banc d'intégration exige le conteneur et les trois variables (décor `KEYCLOAK_GATE` de
819
+ `scripts/test/vitest/gates.ts`) ; sans eux il est sauté, et la passe le dit en fin de run. Le cas du canal
820
+ arrière constate en plus que le conteneur JOINT l'application (`host.docker.internal`) : un serveur
821
+ de développement sous Linux n'écoute que la boucle locale, le cas est alors sauté et le dit.
822
+
823
+ **Ce qui manque** : aucun banc versionné ne joue un Keycloak en `KC_HOSTNAME_BACKCHANNEL_DYNAMIC`
824
+ (points internes en `http`) — le tableau « Ce qu'on observe » a été constaté à la main sur un
825
+ Keycloak 26.8 jetable, et le banc versionné ne couvre que le cas `https`. Aucun ne joue non plus un
826
+ Keycloak derrière un proxy (émetteur variable), ni une rotation des clés du realm pendant une
827
+ session.
828
+
829
+ ## 🔗 Pour aller plus loin
830
+
831
+ - ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
832
+ - [OAuth2](oauth2.md) — le flux, le contrat des fournisseurs, les autres fournisseurs (Google, GitHub, tout OIDC).
833
+ - [Jetons d'un émetteur tiers](external-jwt.md) — accepter les jetons d'accès du realm sur une API.
834
+ - [Authenticators](authenticators.md) — la session BFF qui suit la connexion.
835
+ - [`@nodefony/user`](../../user/docs/index.md) — le compte local et ses liens externes.