@nodefony/security 10.0.0-beta.2 → 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 (160) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.152.0 → _@oxc-project_runtime@0.153.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +44 -10
  4. package/dist/nodefony/command/security-oauth-doctor.js +66 -0
  5. package/dist/nodefony/command/security-secrets.js +42 -25
  6. package/dist/nodefony/command/security-token.js +46 -15
  7. package/dist/nodefony/command/security-user-delete.js +5 -4
  8. package/dist/nodefony/command/security-user-password.js +4 -11
  9. package/dist/nodefony/config/config.js +80 -5
  10. package/dist/nodefony/contracts/index.js +1 -2
  11. package/dist/nodefony/errors/InsecureTransportError.js +24 -0
  12. package/dist/nodefony/errors/index.js +2 -1
  13. package/dist/nodefony/keycloak/keycloakAdmin.js +255 -0
  14. package/dist/nodefony/keycloak/keycloakRealm.js +260 -0
  15. package/dist/nodefony/keycloak/keycloakRealmInput.js +124 -0
  16. package/dist/nodefony/keycloak/scaffold.js +231 -0
  17. package/dist/nodefony/keycloak/security-keycloak-realm.js +128 -0
  18. package/dist/nodefony/scaffold/contribute.js +20 -0
  19. package/dist/nodefony/service/authFlow.js +111 -11
  20. package/dist/nodefony/service/firewall.js +111 -21
  21. package/dist/nodefony/service/keycloak.js +209 -0
  22. package/dist/nodefony/service/oauth2.js +379 -26
  23. package/dist/nodefony/service/tokenService.js +57 -6
  24. package/dist/nodefony/service/totp.js +51 -6
  25. package/dist/nodefony/service/webAuthn.js +20 -2
  26. package/dist/nodefony/src/SecuredArea.js +14 -3
  27. package/dist/nodefony/src/admin/SecurityAdminApi.js +70 -40
  28. package/dist/nodefony/src/admin/adminAudit.js +0 -1
  29. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  30. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +66 -6
  31. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +38 -15
  32. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +1 -0
  33. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +13 -3
  34. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +9 -2
  35. package/dist/nodefony/src/authenticator/bearer.js +1 -2
  36. package/dist/nodefony/src/boot/publicRoutesNotice.js +27 -0
  37. package/dist/nodefony/src/entryPoint.js +39 -0
  38. package/dist/nodefony/src/oauth/clientCredentials.js +40 -0
  39. package/dist/nodefony/src/oauth/metadata.js +36 -9
  40. package/dist/nodefony/src/oauth/oauth2Client.js +60 -33
  41. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +39 -6
  42. package/dist/nodefony/src/oauth/providerDiagnosis.js +283 -0
  43. package/dist/nodefony/src/oauth/providerRoles.js +79 -0
  44. package/dist/nodefony/src/oauth/providers/github.js +32 -6
  45. package/dist/nodefony/src/oauth/providers/oidc.js +82 -3
  46. package/dist/nodefony/src/openToSignup.js +47 -0
  47. package/dist/nodefony/src/sessionAuthentication.js +26 -0
  48. package/dist/nodefony/src/token/JwtKeystore.js +78 -46
  49. package/dist/nodefony/src/token/RemoteJwtVerifier.js +40 -13
  50. package/dist/nodefony/src/token/secretFile.js +59 -2
  51. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +51 -0
  52. package/dist/types/index.d.ts +26 -6
  53. package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
  54. package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
  55. package/dist/types/nodefony/command/security-token.d.ts +9 -0
  56. package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
  57. package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
  58. package/dist/types/nodefony/config/config.d.ts +63 -1
  59. package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
  60. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
  61. package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
  62. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
  63. package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
  64. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
  65. package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
  66. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
  67. package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
  68. package/dist/types/nodefony/errors/index.d.ts +1 -0
  69. package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
  70. package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
  71. package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
  72. package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
  73. package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
  74. package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
  75. package/dist/types/nodefony/service/authFlow.d.ts +35 -3
  76. package/dist/types/nodefony/service/firewall.d.ts +17 -3
  77. package/dist/types/nodefony/service/keycloak.d.ts +115 -0
  78. package/dist/types/nodefony/service/oauth2.d.ts +125 -9
  79. package/dist/types/nodefony/service/tokenService.d.ts +7 -1
  80. package/dist/types/nodefony/service/totp.d.ts +28 -4
  81. package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
  82. package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
  83. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +54 -12
  84. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +18 -0
  85. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +13 -0
  86. package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
  87. package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
  88. package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
  89. package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
  90. package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
  91. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
  92. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
  93. package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
  94. package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
  95. package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
  96. package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
  97. package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
  98. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
  99. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
  100. package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
  101. package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
  102. package/docs/api-keys.md +17 -17
  103. package/docs/audit.md +16 -16
  104. package/docs/authenticators.md +60 -27
  105. package/docs/authorization.md +16 -16
  106. package/docs/cors.md +29 -29
  107. package/docs/csrf.md +35 -33
  108. package/docs/external-jwt.md +66 -4
  109. package/docs/firewall.md +57 -24
  110. package/docs/headers.md +30 -30
  111. package/docs/index.md +12 -4
  112. package/docs/keycloak.md +818 -0
  113. package/docs/login-page.md +361 -0
  114. package/docs/oauth2.md +236 -122
  115. package/docs/obtenir-un-jeton.md +18 -11
  116. package/docs/tokens.md +136 -85
  117. package/docs/totp.md +105 -64
  118. package/docs/webauthn.md +90 -87
  119. package/docs/webhooks.md +3 -3
  120. package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
  121. package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
  122. package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
  123. package/keycloak/themes/nodefony/account/theme.properties +14 -0
  124. package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
  125. package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
  126. package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
  127. package/keycloak/themes/nodefony/admin/theme.properties +12 -0
  128. package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
  129. package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
  130. package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
  131. package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
  132. package/keycloak/themes/nodefony/email/theme.properties +8 -0
  133. package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
  134. package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
  135. package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
  136. package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
  137. package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
  138. package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
  139. package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
  140. package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
  141. package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
  142. package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
  143. package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
  144. package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
  145. package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
  146. package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
  147. package/keycloak/themes/nodefony/login/template.ftl +423 -0
  148. package/keycloak/themes/nodefony/login/theme.properties +17 -0
  149. package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
  150. package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
  151. package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
  152. package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
  153. package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
  154. package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
  155. package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
  156. package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
  157. package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
  158. package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
  159. package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
  160. 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,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: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`, `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
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
151
161
  que `@nodefony/security` est chargé (`framework/index.ts:460`) : 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:280`) 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
@@ -215,15 +225,16 @@ export default defineConfig<typeof env>((ctx) => ({
215
225
 
216
226
  ### Les routes sont FOURNIES — tu n'écris aucun controller
217
227
 
218
- `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
219
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`) dès que le service `oauth2` est
228
+ `mountOAuth2Routes()` (`OAuth2Controller.ts:432`) monte quatre routes sous
229
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:373`) dès que le service `oauth2` est
220
230
  enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts:460`) :
221
231
 
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`. |
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)). |
227
238
 
