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

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 (165) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
  2. package/dist/index.js +6 -8
  3. package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
  4. package/dist/nodefony/command/security-oauth-doctor.js +5 -3
  5. package/dist/nodefony/command/security-secrets.js +10 -4
  6. package/dist/nodefony/command/security-user-add.js +9 -3
  7. package/dist/nodefony/command/security-user-delete.js +5 -3
  8. package/dist/nodefony/command/security-user-list.js +5 -3
  9. package/dist/nodefony/command/security-user-password.js +5 -3
  10. package/dist/nodefony/config/config.js +20 -4
  11. package/dist/nodefony/config/defineModuleConfig.js +1 -2
  12. package/dist/nodefony/contracts/IAccessVoter.js +1 -2
  13. package/dist/nodefony/errors/AccessDeniedError.js +1 -2
  14. package/dist/nodefony/errors/ApiKeyError.js +3 -2
  15. package/dist/nodefony/errors/AuthenticationError.js +1 -2
  16. package/dist/nodefony/errors/CsrfError.js +1 -2
  17. package/dist/nodefony/errors/InsecureTransportError.js +1 -2
  18. package/dist/nodefony/errors/InvalidTargetError.js +1 -2
  19. package/dist/nodefony/errors/SsrfError.js +1 -2
  20. package/dist/nodefony/errors/ThrottledError.js +1 -2
  21. package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
  22. package/dist/nodefony/errors/WebAuthnError.js +1 -2
  23. package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
  24. package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
  25. package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
  26. package/dist/nodefony/keycloak/scaffold.js +3 -3
  27. package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
  28. package/dist/nodefony/scaffold/contribute.js +1 -2
  29. package/dist/nodefony/service/accessTokenVerifier.js +101 -34
  30. package/dist/nodefony/service/apiKeys.js +45 -5
  31. package/dist/nodefony/service/auditService.js +4 -5
  32. package/dist/nodefony/service/authFlow.js +10 -8
  33. package/dist/nodefony/service/authorization.js +1 -2
  34. package/dist/nodefony/service/cors.js +1 -2
  35. package/dist/nodefony/service/csrf.js +1 -2
  36. package/dist/nodefony/service/firewall.js +5 -4
  37. package/dist/nodefony/service/keycloak.js +1 -2
  38. package/dist/nodefony/service/oauth2.js +29 -7
  39. package/dist/nodefony/service/securityHeaders.js +1 -2
  40. package/dist/nodefony/service/tokenService.js +38 -22
  41. package/dist/nodefony/service/totp.js +4 -5
  42. package/dist/nodefony/service/webAuthn.js +4 -5
  43. package/dist/nodefony/service/webhooks.js +4 -5
  44. package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
  45. package/dist/nodefony/src/SecuredArea.js +1 -2
  46. package/dist/nodefony/src/accountBinding.js +96 -0
  47. package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
  48. package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
  49. package/dist/nodefony/src/admin/adminAudit.js +1 -2
  50. package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
  51. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
  52. package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
  53. package/dist/nodefony/src/audit/auditBridge.js +1 -2
  54. package/dist/nodefony/src/audit/auditFilters.js +1 -2
  55. package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
  56. package/dist/nodefony/src/audit/readAuditContext.js +1 -2
  57. package/dist/nodefony/src/audit/recordAudit.js +1 -2
  58. package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
  59. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
  60. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
  61. package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
  62. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
  63. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
  64. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
  65. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
  66. package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
  67. package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
  68. package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
  69. package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
  70. package/dist/nodefony/src/crypto/secretCipher.js +1 -2
  71. package/dist/nodefony/src/csp.js +1 -2
  72. package/dist/nodefony/src/csrfToken.js +1 -2
  73. package/dist/nodefony/src/entryPoint.js +1 -2
  74. package/dist/nodefony/src/net/ssrfGuard.js +1 -2
  75. package/dist/nodefony/src/net/transportFailure.js +57 -0
  76. package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
  77. package/dist/nodefony/src/oauth/httpJson.js +3 -3
  78. package/dist/nodefony/src/oauth/metadata.js +5 -16
  79. package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
  80. package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
  81. package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
  82. package/dist/nodefony/src/oauth/providerRoles.js +1 -2
  83. package/dist/nodefony/src/oauth/providers/github.js +16 -6
  84. package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
  85. package/dist/nodefony/src/openToSignup.js +1 -2
  86. package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
  87. package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
  88. package/dist/nodefony/src/rolesWithin.js +57 -0
  89. package/dist/nodefony/src/sessionAuthentication.js +1 -2
  90. package/dist/nodefony/src/sessionIdentity.js +29 -3
  91. package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
  92. package/dist/nodefony/src/token/AnonymousToken.js +1 -2
  93. package/dist/nodefony/src/token/JwtKeystore.js +14 -5
  94. package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
  95. package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
  96. package/dist/nodefony/src/token/UserToken.js +1 -2
  97. package/dist/nodefony/src/token/jwtRuntime.js +1 -2
  98. package/dist/nodefony/src/token/scopeGrants.js +109 -0
  99. package/dist/nodefony/src/token/secretFile.js +1 -2
  100. package/dist/nodefony/src/token/tokenCriteria.js +1 -2
  101. package/dist/nodefony/src/token/tokenFilters.js +1 -2
  102. package/dist/nodefony/src/token/tokenSort.js +1 -2
  103. package/dist/nodefony/src/token/tokenStatus.js +1 -2
  104. package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
  105. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
  106. package/dist/nodefony/src/totp/totpCipher.js +1 -2
  107. package/dist/nodefony/src/totp/totpCrypto.js +1 -2
  108. package/dist/nodefony/src/totp/totpOperations.js +1 -2
  109. package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
  110. package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
  111. package/dist/nodefony/src/voter/RoleVoter.js +1 -2
  112. package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
  113. package/dist/nodefony/src/voter/voterRegistry.js +1 -2
  114. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
  115. package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
  116. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
  117. package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
  118. package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
  119. package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
  120. package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
  121. package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
  122. package/dist/nodefony/src/webhook/webhookSort.js +1 -2
  123. package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
  124. package/dist/nodefony/testing/index.js +108 -0
  125. package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
  126. package/dist/types/nodefony/config/config.d.ts +1 -0
  127. package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
  128. package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
  129. package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
  130. package/dist/types/nodefony/service/oauth2.d.ts +20 -0
  131. package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
  132. package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
  133. package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
  134. package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
  135. package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
  136. package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
  137. package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
  138. package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
  139. package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
  140. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
  141. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
  142. package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
  143. package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
  144. package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
  145. package/dist/types/nodefony/testing/index.d.ts +51 -0
  146. package/docs/api-keys.md +181 -174
  147. package/docs/audit.md +140 -140
  148. package/docs/authenticators.md +101 -101
  149. package/docs/authorization.md +116 -69
  150. package/docs/cors.md +64 -64
  151. package/docs/csrf.md +74 -71
  152. package/docs/external-jwt.md +22 -12
  153. package/docs/firewall.md +85 -82
  154. package/docs/headers.md +81 -81
  155. package/docs/index.md +4 -4
  156. package/docs/keycloak.md +95 -78
  157. package/docs/login-page.md +23 -23
  158. package/docs/oauth2.md +157 -146
  159. package/docs/obtenir-un-jeton.md +116 -26
  160. package/docs/tokens.md +128 -122
  161. package/docs/totp.md +136 -136
  162. package/docs/webauthn.md +153 -153
  163. package/docs/webhooks.md +136 -124
  164. package/package.json +19 -13
  165. package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/oauth2.md CHANGED
