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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -5
  2. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  3. package/dist/_virtual/{_@oxc-project_runtime@0.151.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  4. package/dist/index.js +44 -10
  5. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  6. package/dist/nodefony/command/security-secrets.js +42 -25
  7. package/dist/nodefony/command/security-token.js +46 -15
  8. package/dist/nodefony/command/security-user-delete.js +5 -4
  9. package/dist/nodefony/command/security-user-password.js +4 -11
  10. package/dist/nodefony/config/config.js +80 -5
  11. package/dist/nodefony/contracts/index.js +1 -2
  12. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  13. package/dist/nodefony/errors/index.js +2 -1
  14. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  15. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  16. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  17. package/dist/nodefony/keycloak/scaffold.js +231 -0
  18. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  19. package/dist/nodefony/scaffold/contribute.js +20 -0
  20. package/dist/nodefony/service/authFlow.js +111 -11
  21. package/dist/nodefony/service/firewall.js +111 -21
  22. package/dist/nodefony/service/keycloak.js +209 -0
  23. package/dist/nodefony/service/oauth2.js +379 -26
  24. package/dist/nodefony/service/securityHeaders.js +12 -1
  25. package/dist/nodefony/service/tokenService.js +57 -6
  26. package/dist/nodefony/service/totp.js +51 -6
  27. package/dist/nodefony/service/webAuthn.js +20 -2
  28. package/dist/nodefony/src/SecuredArea.js +14 -3
  29. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  30. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  31. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  32. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  33. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  34. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  35. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  36. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  37. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  38. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  39. package/dist/nodefony/src/entryPoint.js +39 -0
  40. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  41. package/dist/nodefony/src/oauth/metadata.js +36 -9
  42. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  43. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  44. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  45. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  46. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  47. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  48. package/dist/nodefony/src/openToSignup.js +47 -0
  49. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  50. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  51. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  52. package/dist/nodefony/src/token/secretFile.js +59 -2
  53. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  54. package/dist/types/index.d.ts +26 -6
  55. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  56. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  57. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  58. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  59. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  60. package/dist/types/nodefony/config/config.d.ts +63 -1
  61. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  62. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  63. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  64. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  65. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  67. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  68. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  69. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  70. package/dist/types/nodefony/errors/index.d.ts +1 -0
  71. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  72. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  73. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  74. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  75. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  76. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  77. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  78. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  79. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  80. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  81. package/dist/types/nodefony/service/securityHeaders.d.ts +2 -1
  82. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  83. package/dist/types/nodefony/service/totp.d.ts +28 -4
  84. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  85. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  86. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  87. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  88. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  89. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  90. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  91. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  92. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  93. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  94. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  95. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  96. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  97. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  98. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  99. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  100. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  101. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  102. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  103. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  104. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  105. package/docs/api-keys.md +18 -18
  106. package/docs/audit.md +24 -22
  107. package/docs/authenticators.md +62 -29
  108. package/docs/authorization.md +16 -16
  109. package/docs/cors.md +30 -30
  110. package/docs/csrf.md +35 -33
  111. package/docs/external-jwt.md +68 -5
  112. package/docs/firewall.md +78 -45
  113. package/docs/headers.md +35 -35
  114. package/docs/index.md +15 -7
  115. package/docs/keycloak.md +818 -0
  116. package/docs/lexique.md +15 -15
  117. package/docs/login-page.md +361 -0
  118. package/docs/oauth2.md +242 -125
  119. package/docs/obtenir-un-jeton.md +18 -11
  120. package/docs/tokens.md +138 -86
  121. package/docs/totp.md +113 -71
  122. package/docs/webauthn.md +115 -115
  123. package/docs/webhooks.md +21 -21
  124. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  128. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  129. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  130. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  131. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  132. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  133. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  134. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  135. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  136. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  137. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  138. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  139. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  140. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  145. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  146. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  147. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  148. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  149. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  150. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  152. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  153. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  154. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  155. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  156. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  160. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  161. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  162. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  163. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  164. package/package.json +17 -15
package/docs/oauth2.md CHANGED
@@ -33,7 +33,7 @@ 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
36
+ > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:280`) et le controller BFF
37
37
  > `OAuth2Controller` (`OAuth2Controller.ts:89`).
38
38
 
39
39
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
@@ -114,12 +114,12 @@ qui les ferme ici :
114
114
  de session opaque (`OAuth2Controller.ts:192`).
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:463-474`).
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:234-263`).
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
122
+ fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:402-408`) **et** par
123
123
  l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