228
239
  Ton écran de login n'a donc qu'un lien à poser :
229
240
 
@@ -234,10 +245,12 @@ Ton écran de login n'a donc qu'un lien à poser :
234
245
  ```
235
246
 
236
247
  > [!WARNING]
237
- > 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
238
249
  > d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
239
250
  > 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.
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.
241
254
 
242
255
  ### Ce qu'on observe
243
256
 
@@ -256,7 +269,7 @@ curl -si -b /tmp/jar -c /tmp/jar \
256
269
 
257
270
  # 3) L'identité est résolue comme après un login classique
258
271
  curl -s -b /tmp/jar http://localhost:5151/nodefony/security/api/auth/me
259
- # {"user":{"username":"jane@example.com","roles":["ROLE_USER"]}}
272
+ # {"user":{"id":"…","identifier":"jane@example.com","roles":["ROLE_USER"]}}
260
273
 
261
274
  # 4) Ce que l'UI de login interroge pour n'afficher que des boutons vivants
262
275
  curl -s http://localhost:5151/nodefony/security/api/oauth2/providers
@@ -269,45 +282,67 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
269
282
 
270
283
  ### Étape 1 — `createAuthorization(provider)`
271
284
 
272
- `OAuth2Service.createAuthorization()` (`oauth2.ts:232`) fabrique trois choses :
285
+ `OAuth2Service.createAuthorization()` (`oauth2.ts:477`) fabrique trois choses :
273
286
 
274
287
  1. un **`state`** aléatoire (anti-CSRF) ;
275
288
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
276
- `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
289
+ `oauth2.ts:458-460`) ; `null` sinon (GitHub) ;
277
290
  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`).
291
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:576`).
279
292
 
