@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
package/docs/oauth2.md CHANGED
@@ -33,8 +33,8 @@ source: "src/packages/@nodefony/security/docs/oauth2.md"
33
33
  > connecté à **ton** application. Nodefony orchestre ce voyage avec la posture **OAuth 2.1**
34
34
  > (RFC 9700) : Authorization Code, PKCE, `state` anti-CSRF, `iss` anti-mix-up. Point clé :
35
35
  > **aucun jeton n'atteint le navigateur** — le retour produit une **session BFF**, exactement la même
36
- > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:116`) et le controller BFF
37
- > `OAuth2Controller` (`OAuth2Controller.ts:89`).
36
+ > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts#OAuth2Service`) et le controller BFF
37
+ > `OAuth2Controller` (`OAuth2Controller.ts#OAuth2Controller`).
38
38
 
39
39
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
40
40
 
@@ -111,16 +111,16 @@ qui les ferme ici :
111
111
 
112
112
  - **Vol de jeton via XSS** — si un `access_token` transite par le navigateur, tout script injecté
113
113
  peut le lire. _Fermé par construction_ : aucun jeton ne sort du serveur, le résultat est un cookie
114
- de session opaque (`OAuth2Controller.ts:192`).
114
+ de session opaque (`OAuth2Controller.ts#OAuth2Controller.constructor`).
115
115
  - **Interception du `code`** — un `code` capté (log de proxy, historique, redirection ouverte) est
116
116
  échangeable par l'attaquant. _Fermé par **PKCE**_ : l'échange exige le `code_verifier` resté en
117
- session (`OAuth2Service.createAuthorization()`, `oauth2.ts:232-238`).
117
+ session (`OAuth2Service.createAuthorization()`, `oauth2.ts#OAuth2Service.createAuthorization`).
118
118
  - **CSRF de login** — un tiers force ta victime à terminer **son** flux à lui : elle se retrouve
119
119
  connectée sur le compte de l'attaquant, qui lit ensuite ce qu'elle y dépose. _Fermé par le `state`_
120
- comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts:150-158`).
120
+ comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts#OAuth2Controller.callback`).
121
121
  - **Mix-up d'IdP** — un `code` obtenu chez un fournisseur malveillant est présenté au callback d'un
122
- fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:265-273`) **et** par
123
- l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
122
+ fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts#OAuth2Service.listProviders`) **et** par
123
+ l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts#OAuth2Controller`).
124
124
 
125
125
  > [!IMPORTANT]
126
126
  > Le fournisseur social te dit **qui** est la personne. Il ne te dit **rien** de ses droits.
@@ -133,28 +133,38 @@ Trois partis pris, tous vérifiables au code.
133
133
 
134
134
  **Le service ne touche ni HTTP ni session.** `OAuth2Service` rend à l'appelant les éléments à
135
135
  persister (`url`, `state`, `codeVerifier`) et un simple `{ identifier }` en sortie
136
- (`IOAuthAuthorization`, `oauth2.ts:82`). Conséquence pratique : la logique OAuth se teste **sans
136
+ (`IOAuthAuthorization`, `oauth2.ts#IOAuthAuthorization`). Conséquence pratique : la logique OAuth se teste **sans
137
137
  serveur**, comme `AuthFlow`. Le transport (cookies, redirections 302) vit dans le controller BFF.
138
138
 
139
139
  **Le login social finit exactement comme un login classique.** Le callback appelle
140
- `AuthFlow.establishSessionFor()` (`authFlow.ts:215`), qui re-résout l'identité, vérifie que le compte
141
- est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts:388`)
140
+ `AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), qui re-résout l'identité, vérifie que le compte
141
+ est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts#AuthFlow.logout`)
142
142
  et journalise l'événement
143
143
  d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewall : après le retour,
144
144
  c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
145
145
 
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
- dans le module (`OAuth2Client`, `oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
148
- l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
149
- mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : c'est là, une seule fois par
150
- processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes sont montées dès
151
- que `@nodefony/security` est chargé (`framework/index.ts:460`) : sans social login configuré,
147
+ dans le module (`OAuth2Client`, `oauth2Client.ts#OAuth2Client`), et `jose` — seul recours externe, pour lire les claims de
148
+ l'ID token — est importé **paresseusement**. Les fournisseurs sont construits une fois puis
149
+ mémoïsés (`OAuth2Service.#resolveProvider()`) : c'est là, une seule fois par processus, que les
150
+ points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports ouverts) les construit
151
+ dès le boot, sans l'attendre ; une commande console ne touche jamais le réseau.
152
+
153
+ **Une configuration fausse se montre au démarrage, pas au premier clic.** Au boot, l'`issuer` de
154
+ chaque fournisseur est confronté à ce que sa fabrique exige (`checkProviderIssuer()`,
155
+ `oauth2.ts`) : absent alors que la fabrique l'a déclaré requis, ou qui n'est pas une URL d'émetteur
156
+ valide (https, sans requête ni fragment — RFC 8414 §2), il **interrompt le démarrage** en nommant
157
+ la clé (`security.oauth2.providers.<nom>.issuer`). Un émetteur **injoignable**, lui, n'est pas une
158
+ faute de configuration : le boot continue, un WARNING le nomme, et son bouton quitte l'écran de
159
+ connexion — le flux `authorize` reste ouvert. Une nouvelle tentative part au plus toutes les 30 s
160
+ pendant que l'écran est affiché ; le bouton revient dès qu'elle réussit. Les routes sont montées dès
161
+ que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) : sans social login configuré,
152
162
  `…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
153
163
  rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
154
164
 
155
165
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
156
166
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
157
- configurés à `listOAuthProviders()` (`oauth2.ts:135-155`) et le
167
+ configurés à `listOAuthProviders()` (`oauth2.ts#OAuth2Service`) et le
158
168
  reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