124
124
 
125
125
  > [!IMPORTANT]
@@ -133,27 +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:181`). 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
140
+ `AuthFlow.establishSessionFor()` (`authFlow.ts:254`), qui re-résout l'identité, vérifie que le compte
141
141
  est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts:388`)
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.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 ne sont montées
151
- que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
152
- est **404**, pas « désactivée ».
147
+ dans le module (`OAuth2Client`, `oauth2Client.ts:345`), 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:460`) : sans social login configuré,
162
+ `…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
163
+ rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
153
164
 
154
165
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
155
166
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
156
- configurés à `listOAuthProviders()` (`oauth2.ts:135-155`) et le
167
+ configurés à `listOAuthProviders()` (`oauth2.ts:280`) et le
157
168
  reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
158
169
 
159
170
  ## 🚀 Démarrage rapide
@@ -214,15 +225,16 @@ export default defineConfig<typeof env>((ctx) => ({
214
225
 
215
226
  ### Les routes sont FOURNIES — tu n'écris aucun controller
216
227
 
217
- `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
218
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`), et **seulement si** le service `oauth2`
219
- est présent (`framework/index.ts:460`) :
228
+ `mountOAuth2Routes()` (`OAuth2Controller.ts:432`) monte quatre routes sous
229
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:373`) dès que le service `oauth2` est
230
+ enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
220
231
 
221
- | Route | Rôle |
222
- | ---------------------------- | --------------------------------------------------------------------- |
223
- | `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
224
- | `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
225
- | `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
232
+ | Route | Rôle |
233
+ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
234
+ | `GET …/providers` | Noms des fournisseurs **opérationnels** — l'UI n'affiche que ceux-là. |
235
+ | `GET …/{provider}/authorize` | Démarre le flux : pose l'état en session, `302` vers le fournisseur. |
236
+ | `GET …/{provider}/callback` | Valide, échange, provisionne, ouvre la session BFF, `302`. |
237
+ | `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)). |
226
238
 
227
239
  Ton écran de login n'a donc qu'un lien à poser :
228
240
 
@@ -233,10 +245,12 @@ Ton écran de login n'a donc qu'un lien à poser :
233
245
  ```
234
246
 
235
247
  > [!WARNING]
236
- > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:262`) — elles **sont** le mécanisme
248
+ > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:468`) — elles **sont** le mécanisme
237
249
  > d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
238
250
  > un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
239
- > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion.
251
+ > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
252
+ > est appelé **serveur à serveur** : ni cookie, ni session, ni jeton CSRF — la signature du jeton de
253
+ > déconnexion est sa seule authentification.
240
254
 
241
255
  ### Ce qu'on observe
242
256
 
@@ -255,7 +269,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
255
269
 
256
270
  # 3) L'identité est résolue comme après un login classique
257
271
  curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
258
- # {"user":{"username":"jane@example.com","roles":["ROLE_USER"]}}
272
+ # {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
259
273
 
260
274
  # 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
261
275
  curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
@@ -268,45 +282,67 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
268
282
 
269
283
  ### Étape 1 — `createAuthorization(provider)`
270
284
 
271
- `OAuth2Service.createAuthorization()` (`oauth2.ts:232`) fabrique trois choses :
285
+ `OAuth2Service.createAuthorization()` (`oauth2.ts:477`) fabrique trois choses :
272
286
 
273
287
  1. un **`state`** aléatoire (anti-CSRF) ;
274
288
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
275
- `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
289
+ `oauth2.ts:458-460`) ; `null` sinon (GitHub) ;
276
290
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
277
- (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:321`).
291
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:576`).
278
292
 
279
293
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
280
- mémoire, `OAuth2Controller.ts:145`), puis redirige en 302.
294
+ mémoire, `OAuth2Controller.ts:64`), puis redirige en 302.
281
295
 
282
296
  ### Étape 2 — le retour, validé avant tout appel réseau
283
297
 
284
- `OAuth2Controller.callback()` (`OAuth2Controller.ts:150`) travaille dans cet ordre, et l'ordre est la
298
+ `OAuth2Controller.callback()` (`OAuth2Controller.ts:258`) travaille dans cet ordre, et l'ordre est la
285
299
  défense :
286
300
 
287
301
  1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
288
302
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
289
303
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
290
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
291
- `failureRedirect`, **sans jamais contacter le fournisseur** ;
304
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts:231-241`). Un seul écart → `302` vers
305
+ l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
292
306
  3. seulement ensuite, `exchangeAndProvision()`.
