@nodefony/security 10.0.0-alpha.7 → 10.0.0-alpha.9

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 (41) hide show
  1. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
  2. package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
  3. package/dist/index.js +2 -2
  4. package/dist/nodefony/command/security-user-add.js +2 -18
  5. package/dist/nodefony/command/security-user-password.js +2 -2
  6. package/dist/nodefony/config/config.js +4 -3
  7. package/dist/nodefony/service/auditService.js +3 -2
  8. package/dist/nodefony/service/tokenService.js +3 -2
  9. package/dist/nodefony/service/totp.js +5 -3
  10. package/dist/nodefony/service/webAuthn.js +3 -2
  11. package/dist/nodefony/service/webhooks.js +5 -3
  12. package/dist/nodefony/src/SecuredArea.js +7 -0
  13. package/dist/nodefony/src/realtime/frameAuthorizer.js +23 -9
  14. package/dist/nodefony/src/token/MemoryTokenStore.js +18 -5
  15. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  16. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  17. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  18. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  19. package/dist/types/nodefony/config/config.d.ts +2 -0
  20. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +3 -3
  21. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +15 -0
  22. package/dist/types/nodefony/src/SecuredArea.d.ts +6 -0
  23. package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +22 -8
  24. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  25. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  26. package/docs/api-keys.md +62 -60
  27. package/docs/audit.md +77 -78
  28. package/docs/authenticators.md +55 -43
  29. package/docs/authorization.md +128 -23
  30. package/docs/cors.md +29 -29
  31. package/docs/csrf.md +26 -26
  32. package/docs/external-jwt.md +4 -4
  33. package/docs/firewall.md +80 -29
  34. package/docs/headers.md +26 -26
  35. package/docs/oauth2.md +69 -62
  36. package/docs/obtenir-un-jeton.md +2 -2
  37. package/docs/tokens.md +55 -55
  38. package/docs/totp.md +73 -72
  39. package/docs/webauthn.md +81 -81
  40. package/docs/webhooks.md +43 -37
  41. package/package.json +16 -13
package/docs/oauth2.md CHANGED
@@ -111,15 +111,15 @@ 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:155`).
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
117
  session (`OAuth2Service.createAuthorization()`, `oauth2.ts:232-238`).
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:137-145`).
120
+ comparé au retour (`OAuth2Controller.callback()`, `OAuth2Controller.ts:150-158`).
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:170-174`) **et** par
122
+ fournisseur de confiance. _Fermé par la vérification de l'`iss`_ (`oauth2.ts:265-273`) **et** par
123
123
  l'exigence « même fournisseur qu'à l'aller » côté controller (`OAuth2Controller.ts:170`).
124
124
 
125
125
  > [!IMPORTANT]
@@ -133,7 +133,7 @@ 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:21`). Conséquence pratique : la logique OAuth se teste **sans
136
+ (`IOAuthAuthorization`, `oauth2.ts:82`). 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
@@ -144,11 +144,11 @@ d'audit. Il n'existe **aucun** authenticator `oauth2` dans la chaîne du firewal
144
144
  c'est l'authenticator `session` qui identifie chaque requête, comme après un mot de passe.
145
145
 
146
146
  **Coût nul quand on ne s'en sert pas.** Aucune dépendance tierce : le client OAuth 2.0 est écrit
147
- dans le module (`oauth2Client.ts:207`), et `jose` — seul recours externe, pour lire les claims de
147
+ dans le module (`oauth2Client.ts:321`), et `jose` — seul recours externe, pour lire les claims de
148
148
  l'ID token — est importé **paresseusement**. Les fournisseurs sont construits au premier login puis