159
169
 
160
170
  ## 🚀 Démarrage rapide
@@ -171,10 +181,41 @@ de login quand la variable manque.
171
181
  > valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
172
182
  > personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
173
183
 
174
- ```typescript
184
+ ```ts ignore
185
+ // nodefony/config/security.ts — le fragment du module, importé par le manifeste
186
+ import type { ConfigContext } from "nodefony";
187
+ import type { ISecurityConfigInput } from "@nodefony/security";
188
+ import type { env } from "../../env";
189
+
190
+ export const securityConfig = (ctx: ConfigContext<typeof env>) =>
191
+ ({
192
+ oauth2: {
193
+ // Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
194
+ defaultRoles: ["ROLE_USER"],
195
+ allowSignup: true, // false = un compte local déjà lié est exigé
196
+ successRedirect: "/",
197
+ failureRedirect: "/login?error=oauth",
198
+ providers: {
199
+ // Secrets absents → fournisseur non monté, bouton non affiché.
200
+ ...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
201
+ ? {
202
+ github: {
203
+ clientId: ctx.env.GITHUB_CLIENT_ID,
204
+ clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
205
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
206
+ },
207
+ }
208
+ : {}),
209
+ },
210
+ },
211
+ }) satisfies ISecurityConfigInput;
212
+ ```
213
+
214
+ ```typescript ignore
175
215
  // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
176
216
  // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
177
217
  import { defineConfig, defineEnv, envString, use } from "nodefony";
218
+ import { securityConfig } from "./nodefony/config/security";
178
219
 
179
220
  export const env = defineEnv({
180
221
  GITHUB_CLIENT_ID: envString({ optional: true }),
@@ -188,42 +229,23 @@ export default defineConfig<typeof env>((ctx) => ({
188
229
  modules: [
189
230
  "@nodefony/http",
190
231
  "@nodefony/framework",
191
- use("@nodefony/security", {
192
- oauth2: {
193
- // Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
194
- defaultRoles: ["ROLE_USER"],
195
- allowSignup: true, // false = un compte local déjà lié est exigé
196
- successRedirect: "/",
197
- failureRedirect: "/login?error=oauth",
198
- providers: {
199
- // Secrets absents → fournisseur non monté, bouton non affiché.
200
- ...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
201
- ? {
202
- github: {
203
- clientId: ctx.env.GITHUB_CLIENT_ID,
204
- clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
205
- redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
206
- },
207
- }
208
- : {}),
209
- },
210
- },
211
- }),
232
+ use("@nodefony/security", securityConfig(ctx)),
212
233
  ],
213
234
  }));
214
235
  ```
215
236
 
216
237
  ### Les routes sont FOURNIES — tu n'écris aucun controller
217
238
 
218
- `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
219
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
220
- enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
239
+ `mountOAuth2Routes()` (`OAuth2Controller.ts#mountOAuth2Routes`) monte quatre routes sous
240
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts#OAuth2Controller.backchannelLogout`) dès que le service `oauth2` est
241
+ enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) :
221
242
 