@@ -33,8 +33,8 @@ source: "src/packages/@nodefony/security/docs/oauth2.md"
33
33
  > connecté à **ton** application. Nodefony orchestre ce voyage avec la posture **OAuth 2.1**
34
34
  > (RFC 9700) : Authorization Code, PKCE, `state` anti-CSRF, `iss` anti-mix-up. Point clé :
35
35
  > **aucun jeton n'atteint le navigateur** — le retour produit une **session BFF**, exactement la même
36
- > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts:280`) et le controller BFF
37
- > `OAuth2Controller` (`OAuth2Controller.ts:89`).
36
+ > qu'un login par mot de passe. Ancré sur `OAuth2Service` (`oauth2.ts#OAuth2Service`) et le controller BFF
37
+ > `OAuth2Controller` (`OAuth2Controller.ts#OAuth2Controller`).
38
38
 
39
39
  📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **OAuth2**
40
40
 
@@ -111,16 +111,16 @@ qui les ferme ici :
111
111
 
112
112
  - **Vol de jeton via XSS** — si un `access_token` transite par le navigateur, tout script injecté
113
113
  peut le lire. _Fermé par construction_ : aucun jeton ne sort du serveur, le résultat est un cookie
114
- de session opaque (`OAuth2Controller.ts:192`).
114
+ de session opaque (`OAuth2Controller.ts#OAuth2Controller.constructor`).
115
115
  - **Interception du `code`** — un `code` capté (log de proxy, historique, redirection ouverte) est
116
116
  échangeable par l'attaquant. _Fermé par **PKCE**_ : l'échange exige le `code_verifier` resté en
117
- session (`OAuth2Service.createAuthorization()`, `oauth2.ts:463-474`).
117
+ session (`OAuth2Service.createAuthorization()`, `oauth2.ts#OAuth2Service.createAuthorization`).
118
118
  - **CSRF de login** — un tiers force ta victime à terminer **son** flux à lui : elle se retrouve
119
119
  connectée sur le compte de l'attaquant, qui lit ensuite ce qu'elle y dépose. _Fermé par le `state`_
120
- comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts:234-263`).
120
+ comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts#OAuth2Controller.callback`).
121
121
  - **Mix-up d'IdP** — un `code` obtenu chez un fournisseur malveillant est présenté au callback d'un
122
- fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:402-408`) **et** par
123
- l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
122
+ fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts#OAuth2Service.listProviders`) **et** par
123
+ l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts#OAuth2Controller`).
124
124
 
125
125
  > [!IMPORTANT]
126
126
  > Le fournisseur social te dit **qui** est la personne. Il ne te dit **rien** de ses droits.
@@ -133,18 +133,18 @@ 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:181`). Conséquence pratique : la logique OAuth se teste **sans
136
+ (`IOAuthAuthorization`, `oauth2.ts#IOAuthAuthorization`). Conséquence pratique : la logique OAuth se teste **sans
137
137
  serveur**, comme `AuthFlow`. Le transport (cookies, redirections 302) vit dans le controller BFF.