149
149
  mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:290`) : c'est là, une seule fois par
150
150
  processus, que les points d'entrée d'un émetteur OIDC sont découverts. Les routes ne sont montées
151
- que si le service existe (`framework/index.ts:379`) : sans social login configuré, la surface HTTP
151
+ que si le service existe (`framework/index.ts:460`) : sans social login configuré, la surface HTTP
152
152
  est **404**, pas « désactivée ».
153
153
 
154
154
  Au boot, la config est validée et les fournisseurs configurés sont confrontés au registre : un nom
@@ -163,6 +163,13 @@ reste de l'application démarre, le bouton correspondant n'apparaît simplement
163
163
  Un fournisseur n'est monté **que si ses deux secrets sont présents** : pas de bouton mort sur l'écran
164
164
  de login quand la variable manque.
165
165
 
166
+ > **Pourquoi ces deux noms n'ont pas de préfixe `NF_`, contrairement à la règle.** Le préfixe dit à
167
+ > qui appartient la valeur, pas qui la lit — et `GITHUB_CLIENT_ID` vous est **délivré par GitHub**.
168
+ > Son nom est celui que la documentation du fournisseur, Auth.js et Passport emploient déjà : si
169
+ > votre application en a une, Nodefony la lit telle quelle plutôt que de vous faire recopier la même
170
+ > valeur sous un second nom, qui finirait par diverger. La base des callbacks, elle, n'est émise par
171
+ > personne : c'est un réglage que votre application se donne, donc `NF_OAUTH_REDIRECT_BASE`.
172
+
166
173
  ```typescript
167
174
  // env.ts — SEUL lecteur de process.env (catalogue typé, validé au boot).
168
175
  // nodefony.config.ts — `ctx.env` EST ce catalogue (typé par le paramètre générique).
@@ -173,7 +180,7 @@ export const env = defineEnv({
173
180
  GITHUB_CLIENT_SECRET: envString({ optional: true }),
174
181
  // Base des callbacks : doit correspondre EXACTEMENT à l'URL enregistrée chez
175
182
  // le fournisseur (RFC 9700 — comparaison de chaînes, pas de préfixe).
176
- OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
183
+ NF_OAUTH_REDIRECT_BASE: envString({ default: "https://localhost:5152" }),
177
184
  });
178
185
 