222
- | Route | Rôle |
223
- | ---------------------------- | --------------------------------------------------------------------- |
224
- | `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
225
- | `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
226
- | `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
243
+ | Route | Rôle |
244
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
245
+ | `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
246
+ | `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
247
+ | `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
248
+ | `POST …/{provider}/backchannel-logout` | Canal arrière : le fournisseur demande de fermer des sessions (cf [§ canal arrière](#déconnexion-demandée-par-le-fournisseur--le-canal-arrière)). |
227
249
 
228
250
  Ton écran de login n'a donc qu'un lien à poser :
229
251
 
@@ -234,10 +256,12 @@ Ton écran de login n'a donc qu'un lien à poser :
234
256
  ```
235
257
 
236
258
  > [!WARNING]
237
- > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:262`) — elles **sont** le mécanisme
259
+ > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts#mountOAuth2Routes`) — elles **sont** le mécanisme
238
260
  > d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
239
261
  > un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
240
- > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
262
+ > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
263
+ > est appelé **serveur à serveur** : ni cookie, ni session, ni jeton CSRF — la signature du jeton de
264
+ > déconnexion est sa seule authentification.
241
265
 
242
266
  ### Ce qu'on observe
243
267
 
@@ -256,7 +280,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
256
280
 
257
281
  # 3) L'identité est résolue comme après un login classique
258
282
  curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
259
- # {"user":{"username":"jane@example.com","roles":["ROLE_USER"]}}
283
+ # {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
260
284
 
261
285
  # 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
262
286
  curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
@@ -269,61 +293,92 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
269
293
 
270
294
  ### Étape 1 — `createAuthorization(provider)`
271
295
 
272
- `OAuth2Service.createAuthorization()` (`oauth2.ts:232`) fabrique trois choses :
296
+ `OAuth2Service.createAuthorization()` (`oauth2.ts#OAuth2Service.createAuthorization`) fabrique trois choses :
273
297
 
274
298
  1. un **`state`** aléatoire (anti-CSRF) ;
275
299
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
276
- `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
300
+ `oauth2.ts#OAuth2Service.getRedirects`) ; `null` sinon (GitHub) ;
277
301
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
278
- (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:321`).
302
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts#OAuth2Service.rememberLogout`).
279
303
 
280
304
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
281
- mémoire, `OAuth2Controller.ts:145`), puis redirige en 302.
305
+ mémoire, `OAuth2Controller.ts#IOAuth2Session.save`), puis redirige en 302.
282
306
 
283
307
  ### Étape 2 — le retour, validé avant tout appel réseau
284
308
 
285
- `OAuth2Controller.callback()` (`OAuth2Controller.ts:150`) travaille dans cet ordre, et l'ordre est la
309
+ `OAuth2Controller.callback()` (`OAuth2Controller.ts#OAuth2Controller.callback`) travaille dans cet ordre, et l'ordre est la
286
310
  défense :
287
311
 
288
- 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
312
+ 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts#oauth2FailureTarget`) — le
289
313
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
290
314
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
291
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
292
- `failureRedirect`, **sans jamais contacter le fournisseur** ;
315
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts#OAuth2Controller.authorize`). Un seul écart → `302` vers
316
+ l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
293
317
  3. seulement ensuite, `exchangeAndProvision()`.
294
318
 
295
319
  ### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
296
320
 
297
- `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
321
+ `OAuth2Service.exchangeAndProvision()` (`oauth2.ts#OAuth2Service.exchangeAndProvision`) enchaîne :
298
322
 
299
323
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
300
- et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:265-273`) ;
324
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts#OAuth2Service.listProviders`) ;
301
325
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
302
- (`validateAuthorizationCode`, `oauth2.ts:274`), puis lecture du profil (`fetchProfile`,
303
- `oauth2.ts:275`) ;
326
+ (`validateAuthorizationCode`, `oauth2.ts#OAuth2Service.createAuthorization`), puis lecture du profil (`fetchProfile`,
327
+ `oauth2.ts#OAuth2Service.exchangeAndProvision`) ;
304
328
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
305
- **par fournisseur** (`oauth2.ts:279-280`), `allowSignup` global (`oauth2.ts:283`).
329
+ **par fournisseur** (`oauth2.ts#OAuth2Service.listDisplayProviders`), `allowSignup` global (`oauth2.ts#OAuth2Service.exchangeAndProvision`).
306
330
 