138
138
 
139
139
  **Le login social finit exactement comme un login classique.** Le callback appelle
140
- `AuthFlow.establishSessionFor()` (`authFlow.ts:254`), qui re-résout l'identité, vérifie que le compte
141
- est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts:388`)
140
+ `AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), qui re-résout l'identité, vérifie que le compte
141
+ est actif, **régénère l'ID de session** (anti-fixation, `session.regenerateId()`, `authFlow.ts#AuthFlow.logout`)
142
142
  et journalise l'événement
143
143
  d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewall : après le retour,
144
144
  c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
145
145
 
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
- dans le module (`OAuth2Client`, `oauth2Client.ts:345`), et `jose` — seul recours externe, pour lire les claims de
147
+ dans le module (`OAuth2Client`, `oauth2Client.ts#OAuth2Client`), et `jose` — seul recours externe, pour lire les claims de
148
148
  l'ID token — est importé **paresseusement**. Les fournisseurs sont construits une fois puis
149
149
  mémoïsés (`OAuth2Service.#resolveProvider()`) : c'est là, une seule fois par processus, que les
150
150
  points d'entrée d'un émetteur OIDC sont découverts. Un run qui sert (ports ouverts) les construit
@@ -158,13 +158,13 @@ la clé (`security.oauth2.providers.<nom>.issuer`). Un émetteur **injoignable**
158
158
  faute de configuration : le boot continue, un WARNING le nomme, et son bouton quitte l'écran de
159
159
  connexion — le flux `authorize` reste ouvert. Une nouvelle tentative part au plus toutes les 30 s
160
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é,
161
+ que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) : sans social login configuré,
162
162
  `…/providers` rend une liste vide, `authorize` rend `404 Unknown provider`, et `authorize`/`callback`
163
163
  rendent `503 OAuth unavailable` quand `oauth2.enabled` vaut `false`.
164
164
 
165
165
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
166
166
  inconnu produit un **WARNING, pas un échec fatal** — `OAuth2Service.#build()` confronte les noms
167
- configurés à `listOAuthProviders()` (`oauth2.ts:280`) et le
167
+ configurés à `listOAuthProviders()` (`oauth2.ts#OAuth2Service`) et le
168
168
  reste de l'application démarre, le bouton correspondant n'apparaît simplement pas.
169
169
 
170
170
  ## 🚀 Démarrage rapide
@@ -181,10 +181,41 @@ de login quand la variable manque.
181
181
  > valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
182
182
  > personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
183
183
 
184
- ```typescript
184
+ ```ts ignore
185
+ // nodefony/config/security.ts — le fragment du module, importé par le manifeste
186
+ import type { ConfigContext } from "nodefony";
187
+ import type { ISecurityConfigInput } from "@nodefony/security";
188
+ import type { env } from "../../env";
189
+
190
+ export const securityConfig = (ctx: ConfigContext<typeof env>) =>
191
+ ({
192
+ oauth2: {
193
+ // Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
194
+ defaultRoles: ["ROLE_USER"],
195
+ allowSignup: true, // false = un compte local déjà lié est exigé
196
+ successRedirect: "/",
197
+ failureRedirect: "/login?error=oauth",
198
+ providers: {
199
+ // Secrets absents → fournisseur non monté, bouton non affiché.
200
+ ...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
201
+ ? {
202
+ github: {
203
+ clientId: ctx.env.GITHUB_CLIENT_ID,
204
+ clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
205
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
206
+ },
207
+ }
208
+ : {}),
209
+ },
210
+ },
211
+ }) satisfies ISecurityConfigInput;
212
+ ```
213
+
214
+ ```typescript ignore
185
215
  // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
186
216
  // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
187
217
  import { defineConfig, defineEnv, envString, use } from "nodefony";
218
+ import { securityConfig } from "./nodefony/config/security";
188
219
 
189
220
  export const env = defineEnv({
190
221
  GITHUB_CLIENT_ID: envString({ optional: true }),
@@ -198,36 +229,16 @@ export default defineConfig<typeof env>((ctx) => ({
198
229
  modules: [
199
230
  "@nodefony/http",
200
231
  "@nodefony/framework",
201
- use("@nodefony/security", {
202
- oauth2: {
203
- // Rôles posés à la CRÉATION du compte local, jamais réécrits ensuite.
204
- defaultRoles: ["ROLE_USER"],
205
- allowSignup: true, // false = un compte local déjà lié est exigé
206
- successRedirect: "/",
207
- failureRedirect: "/login?error=oauth",
208
- providers: {
209
- // Secrets absents → fournisseur non monté, bouton non affiché.
210
- ...(ctx.env.GITHUB_CLIENT_ID && ctx.env.GITHUB_CLIENT_SECRET
211
- ? {
212
- github: {
213
- clientId: ctx.env.GITHUB_CLIENT_ID,
214
- clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
215
- redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
216
- },
217
- }
218
- : {}),
219
- },
220
- },
221
- }),
232
+ use("@nodefony/security", securityConfig(ctx)),
222
233
  ],
223
234
  }));
224
235
  ```
225
236
 
226
237
  ### Les routes sont FOURNIES — tu n'écris aucun controller
227
238
 
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`) :
239
+ `mountOAuth2Routes()` (`OAuth2Controller.ts#mountOAuth2Routes`) monte quatre routes sous
240
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts#OAuth2Controller.backchannelLogout`) dès que le service `oauth2` est
241
+ enregistré, c'est-à-dire dès que `@nodefony/security` est chargé (`framework/index.ts#Framework.onKernelReady`) :
231
242
 
232
243
  | Route | Rôle |
233
244
  | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
@@ -245,7 +256,7 @@ Ton écran de login n'a donc qu'un lien à poser :
245
256
  ```
246
257
 
247
258
  > [!WARNING]
248
- > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts:468`) — elles **sont** le mécanisme
259
+ > Ces routes portent `bypassFirewall: true` (`OAuth2Controller.ts#mountOAuth2Routes`) — elles **sont** le mécanisme
249
260
  > d'authentification : l'utilisateur est anonyme pendant tout l'aller-retour. Les protéger créerait