179
186
  export default defineConfig<typeof env>((ctx) => ({
@@ -194,7 +201,7 @@ export default defineConfig<typeof env>((ctx) => ({
194
201
  github: {
195
202
  clientId: ctx.env.GITHUB_CLIENT_ID,
196
203
  clientSecret: ctx.env.GITHUB_CLIENT_SECRET,
197
- redirectUri: `${ctx.env.OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
204
+ redirectUri: `${ctx.env.NF_OAUTH_REDIRECT_BASE}/nodefony/security/api/oauth2/github/callback`,
198
205
  },
199
206
  }
200
207
  : {}),
@@ -208,8 +215,8 @@ export default defineConfig<typeof env>((ctx) => ({
208
215
  ### Les routes sont FOURNIES — tu n'écris aucun controller
209
216
 
210
217
  `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
211
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:234`), et **seulement si** le service `oauth2`
212
- est présent (`framework/index.ts:379`) :
218
+ `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:236`), et **seulement si** le service `oauth2`
219
+ est présent (`framework/index.ts:460`) :
213
220
 
214
221
  | Route | Rôle |
215
222
  | ---------------------------- | --------------------------------------------------------------------- |
@@ -267,20 +274,20 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
267
274
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
268
275
  `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
269
276
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
270
- (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:216`).
277
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:321`).
271
278
 
272
279
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
273
- mémoire, `OAuth2Controller.ts:105-108`), puis redirige en 302.
280
+ mémoire, `OAuth2Controller.ts:145`), puis redirige en 302.
274
281
 
275
282
  ### Étape 2 — le retour, validé avant tout appel réseau
276
283
 
277
284
  `OAuth2Controller.callback()` (`OAuth2Controller.ts:150`) travaille dans cet ordre, et l'ordre est la
278
285
  défense :
279
286
 
280
- 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:126-129`) — le
287
+ 1. **lire l'état de session, puis l'invalider immédiatement** (`OAuth2Controller.ts:158-166`) — le
281
288
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
282
289
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
283
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:137-145`). Un seul écart → `302` vers
290
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts:174-182`). Un seul écart → `302` vers
284
291
  `failureRedirect`, **sans jamais contacter le fournisseur** ;
285
292
  3. seulement ensuite, `exchangeAndProvision()`.
286
293
 
@@ -289,15 +296,15 @@ défense :
289
296
  `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
290
297
 
291
298
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
292
- et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:170-174`) ;
299
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:265-273`) ;
293
300
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
294
- (`validateAuthorizationCode`, `oauth2.ts:256`), puis lecture du profil (`fetchProfile`,
301
+ (`validateAuthorizationCode`, `oauth2.ts:274`), puis lecture du profil (`fetchProfile`,
295
302
  `oauth2.ts:275`) ;
296
303
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
297
304
  **par fournisseur** (`oauth2.ts:279-280`), `allowSignup` global (`oauth2.ts:283`).
298
305
 
299
306
  Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
300
- failureRedirect`, `OAuth2Controller.ts:157-160`) : le client ne distingue pas un `iss` invalide d'un
307
+ failureRedirect`, `OAuth2Controller.ts:198-209`) : le client ne distingue pas un `iss` invalide d'un
301
308
  échange refusé ou d'un signup interdit.
302
309
 
303
310
  ## 🧑‍⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
@@ -307,13 +314,13 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
307
314
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
308
315
 
309
316
  Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
310
- défaut est `UserService.provisionOAuthUser()` (`UserService.ts:306`), en **find-or-create** :
317
+ défaut est `UserService.provisionOAuthUser()` (`UserService.ts:363`), en **find-or-create** :
311
318
 
312
319
  | Situation au retour du fournisseur | Comportement |
313
320
  | ---------------------------------------- | ------------------------------------------------------------------------------ |
314
321
  | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
315
322
  | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
316
- | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:363`) — un compte lié est exigé. |
323
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:374`) — un compte lié est exigé. |
317
324
  | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
318
325
  | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
319
326
 
@@ -330,14 +337,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
330
337
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
331
338
 
332
339
  L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
333
- préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:325-326`).
340
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:382-383`).
334
341
 
335
342
  ### Les rôles sont posés à la création, et plus jamais
336
343
 
337
- `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:348`). Un second login
344
+ `defaultRoles` s'applique **au moment du `create`** (`UserService.ts:405`). Un second login
338
345
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
339
346
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
340
- authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1042-1046`).
347
+ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053`).
341
348
 
342
349
  > [!TIP]
343
350
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -356,9 +363,9 @@ et la charge brute `raw`.
356
363
 
357
364
  ## 🧩 Fournisseurs — catalogue et extension
358
365
 
359
- Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:21`) : il masque
366
+ Un fournisseur est un adaptateur qui implémente `IOAuthProvider` (`IOAuthProvider.ts:53`) : il masque
360
367
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
361
- Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:50`.
368
+ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:62`.
362
369
 
363
370
  | Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
364
371
  | ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
@@ -369,23 +376,23 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
369
376
 
370
377
  ### `google` — OIDC, le cas nominal
371
378
 
372
- Construit par le helper générique `createOidcProvider()` (`oidc.ts:103`) : PKCE systématique
373
- (`usesPkce: true`, `oidc.ts:111`), émetteur figé `https://accounts.google.com`
374
- (`oauthProviderRegistry.ts:80`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
379
+ Construit par le helper générique `createOidcProvider()` (`oidc.ts:106`) : PKCE systématique
380
+ (`usesPkce: true`, `oidc.ts:114`), émetteur figé `https://accounts.google.com`
381
+ (`oauthProviderRegistry.ts:90`). Ses points d'entrée ne sont **pas** écrits en dur : ils sont
375
382
  demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
376
- claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:134`), après les contrôles
383
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:146`), après les contrôles
377
384
  obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