293
307
 
294
308
  ### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
295
309
 
296
- `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
310
+ `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:503`) enchaîne :
297
311
 
298
312
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
299
- et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:265-273`) ;
313
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:402-408`) ;
300
314
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
301
- (`validateAuthorizationCode`, `oauth2.ts:274`), puis lecture du profil (`fetchProfile`,
302
- `oauth2.ts:275`) ;
315
+ (`validateAuthorizationCode`, `oauth2.ts:479`), puis lecture du profil (`fetchProfile`,
316
+ `oauth2.ts:519`) ;
303
317
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
304
- **par fournisseur** (`oauth2.ts:279-280`), `allowSignup` global (`oauth2.ts:283`).
318
+ **par fournisseur** (`oauth2.ts:417-418`), `allowSignup` global (`oauth2.ts:542`).
305
319
 
306
- Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
307
- failureRedirect`, `OAuth2Controller.ts:198-209`) : le client ne distingue pas un `iss` invalide d'un
320
+ Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
321
+ l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
308
322
  échange refusé ou d'un signup interdit.
309
323
 
324
+ ### Les retours — `from` et `back`
325
+
326
+ La page qui lance le flux dit deux choses à `/authorize`, que le serveur revalide en chemins
327
+ locaux (`safeRedirectPath`, garde anti-redirection ouverte du cœur), garde en session avec le
328
+ `state` et purge au callback :
329
+
330
+ | Paramètre | Sens | Absent ou refusé |
331
+ | --------- | ---------------------------------------------------------------------------------------------- | ----------------- |
332
+ | `from` | page où aller une fois connecté | `successRedirect` |
333
+ | `back` | écran où revenir si la connexion n'aboutit pas — le bouton « Retour » du fournisseur, un échec | `failureRedirect` |
334
+
335
+ `back` l'emporte sur `failureRedirect` parce qu'une application peut avoir PLUSIEURS écrans de
336
+ connexion (la page par défaut, la console d'administration) : une seule adresse d'échec renverrait
337
+ l'un chez l'autre. La page par défaut et la console l'envoient d'elles-mêmes, et
338
+ `NodefonyLogin.startProvider()` pose la page courante.
339
+
340
+ L'écran d'échec reçoit `reason` et `from` (une `reason` ou un `from` déjà présents sont
341
+ remplacés) : `reason=access_denied` dit une ANNULATION — l'écran se tait, l'utilisateur est revenu
342
+ choisir une autre méthode —, toute autre valeur un échec à annoncer. Seuls les codes normalisés
343
+ d'OAuth 2.0 et d'OpenID Connect sont recopiés, et seulement sous un `state` valide ; tout le reste
344
+ devient `failed` (`OAUTH2_FAILED_REASON`, cœur).
345
+
310
346
  ## 🧑‍⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
311
347
 
312
348
  Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
@@ -314,16 +350,25 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
314
350
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
315
351
 
316
352
  Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
317
- défaut est `UserService.provisionOAuthUser()` (`UserService.ts:363`), en **find-or-create** :
353
+ défaut est `UserService.provisionOAuthUser()` (`UserService.ts:603`), en **find-or-create** :
318
354
 
319
355
  | Situation au retour du fournisseur | Comportement |
320
356
  | ---------------------------------------- | ------------------------------------------------------------------------------ |
321
357
  | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
322
358
  | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
323
- | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:374`) — un compte lié est exigé. |
359
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:599`) — un compte lié est exigé. |
324
360
  | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
325
361
  | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
326
362
 
363
+ ⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
364
+ fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
365
+ création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
366
+ signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
367
+ `nodefony doctor --live` — c'est un constat, pas une faute (`findZonesOpenToSignup`,
368
+ `openToSignup.ts:40`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
369
+ rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
370
+ préexistant.
371
+
327
372
  ### Pourquoi l'e-mail ne lie jamais automatiquement un compte
328
373
 