250
261
  > un interblocage (il faudrait être connecté pour pouvoir se connecter). La session anonyme ne porte
251
262
  > que `state`/`code_verifier`, et son ID est **régénéré** à la promotion. Le canal arrière, lui,
@@ -282,40 +293,40 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
282
293
 
283
294
  ### Étape 1 — `createAuthorization(provider)`
284
295
 
285
- `OAuth2Service.createAuthorization()` (`oauth2.ts:477`) fabrique trois choses :
296
+ `OAuth2Service.createAuthorization()` (`oauth2.ts#OAuth2Service.createAuthorization`) fabrique trois choses :
286
297
 
287
298
  1. un **`state`** aléatoire (anti-CSRF) ;
288
299
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
289
- `oauth2.ts:458-460`) ; `null` sinon (GitHub) ;
300
+ `oauth2.ts#OAuth2Service.getRedirects`) ; `null` sinon (GitHub) ;
290
301
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
291
- (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:576`).
302
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts#OAuth2Service.rememberLogout`).
292
303
 
293
304
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
294
- mémoire, `OAuth2Controller.ts:64`), puis redirige en 302.
305
+ mémoire, `OAuth2Controller.ts#IOAuth2Session.save`), puis redirige en 302.
295
306
 
296
307
  ### Étape 2 — le retour, validé avant tout appel réseau
297
308
 
298
- `OAuth2Controller.callback()` (`OAuth2Controller.ts:258`) travaille dans cet ordre, et l'ordre est la
309
+ `OAuth2Controller.callback()` (`OAuth2Controller.ts#OAuth2Controller.callback`) travaille dans cet ordre, et l'ordre est la
299
310
  défense :
300
311
 
301
- 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
312
+ 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts#oauth2FailureTarget`) — le
302
313
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
303
314
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
304
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:231-241`). Un seul écart → `302` vers
315
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts#OAuth2Controller.authorize`). Un seul écart → `302` vers
305
316
  l'écran d'échec avec `reason=failed`, **sans jamais contacter le fournisseur** ;
306
317
  3. seulement ensuite, `exchangeAndProvision()`.
307
318
 
308
319
  ### Étape 3 — `exchangeAndProvision(provider, code, verifier, iss)`
309
320
 
310
- `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:503`) enchaîne :
321
+ `OAuth2Service.exchangeAndProvision()` (`oauth2.ts#OAuth2Service.exchangeAndProvision`) enchaîne :
311
322
 
312
323
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
313
- et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:402-408`) ;
324
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts#OAuth2Service.listProviders`) ;
314
325
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
315
- (`validateAuthorizationCode`, `oauth2.ts:479`), puis lecture du profil (`fetchProfile`,
316
- `oauth2.ts:519`) ;
326
+ (`validateAuthorizationCode`, `oauth2.ts#OAuth2Service.createAuthorization`), puis lecture du profil (`fetchProfile`,
327
+ `oauth2.ts#OAuth2Service.exchangeAndProvision`) ;
317
328
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
318
- **par fournisseur** (`oauth2.ts:417-418`), `allowSignup` global (`oauth2.ts:542`).
329
+ **par fournisseur** (`oauth2.ts#OAuth2Service.listDisplayProviders`), `allowSignup` global (`oauth2.ts#OAuth2Service.exchangeAndProvision`).
319
330
 
320
331
  Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302` vers
321
332
  l'écran d'échec avec `reason=failed`) : le client ne distingue pas un `iss` invalide d'un
@@ -349,23 +360,23 @@ Nodefony ne « connecte pas un compte Google ». Il crée et retrouve une **lign
349
360
  compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, les rôles, l'état
350
361
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
351
362
 
352
- Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
353
- défaut est `UserService.provisionOAuthUser()` (`UserService.ts:603`), en **find-or-create** :
363
+ Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts#IOAuthUserProvisioner`) ; l'implémentation par
364
+ défaut est `UserService.provisionOAuthUser()` (`UserService.ts#UserService.provisionOAuthUser`), en **find-or-create** :
354
365
 
355
- | Situation au retour du fournisseur | Comportement |
356
- | ---------------------------------------- | ------------------------------------------------------------------------------ |
357
- | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
358
- | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
359
- | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:599`) — un compte lié est exigé. |
360
- | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
361
- | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
366
+ | Situation au retour du fournisseur | Comportement |
367
+ | ---------------------------------------- | -------------------------------------------------------------------------------------------------- |
368
+ | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
369
+ | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
370
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts#UserService.provisionOAuthUser`) — un compte lié est exigé. |
371
+ | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
372
+ | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
362
373
 