307
- Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
308
- failureRedirect`, `OAuth2Controller.ts:198-209`) : le client ne distingue pas un `iss` invalide d'un
331
+ Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
332
+ l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
309
333
  échange refusé ou d'un signup interdit.
310
334
 
335
+ ### Les retours — `from` et `back`
336
+
337
+ La page qui lance le flux dit deux choses à `/authorize`, que le serveur revalide en chemins
338
+ locaux (`safeRedirectPath`, garde anti-redirection ouverte du cœur), garde en session avec le
339
+ `state` et purge au callback :
340
+
341
+ | Paramètre | Sens | Absent ou refusé |
342
+ | --------- | ---------------------------------------------------------------------------------------------- | ----------------- |
343
+ | `from` | page où aller une fois connecté | `successRedirect` |
344
+ | `back` | écran où revenir si la connexion n'aboutit pas — le bouton « Retour » du fournisseur, un échec | `failureRedirect` |
345
+
346
+ `back` l'emporte sur `failureRedirect` parce qu'une application peut avoir PLUSIEURS écrans de
347
+ connexion (la page par défaut, la console d'administration) : une seule adresse d'échec renverrait
348
+ l'un chez l'autre. La page par défaut et la console l'envoient d'elles-mêmes, et
349
+ `NodefonyLogin.startProvider()` pose la page courante.
350
+
351
+ L'écran d'échec reçoit `reason` et `from` (une `reason` ou un `from` déjà présents sont
352
+ remplacés) : `reason=access_denied` dit une ANNULATION — l'écran se tait, l'utilisateur est revenu
353
+ choisir une autre méthode —, toute autre valeur un échec à annoncer. Seuls les codes normalisés
354
+ d'OAuth 2.0 et d'OpenID Connect sont recopiés, et seulement sous un `state` valide ; tout le reste
355
+ devient `failed` (`OAUTH2_FAILED_REASON`, cœur).
356
+
311
357
  ## 🧑‍⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
312
358
 
313
359
  Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
314
360
  compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, les rôles, l'état
315
361
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
316
362
 
317
- Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
318
- défaut est `UserService.provisionOAuthUser()` (`UserService.ts:363`), en **find-or-create** :
319
-
320
- | Situation au retour du fournisseur | Comportement |
321
- | ---------------------------------------- | ------------------------------------------------------------------------------ |
322
- | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
323
- | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
324
- | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:374`) — un compte lié est exigé. |
325
- | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
326
- | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
363
+ Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts#IOAuthUserProvisioner`) ; l'implémentation par
364
+ défaut est `UserService.provisionOAuthUser()` (`UserService.ts#UserService.provisionOAuthUser`), en **find-or-create** :
365
+
366
+ | Situation au retour du fournisseur | Comportement |
367
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
368
+ | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
369
+ | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
370
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts#UserService.provisionOAuthUser`) — un compte lié est exigé. |
371
+ | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
372
+ | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
373
+
374
+ ⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
375
+ fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
376
+ création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
377
+ signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
378
+ `nodefony doctor --live` — c'est un constat, pas une faute (`findZonesOpenToSignup`,
379
+ `openToSignup.ts#findZonesOpenToSignup`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
380
+ rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
381
+ préexistant.
327
382
 
328
383
  ### Pourquoi l'e-mail ne lie jamais automatiquement un compte
329
384
 
@@ -331,21 +386,21 @@ C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si
331
386
  l'e-mail vaut `admin@ton-domaine.fr` liait automatiquement l'administrateur local, il suffirait de
332
387
  créer un compte chez un fournisseur laxiste avec cette adresse pour **prendre le compte admin**. La
333
388
  liaison par e-mail est donc refusée y compris quand le fournisseur certifie l'adresse
334
- (`emailVerified` reste informatif, `IOAuthUserProvisioner.ts:24`).
389
+ (`emailVerified` reste informatif, `IOAuthUserProvisioner.ts#IOAuthProfile.emailVerified`).
335
390
 
336
391
  Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « avec GitHub » obtient un
337
392
  **second** compte. Le rattachement d'un compte externe à un compte existant est une action explicite,
338
393
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
339
394
 
340
395
  L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
341
- préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:382-383`).
396
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts#UserService.provisionOAuthUser`).
342
397
 
343
398
  ### Les rôles sont posés à la création, et plus jamais
344
399
 
345
- `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:405`). Un second login
400
+ `defaultRoles` s'applique **au moment du `create`** (`UserService.ts#UserService.create`). Un second login
346
401
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
347
402
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
348
- authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053`).
403
+ authentification, pas autorisation » (`oauth2.ts#OAuth2Service.listDisplayProviders`, `config.ts#loginPageSchema`).
349
404
 
350
405
  > [!TIP]
351
406
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -355,18 +410,18 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
355
410
  ### Brancher sa propre politique
356
411
 
357
412
  Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
358
- (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:327-333`). S'il ne l'implémente pas, le login
413
+ (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts#OAuth2Service.#resolveProvisioner`). S'il ne l'implémente pas, le login
359
414
  **échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
360
415
  (quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
361
416
  `provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
362
- (`IOAuthUserProvisioner.ts:12`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
417
+ (`IOAuthUserProvisioner.ts#IOAuthProfile`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
363
418
  et la charge brute `raw`.
364
419
 
365
420
  ## 🧩 Fournisseurs — catalogue et extension
366
421
 
367
- Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:53`) : il masque
422
+ Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts#IOAuthProvider`) : il masque
368
423
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
369
- Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
424
+ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts#OAuthProviderFactory`.
370
425
 
371
426
  | Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
372
427
  | ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
@@ -377,23 +432,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
377
432
 
378
433
  ### `google` — OIDC, le cas nominal
379
434
 