378
- (`assertIdTokenClaims()`, `oidc.ts:132`). Pas d'identifiant stable, pas d'identité.
385
+ (`assertIdTokenClaims()`, `oidc.ts:139`). Pas d'identifiant stable, pas d'identité.
379
386
 
380
387
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
381
388
 
382
389
  Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
383
- valider l'`iss` (`oauthProviderRegistry.ts:85`). Il est donc **obligatoire** : sans lui, la fabrique
390
+ valider l'`iss` (`oauthProviderRegistry.ts:95-97`). Il est donc **obligatoire** : sans lui, la fabrique
384
391
  lève au premier login avec un message explicite.
385
392
 
386
393
  ### `oidc` — n'importe quel serveur OpenID Connect
387
394
 
388
- La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:89`) prend
395
+ La même mécanique, sans nom de marque : l'entrée `oidc` (`oauthProviderRegistry.ts:101`) prend
389
396
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
390
397
  par fournisseur.
391
398
 
@@ -410,7 +417,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
410
417
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
411
418
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
412
419
 
413
- La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:61`) et remplie par
420
+ La politique est portée par le fournisseur (`issuerPolicy`, `IOAuthProvider.ts:65`) et remplie par
414
421
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
415
422
 
416
423
  ### Découverte des points d'entrée (RFC 8414)
@@ -422,7 +429,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
422
429
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
423
430
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
424
431
  PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
425
- redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:126`).
432
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:132`).
426
433
 
427
434
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
428
435
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -438,16 +445,16 @@ donnerait l'illusion de PKCE.
438
445
  ### `github` — OAuth simple, l'archétype non-OIDC
439
446
 
440
447
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
441
- `github.ts:43-44`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
442
- de l'API REST `/user` (`createGithubProvider()`, `github.ts:34`). Subtilité GitHub : l'e-mail
448
+ `github.ts:68-69`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
449
+ de l'API REST `/user` (`createGithubProvider()`, `github.ts:53`). Subtilité GitHub : l'e-mail
443
450
  primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
444
- `emailVerified` que si GitHub le certifie (`github.ts:68-77`).
451
+ `emailVerified` que si GitHub le certifie (`github.ts:98-107`).
445
452
 
446
453
  ### Enregistrer le sien — sans éditer le cœur
447
454
 
448
455
  **Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
449
456
  mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
450
- pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:56`) au chargement de
457
+ pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:68`) au chargement de
451
458
  ton module (avant le `onBoot` du service) :
452
459
 
453
460
  ```typescript ignore
@@ -468,39 +475,39 @@ callback issus de la config, rien d'autre. Elle peut être **asynchrone** — d
468
475
  une opération de construction, faite une fois par processus.
469
476
 
470
477
  Un fournisseur qui n'est **pas** OIDC (pas de métadonnées, pas d'ID token) demande un adaptateur : le
471
- protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est une quarantaine de
478
+ protocole vient de `OAuth2Client`, la fabrique ne fait que lire le profil. C'est une centaine de
472
479
  lignes — `github.ts` en est le modèle.
473
480
 
474
481
  Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
475
- (`createGithubProvider()`, `github.ts:40`) : `OAuth2Client` porte le protocole, la fabrique ne fait
482
+ (`createGithubProvider()`, `github.ts:53`) : `OAuth2Client` porte le protocole, la fabrique ne fait
476
483
  que le mapping du profil. Exemple sans réseau dans le dépôt :
477
484
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
478
485
 
479
486
  ## ⚙️ Configuration
480
487
 
481
- Section `oauth2` du schéma Zod (`config.ts:1034`), branchée sur la config du module
482
- (`config.ts:1149`). Table dérivée du schéma — les défauts sont ceux du code.
488
+ Section `oauth2` du schéma Zod (`config.ts:1155`), branchée sur la config du module
489
+ (`config.ts:1155`). Table dérivée du schéma — les défauts sont ceux du code.
483
490
 
484
491
  | Option | Type | Défaut | Effet |
485
492
  | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
486
493
  | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
487
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1042`). |
488
- | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1048`). |
494
+ | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1048`). |
495
+ | `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:1054`). |
489
496
  | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