329
374
  C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si un compte externe dont
@@ -337,14 +382,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
337
382
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
338
383
 
339
384
  L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
340
- préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:382-383`).
385
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:607-608`).
341
386
 
342
387
  ### Les rôles sont posés à la création, et plus jamais
343
388
 
344
- `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:405`). Un second login
389
+ `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:203`). Un second login
345
390
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
346
391
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
347
- authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053`).
392
+ authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070`).
348
393
 
349
394
  > [!TIP]
350
395
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -354,7 +399,7 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
354
399
  ### Brancher sa propre politique
355
400
 
356
401
  Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
357
- (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:327-333`). S'il ne l'implémente pas, le login
402
+ (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:863-871`). S'il ne l'implémente pas, le login
358
403
  **échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
359
404
  (quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
360
405
  `provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
@@ -363,7 +408,7 @@ et la charge brute `raw`.
363
408
 
364
409
  ## 🧩 Fournisseurs — catalogue et extension
365
410
 
366
- Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:53`) : il masque
411
+ Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:67`) : il masque
367
412
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
368
413
  Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
369
414
 
@@ -376,23 +421,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
376
421
 
377
422
  ### `google` — OIDC, le cas nominal
378
423
 
379
- Construit par le helper générique `createOidcProvider()` (`oidc.ts:106`) : PKCE systématique
424
+ Construit par le helper générique `createOidcProvider()` (`oidc.ts:177`) : PKCE systématique
380
425
  (`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
381
- (`oauthProviderRegistry.ts:90`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
426
+ (`oauthProviderRegistry.ts:157`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
382
427
  demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
383
- claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:146`), après les contrôles
428
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:217`), après les contrôles
384
429
  obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
385
- (`assertIdTokenClaims()`, `oidc.ts:139`). Pas d'identifiant stable, pas d'identité.
430
+ (`assertIdTokenClaims()`, `oidc.ts:143`). Pas d'identifiant stable, pas d'identité.
386
431
 
387
432
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
388
433
 
389
434
  Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
390
- valider l'`iss` (`oauthProviderRegistry.ts:95-97`). Il est donc **obligatoire** : sans lui, la fabrique
391
- lève au premier login avec un message explicite.
435
+ valider l'`iss`. Il est donc **obligatoire**, et déclaré comme tel au registre
436
+ (`requiresIssuer: true`) : sans lui, le démarrage est refusé en nommant la clé.
437
+ Créer le realm et le client, lire l'émetteur, réussir le premier login : la page
438
+ [Keycloak](keycloak.md) fait le chemin entier.
392
439
 
393
440
  ### `oidc` — n'importe quel serveur OpenID Connect
394
441
 
395
- La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:101`) prend
442
+ La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:142`) prend
396
443
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
397
444
  par fournisseur.
398
445
 
@@ -417,7 +464,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
417
464
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
418
465
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
419
466
 
420
- La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:65`) et remplie par
467
+ La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:79`) et remplie par
421
468
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
422
469
 
423
470
  ### Découverte des points d'entrée (RFC 8414)
@@ -429,7 +476,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
429
476
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
430
477
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
431
478
  PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
432
- redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:132`).
479
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:201`).
433
480
 
434
481
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
435
482
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -446,16 +493,16 @@ donnerait l'illusion de PKCE.
446
493
 
447
494
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
448
495
  `github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
449
- de l'API REST `/user` (`createGithubProvider()`, `github.ts:53`). Subtilité GitHub : l'e-mail
496
+ de l'API REST `/user` (`createGithubProvider()`, `github.ts:78`). Subtilité GitHub : l'e-mail
450
497
  primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
451
- `emailVerified` que si GitHub le certifie (`github.ts:98-107`).
498
+ `emailVerified` que si GitHub le certifie (`github.ts:127-136`).
452
499
 
453
500
  ### Enregistrer le sien — sans éditer le cœur
454
501
 
455
502
  **Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
456
503
  mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
457
- pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:68`) au chargement de
458
- ton module (avant le `onBoot` du service) :
504
+ pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
505
+ `onBoot` du service) :
459
506
 