363
374
  ⚠️ **`allowSignup: true` (le défaut) + une zone sans `roles` = une zone ouverte à tout compte du
364
375
  fournisseur.** Une zone qui n'exige aucun rôle laisse passer toute identité authentifiée ; avec la
365
376
  création à la volée, cela veut dire quiconque obtient un compte chez Google ou GitHub. Le pare-feu le
366
377
  signale au démarrage (`WARNING`), dans la console (`openToSignup` de la zone) et dans
367
378
  `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
379
+ `openToSignup.ts#findZonesOpenToSignup`). Deux gestes le soldent : `roles: ["ROLE_USER"]` sur la zone, qui ne change
369
380
  rien au comportement mais **écrit** l'intention, ou `allowSignup: false`, qui exige un compte
370
381
  préexistant.
371
382
 
@@ -375,21 +386,21 @@ C'est le point le plus contre-intuitif, et c'est une décision de sécurité. Si
375
386
  l'e-mail vaut `admin@ton-domaine.fr` liait automatiquement l'administrateur local, il suffirait de
376
387
  créer un compte chez un fournisseur laxiste avec cette adresse pour **prendre le compte admin**. La
377
388
  liaison par e-mail est donc refusée y compris quand le fournisseur certifie l'adresse
378
- (`emailVerified` reste informatif, `IOAuthUserProvisioner.ts:24`).
389
+ (`emailVerified` reste informatif, `IOAuthUserProvisioner.ts#IOAuthProfile.emailVerified`).
379
390
 
380
391
  Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « avec GitHub » obtient un
381
392
  **second** compte. Le rattachement d'un compte externe à un compte existant est une action explicite,
382
393
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
383
394
 
384
395
  L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
385
- préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:607-608`).
396
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts#UserService.provisionOAuthUser`).
386
397
 
387
398
  ### Les rôles sont posés à la création, et plus jamais
388
399
 
389
- `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:203`). Un second login
400
+ `defaultRoles` s'applique **au moment du `create`** (`UserService.ts#UserService.create`). Un second login
390
401
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
391
402
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
392
- authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070`).
403
+ authentification, pas autorisation » (`oauth2.ts#OAuth2Service.listDisplayProviders`, `config.ts#loginPageSchema`).
393
404
 
394
405
  > [!TIP]
395
406
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -399,18 +410,18 @@ authentification, pas autorisation » (`oauth2.ts:414-418`, `config.ts:1065-1070
399
410
  ### Brancher sa propre politique
400
411
 
401
412
  Le provisioner est le service `users` **s'il implémente la capability**, détecté par duck-typing
402
- (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts:863-871`). S'il ne l'implémente pas, le login
413
+ (`OAuth2Service.#resolveProvisioner()`, `oauth2.ts#OAuth2Service.#resolveProvisioner`). S'il ne l'implémente pas, le login
403
414
  **échoue** — jamais de création silencieuse par défaut. Une application qui veut sa propre politique
404
415
  (quota d'inscriptions, allowlist de domaines e-mail, rattachement à un tenant) implémente
405
416
  `provisionOAuthUser()` sur son service `users` : le profil normalisé `IOAuthProfile`
406
- (`IOAuthUserProvisioner.ts:12`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
417
+ (`IOAuthUserProvisioner.ts#IOAuthProfile`) lui donne `provider`, `providerId`, `email`, `emailVerified`, `name`
407
418
  et la charge brute `raw`.
408
419
 
409
420
  ## 🧩 Fournisseurs — catalogue et extension
410
421
 
411
- Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:67`) : il masque
422
+ Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts#IOAuthProvider`) : il masque
412
423
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
413
- Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
424
+ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts#OAuthProviderFactory`.
414
425
 
415
426
  | Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
416
427
  | ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
@@ -421,13 +432,13 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
421
432
 
422
433
  ### `google` — OIDC, le cas nominal
423
434
 
424
- Construit par le helper générique `createOidcProvider()` (`oidc.ts:177`) : PKCE systématique
425
- (`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
426
- (`oauthProviderRegistry.ts:157`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
435
+ Construit par le helper générique `createOidcProvider()` (`oidc.ts#createOidcProvider`) : PKCE systématique
436
+ (`usesPkce: true`, `oidc.ts#readLogoutTokenClaims`), émetteur figé `https://accounts.google.com`
437
+ (`oauthProviderRegistry.ts#withKeycloakThemeHint`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
427
438
  demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
428
- claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:217`), après les contrôles
439
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts#IOidcProviderOptions.name`), après les contrôles
429
440
  obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
430
- (`assertIdTokenClaims()`, `oidc.ts:143`). Pas d'identifiant stable, pas d'identité.
441
+ (`assertIdTokenClaims()`, `oidc.ts#assertIdTokenClaims`). Pas d'identifiant stable, pas d'identité.
431
442
 
432
443
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
433
444
 
@@ -439,7 +450,7 @@ Créer le realm et le client, lire l'émetteur, réussir le premier login : la p
439
450
 
440
451
  ### `oidc` — n'importe quel serveur OpenID Connect
441
452
 
442
- La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:142`) prend
453
+ La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts#withKeycloakThemeHint`) prend
443
454
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
444
455
  par fournisseur.
445
456
 
@@ -464,7 +475,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
464
475
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
465
476
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
466
477
 
467
- La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:79`) et remplie par
478
+ La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) et remplie par
468
479
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
469
480
 
470
481
  ### Découverte des points d'entrée (RFC 8414)
@@ -476,7 +487,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
476
487
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
477
488
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
478
489
  PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
479
- redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:201`).
490
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts#discoverAuthorizationServer`).
480
491
 