490
497
  | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
491
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1064`). |
498
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
492
499
 
493
- Par fournisseur (`oauthProviderSchema`, `config.ts:948`) :
500
+ Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
494
501
 
495
502
  <!-- prettier-ignore -->
496
503
  | Option | Requis | Effet |
497
504
  | --- | :---: | --- |
498
505
  | `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
499
- | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:958`). |
506
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:975`). |
500
507
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
501
508
  | `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`. |
502
509
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
503
- | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:218-222`). |
510
+ | `successRedirect` / `failureRedirect` / `defaultRoles` | | Surchargent le global **pour ce fournisseur** (`oauth2.ts:279-283`). |
504
511
 
505
512
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
506
513
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -543,12 +550,12 @@ ou détruire les sessions), pas chez le fournisseur.
543
550
  | -------------------------------------------------- | ------------------------------------------------------- | -------------------------------- |
544
551
  | Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts:89` (S5) |
545
552
  | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
546
- | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:167` |
553
+ | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:170` |
547
554
  | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
548
555
  | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
549
556
  | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
550
- | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:67` |
551
- | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:52` |
557
+ | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:79` |
558
+ | Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:266` |
552
559
 
553
560
  ## 📜 Normes appliquées
554
561
 
@@ -556,11 +563,11 @@ ou détruire les sessions), pas chez le fournisseur.
556
563
  | --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
557
564
  | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
558
565
  | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
559
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1034`) |
560
- | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:61`) · `oauth2.ts:170-181` |
561
- | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:958`) |
566
+ | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1040`) |
567
+ | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:65`) · `oauth2.ts:265-273` |
568
+ | Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:975`) |
562
569
  | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
563
- | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:132`) |
570
+ | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:139`) |
564
571
  | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
565
572
 
566
573
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
@@ -596,7 +603,7 @@ retirer de la configuration.
596
603
  technique brut.
597
604
 
598
605
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
599
- `AuthFlow.establishSessionFor()` (`authFlow.ts:229-236`), consultable dans l'écran **Audit**. La
606
+ `AuthFlow.establishSessionFor()` (`authFlow.ts:215-222`), consultable dans l'écran **Audit**. La
600
607
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
601
608
  provisionné dans l'écran **Users**, avec ses rôles réels.
602
609
 
@@ -611,17 +618,17 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
611
618
  | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
612
619
  | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
613
620
  | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
614
- | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:97`) | Vérifier le nom exact **et** la présence des secrets |
621
+ | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts:14`) | Vérifier le nom exact **et** la présence des secrets |
615
622
  | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
616
- | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:958`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
623
+ | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts:975`) | Aligner schéma, hôte, port et chemin `/…/{provider}/callback` |
617
624
  | Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