460
507
  ```typescript ignore
461
508
  import {
@@ -465,8 +512,12 @@ import {
465
512
 
466
513
  // Le nom sert de clé de configuration ET de `provider` du Shadow User ;
467
514
  // l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
468
- registerOAuthProvider("microsoft", (ctx) =>
469
- createDiscoveredOidcProvider("microsoft", ctx),
515
+ registerOAuthProvider(
516
+ "microsoft",
517
+ (ctx) => createDiscoveredOidcProvider("microsoft", ctx),
518
+ // Déclarer l'émetteur requis : son oubli refuse le démarrage au lieu
519
+ // d'échouer au premier clic.
520
+ { requiresIssuer: true },
470
521
  );
471
522
  ```
472
523
 
@@ -479,35 +530,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
479
530
  lignes — `github.ts` en est le modèle.
480
531
 
481
532
  Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
482
- (`createGithubProvider()`, `github.ts:53`) : `OAuth2Client` porte le protocole, la fabrique ne fait
533
+ (`createGithubProvider()`, `github.ts:78`) : `OAuth2Client` porte le protocole, la fabrique ne fait
483
534
  que le mapping du profil. Exemple sans réseau dans le dépôt :
484
535
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
485
536
 
486
537
  ## ⚙️ Configuration
487
538
 
488
- Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
489
- (`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
539
+ Schéma Zod `oauth2Schema` (`config.ts:1357`), branché sur la section `oauth2` de la config du module
540
+ (`config.ts:1472`). Table dérivée du schéma — les défauts sont ceux du code.
490
541
 
491
- | Option | Type | Défaut | Effet |
492
- | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
493
- | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
494
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
495
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
496
- | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
497
- | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
498
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
542
+ | Option | Type | Défaut | Effet |
543
+ | ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
544
+ | `enabled` | booléen | `true` | Coupe le social login : `authorize`/`callback` rendent 503. |
545
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1233`). |
546
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1371`). |
547
+ | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
548
+ | `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. |
549
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1387`). |
499
550
 
500
- Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
551
+ Par fournisseur (`oauthProviderSchema`, `config.ts:1169`) :
501
552
 
502
553
  <!-- prettier-ignore -->
503
554
  | Option | Requis | Effet |
504
555
  | --- | :---: | --- |
505
556
  | `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
506
- | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:975`). |
557
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:1190`). |
507
558
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
508
559
  | `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`. |