280
293
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
281
- mémoire, `OAuth2Controller.ts:145`), puis redirige en 302.
294
+ mémoire, `OAuth2Controller.ts:64`), puis redirige en 302.
282
295
 
283
296
  ### Étape 2 — le retour, validé avant tout appel réseau
284
297
 
285
- `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
286
299
  défense :
287
300
 
288
301
  1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
289
302
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
290
303
  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** ;
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** ;
293
306
  3. seulement ensuite, `exchangeAndProvision()`.
294
307
 
295
308
  ### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
296
309
 
297
- `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
310
+ `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:503`) enchaîne :
298
311
 
299
312
  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`) ;
313
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:402-408`) ;
301
314
  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`) ;
315
+ (`validateAuthorizationCode`, `oauth2.ts:479`), puis lecture du profil (`fetchProfile`,
316
+ `oauth2.ts:519`) ;
304
317
  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`).
318
+ **par fournisseur** (`oauth2.ts:417-418`), `allowSignup` global (`oauth2.ts:542`).
306
319
 
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
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
309
322
  échange refusé ou d'un signup interdit.
310
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
+
311
346
  ## 🧑‍⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
312
347
 
313
348
  Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **ligne locale** liée au
@@ -315,16 +350,25 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
315
350
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
316
351
 
317
352
  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** :
353
+ défaut est `UserService.provisionOAuthUser()` (`UserService.ts:603`), en **find-or-create** :
319
354
 
320
355
  | Situation au retour du fournisseur | Comportement |
321
356
  | ---------------------------------------- | ------------------------------------------------------------------------------ |
322
357
  | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
323
358
  | 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é. |
359
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:599`) — un compte lié est exigé. |
325
360
  | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
326
361
  | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
327
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
+
328
372
  ### Pourquoi l'e-mail ne lie jamais automatiquement un compte
329
373
 
330
374
  C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si un compte externe dont
@@ -338,14 +382,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
338
382
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
339
383
 
340
384
  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`).
385
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:607-608`).
342
386
 
343
387
  ### Les rôles sont posés à la création, et plus jamais
344
388
 
345
- `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
346
390
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
347
391
  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`).
392
+ authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070`).
349
393
 
350
394
  > [!TIP]
351
395
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -355,7 +399,7 @@ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053
355
399
  ### Brancher sa propre politique
356
400
 
357
401
  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
402
+ (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:863-871`). S'il ne l'implémente pas, le login
359
403
  **échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
360
404
  (quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
361
405
  `provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
@@ -364,7 +408,7 @@ et la charge brute `raw`.
364
408
 
365
409
  ## 🧩 Fournisseurs — catalogue et extension
366
410
 
367
- 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
368
412
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
369
413
  Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
370
414
 
@@ -377,23 +421,25 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
377
421
 
378
422
  ### `google` — OIDC, le cas nominal
379
423
 
380
- 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
381
425
  (`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
426
+ (`oauthProviderRegistry.ts:157`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
383
427
  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
428
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:217`), après les contrôles
385
429
  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é.
430
+ (`assertIdTokenClaims()`, `oidc.ts:143`). Pas d'identifiant stable, pas d'identité.
387
431
 
388
432
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
389
433
 
390
434
  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.
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.
393
439
 
394
440
  ### `oidc` — n'importe quel serveur OpenID Connect
395
441
 
396
- 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
397
443
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
398
444
  par fournisseur.
399
445
 
@@ -418,7 +464,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
418
464
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
419
465
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
420
466
 
421
- 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
422
468
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
423
469
 
424
470
  ### Découverte des points d'entrée (RFC 8414)
@@ -430,7 +476,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
430
476
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
431
477
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
432
478
  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`).
479
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:201`).
434
480
 
435
481
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
436
482
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -447,16 +493,16 @@ donnerait l'illusion de PKCE.
447
493
 
448
494
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
449
495
  `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
496
+ de l'API REST `/user` (`createGithubProvider()`, `github.ts:78`). Subtilité GitHub : l'e-mail
451
497
  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`).
498
+ `emailVerified` que si GitHub le certifie (`github.ts:127-136`).
453
499
 
454
500
  ### Enregistrer le sien — sans éditer le cœur
