@nodefony/security 10.0.0-alpha.8 → 10.0.0-beta.1

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 (83) 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-token.js +4 -4
  5. package/dist/nodefony/command/security-user-add.js +2 -18
  6. package/dist/nodefony/command/security-user-delete.js +2 -2
  7. package/dist/nodefony/command/security-user-list.js +1 -1
  8. package/dist/nodefony/command/security-user-password.js +2 -2
  9. package/dist/nodefony/config/config.js +2 -2
  10. package/dist/nodefony/service/apiKeys.js +2 -2
  11. package/dist/nodefony/service/auditService.js +6 -5
  12. package/dist/nodefony/service/authFlow.js +2 -2
  13. package/dist/nodefony/service/authorization.js +3 -4
  14. package/dist/nodefony/service/firewall.js +21 -9
  15. package/dist/nodefony/service/oauth2.js +5 -3
  16. package/dist/nodefony/service/securityHeaders.js +1 -1
  17. package/dist/nodefony/service/tokenService.js +30 -20
  18. package/dist/nodefony/service/totp.js +5 -3
  19. package/dist/nodefony/service/webAuthn.js +41 -33
  20. package/dist/nodefony/service/webhooks.js +55 -44
  21. package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
  22. package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
  23. package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
  24. package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
  25. package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
  26. package/dist/nodefony/src/audit/auditBridge.js +9 -5
  27. package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
  28. package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
  29. package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
  30. package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
  31. package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
  32. package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
  33. package/dist/nodefony/src/csp.js +4 -5
  34. package/dist/nodefony/src/oauth/providers/github.js +1 -1
  35. package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
  36. package/dist/nodefony/src/token/JwtKeystore.js +7 -6
  37. package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
  38. package/dist/nodefony/src/token/jwtRuntime.js +13 -1
  39. package/dist/nodefony/src/token/tokenCriteria.js +1 -0
  40. package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
  41. package/dist/nodefony/src/totp/totpCrypto.js +9 -9
  42. package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
  43. package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
  44. package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
  45. package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
  46. package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
  47. package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
  48. package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
  49. package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
  50. package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
  51. package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
  52. package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
  53. package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
  54. package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
  55. package/dist/types/nodefony/service/firewall.d.ts +3 -2
  56. package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
  57. package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
  58. package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
  59. package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
  60. package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
  61. package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
  62. package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
  63. package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
  64. package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
  65. package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
  66. package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
  67. package/docs/api-keys.md +67 -65
  68. package/docs/audit.md +87 -82
  69. package/docs/authenticators.md +61 -49
  70. package/docs/authorization.md +21 -21
  71. package/docs/cors.md +30 -30
  72. package/docs/csrf.md +28 -28
  73. package/docs/external-jwt.md +21 -13
  74. package/docs/firewall.md +32 -32
  75. package/docs/headers.md +29 -29
  76. package/docs/index.md +3 -1
  77. package/docs/oauth2.md +61 -61
  78. package/docs/obtenir-un-jeton.md +2 -2
  79. package/docs/tokens.md +56 -56
  80. package/docs/totp.md +73 -72
  81. package/docs/webauthn.md +85 -85
  82. package/docs/webhooks.md +50 -47
  83. package/package.json +19 -16
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
- mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:290`) : c'est là, une seule fois par
149
+ mémoïsés (`OAuth2Service.#resolveProvider()`, `oauth2.ts:296`) : 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
@@ -215,8 +215,8 @@ export default defineConfig<typeof env>((ctx) => ({
215
215
  ### Les routes sont FOURNIES — tu n'écris aucun controller
216
216
 
217
217
  `mountOAuth2Routes()` (`OAuth2Controller.ts:234`) monte trois routes sous
218
- `/nodefony/security/api/oauth2` (`OAuth2Controller.ts:234`), et **seulement si** le service `oauth2`
219
- 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`) :
220
220
 
221
221
  | Route | Rôle |
222
222
  | ---------------------------- | --------------------------------------------------------------------- |
@@ -274,20 +274,20 @@ Séquence identique prouvée de bout en bout sur serveur réel par `oauth2-flow.
274
274
  2. un **`code_verifier`** — **seulement si** le fournisseur pratique PKCE (`usesPkce`,
275
275
  `oauth2.ts:235-237`) ; `null` sinon (GitHub) ;
276
276
  3. l'**URL d'autorisation** construite par l'adaptateur du fournisseur, avec les scopes effectifs
277
- (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:216`).
277
+ (ceux de la config, sinon les scopes par défaut du fournisseur, `oauth2.ts:321`).
278
278
 
279
279
  Le controller pose les trois valeurs en session, **persiste** (`session.save()` — pas seulement en
280
- mémoire, `OAuth2Controller.ts:105-108`), puis redirige en 302.
280
+ mémoire, `OAuth2Controller.ts:145`), puis redirige en 302.
281
281
 
282
282
  ### Étape 2 — le retour, validé avant tout appel réseau
283
283
 
284
284
  `OAuth2Controller.callback()` (`OAuth2Controller.ts:150`) travaille dans cet ordre, et l'ordre est la
285
285
  défense :
286
286
 
287
- 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
288
288
  `state` est à **usage unique** : un rejeu du même retour échoue, même avec le bon cookie ;
289
289
  2. **comparer** : `code` et `state` présents, `state` reçu ≡ `state` attendu, **et** fournisseur du
290
- callback ≡ fournisseur démarré (`OAuth2Controller.ts:137-145`). Un seul écart → `302` vers
290
+ callback ≡ fournisseur démarré (`OAuth2Controller.ts:172-180`). Un seul écart → `302` vers
291
291
  `failureRedirect`, **sans jamais contacter le fournisseur** ;
292
292
  3. seulement ensuite, `exchangeAndProvision()`.
293
293
 
@@ -296,15 +296,15 @@ défense :
296
296
  `OAuth2Service.exchangeAndProvision()` (`oauth2.ts:254`) enchaîne :
297
297
 
298
298
  1. **anti-mix-up** — si le fournisseur annonce un émetteur attendu, l'`iss` reçu doit correspondre,
299
- et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:170-174`) ;
299
+ et un `iss` **absent** est un rejet, pas une tolérance (`oauth2.ts:265-273`) ;
300
300
  2. **échange** du `code` sur le canal serveur, avec le `code_verifier`
301
- (`validateAuthorizationCode`, `oauth2.ts:256`), puis lecture du profil (`fetchProfile`,
301
+ (`validateAuthorizationCode`, `oauth2.ts:274`), puis lecture du profil (`fetchProfile`,
302
302
  `oauth2.ts:275`) ;
303
303
  3. **provisionnement** du Shadow User avec la politique effective — rôles par défaut surchargeables
304
304
  **par fournisseur** (`oauth2.ts:279-280`), `allowSignup` global (`oauth2.ts:283`).
305
305
 
306
306
  Toute erreur de cette étape est convertie en **échec uniforme** par le controller (`302
307
- 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
308
308
  échange refusé ou d'un signup interdit.
309
309
 
310
310
  ## 🧑‍⚖️ Le Shadow User — l'identité locale, et pourquoi OAuth n'accorde aucun droit
@@ -314,13 +314,13 @@ compte externe : le _Shadow User_. C'est cette ligne qui porte l'identifiant, le
314
314
  actif/verrouillé — donc **tout** ce dont l'autorisation a besoin.
315
315
 
316
316
  Le contrat s'appelle `IOAuthUserProvisioner` (`IOAuthUserProvisioner.ts:61`) ; l'implémentation par
317
- 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** :
318
318
 
319
319
  | Situation au retour du fournisseur | Comportement |
320
320
  | ---------------------------------------- | ------------------------------------------------------------------------------ |
321
321
  | Lien social déjà connu | Le compte existant est rendu tel quel — rien n'est créé, rien n'est réécrit. |
322
322
  | Lien inconnu, `allowSignup: true` | Création JIT : `password: null`, rôles = `defaultRoles`, lien social persisté. |
323
- | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:363`) — un compte lié est exigé. |
323
+ | Lien inconnu, `allowSignup: false` | **Échec fail-closed** (`UserService.ts:374`) — un compte lié est exigé. |
324
324
  | E-mail identique à un compte local | **Aucune liaison automatique** — un compte SÉPARÉ est créé. |
325
325
  | Même `providerId` chez deux fournisseurs | Comptes séparés (le couple `provider` + `providerId` fait la clé). |
326
326
 
@@ -337,14 +337,14 @@ Conséquence assumée : l'utilisateur qui avait un mot de passe et clique « ave
337
337
  faite **utilisateur déjà connecté** — jamais un effet de bord du login.
338
338
 
339
339
  L'identifiant du compte créé dérive de l'e-mail si le fournisseur en donne un, sinon d'une clé
340
- préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:325-326`).
340
+ préfixée `provider:providerId` — jamais de collision entre fournisseurs (`UserService.ts:382-383`).
341
341
 
342
342
  ### Les rôles sont posés à la création, et plus jamais
343
343
 
344
- `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
345
345
  n'écrase rien : promouvoir quelqu'un dans ta base reste effectif, et modifier `defaultRoles` en
346
346
  config ne repeint pas les comptes existants. C'est la traduction de la règle « OAuth =
347
- authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1042-1046`).
347
+ authentification, pas autorisation » (`oauth2.ts:279-283`, `config.ts:1048-1053`).
348
348
 
349
349
  > [!TIP]
350
350
  > Un fournisseur social ne doit **jamais** figurer dans le chemin d'obtention d'un rôle privilégié.
@@ -363,9 +363,9 @@ et la charge brute `raw`.
363
363
 
364
364
  ## 🧩 Fournisseurs — catalogue et extension
365
365
 
366
- 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
367
367
  les divergences (PKCE ou non, profil par ID token ou par appel d'API) derrière un contrat unique.
368
- 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`.
369
369
 
370
370
  | Nom | Famille | PKCE | `iss` vérifié | Profil lu depuis | Scopes par défaut |
371
371
  | ---------- | ---------------- | :--: | --------------------- | ----------------- | ---------------------------- |
@@ -376,23 +376,23 @@ Quatre sont livrés, résolus par nom via le registre `oauthProviderRegistry.ts:
376
376
 
377
377
  ### `google` — OIDC, le cas nominal
378
378
 
379
- Construit par le helper générique `createOidcProvider()` (`oidc.ts:103`) : PKCE systématique
380
- (`usesPkce: true`, `oidc.ts:111`), émetteur figé `https://accounts.google.com`
381
- (`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
382
382
  demandés à l'émetteur (RFC 8414, cf. « Découverte » plus bas). Le profil se lit dans l'**ID token** —
383
- claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:134`), après les contrôles
383
+ claims standard `sub`, `email`, `email_verified`, `name` (`oidc.ts:146`), après les contrôles
384
384
  obligatoires d'OpenID Connect Core §3.1.3.7 : `iss`, `aud`, `exp`, et un `sub` non vide
385
- (`assertIdTokenClaims()`, `oidc.ts:132`). Pas d'identifiant stable, pas d'identité.
385
+ (`assertIdTokenClaims()`, `oidc.ts:139`). Pas d'identifiant stable, pas d'identité.
386
386
 
387
387
  ### `keycloak` — OIDC self-hosted, l'émetteur vient de ta config
388
388
 
389
389
  Même helper, mais l'**issuer** (URL du realm) sert à la fois à découvrir les points d'entrée et à
390
- valider l'`iss` (`oauthProviderRegistry.ts: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
391
391
  lève au premier login avec un message explicite.
392
392
 
393
393
  ### `oidc` — n'importe quel serveur OpenID Connect
394
394
 
395
- 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
396
396
  l'émetteur de sa configuration et n'a besoin de rien d'autre. C'est elle qui rend inutile une classe
397
397
  par fournisseur.
398
398
 
@@ -417,7 +417,7 @@ anti-mix-up **principale** est ailleurs — chaque fournisseur a son URL de redi
417
417
  (`…/{provider}/callback`) et le flux vérifie que le fournisseur de retour est celui qui a démarré,
418
418
  ce que la RFC 9700 §4.4.2.2 donne comme la protection de référence. `iss` est la seconde ceinture.
419
419
 
420
- 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
421
421
  la découverte ; elle vaut `null` pour un fournisseur non-OIDC, qui ne relève pas de cette défense.
422
422
 
423
423
  ### Découverte des points d'entrée (RFC 8414)
@@ -429,7 +429,7 @@ points d'entrée sont demandés à l'émetteur, une seule fois par processus, au
429
429
  bien connues (§3.1 : insertion oauth → insertion oidc → ajout oidc) et l'égalité stricte du §3.3
430
430
  vivent dans le cœur (`nodefony` → `src/oauth/authorizationServer.ts`), qui s'en sert aussi pour
431
431
  PUBLIER nos propres métadonnées. `metadata.ts` n'ajoute que le transport : requête bornée, sans
432
- redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:126`).
432
+ redirection suivie, avec un délai d'attente (`discoverAuthorizationServer()`, `metadata.ts:132`).
433
433
 
434
434
  Deux refus valent d'être connus. Un document dont l'`issuer` diffère de celui demandé est rejeté
435
435
  **sans se rabattre** sur l'URL suivante — se rabattre masquerait un document hostile derrière un 404.
@@ -445,16 +445,16 @@ donnerait l'illusion de PKCE.
445
445
  ### `github` — OAuth simple, l'archétype non-OIDC
446
446
 
447
447
  Pas de PKCE, pas d'ID token, pas d'`iss` (`usesPkce: false`, `issuerPolicy: null`,
448
- `github.ts:43-44`) : ici, la défense anti-CSRF repose **entièrement** sur le `state`. Le profil vient
449
- 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
450
450
  primaire est souvent privé — l'adaptateur bascule alors sur `/user/emails` et n'accepte
451
- `emailVerified` que si GitHub le certifie (`github.ts:68-77`).
451
+ `emailVerified` que si GitHub le certifie (`github.ts:98-107`).
452
452
 
453
453
  ### Enregistrer le sien — sans éditer le cœur
454
454
 
455
455
  **Tout serveur OpenID Connect conforme est déjà supporté** — Auth0, Okta, Authentik, Entra
456
456
  mono-locataire… — sans une ligne de code propre. Le builtin `oidc` suffit quand il n'y en a qu'un ;
457
- pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:56`) au chargement de
457
+ pour en nommer plusieurs, `registerOAuthProvider()` (`oauthProviderRegistry.ts:68`) au chargement de
458
458
  ton module (avant le `onBoot` du service) :
459
459
 
460
460
  ```typescript ignore
@@ -475,39 +475,39 @@ callback issus de la config, rien d'autre. Elle peut être **asynchrone** — d
475
475
  une opération de construction, faite une fois par processus.
476
476
 
477
477
  Un fournisseur qui n'est **pas** OIDC (pas de métadonnées, pas d'ID token) demande un adaptateur : le
478
- 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
479
479
  lignes — `github.ts` en est le modèle.
480
480
 
481
481
  Un fournisseur qui n'est pas OIDC (pas d'ID token, profil lu à son API) s'écrit comme GitHub
482
- (`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
483
483
  que le mapping du profil. Exemple sans réseau dans le dépôt :
484
484
  `src/modules/test/nodefony/secure/oauthTestProvider.ts`.
485
485
 
486
486
  ## ⚙️ Configuration
487
487
 
488
- Section `oauth2` du schéma Zod (`config.ts:1034`), branchée sur la config du module
489
- (`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.
490
490
 
491
491
  | Option | Type | Défaut | Effet |
492
492
  | ----------------- | -------------------- | --------------- | ---------------------------------------------------------- |
493
493
  | `enabled` | booléen | `true` | Coupe le social login ; les routes ne montent pas. |
494
- | `defaultRoles` | liste de rôles | `["ROLE_USER"]` | Rôles du Shadow User **à la création** (`config.ts:1042`). |
495
- | `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`). |
496
496
  | `successRedirect` | chemin | `/` | Où revient l'utilisateur après succès. |
497
497
  | `failureRedirect` | chemin | `/login` | Où il revient après échec (uniforme, sans détail). |
498
- | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1064`). |
498
+ | `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
499
499
 
500
- Par fournisseur (`oauthProviderSchema`, `config.ts:948`) :
500
+ Par fournisseur (`oauthProviderSchema`, `config.ts:954`) :
501
501
 
502
502
  <!-- prettier-ignore -->
503
503
  | Option | Requis | Effet |
504
504
  | --- | :---: | --- |
505
505
  | `clientId` / `clientSecret` | ✅ | Identifiants délivrés par l'IdP. Secrets : par `env.ts`, jamais journalisés. |
506
- | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:958`). |
506
+ | `redirectUri` | ✅ | URL de callback **exacte** (`config.ts:975`). |
507
507
  | `issuer` | OIDC self-hosted | Realm Keycloak ; ignoré par les IdP à endpoints fixes. |
508
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`. |
509
509
  | `scopes` | | Vide = scopes par défaut du fournisseur. |
510
- | `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`). |
511
511
 
512
512
  Les surcharges par fournisseur permettent la cohabitation : un IdP de recette garde ses redirections
513
513
  et ses rôles pendant qu'un IdP de production pointe ailleurs.
@@ -550,12 +550,12 @@ ou détruire les sessions), pas chez le fournisseur.
550
550
  | -------------------------------------------------- | ------------------------------------------------------- | -------------------------------- |
551
551
  | Rejeu du retour (même `code`, même `state`) | `state` consommé + session régénérée à la promotion | `oauth2-attack.test.ts:89` (S5) |
552
552
  | `state` valide présenté au callback d'un autre IdP | Fournisseur attendu conservé en session et comparé | `oauth2-attack.test.ts:115` (S6) |
553
- | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:167` |
553
+ | `iss` falsifié | Comparaison stricte à l'émetteur de la politique | `oauth2Service.test.ts:170` |
554
554
  | Prise de compte par e-mail collidant un admin | Aucune liaison auto : compte séparé, admin intact | `oauth.attack.test.ts:71` (A1) |
555
555
  | Élévation de privilège par re-login | Rôles posés à la création, jamais réécrits | `oauth.attack.test.ts:123` (A2) |
556
556
  | Collision d'identifiants entre fournisseurs | Clé = `provider` + `providerId` | `oauth.attack.test.ts:155` (A3) |
557
- | Interception du `code` | PKCE : `code_verifier` exigé, refus si absent | `oauthProviders.test.ts:67` |
558
- | 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` |
559
559
 
560
560
  ## 📜 Normes appliquées
561
561
 
@@ -563,11 +563,11 @@ ou détruire les sessions), pas chez le fournisseur.
563
563
  | --------------------------------- | ------------------------ | --------------------------------------------------------------------- |
564
564
  | Flux Authorization Code | RFC 6749 | `IOAuthProvider.validateAuthorizationCode()` (`IOAuthProvider.ts:85`) |
565
565
  | PKCE | RFC 7636 | `usesPkce` (`IOAuthProvider.ts:58`) · `oidc.ts:104-111` |
566
- | Sécurité OAuth (BCP 2.1) | RFC 9700 | `OAuth2Service` (`oauth2.ts:116`) · `oauth2Schema` (`config.ts:1034`) |
567
- | Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:61`) · `oauth2.ts:170-181` |
568
- | 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`) |
569
569
  | Claims d'identité OIDC | OpenID Connect Core | `fetchProfile()` du helper OIDC (`oidc.ts:127-145`) |
570
- | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:132`) |
570
+ | ID token consommé en code flow | OIDC Core §3.1.3.7 | `assertIdTokenClaims()` (`oidc.ts:139`) |
571
571
  | Anti-fixation de session | OWASP Session Management | `session.regenerateId()` au login (`authFlow.ts:388`) |
