@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.
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.150.0 → _@oxc-project_runtime@0.151.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/index.js +2 -2
- package/dist/nodefony/command/security-token.js +4 -4
- package/dist/nodefony/command/security-user-add.js +2 -18
- package/dist/nodefony/command/security-user-delete.js +2 -2
- package/dist/nodefony/command/security-user-list.js +1 -1
- package/dist/nodefony/command/security-user-password.js +2 -2
- package/dist/nodefony/config/config.js +2 -2
- package/dist/nodefony/service/apiKeys.js +2 -2
- package/dist/nodefony/service/auditService.js +6 -5
- package/dist/nodefony/service/authFlow.js +2 -2
- package/dist/nodefony/service/authorization.js +3 -4
- package/dist/nodefony/service/firewall.js +21 -9
- package/dist/nodefony/service/oauth2.js +5 -3
- package/dist/nodefony/service/securityHeaders.js +1 -1
- package/dist/nodefony/service/tokenService.js +30 -20
- package/dist/nodefony/service/totp.js +5 -3
- package/dist/nodefony/service/webAuthn.js +41 -33
- package/dist/nodefony/service/webhooks.js +55 -44
- package/dist/nodefony/src/admin/SecurityAdminApi.js +2 -1
- package/dist/nodefony/src/admin/WebhookAdminApi.js +1 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +1 -1
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -1
- package/dist/nodefony/src/audit/MemoryAuditStore.js +4 -8
- package/dist/nodefony/src/audit/auditBridge.js +9 -5
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -3
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +2 -2
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +5 -3
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +2 -2
- package/dist/nodefony/src/csp.js +4 -5
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/frameAuthorizer.js +6 -6
- package/dist/nodefony/src/token/JwtKeystore.js +7 -6
- package/dist/nodefony/src/token/MemoryTokenStore.js +19 -6
- package/dist/nodefony/src/token/jwtRuntime.js +13 -1
- package/dist/nodefony/src/token/tokenCriteria.js +1 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +15 -3
- package/dist/nodefony/src/totp/totpCrypto.js +9 -9
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +24 -6
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +18 -10
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +4 -3
- package/dist/types/nodefony/command/security-user-add.d.ts +0 -16
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +8 -4
- package/dist/types/nodefony/contracts/IFirewall.d.ts +2 -2
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +5 -5
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +4 -4
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +3 -3
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +4 -4
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +1 -1
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +1 -1
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +1 -1
- package/dist/types/nodefony/service/firewall.d.ts +3 -2
- package/dist/types/nodefony/service/securityHeaders.d.ts +5 -5
- package/dist/types/nodefony/src/SecuredArea.d.ts +2 -2
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +5 -3
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +2 -2
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +3 -3
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +2 -2
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +2 -2
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +10 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +0 -9
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +0 -8
- package/docs/api-keys.md +67 -65
- package/docs/audit.md +87 -82
- package/docs/authenticators.md +61 -49
- package/docs/authorization.md +21 -21
- package/docs/cors.md +30 -30
- package/docs/csrf.md +28 -28
- package/docs/external-jwt.md +21 -13
- package/docs/firewall.md +32 -32
- package/docs/headers.md +29 -29
- package/docs/index.md +3 -1
- package/docs/oauth2.md +61 -61
- package/docs/obtenir-un-jeton.md +2 -2
- package/docs/tokens.md +56 -56
- package/docs/totp.md +73 -72
- package/docs/webauthn.md +85 -85
- package/docs/webhooks.md +50 -47
- 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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
219
|
-
est présent (`framework/index.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
380
|
-
(`usesPkce: true`, `oidc.ts:
|
|
381
|
-
(`oauthProviderRegistry.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
449
|
-
de l'API REST `/user` (`createGithubProvider()`, `github.ts:
|
|
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:
|
|
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:
|
|
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
|
|
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:
|
|
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:
|
|
489
|
-
(`config.ts:
|
|
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:
|
|
495
|
-
| `allowSignup` | booléen | `true` | `false` = compte préexistant lié exigé (`config.ts:
|
|
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:
|
|
498
|
+
| `providers` | dictionnaire par nom | `{}` | Fournisseurs activés (`config.ts:1070`). |
|
|
499
499
|
|
|
500
|
-
Par fournisseur (`oauthProviderSchema`, `config.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
558
|
-
| Création de compte non voulue | Provisioner absent (`provisionOAuthUser`) → fail-closed | `oauth2Service.test.ts:
|
|
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:
|
|
567
|
-
| Anti-mix-up (`iss`) | RFC 9207 | `issuerPolicy` (`IOAuthProvider.ts:
|
|
568
|
-
| Callback en correspondance exacte | RFC 9700 §4 | `redirectUri` (`config.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
626
|
-
| `OAuth issuer mismatch` | `iss` reçu ≠ l'émetteur attendu (`oauth2.ts:
|
|
627
|
-
| Keycloak : erreur dès le premier login | `issuer` absent en config (`
|
|
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:
|
|
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:
|
|
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
|
package/docs/obtenir-un-jeton.md
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
91
|
-
`tokenStore` au container (`tokenService.ts:
|
|
92
|
-
(`tokenService.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
234
|
-
par `#auditGrant()` (`tokenService.ts:
|
|
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:
|
|
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:
|
|
241
|
+
(`tokenService.ts:681-716`).
|
|
242
242
|
|
|
243
|
-
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
253
|
-
3. Expiration `expiresAt` vérifiée (`tokenService.ts:
|
|
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:
|
|
255
|
+
`#resolveUserForRefresh()` (`tokenService.ts:770`).
|
|
256
256
|
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
257
|
-
(`tokenService.ts:
|
|
257
|
+
(`tokenService.ts:600`).
|
|
258
258
|
6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
|
|
259
|
-
`"rotated"` (`tokenService.ts:
|
|
260
|
-
et le refresh courant reste valide (`tokenService.ts:
|
|
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:
|
|
339
|
+
(`MemoryTokenStore.ts:165-185`).
|
|
340
340
|
- Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
|
|
341
|
-
(`MemoryTokenStore.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
356
|
-
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:
|
|
357
|
-
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:
|
|
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:
|
|
362
|
-
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
402
|
-
logouts successifs ne le reculent pas (`MemoryTokenStore.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
419
|
-
(`config.ts:
|
|
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:
|
|
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:
|
|
429
|
-
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:
|
|
430
|
-
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:
|
|
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:
|
|
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:
|
|
435
|
-
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:
|
|
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:
|
|
442
|
-
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:
|
|
443
|
-
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:
|
|
444
|
-
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:
|
|
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:
|
|
451
|
-
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:
|
|
452
|
-
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:
|
|
453
|
-
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|