481
492
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
482
493
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -492,10 +503,10 @@ donnerait l'illusion de PKCE.
492
503
  ### `github` — OAuth simple, l'archétype non-OIDC
493
504
 
494
505
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
495
- `github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
496
- de l'API REST `/user` (`createGithubProvider()`, `github.ts:78`). Subtilité GitHub : l'e-mail
506
+ `github.ts#normalizeGithubError`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
507
+ de l'API REST `/user` (`createGithubProvider()`, `github.ts#createGithubProvider`). Subtilité GitHub : l'e-mail
497
508
  primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
498
- `emailVerified` que si GitHub le certifie (`github.ts:127-136`).
509
+ `emailVerified` que si GitHub le certifie (`github.ts#createGithubProvider`).
499
510
 
500
511
  ### Enregistrer le sien — sans éditer le cœur
501
512
 
@@ -521,7 +532,7 @@ registerOAuthProvider(
521
532
  );
522
533
  ```
523
534
 
524
- La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts:24`) : les secrets et l'URL de
535
+ La fabrique reçoit `IOAuthProviderContext` (`oauthProviderRegistry.ts#IOAuthProviderContext`) : les secrets et l'URL de
525
536
  callback issus de la config, rien d'autre. Elle peut être **asynchrone** — découvrir un émetteur est
526
537
  une opération de construction, faite une fois par processus.
527
538
 
@@ -530,35 +541,35 @@ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est
530
541
  lignes — `github.ts` en est le modèle.
531
542
 
532
543
  Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
533
- (`createGithubProvider()`, `github.ts:78`) : `OAuth2Client` porte le protocole, la fabrique ne fait
544
+ (`createGithubProvider()`, `github.ts#createGithubProvider`) : `OAuth2Client` porte le protocole, la fabrique ne fait
534
545
  que le mapping du profil. Exemple sans réseau dans le dépôt :
535
546
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
536
547
 
537
548
  ## ⚙️ Configuration
538
549
 
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.
550
+ Schéma Zod `oauth2Schema` (`config.ts#oauth2Schema`), branché sur la section `oauth2` de la config du module
551
+ (`config.ts#securityConfigSchema`). Table dérivée du schéma — les défauts sont ceux du code.
541
552
 
542
553
  | Option | Type | Défaut | Effet |
543
554
  | ----------------- | -------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
544
555
  | `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`). |
556
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts#oauthProviderSchema`). |
557
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts#oauth2Schema`). |
547
558
  | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès, quand la page de départ n'a pas donné `from`. |
548
559
  | `failureRedirect` | chemin | `loginPage.path` (`/login`) | Où il revient après échec, quand la page de départ n'a pas donné `back`. Omis = la page de connexion, qu'il suit si elle change de chemin. |
549
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1387`). |
560
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts#oauth2Schema`). |
550
561
 
551
- Par fournisseur (`oauthProviderSchema`, `config.ts:1169`) :
562
+ Par fournisseur (`oauthProviderSchema`, `config.ts#oauthProviderSchema`) :
552
563
 
553
564
  <!-- prettier-ignore -->
554
565
  | Option | Requis | Effet |
555
566
  | --- | :---: | --- |
556
567
  | `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
557
- | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:1190`). |
568
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts#oauthProviderSchema`). |
558
569
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
559
570
  | `clientAuthMethod` | | Comment le client s'authentifie au point de jeton (RFC 6749 §2.3). Omis = `client_secret_basic`, ce que la RFC demande de préférer. Poser `client_secret_post` quand le serveur l'EXIGE — il le publie dans `token_endpoint_auth_methods_supported`. |
560
571
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
561
- | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:515-532`). |
572
+ | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts#OAuth2Service.exchangeAndProvision`). |
562
573
  | `postLogoutRedirectUri` | | Adresse **absolue** où le fournisseur renvoie le navigateur après la déconnexion, enregistrée chez lui. Omis = la page de connexion (`failureRedirect` sans sa query) résolue contre `redirectUri`. Sans effet pour un fournisseur qui ne publie pas de point de déconnexion. |
563
574
 
564
575
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
@@ -569,10 +580,10 @@ et ses rôles pendant qu'un IdP de production pointe ailleurs.
569
580
  ### Les jetons du fournisseur ne sont pas conservés
570
581
 
571
582
  C'est un choix, et il a des conséquences à connaître. Les jetons obtenus à l'échange vivent dans la
572
- portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts:518-519`) :
583
+ portée locale de l'échange (`validateAuthorizationCode` puis `fetchProfile`, `oauth2.ts#OAuth2Service.exchangeAndProvision`) :
573
584
  ils ne sont ni retournés, ni mis en
574
585
  session, ni persistés. Le profil normalisé qui traverse le système n'en contient aucun