618
- | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:126-129`) | Refaire le flux depuis `authorize` — comportement attendu |
619
- | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:170-181`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
620
- | Keycloak : erreur dès le premier login | `issuer` absent en config (`oauthProviderRegistry.ts:89-93`) | Renseigner l'URL du realm |
625
+ | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:163-165`) | Refaire le flux depuis `authorize` — comportement attendu |
626
+ | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:265-273`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
627
+ | Keycloak : erreur dès le premier login | `issuer` absent en config (`oidc.ts:176-181`) | Renseigner l'URL du realm |
621
628
  | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:327-333`) | Implémenter `provisionOAuthUser()` sur le service `users` |
622
- | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:363`) | Activer `allowSignup` ou lier le compte au préalable |
629
+ | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts:374`) | Activer `allowSignup` ou lier le compte au préalable |
623
630
  | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
624
- | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`UserService.ts:348`) | Modifier les rôles en base ; `defaultRoles` ne réécrit rien |
631
+ | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`UserService.ts:405`) | Modifier les rôles en base ; `defaultRoles` ne réécrit rien |
625
632
  | Jeton du fournisseur introuvable côté application | `IOAuthProfile` n'en porte aucun, par choix (`IOAuthUserProvisioner.ts:8-10`) | Le capturer dans son propre `fetchProfile()` et le stocker soi-même |
626
633
 
627
634
  ## 🧪 Tests & couverture
@@ -136,14 +136,14 @@ Puis `npm run build` — le runtime lit le `dist`, pas la source.
136
136
  | `-j, --json` | — | sortie machine, sans invite |
137
137
 
138
138
  Le plafond de `--ttl` n'est pas décoratif : un jeton posé dans un fichier **est une clé**, et une
139
- clé se remplace (`security-token.ts:52`, `security-token.ts:64-79`).
139
+ clé se remplace (`security-token.ts:67-84`).
140
140
 
141
141
  ### Où `--write` pose la valeur
142
142
 
143
143
  La variable est `NF_MCP_TOKEN` (`src/nodefony/src/cli/aiMcpReport.ts:32`). La commande ne sert que
144
144
  les agents dont la présence est **constatée** dans le projet — on ne crée pas la configuration d'un
145
145
  outil que personne n'utilise ici. La table des cibles vit au cœur
146
- (`src/nodefony/src/cli/agentTargets.ts:239`) : Claude, Gemini, Vibe, Codex.
146
+ (`src/nodefony/src/cli/agentTargets.ts:280`) : Claude, Gemini, Vibe, Codex.
147
147
 
148
148
  Deux garanties qui ne se négocient pas :
149
149
 
package/docs/tokens.md CHANGED
@@ -87,11 +87,11 @@ défaut » en prod.
87
87
  ## La vision Nodefony — un service propriétaire, des endpoints minces
88
88
 
89
89
  `TokenService` est **propriétaire** du store et du keystore : à `TokenService.#build()`
90
- (`tokenService.ts:93`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
- `tokenStore` au container (`tokenService.ts:163`) puis crée le keystore et pose `jwtKeystore`
92
- (`tokenService.ts:167-173`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
90
+ (`tokenService.ts:101`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
91
+ `tokenStore` au container (`tokenService.ts:175`) puis crée le keystore et pose `jwtKeystore`
92
+ (`tokenService.ts:181-186`) — consommés par le `JwtAuthenticator` et les endpoints. Il arme un
93
93
  **gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
94
- `tokenService.ts:175-181`).
94
+ `tokenService.ts:188-194`).
95
95
 
96
96
  Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
97
97
  nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
@@ -136,7 +136,7 @@ seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncera
136
136
  ### Les endpoints d'émission sont FOURNIS
137
137
 
138
138
  Dans une app `nodefony create app`, dès que le module security est chargé avec `jwt.enabled` (défaut),
139
- le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:132-147`) :
139
+ le framework monte deux routes (`mountTokenAuthRoutes()`, `TokenAuthController.ts:161-176`) :
140
140
 
141
141
  - `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
142
142
  - `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
@@ -228,36 +228,36 @@ Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController
228
228
 
229
229
  ### Émission (grant M2M/CLI)
230
230
 
231
- `issueForCredentials()` (`tokenService.ts:317`) vérifie l'identifiant/mot de passe via le
231
+ `issueForCredentials()` (`tokenService.ts:324`) vérifie l'identifiant/mot de passe via le
232
232
  service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
233
- (`tokenService.ts:733`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
234
- par `#auditGrant()` (`tokenService.ts:362-374`). Puis `issueTokens()` (`tokenService.ts:490`)
233
+ (`tokenService.ts:353`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
234
+ par `#auditGrant()` (`tokenService.ts:369-381`). Puis `issueTokens()` (`tokenService.ts:497`)
235
235
  produit :
236
236
 
237
237
  - un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
238
- `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:402-415`) ;
238
+ `iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts:649-662`) ;
239
239
  - un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