572
572
 
573
573
  Flux **exclus** par posture 2.1, et donc absents du code : `implicit` (jeton en fragment d'URL) et
@@ -603,7 +603,7 @@ retirer de la configuration.
603
603
  technique brut.
604
604
 
605
605
  Côté suivi, chaque login réussi produit un événement d'audit `auth` / `login.success` via
606
- `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
607
607
  session ouverte apparaît dans l'écran **Sessions** (IP et agent capturés à l'ouverture) ; le compte
608
608
  provisionné dans l'écran **Users**, avec ses rôles réels.
609
609
 
@@ -618,17 +618,17 @@ provisionné dans l'écran **Users**, avec ses rôles réels.
618
618
  | ------------------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- |
619
619
  | `404` sur `…/oauth2/…` | Service `oauth2` absent (module non chargé / `enabled: false`) | Charger `@nodefony/security` et activer `oauth2` |
620
620
  | WARNING « inconnu du registre » au boot | Nom configuré sans fabrique (`oauth2.ts:149-155`) | `registerOAuthProvider()` au chargement du module, ou builtin |
621
- | `404` « Unknown provider » sur `authorize` | Le nom n'est pas dans `listProviders()` (`OAuth2Controller.ts: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 |
622
622
  | Bouton absent de l'écran de login | Secrets manquants → fournisseur non monté (spread conditionnel) | Renseigner `clientId`/`clientSecret` dans l'env |
623
- | `redirect_uri_mismatch` chez le fournisseur | `redirectUri` ≠ URL enregistrée, au caractère près (`config.ts: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` |
624
624
  | Retour systématique sur `failureRedirect` | `state`/`verifier` absents (cookie perdu entre les deux requêtes) | Vérifier `SameSite`/domaine du cookie ; un seul hôte en dev |
625
- | Callback échoue au **deuxième** essai | `state` à usage unique, consommé (`OAuth2Controller.ts:126-129`) | Refaire le flux depuis `authorize` — comportement attendu |
626
- | `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:170-181`) | Corriger `issuer` (Keycloak : URL exacte du realm) |
627
- | 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 |
628
628
  | « provisioning indisponible » | `users` n'implémente pas la capability (`oauth2.ts:327-333`) | Implémenter `provisionOAuthUser()` sur le service `users` |
629
- | Profil connu refusé | `allowSignup: false` sans lien préexistant (`UserService.ts: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 |
630
630
  | Doublon de compte pour un utilisateur existant | Aucune liaison auto par e-mail (choix de sécurité) | Rattacher explicitement, utilisateur connecté |
631
- | Rôle attendu absent après re-login | Rôles posés à la **création** seulement (`UserService.ts: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 |
632
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 |
633
633
 
634
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:711`).
254
254
  4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
255
- `#resolveUserForRefresh()` (`tokenService.ts:745`).
255
+ `#resolveUserForRefresh()` (`tokenService.ts:770`).
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:41`) — 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
 
@@ -390,7 +390,7 @@ Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-trip
390
390
  - `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
391
391
  (`ITokenStore.ts:222`).
392
392
  - Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
393
- (`apiKeys.ts:216`), jamais un listAll matérialisé.
393
+ (`apiKeys.ts:209`), jamais un listAll matérialisé.
394
394
 
395
395
  ### Révoquer — trois portées
396
396
 
@@ -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:396`) |
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:744`)
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