455
501
 
456
502
  **Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
457
503
  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) :
504
+ pour en nommer plusieurs, `registerOAuthProvider()` au chargement de ton module (avant le
505
+ `onBoot` du service) :
460
506
 
461
507
  ```typescript ignore
462
508
  import {
@@ -466,8 +512,12 @@ import {
466
512
 
467
513
  // Le nom sert de clé de configuration ET de `provider` du Shadow User ;
468
514
  // l'émetteur vient de la config (`oauth2.providers.microsoft.issuer`).
469
- registerOAuthProvider("microsoft", (ctx) =>
470
- 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 },
471
521
  );
472
522
  ```
473
523
 
@@ -480,35 +530,36 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
480
530
  lignes — `github.ts` en est le modèle.
481
531
 
482
532
  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
533
+ (`createGithubProvider()`, `github.ts:78`) : `OAuth2Client` porte le protocole, la fabrique ne fait
484
534
  que le mapping du profil. Exemple sans réseau dans le dépôt :
485
535
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
486
536
 
487
537
  ## ⚙️ Configuration
488
538
 
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.
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.
491
541
 
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`). |
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`). |
500
550
 
501
- Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
551
+ Par fournisseur (`oauthProviderSchema`, `config.ts:1169`) :
502
552
 
503
553
  <!-- prettier-ignore -->
504
554
  | Option | Requis | Effet |
505
555
  | --- | :---: | --- |
506
556
  | `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`). |
557
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:1190`). |
508
558
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
509
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`. |
510
560
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
511
- | `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. |
512
563
 
513
564
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
514
565
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -518,7 +569,7 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
518
569
  ### Les jetons du fournisseur ne sont pas conservés
519
570
 
520
571
  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`) :
572
+ portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:518-519`) :
522
573
  ils ne sont ni retournés, ni mis en
523
574
  session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
524
575
  (`IOAuthUserProvisioner.ts:8-10`).
@@ -529,47 +580,106 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
529
580
  l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
530
581
  pas de la **délégation d'accès**.
531
582
  - **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
583
+ `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:106`) — c'est là que ton implémentation les
533
584
  capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
534
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
+
535
640
  ### Ce que « révoquer » veut dire ici
536
641
 
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. |
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. |
543
648
 
544
649
  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
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
546
651
  ou détruire les sessions), pas chez le fournisseur.
547
652
 
548
653
  ### Attaques couvertes, prouvées par les tests
549
654
 
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` |
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` |
560
668
 
561
669
  ## 📜 Normes appliquées
562
670
 
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`) |
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`) |
573
683
 
574
684
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
575
685
  `password` / ROPC (l'application verrait le mot de passe du fournisseur).
@@ -604,35 +714,38 @@ retirer de la configuration.
604
714
  technique brut.
605
715
 
606
716
  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
717
+ `AuthFlow.establishSessionFor()` (`authFlow.ts:245-271`), consultable dans l'écran **Audit**. La
608
718
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
609
719
  provisionné dans l'écran **Users**, avec ses rôles réels.
610
720
 
611
721
  > [!NOTE]
612
722
  > 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`.
723
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts:74`), comme WebAuthn passe `webauthn`.
614
724
  > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
615
- > (`authFlow.ts:215`).
725
+ > (`authFlow.ts:248`).
616
726
 
617
727
  ## ⚠️ Pièges (symptôme → cause → correction)
618
728
 
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 |
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_ |
636
749
 
637
750
  ## 🧪 Tests & couverture
638
751
 
@@ -645,13 +758,14 @@ Trois familles couvrent le social login — les chiffres exacts vivent dans la c
645
758
  par e-mail) ;
646
759
  - **intégration** — `oauth2-flow.test` : le flux complet sur **serveur réel**, du `302` d'`authorize`
647
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) ;
648
763
  - **attaque** — `oauth2-attack.test` (rejeu du `state`, mix-up de fournisseur) et
649
764
  `oauth.attack.test` (collision d'e-mail avec un admin, élévation par re-login, collision
650
765
  d'identifiants entre fournisseurs).
651
766
 
652
767
  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).
768
+ requêtes par connexion), et aucun test contre un fournisseur réel autre que Keycloak.
655
769
 
656
770
  Couverture : `npm run coverage` dans `@nodefony/security`. Campagnes d'attaque : skill
657
771
  `nodefony-security-review` (mode red/blue-team).