240
240
  `sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
241
- (`tokenService.ts:674-709`).
241
+ (`tokenService.ts:681-716`).
242
242
 
243
- La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:43-51`). Tout succès est audité
243
+ La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:49-57`). Tout succès est audité
244
244
  `token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:312-318`).
245
245
 
246
246
  ### Rotation & détection de rejeu (RFC 9700 §4.14)
247
247
 
248
- `refresh()` (`tokenService.ts:555`) est le cœur défensif, dans l'ordre :
248
+ `refresh()` (`tokenService.ts:562`) est le cœur défensif, dans l'ordre :
249
249
 
250
- 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:564`).
250
+ 1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:571`).
251
251
  2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
252
- famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:345-361`).
253
- 3. Expiration `expiresAt` vérifiée (`tokenService.ts:363-368`).
252
+ famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts:577-591`).
253
+ 3. Expiration `expiresAt` vérifiée (`tokenService.ts:594`).
254
254
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:745`).
255
+ `#resolveUserForRefresh()` (`tokenService.ts:752`).
256
256
  5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
257
- (`tokenService.ts:592`).
257
+ (`tokenService.ts:600`).
258
258
  6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
259
- `"rotated"` (`tokenService.ts:698`). Si `rotateRefresh` est désactivé, l'access est réémis
260
- et le refresh courant reste valide (`tokenService.ts:627`).
259
+ `"rotated"` (`tokenService.ts:634-636`). Si `rotateRefresh` est désactivé, l'access est réémis
260
+ et le refresh courant reste valide (`tokenService.ts:620`).
261
261
 
262
262
  ### Mise en situation — ton refresh token a été volé
263
263
 
@@ -336,35 +336,35 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
336
336
 
337
337
  - Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
338
338
  - `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
339
- (`MemoryTokenStore.ts:122-142`).
339
+ (`MemoryTokenStore.ts:165-185`).
340
340
  - Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
341
- (`MemoryTokenStore.ts:360`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
341
+ (`MemoryTokenStore.ts:393`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
342
342
  - `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
343
- (`MemoryTokenStore.ts:253-283`).
343
+ (`MemoryTokenStore.ts:324-354`).
344
344
  - Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
345
345
 
346
346
  ### `drizzle` — SQL, le défaut durable
347
347
 
348
- - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:220`).
348
+ - Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:261`).
349
349
  - Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
350
- est chargé (`config.ts:394-399`).
350
+ est chargé (`config.ts:428-433`).
351
351
  - Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
352
352
 
353
353
  ### `mongoose` — MongoDB
354
354
 
355
- - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:119`).
356
- - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:163-173`).
357
- - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:196`).
355
+ - Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:170`).
356
+ - Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:236-246`).
357
+ - Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:170`).
358
358
 
359
359
  ### `redis` — cluster, TTL natif
360
360
 
361
- - Enregistré par le module redis (`redis/nodefony/registerStores.ts:49`).
362
- - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:254`) — l'expiration ne
361
+ - Enregistré par le module redis (`redis/nodefony/registerStores.ts:75`).
362
+ - TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:308`) — l'expiration ne
363
363
  dépend pas du gc.
364
364
  - Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