575
- (`IOAuthUserProvisioner.ts:8-10`).
586
+ (`IOAuthUserProvisioner.ts#IOAuthProfile`).
576
587
 
577
588
  - **Conséquence 1** — la surface d'exposition est minimale : pas de coffre de jetons à protéger, pas
578
589
  de fuite possible par la base ni par la session.
@@ -580,7 +591,7 @@ session, ni persistés. Le profil normalisé qui traverse le système n'en conti
580
591
  l'utilisateur plus tard (lire ses dépôts, envoyer un mail). Nodefony fait de l'**authentification**,
581
592
  pas de la **délégation d'accès**.
582
593
  - **Si tu as besoin de cette délégation** : le seul endroit où les jetons sont visibles est le
583
- `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts:106`) — c'est là que ton implémentation les
594
+ `fetchProfile()` de ton adaptateur (`IOAuthProvider.ts#IOAuthProvider.fetchProfile`) — c'est là que ton implémentation les
584
595
  capture et les persiste, sous ta responsabilité (chiffrement au repos, rotation, révocation).
585
596
 
586
597
  **Une exception, et une seule : l'ID token d'un fournisseur qui sait déconnecter.** Quand le
@@ -597,7 +608,7 @@ retenu.
597
608
 
598
609
  Pour que le fournisseur puisse, à l'inverse, fermer lui-même ces sessions, le login retient aussi
599
610
  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
611
+ (`oauth2.ts#IFederatedLogoutHint.sid`). Il est là parce que l'énumération des sessions efface les attributs, dans tous
601
612
  les stores : c'est la seule partie que le canal arrière peut lire en parcourant le parc. L'écran
602
613
  **Sessions** ne l'affiche pas (résumé construit par liste blanche).
603
614
 
@@ -606,21 +617,21 @@ les stores : c'est la seule partie que le canal arrière peut lire en parcourant
606
617
  Quand l'utilisateur se déconnecte **ailleurs** — la console d'administration de Keycloak, une autre
607
618
  application du même SSO —, le fournisseur appelle l'application : `POST
608
619
  …/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`) :
620
+ Back-Channel Logout 1.0). Le contrôleur (`OAuth2Controller.ts#OAuth2Controller.callback`) délègue au service
621
+ (`OAuth2Service.backchannelLogout()`, `oauth2.ts#OAuth2Service.backchannelLogout`) :
611
622
 
612
623
  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` :
624
+ `RemoteJwtVerifier.ts#RemoteJwtVerifier.verifyClaims`), avec les clés publiques du fournisseur découvertes à son `jwks_uri` :
614
625
  é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
626
+ (`oidc.ts#createDiscoveredOidcProvider`). Les algorithmes sont ceux des ID tokens annoncés par le fournisseur, **sans**
627
+ ceux à secret partagé (`oidc.ts#createDiscoveredOidcProvider`) — Keycloak annonce `HS256`, qui ferait d'une clé publique un
617
628
  secret de signature.
618
- 2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts:93`) : `sub` ou `sid` présent ; `events`
629
+ 2. **Le sens ensuite** (`readLogoutTokenClaims()`, `oidc.ts#readLogoutTokenClaims`) : `sub` ou `sid` présent ; `events`
619
630
  qui le déclare jeton de déconnexion ; **aucun** `nonce`. C'est ce qui empêche un ID token, signé
620
631
  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`).
632
+ 3. **Pas de rejeu** : un `jti` déjà vu est refusé (`oauth2.ts#OAuth2Service.backchannelLogout`).
622
633
  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
634
+ (`sessions-service.ts#SessionsService.destroyWhere`) : celles ouvertes par **ce** fournisseur et qui portent le `sid` du
624
635
  jeton ; sans `sid`, toutes celles du compte lié au `sub`. Une session ouverte par mot de passe ou
625
636
  par un autre fournisseur n'est **jamais** touchée : le fournisseur ne ferme que ce qu'il a ouvert.
626
637
 
@@ -630,7 +641,7 @@ injoignable), la cause n'allant qu'au journal ; `404` fournisseur inconnu ; `501
630
641
  n'émet pas de jeton de déconnexion (GitHub). Toujours `Cache-Control: no-store`.
631
642
 
632
643
  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`.
644
+ réel, au plus 30 secondes plus tard (`RealtimeHub.ts#channelFamily`), code `4001`.
634
645
 
635
646
  > [!NOTE]
636
647
  > L'anti-rejeu vit **dans le processus**. Plusieurs exemplaires : un jeton rejoué sur un autre
@@ -654,32 +665,32 @@ ou détruire les sessions), pas chez le fournisseur.
654
665
 
655
666
  | Vecteur | Défense | Preuve |
656
667
  | ----------------------------------------------------- | ------------------------------------------------------- | ---------------------------------------------------- |
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` |
668
+ | Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts` (S5) |
669
+ | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts` (S6) |
670
+ | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts` |
671
+ | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts` (A1) |
672
+ | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts` (A2) |
673
+ | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts` (A3) |
674
+ | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts` |
675
+ | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts#makeUsers` |
665
676
  | Jeton de déconnexion forgé ou signé par une autre clé | Signature vérifiée sur le jeu de clés de l'émetteur | `oauthProviders.test.ts` · `oauth2-keycloak.test.ts` |