380
- Construit par le helper générique `createOidcProvider()` (`oidc.ts:106`) : PKCE systématique
381
- (`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
382
- (`oauthProviderRegistry.ts:90`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
435
+ Construit par le helper générique `createOidcProvider()` (`oidc.ts#createOidcProvider`) : PKCE systématique
436
+ (`usesPkce: true`, `oidc.ts#readLogoutTokenClaims`), émetteur figé `https://accounts.google.com`
437
+ (`oauthProviderRegistry.ts#withKeycloakThemeHint`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
383
438
  demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
384
- claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:146`), après les contrôles
439
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts#IOidcProviderOptions.name`), après les contrôles
385
440
  obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
386
- (`assertIdTokenClaims()`, `oidc.ts:139`). Pas d'identifiant stable, pas d'identité.
441
+ (`assertIdTokenClaims()`, `oidc.ts#assertIdTokenClaims`). Pas d'identifiant stable, pas d'identité.
387
442
 
388
443
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
389
444
 
390
445
  Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
391
- valider l'`iss` (`oauthProviderRegistry.ts:95-97`). Il est donc **obligatoire** : sans lui, la fabrique
392
- lève au premier login avec un message explicite.
446
+ valider l'`iss`. Il est donc **obligatoire**, et déclaré comme tel au registre
447
+ (`requiresIssuer: true`) : sans lui, le démarrage est refusé en nommant la clé.
448
+ Créer le realm et le client, lire l'émetteur, réussir le premier login : la page
449
+ [Keycloak](keycloak.md) fait le chemin entier.
393
450
 
394
451
  ### `oidc` — n'importe quel serveur OpenID Connect
395
452
 
396
- La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:101`) prend
453
+ La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts#withKeycloakThemeHint`) prend
397
454
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
398
455
  par fournisseur.
399
456
 
@@ -418,7 +475,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
418
475
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
419
476
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
420
477
 
421
- La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:65`) et remplie par
478
+ La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) et remplie par
422
479
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
423
480
 
424
481
  ### Découverte des points d'entrée (RFC 8414)
@@ -430,7 +487,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
430
487
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
431
488
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
432
489
  PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
433
- redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:132`).
490
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts#discoverAuthorizationServer`).
434
491
 
435
492
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
436
493
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -446,17 +503,17 @@ donnerait l'illusion de PKCE.
446
503
  ### `github` — OAuth simple, l'archétype non-OIDC
447
504
 
448
505
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
449
- `github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
450
- de l'API REST `/user` (`createGithubProvider()`, `github.ts:53`). Subtilité GitHub : l'e-mail
506
+ `github.ts#normalizeGithubError`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
507
+ de l'API REST `/user` (`createGithubProvider()`, `github.ts#createGithubProvider`). Subtilité GitHub : l'e-mail
451
508
  primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
452
- `emailVerified` que si GitHub le certifie (`github.ts:98-107`).
509
+ `emailVerified` que si GitHub le certifie (`github.ts#createGithubProvider`).
453
510
 
454
511
  ### Enregistrer le sien — sans éditer le cœur
455
512
 
456
513
  **Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
457
514
  mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
458
- pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:68`) au chargement de
459
- ton module (avant le `onBoot` du service) :
515
+ pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
516
+ `onBoot` du service) :
460
517
 
461
518
  ```typescript ignore
462
519
  import {
@@ -466,12 +523,16 @@ import {
466
523
 
467
524
  // Le nom sert de clé de configuration ET de `provider` du Shadow User ;
468
525
  // l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
469
- registerOAuthProvider("microsoft", (ctx) =>
470
- createDiscoveredOidcProvider("microsoft", ctx),
526
+ registerOAuthProvider(
527
+ "microsoft",
528
+ (ctx) => createDiscoveredOidcProvider("microsoft", ctx),
529
+ // Déclarer l'émetteur requis : son oubli refuse le démarrage au lieu
530
+ // d'échouer au premier clic.
531
+ { requiresIssuer: true },
471
532
  );
472
533
  ```
473
534
 
474
- La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts:24`) : les secrets et l'URL de
535
+ La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts#IOAuthProviderContext`) : les secrets et l'URL de
475
536
  callback issus de la config, rien d'autre. Elle peut être **asynchrone** — découvrir un émetteur est
476
537
  une opération de construction, faite une fois par processus.
477
538
 
@@ -480,35 +541,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
480
541
  lignes — `github.ts` en est le modèle.
481
542
 
482
543
  Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
483
- (`createGithubProvider()`, `github.ts:53`) : `OAuth2Client` porte le protocole, la fabrique ne fait
544
+ (`createGithubProvider()`, `github.ts#createGithubProvider`) : `OAuth2Client` porte le protocole, la fabrique ne fait
484
545
  que le mapping du profil. Exemple sans réseau dans le dépôt :
485
546
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
486
547
 
487
548
  ## ⚙️ Configuration
488
549
 
489
- Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
490
- (`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
550
+ Schéma Zod `oauth2Schema` (`config.ts#oauth2Schema`), branché sur la section `oauth2` de la config du module
551
+ (`config.ts#securityConfigSchema`). Table dérivée du schéma — les défauts sont ceux du code.
491
552
 
492
- | Option | Type | Défaut | Effet |
493
- | ----------------- | -------------------- | --------------- | ----------------------------------------------------------- |
494
- | `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
495
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
496
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
497
- | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
498
- | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
499
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
553
+ | Option | Type | Défaut | Effet |
554
+ | ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
555
+ | `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
556
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts#oauthProviderSchema`). |
557
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts#oauth2Schema`). |
558
+ | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
559
+ | `failureRedirect` | chemin | `loginPage.path` (`/login`) | Où il revient après échec, quand la page de départ n'a pas donné `back`. Omis = la page de connexion, qu'il suit si elle change de chemin. |
560
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts#oauth2Schema`). |
500
561
 
501
- Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
562
+ Par fournisseur (`oauthProviderSchema`, `config.ts#oauthProviderSchema`) :
502
563
 
503
564
  <!-- prettier-ignore -->
504
565
  | Option | Requis | Effet |
505
566
  | --- | :---: | --- |
506
567
  | `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
507
- | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:975`). |
568
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts#oauthProviderSchema`). |
508
569
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
509
570
  | `clientAuthMethod` | | Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). Omis = `client_secret_basic`, ce que la RFC demande de préférer. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans `token_endpoint_auth_methods_supported`. |
510
571
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
511
- | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:279-283`). |
572
+ | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts#OAuth2Service.exchangeAndProvision`). |
573
+ | `postLogoutRedirectUri` | | Adresse **absolue** où le fournisseur renvoie le navigateur après la déconnexion, enregistrée chez lui. Omis = la page de connexion (`failureRedirect` sans sa query) résolue contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion. |
512
574
 