365
- (`RedisTokenStore.ts:35-44`) — sans ordre global ni total, capacité réduite **assumée**.
365
+ (`RedisTokenStore.ts:428`) — sans ordre global ni total, capacité réduite **assumée**.
366
366
  - `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
367
- (`RedisTokenStore.ts:432`).
367
+ (`RedisTokenStore.ts:483`).
368
368
 
369
369
  ### Le record — une seule table pour PAT et refresh
370
370
 
@@ -398,72 +398,72 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
398
398
  - **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
399
399
  (`ITokenStore.ts:242`).
400
400
  - **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
401
- `iat < invalidBefore` est rejeté (`ITokenStore.ts:226-234`) ; le seuil est **monotone**, deux
402
- logouts successifs ne le reculent pas (`MemoryTokenStore.ts:229`).
401
+ `iat < invalidBefore` est rejeté (`ITokenStore.ts:259-263`) ; le seuil est **monotone**, deux
402
+ logouts successifs ne le reculent pas (`MemoryTokenStore.ts:262-267`).
403
403
 
404
404
  ### La maintenance (gc)
405
405
 
406
406
  Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
407
- la rétention (`ITokenStore.ts:237-251`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
407
+ la rétention (`ITokenStore.ts:274-280`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
408
408
  un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
409
409
 
410
410
  > [!TIP]
411
411
  > Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
412
412
  > `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
413
- > (`MemoryTokenStore.ts:239-248`). Un store **local** (memory) est par-process : seul SON process
413
+ > (`MemoryTokenStore.ts:301-310`). Un store **local** (memory) est par-process : seul SON process
414
414
  > peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
415
415
 
416
416
  ## ⚙️ Configuration
417
417
 
418
- Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:334-390`) et `tokenStoreSchema`
419
- (`config.ts:392-425`), défauts inclus.
418
+ Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:366-425`) et `tokenStoreSchema`
419
+ (`config.ts:426-458`), défauts inclus.
420
420
 
421
421
  ### `jwt.*`
422
422
 
423
423
  <!-- prettier-ignore -->
424
424
  | Option | Type | Défaut | Effet |
425
425
  | --- | --- | --- | --- |
426
- | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:317`) |
426
+ | `enabled` | boolean | `true` | Active signature + refresh (`config.ts:368`) |
427
427
  | `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
428
- | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:364`) |
429
- | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:369`) |
430
- | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:374`) |
428
+ | `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:370`) |
429
+ | `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:375`) |
430
+ | `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:380`) |
431
431
  | `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
432
- | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:384`) |
432
+ | `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:390`) |
433
433
  | `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
434
- | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:398`) |
435
- | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:376-381`) |
434
+ | `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:404`) |
435
+ | `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:410-418`) |
436
436
 
437
437
  ### `tokenStore.*`
438
438
 
439
439
  | Option | Type | Défaut | Effet |
440
440
  | ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
441
- | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:394-399`) |
442
- | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:428`) |
443
- | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:436`) |
444
- | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:442`) |
441
+ | `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:428-433`) |
442
+ | `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:434`) |
443
+ | `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:442`) |
444
+ | `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:448`) |
445
445
 
446
446
  ## 📜 Normes appliquées
447
447
 
448
448
  | Domaine | Norme | Ancrage |
449
449
  | -------------------------------- | --------------- | ------------------------------------------------------- |
450
- | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:43-51`) |
451
- | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:555`) |
452
- | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:402-415`) |
453
- | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:406-414`) |
450
+ | Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:49-57`) |
451
+ | Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:562`) |
452
+ | Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:649-662`) |
453
+ | Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:649-657`) |
454
454
  | Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:156-158`) |
455
455
  | Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
456
456
  | 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
457
- | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:346`) |
457
+ | Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:353`) |
458
458
 
459
459
  ## ⚡ Performance & mémoire
460
460
 
461
- - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:648`)
461
+ - **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:726`)
462
462
  — le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
463
463
  - **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
464
464
  vérification (hot path) vit chez le `JwtAuthenticator`.
465
465
  - **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
466
- simultanés (`tokenService.ts:175-181`) ; denylist mémoire bornée par purge amortie
466
+ simultanés (`tokenService.ts:188-194`) ; denylist mémoire bornée par purge amortie
467
467
  (`MemoryTokenStore.ts:331-341`).
468
468
  - **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
469
469