666
677
  | ID token présenté comme jeton de déconnexion | `events` exigé, `nonce` interdit | `oauthProviders.test.ts` |
667
678
  | Rejeu d'un jeton de déconnexion | `jti` déjà vu → refusé | `oauth2Service.test.ts` |
668
679
 
669
680
  ## 📜 Normes appliquées
670
681
 
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`) |
682
+ | Domaine | Norme | Ancrage |
683
+ | ------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
684
+ | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts#IOAuthProvider.validateAuthorizationCode`) |
685
+ | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts#IOAuthProvider.usesPkce`) · `oidc.ts#readLogoutTokenClaims` |
686
+ | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts#OAuth2Service`) · `oauth2Schema` (`config.ts#oauth2Schema`) |
687
+ | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts#IOAuthProvider.issuerPolicy`) · `oauth2.ts#OAuth2Service.listProviders` |
688
+ | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts#oauthProviderSchema`) |
689
+ | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts#createOidcProvider`) |
690
+ | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts#assertIdTokenClaims`) |
691
+ | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts#AuthFlow.logout`) |
692
+ | Déconnexion initiée par l'application | OpenID Connect RP-Initiated Logout 1.0 | `createLogoutURL` (`oidc.ts#createOidcProvider`) · `end_session_endpoint` découvert (`metadata.ts#discoverAuthorizationServer`) |
693
+ | Déconnexion par le fournisseur | OpenID Connect Back-Channel Logout 1.0 | `verifyLogoutToken` (`oidc.ts#createOidcProvider`) · `backchannelLogout()` (`oauth2.ts#OAuth2Service.backchannelLogout`) |
683
694
 
684
695
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
685
696
  `password` / ROPC (l'application verrait le mot de passe du fournisseur).
@@ -714,38 +725,38 @@ retirer de la configuration.
714
725
  technique brut.
715
726
 
716
727
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
717
- `AuthFlow.establishSessionFor()` (`authFlow.ts:245-271`), consultable dans l'écran **Audit**. La
728
+ `AuthFlow.establishSessionFor()` (`authFlow.ts#AuthFlow.establishSessionFor`), consultable dans l'écran **Audit**. La
718
729
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
719
730
  provisionné dans l'écran **Users**, avec ses rôles réels.
720
731
 
721
732
  > [!NOTE]
722
733
  > 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`.
734
+ > `AuthFlow.establishSessionFor()` (`OAuth2Controller.ts#IOAuth2BffFlow.establishSessionFor`), comme WebAuthn passe `webauthn`.
724
735
  > `federated` n'est que la valeur par défaut d'un appelant qui n'a pas nommé son facteur
725
- > (`authFlow.ts:248`).
736
+ > (`authFlow.ts#AuthFlow.establishSessionFor`).
726
737
 
727
738
  ## ⚠️ Pièges (symptôme → cause → correction)
728
739
 
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_ |
740
+ | Symptôme | Cause (dans le code) | Correction |
741
+ | ---------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
742
+ | `404` sur `…/oauth2/…` | Module `@nodefony/security` non chargé, ou nom de fournisseur inconnu | Charger `@nodefony/security` ; vérifier le nom du fournisseur |
743
+ | `503 OAuth unavailable` | `oauth2.enabled: false`, ou boot du service échoué | Activer `oauth2` ; lire le WARNING de boot |
744
+ | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`OAuth2Service.#build()`) | `registerOAuthProvider()` au chargement du module, ou builtin |
745
+ | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts#IOAuth2Service.listProviders`) | Vérifier le nom exact **et** la présence des secrets |
746
+ | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
747
+ | Bouton absent + WARNING « indisponible » au boot | Émetteur injoignable à la construction (`OAuth2Service.#resolveProvider()`) | Démarrer le serveur d'autorisation ; le bouton revient sous 30 s |
748
+ | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts#oauthProviderSchema`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
749
+ | Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
750
+ | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts#oauth2FailureTarget`) | Refaire le flux depuis `authorize` — comportement attendu |
751
+ | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts#OAuth2Service.listProviders`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
752
+ | Boot refusé : `….providers.<nom>.issuer` | Émetteur absent (fabrique `requiresIssuer`) ou mal formé (`checkProviderIssuer()`) | Renseigner l'URL https du realm / de l'émetteur |
753
+ | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts#OAuth2Service`) | Implémenter `provisionOAuthUser()` sur le service `users` |
754
+ | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts#UserService.provisionOAuthUser`) | Activer `allowSignup` ou lier le compte au préalable |
755
+ | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
756
+ | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`provisionOAuthUser`, `UserService.ts#UserService.provisionOAuthUser`), sauf `roleMapping` | Modifier les rôles en base, ou déclarer `roleMapping` ([Keycloak](keycloak.md)) |
757
+ | Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts#IOAuthProfile`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
758
+ | Keycloak : « Some clients have not been logged out » | Canal arrière injoignable, ou client en _Front channel logout_ ON | Adresse joignable par Keycloak ; _Front channel logout_ OFF |
759
+ | Canal arrière : `0 session(s) fermée(s)` au journal | Session ouverte avant la mise en place de l'index fédéré, ou `sid` différent | Se reconnecter ; vérifier _Backchannel logout session required_ |
749
760
 
750
761
  ## 🧪 Tests & couverture
751
762