513
575
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
514
576
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -518,10 +580,10 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
518
580
  ### Les jetons du fournisseur ne sont pas conservés
519
581
 
520
582
  C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
521
- portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:274-275`) :
583
+ portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts#OAuth2Service.exchangeAndProvision`) :
522
584
  ils ne sont ni retournés, ni mis en
523
585
  session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
524
- (`IOAuthUserProvisioner.ts:8-10`).
586
+ (`IOAuthUserProvisioner.ts#IOAuthProfile`).
525
587
 
526
588
  - **Conséquence 1** — la surface d'exposition est minimale : pas de coffre de jetons à protéger, pas
527
589
  de fuite possible par la base ni par la session.
@@ -529,47 +591,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
529
591
  l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
530
592
  pas de la **délégation d'accès**.
531
593
  - **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
532
- `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:90`) — c'est là que ton implémentation les
594
+ `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts#IOAuthProvider.fetchProfile`) — c'est là que ton implémentation les
533
595
  capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
534
596
 
597
+ **Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
598
+ fournisseur publie un point de déconnexion (`end_session_endpoint`, lu par la découverte — Keycloak
599
+ le fait), la session BFF retient, **côté serveur**, le nom du fournisseur, l'ID token et le claim
600
+ `sid`. La console d'administration des sessions n'en montre rien. Le navigateur ne le voit qu'**une
601
+ fois**, à la déconnexion, dans l'adresse `logoutUrl` (`id_token_hint`) : RP-Initiated Logout passe
602
+ par une redirection du navigateur, la norme ne laisse pas d'autre chemin — la session locale est
603
+ alors déjà détruite. Pourquoi : à la déconnexion, l'ID token est rejoué en `id_token_hint` — il désigne la session à
604
+ fermer chez le fournisseur, qui sans lui demanderait une confirmation à chaque fois. Il n'ouvre
605
+ **aucun** accès : ni l'API du fournisseur, ni un rafraîchissement. Le jeton d'accès et le jeton de
606
+ rafraîchissement restent jetés. GitHub, ou un serveur OIDC sans point de déconnexion : rien n'est
607
+ retenu.
608
+
609
+ Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
610
+ un **index** — fournisseur et `sid`, **sans** l'ID token — dans les métadonnées de la session
611
+ (`oauth2.ts#IFederatedLogoutHint.sid`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
612
+ les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
613
+ **Sessions** ne l'affiche pas (résumé construit par liste blanche).
614
+
615
+ ### Déconnexion demandée par le fournisseur — le canal arrière
616
+
617
+ Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
618
+ application du même SSO —, le fournisseur appelle l'application : `POST
619
+ …/oauth2/{provider}/backchannel-logout`, avec un paramètre `logout_token` (OpenID Connect
620
+ Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts#OAuth2Controller.callback`) délègue au service
621
+ (`OAuth2Service.backchannelLogout()`, `oauth2.ts#OAuth2Service.backchannelLogout`) :
622
+
623
+ 1. **La signature d'abord.** Le jeton est vérifié par `RemoteJwtVerifier` (`verifyClaims`,
624
+ `RemoteJwtVerifier.ts#RemoteJwtVerifier.verifyClaims`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
625
+ émetteur exact, audience = le `clientId`, `iat`, `exp` et `jti` exigés
626
+ (`oidc.ts#createDiscoveredOidcProvider`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
627
+ ceux à secret partagé (`oidc.ts#createDiscoveredOidcProvider`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
628
+ secret de signature.
629
+ 2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts#readLogoutTokenClaims`) : `sub` ou `sid` présent ; `events`
630
+ qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
631
+ par la même clé, de passer pour un jeton de déconnexion.
632
+ 3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts#OAuth2Service.backchannelLogout`).
633
+ 4. **Les sessions désignées** sont détruites par `SessionsService.destroyWhere()`
634
+ (`sessions-service.ts#SessionsService.destroyWhere`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
635
+ jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
636
+ par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
637
+
638
+ Réponses (norme §2.8) : `200` quand la déconnexion a eu lieu — ou n'avait plus lieu d'être ; `400`
639
+ `invalid_request` pour un jeton refusé **comme** pour une déconnexion impossible (jeu de clés
640
+ injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501` fournisseur qui
641
+ n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
642
+
643
+ Les **WebSockets** portées par une session détruite se ferment au tick de revalidation du hub temps
644
+ réel, au plus 30 secondes plus tard (`RealtimeHub.ts#channelFamily`), code `4001`.
645
+
646
+ > [!NOTE]
647
+ > L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
648
+ > exemplaire passe — et ne ferme que des sessions que le fournisseur a déjà déclarées closes. La
649
+ > borne est la durée de vie du jeton, que la norme recommande courte (§4).
650
+
535
651
  ### Ce que « révoquer » veut dire ici
536
652
 
537
- | Action | Effet sur ton application |
538
- | --------------------------------------------- | ------------------------------------------------------------------------------- |
539
- | Déconnexion (`AuthFlow.logout()`) | Session détruite côté serveur + cookie effacé — immédiat. |
540
- | Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
541
- | Autorisation révoquée **chez le fournisseur** | **Aucun effet automatique** — la session locale reste valide jusqu'à son terme. |
542
- | `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
653
+ | Action | Effet sur ton application |
654
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
655
+ | Déconnexion (`POST …/auth/logout`) | Session détruite côté serveur + cookie effacé — immédiat. Si la session vient d'un fournisseur qui publie un point de déconnexion, la réponse porte `logoutUrl` : le client y envoie le navigateur, qui ferme **aussi** la session du fournisseur. Sans ce détour, le clic suivant sur « Continuer avec Keycloak » reconnecte **sans mot de passe**. |
656
+ | Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
657
+ | Session fermée **chez le fournisseur** | Fournisseur OpenID Connect qui appelle le canal arrière (Keycloak, client configuré) : les sessions qu'il a ouvertes sont détruites. Sinon (GitHub, fournisseur non configuré pour ce canal) : **aucun effet** — la session locale reste valide jusqu'à son terme. |
658
+ | `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
543
659
 
544
660
  La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne
545
- redemande plus rien à GitHub. Pour couper l'accès, il faut agir **localement** (désactiver le compte
661
+ redemande plus rien à GitHub — et Keycloak ne prévient que s'il connaît l'adresse du canal arrière. Pour couper l'accès, il faut agir **localement** (désactiver le compte
546
662
  ou détruire les sessions), pas chez le fournisseur.
547
663
 
548
664
  ### Attaques couvertes, prouvées par les tests
549
665
 
550
- | Vecteur | Défense | Preuve |
551
- | -------------------------------------------------- | ------------------------------------------------------- | -------------------------------- |
552
- | Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts:89` (S5) |
553
- | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
554
- | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:170` |
555
- | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
556
- | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
557
- | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
558
- | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:79` |
559
- | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:266` |
666
+ | Vecteur | Défense | Preuve |
667
+ | ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
668
+ | Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts` (S5) |
669
+ | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts` (S6) |
670
+ | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts` |
671
+ | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts` (A1) |
672
+ | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts` (A2) |
673
+ | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts` (A3) |
674
+ | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts` |
675
+ | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts#makeUsers` |
676
+ | Jeton de déconnexion forgé ou signé par une autre clé | Signature vérifiée sur le jeu de clés de l'émetteur | `oauthProviders.test.ts` · `oauth2-keycloak.test.ts` |
677
+ | ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
678
+ | Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
560
679
 
561
680
  ## 📜 Normes appliquées
562
681
 
563
- | Domaine | Norme | Ancrage |
564
- | --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
565
- | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
566
- | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
567
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1040`) |
568
- | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:65`) · `oauth2.ts:265-273` |
569
- | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:975`) |
570
- | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
571
- | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:139`) |
572
- | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
682
+ | Domaine | Norme | Ancrage |
683
+ | ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
684
+ | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts#IOAuthProvider.validateAuthorizationCode`) |
685
+ | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts#IOAuthProvider.usesPkce`) · `oidc.ts#readLogoutTokenClaims` |
686
+ | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts#OAuth2Service`) · `oauth2Schema` (`config.ts#oauth2Schema`) |
687
+ | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) · `oauth2.ts#OAuth2Service.listProviders` |
688
+ | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts#oauthProviderSchema`) |
689
+ | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts#createOidcProvider`) |
690
+ | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts#assertIdTokenClaims`) |
691
+ | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts#AuthFlow.logout`) |
692
+ | Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts#createOidcProvider`) · `end_session_endpoint` découvert (`metadata.ts#discoverAuthorizationServer`) |
693
+ | Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts#createOidcProvider`) · `backchannelLogout()` (`oauth2.ts#OAuth2Service.backchannelLogout`) |
573
694
 