509
560
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
510
- | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:279-283`). |
561
+ | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:515-532`). |
562
+ | `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. |
511
563
 
512
564
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
513
565
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -517,7 +569,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
517
569
  ### Les jetons du fournisseur ne sont pas conservés
518
570
 
519
571
  C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
520
- portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:274-275`) :
572
+ portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:518-519`) :
521
573
  ils ne sont ni retournés, ni mis en
522
574
  session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
523
575
  (`IOAuthUserProvisioner.ts:8-10`).
@@ -528,47 +580,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
528
580
  l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
529
581
  pas de la **délégation d'accès**.
530
582
  - **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
531
- `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:90`) — c'est là que ton implémentation les
583
+ `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:106`) — c'est là que ton implémentation les
532
584
  capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
533
585
 
586
+ **Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
587
+ fournisseur publie un point de déconnexion (`end_session_endpoint`, lu par la découverte — Keycloak
588
+ le fait), la session BFF retient, **côté serveur**, le nom du fournisseur, l'ID token et le claim
589
+ `sid`. La console d'administration des sessions n'en montre rien. Le navigateur ne le voit qu'**une
590
+ fois**, à la déconnexion, dans l'adresse `logoutUrl` (`id_token_hint`) : RP-Initiated Logout passe
591
+ par une redirection du navigateur, la norme ne laisse pas d'autre chemin — la session locale est
592
+ alors déjà détruite. Pourquoi : à la déconnexion, l'ID token est rejoué en `id_token_hint` — il désigne la session à
593
+ fermer chez le fournisseur, qui sans lui demanderait une confirmation à chaque fois. Il n'ouvre
594
+ **aucun** accès : ni l'API du fournisseur, ni un rafraîchissement. Le jeton d'accès et le jeton de
595
+ rafraîchissement restent jetés. GitHub, ou un serveur OIDC sans point de déconnexion : rien n'est
596
+ retenu.
597
+
598
+ Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
599
+ un **index** — fournisseur et `sid`, **sans** l'ID token — dans les métadonnées de la session
600
+ (`oauth2.ts:558`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
601
+ les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
602
+ **Sessions** ne l'affiche pas (résumé construit par liste blanche).
603
+
604
+ ### Déconnexion demandée par le fournisseur — le canal arrière
605
+
606
+ Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
607
+ application du même SSO —, le fournisseur appelle l'application : `POST
608
+ …/oauth2/{provider}/backchannel-logout`, avec un paramètre `logout_token` (OpenID Connect
609
+ Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts:314`) délègue au service
610
+ (`OAuth2Service.backchannelLogout()`, `oauth2.ts:638`) :
611
+
612
+ 1. **La signature d'abord.** Le jeton est vérifié par `RemoteJwtVerifier` (`verifyClaims`,
613
+ `RemoteJwtVerifier.ts:251`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
614
+ émetteur exact, audience = le `clientId`, `iat`, `exp` et `jti` exigés
615
+ (`oidc.ts:303`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
616
+ ceux à secret partagé (`oidc.ts:293`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
617
+ secret de signature.
618
+ 2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts:93`) : `sub` ou `sid` présent ; `events`
619
+ qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
620
+ par la même clé, de passer pour un jeton de déconnexion.
621
+ 3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts:704`).
622
+ 4. **Les sessions désignées** sont détruites par `SessionsService.destroyWhere()`
623
+ (`sessions-service.ts:788`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
624
+ jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
625
+ par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
626
+
627
+ Réponses (norme §2.8) : `200` quand la déconnexion a eu lieu — ou n'avait plus lieu d'être ; `400`
628
+ `invalid_request` pour un jeton refusé **comme** pour une déconnexion impossible (jeu de clés
629
+ injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501` fournisseur qui
630
+ n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
631
+
632
+ Les **WebSockets** portées par une session détruite se ferment au tick de revalidation du hub temps
633
+ réel, au plus 30 secondes plus tard (`RealtimeHub.ts:113`), code `4001`.
634
+
635
+ > [!NOTE]
636
+ > L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
637
+ > exemplaire passe — et ne ferme que des sessions que le fournisseur a déjà déclarées closes. La
638
+ > borne est la durée de vie du jeton, que la norme recommande courte (§4).
639
+
534
640
  ### Ce que « révoquer » veut dire ici
535
641
 
536
- | Action | Effet sur ton application |
537
- | --------------------------------------------- | ------------------------------------------------------------------------------- |
538
- | Déconnexion (`AuthFlow.logout()`) | Session détruite côté serveur + cookie effacé — immédiat. |
539
- | Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
540
- | Autorisation révoquée **chez le fournisseur** | **Aucun effet automatique** — la session locale reste valide jusqu'à son terme. |
541
- | `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
642
+ | Action | Effet sur ton application |
643
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
644
+ | 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**. |
645
+ | Compte local désactivé/verrouillé | Rejet à la requête suivante : l'identité est **re-résolue** à chaque requête. |
646
+ | 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. |
647
+ | `allowSignup: false` après coup | Bloque les nouveaux comptes, pas les liens existants. |
542
648
 
543
649
  La troisième ligne est le piège courant : une fois la session BFF ouverte, ton application ne
544
- redemande plus rien à GitHub. Pour couper l'accès, il faut agir **localement** (désactiver le compte
650
+ 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
545
651
  ou détruire les sessions), pas chez le fournisseur.
546
652
 
547
653
  ### Attaques couvertes, prouvées par les tests
548
654
 
549
- | Vecteur | Défense | Preuve |
550
- | -------------------------------------------------- | ------------------------------------------------------- | -------------------------------- |
551
- | 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) |
552
- | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
553
- | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:170` |
554
- | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
555
- | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
556
- | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
557
- | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:79` |
558
- | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:266` |
655
+ | Vecteur | Défense | Preuve |
656
+ | ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
657
+ | 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) |
658
+ | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
659
+ | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:218` |
660
+ | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
661
+ | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
662
+ | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
663
+ | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:79` |
664
+ | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:70` |
665
+ | 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` |
666
+ | ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
667
+ | Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
559
668
 
560
669
  ## 📜 Normes appliquées
561
670
 
562
- | Domaine | Norme | Ancrage |
563
- | --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
564
- | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
565
- | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
566
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1040`) |
567
- | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:65`) · `oauth2.ts:265-273` |
568
- | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:975`) |
569
- | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
570
- | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:139`) |
571
- | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
671
+ | Domaine | Norme | Ancrage |
672
+ | ------------------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------- |
673
+ | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:99`) |
674
+ | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:72`) · `oidc.ts:104-111` |
675
+ | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:280`) · `oauth2Schema` (`config.ts:1357`) |
676
+ | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:79`) · `oauth2.ts:402-408` |
677
+ | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:1190`) |
678
+ | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:205-220`) |
679
+ | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:143`) |
680
+ | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
681
+ | Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts:224`) · `end_session_endpoint` découvert (`metadata.ts:242`) |
682
+ | Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts:240`) · `backchannelLogout()` (`oauth2.ts:638`) |
572
683
 