574
695
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
575
696
  `password` / ROPC (l'application verrait le mot de passe du fournisseur).
@@ -604,35 +725,38 @@ retirer de la configuration.
604
725
  technique brut.
605
726
 
606
727
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
607
- `AuthFlow.establishSessionFor()` (`authFlow.ts:215-222`), consultable dans l'écran **Audit**. La
728
+ `AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), consultable dans l'écran **Audit**. La
608
729
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
609
730
  provisionné dans l'écran **Users**, avec ses rôles réels.
610
731
 
611
732
  > [!NOTE]
612
733
  > L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
613
- > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:194`), comme WebAuthn passe `webauthn`.
734
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts#IOAuth2BffFlow.establishSessionFor`), comme WebAuthn passe `webauthn`.
614
735
  > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
615
- > (`authFlow.ts:215`).
736
+ > (`authFlow.ts#AuthFlow.establishSessionFor`).
616
737
 
617
738
  ## ⚠️ Pièges (symptôme → cause → correction)
618
739
 
619
- | Symptôme | Cause (dans le code) | Correction |
620
- | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
621
- | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
622
- | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
623
- | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
624
- | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
625
- | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
626
- | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:975`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
627
- | Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
628
- | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:163-165`) | Refaire le flux depuis `authorize` — comportement attendu |
629
- | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:265-273`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
630
- | Keycloak : erreur dès le premier login | `issuer` absent en config (`oidc.ts:176-181`) | Renseigner l'URL du realm |
631
- | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:327-333`) | Implémenter `provisionOAuthUser()` sur le service `users` |
632
- | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:374`) | Activer `allowSignup` ou lier le compte au préalable |
633
- | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
634
- | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`UserService.ts:405`) | Modifier les rôles en base ; `defaultRoles` ne réécrit rien |
635
- | Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts:8-10`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
740
+ | Symptôme | Cause (dans le code) | Correction |
741
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
742
+ | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
743
+ | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
744
+ | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
745
+ | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts#IOAuth2Service.listProviders`) | Vérifier le nom exact **et** la présence des secrets |
746
+ | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
747
+ | Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
748
+ | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts#oauthProviderSchema`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
749
+ | Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
750
+ | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts#oauth2FailureTarget`) | Refaire le flux depuis `authorize` — comportement attendu |
751
+ | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts#OAuth2Service.listProviders`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
752
+ | Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
753
+ | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts#OAuth2Service`) | Implémenter `provisionOAuthUser()` sur le service `users` |
754
+ | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts#UserService.provisionOAuthUser`) | Activer `allowSignup` ou lier le compte au préalable |
755
+ | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
756
+ | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts#UserService.provisionOAuthUser`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
757
+ | Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts#IOAuthProfile`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
758
+ | Keycloak : « Some clients have not been logged out » | Canal arrière injoignable, ou client en _Front channel logout_ ON | Adresse joignable par Keycloak ; _Front channel logout_ OFF |
759
+ | Canal arrière : `0 session(s) fermée(s)` au journal | Session ouverte avant la mise en place de l'index fédéré, ou `sid` différent | Se reconnecter ; vérifier _Backchannel logout session required_ |
636
760
 
637
761
  ## 🧪 Tests & couverture
638
762
 
@@ -645,13 +769,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
645
769
  par e-mail) ;
646
770
  - **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
647
771
  à l'identité résolue par `/me`, avec un fournisseur de test déterministe et sans réseau ;
772
+ `oauth2-keycloak.test` : le même flux contre un **vrai** Keycloak, déconnexions comprises (les
773
+ deux sens) ;
648
774
  - **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
649
775
  `oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
650
776
  d'identifiants entre fournisseurs).
651
777
 
652
778
  Ce qui **manque** : aucun banc de charge dédié au social login (le flux est un chemin froid, deux
653
- requêtes par connexion), et aucun test contre un IdP réel (impossible à automatiser — le fournisseur
654
- de test couvre la branche PKCE).
779
+ requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
655
780
 
656
781
  Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
657
782
  `nodefony-security-review` (mode red/blue-team).