573
684
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
574
685
  `password` / ROPC (l'application verrait le mot de passe du fournisseur).
@@ -603,33 +714,38 @@ retirer de la configuration.
603
714
  technique brut.
604
715
 
605
716
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
606
- `AuthFlow.establishSessionFor()` (`authFlow.ts:215-222`), consultable dans l'écran **Audit**. La
717
+ `AuthFlow.establishSessionFor()` (`authFlow.ts:245-271`), consultable dans l'écran **Audit**. La
607
718
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
608
719
  provisionné dans l'écran **Users**, avec ses rôles réels.
609
720
 
610
721
  > [!NOTE]
611
- > L'événement d'audit du login social porte la raison par défaut `federated`
612
- > (`authFlow.ts:218`) : le controller n'affine pas le facteur. Pour distinguer OAuth de WebAuthn dans
613
- > un filtre d'audit, s'appuyer sur le contexte de la requête plutôt que sur cette seule valeur.
722
+ > L'événement d'audit du login social porte la raison `oauth` : le controller la passe à
723
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:74`), comme WebAuthn passe `webauthn`.
724
+ > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
725
+ > (`authFlow.ts:248`).
614
726
 
615
727
  ## ⚠️ Pièges (symptôme → cause → correction)
616
728
 
617
- | Symptôme | Cause (dans le code) | Correction |
618
- | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
619
- | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
620
- | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
621
- | `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 |
622
- | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
623
- | `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` |
624
- | 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 |
625
- | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:163-165`) | Refaire le flux depuis `authorize` — comportement attendu |
626
- | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:265-273`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
627
- | Keycloak : erreur dès le premier login | `issuer` absent en config (`oidc.ts:176-181`) | Renseigner l'URL du realm |
628
- | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:327-333`) | Implémenter `provisionOAuthUser()` sur le service `users` |
629
- | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:374`) | Activer `allowSignup` ou lier le compte au préalable |
630
- | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
631
- | 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 |
632
- | 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 |
729
+ | Symptôme | Cause (dans le code) | Correction |
730
+ | ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
731
+ | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
732
+ | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
733
+ | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
734
+ | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:19`) | Vérifier le nom exact **et** la présence des secrets |
735
+ | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
736
+ | Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
737
+ | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:1170`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
738
+ | 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 |
739
+ | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:163-165`) | Refaire le flux depuis `authorize` — comportement attendu |
740
+ | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:402-408`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
741
+ | Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
742
+ | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:863-871`) | Implémenter `provisionOAuthUser()` sur le service `users` |
743
+ | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:599`) | Activer `allowSignup` ou lier le compte au préalable |
744
+ | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
745
+ | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts:603`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
746
+ | 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 |
747
+ | 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 |
748
+ | 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_ |
633
749
 
634
750
  ## 🧪 Tests & couverture
635
751
 
@@ -642,13 +758,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
642
758
  par e-mail) ;
643
759
  - **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
644
760
  à l'identité résolue par `/me`, avec un fournisseur de test déterministe et sans réseau ;
761
+ `oauth2-keycloak.test` : le même flux contre un **vrai** Keycloak, déconnexions comprises (les
762
+ deux sens) ;
645
763
  - **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
646
764
  `oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
647
765
  d'identifiants entre fournisseurs).
648
766
 
649
767
  Ce qui **manque** : aucun banc de charge dédié au social login (le flux est un chemin froid, deux
650
- requêtes par connexion), et aucun test contre un IdP réel (impossible à automatiser — le fournisseur
651
- de test couvre la branche PKCE).
768
+ requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
652
769
 
653
770
  Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
654
771
  `nodefony-security-review` (mode red/blue-team).