@nodefony/security 10.0.0-alpha.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/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
package/docs/tokens.md
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Tokens — émission, clés (keystore), rotation et révocation"
|
|
3
|
+
navTitle: Tokens
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: tokens
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "tokenService,JwtKeystore,MemoryTokenStore,jwtRuntime,tokenStoreRegistry"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer, devops]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
security,
|
|
14
|
+
jwt,
|
|
15
|
+
tokens,
|
|
16
|
+
refresh,
|
|
17
|
+
keystore,
|
|
18
|
+
jwks,
|
|
19
|
+
rotation,
|
|
20
|
+
revocation,
|
|
21
|
+
pagination,
|
|
22
|
+
rfc9700,
|
|
23
|
+
rfc6749,
|
|
24
|
+
ed25519,
|
|
25
|
+
]
|
|
26
|
+
version: "doc"
|
|
27
|
+
status: stable
|
|
28
|
+
updated: 2026-07-19
|
|
29
|
+
source: "src/packages/@nodefony/security/docs/tokens.md"
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
# Tokens — émission, clés, rotation et révocation
|
|
33
|
+
|
|
34
|
+
> Les authenticators _vérifient_ des jetons ; cette page décrit leur **face émission** : comment
|
|
35
|
+
> Nodefony signe un access token JWT, gère la **clé** (keystore Ed25519 + JWKS), fait **tourner** les
|
|
36
|
+
> refresh tokens avec détection de rejeu (RFC 9700), et **révoque** — au-dessus d'un `ITokenStore`
|
|
37
|
+
> pluggable (memory/drizzle/mongoose/redis) désormais **paginé** pour l'admin. Ancré sur
|
|
38
|
+
> `src/packages/@nodefony/security/nodefony/service/tokenService.ts` et `nodefony/src/token/`.
|
|
39
|
+
|
|
40
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Jetons**
|
|
41
|
+
|
|
42
|
+
## 🧠 Le modèle mental — émission, rotation, révocation
|
|
43
|
+
|
|
44
|
+
```mermaid
|
|
45
|
+
flowchart TD
|
|
46
|
+
CLI["POST /nodefony/security/api/token<br/>{username, password, scope?}"] --> VER["users.authenticate<br/>(+ throttle NIST)"]
|
|
47
|
+
VER --> ISS["issueTokens"]
|
|
48
|
+
ISS --> AT["access token<br/>JWT EdDSA, typ at+jwt, 15 min"]
|
|
49
|
+
ISS --> RT["refresh token<br/>secret opaque nfr_…, stocké HACHÉ"]
|
|
50
|
+
RT --> ST[("ITokenStore<br/>memory · drizzle · mongoose · redis")]
|
|
51
|
+
AT -.->|kid| KS["JwtKeystore<br/>Ed25519 · JWKS public"]
|
|
52
|
+
REF["POST …/api/token/refresh<br/>{refresh_token}"] --> ROT{"déjà révoqué ?"}
|
|
53
|
+
ROT -->|"oui = rejeu"| FAM["revokeFamily<br/>toute la famille coupée"]
|
|
54
|
+
ROT -->|non| NEW["rotation : nouveau couple<br/>ancien chaîné + révoqué"]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 📖 Lexique
|
|
58
|
+
|
|
59
|
+
| Terme | Sens |
|
|
60
|
+
| --------------- | --------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| Access token | JWT court (15 min) signé EdDSA, porté en `Authorization: Bearer` — jamais en cookie/URL. |
|
|
62
|
+
| Refresh token | Secret opaque longue durée (`nfr_…`), stocké **haché**, échangé contre un nouvel access. |
|
|
63
|
+
| PAT | _Personal Access Token_ (clé API) — même store, autre page ([authenticators](./authenticators.md)). |
|
|
64
|
+
| Grant | Échange d'un credential (identifiant/mot de passe) contre un couple access+refresh. |
|
|
65
|
+
| Keystore | Gestionnaire des clés de signature Ed25519 + du JWKS public. |
|
|
66
|
+
| JWKS | _JSON Web Key Set_ : les clés **publiques** exposées pour vérifier les signatures. |
|
|
67
|
+
| `kid` | Identifiant de clé (empreinte) posé dans l'en-tête du JWT → sélection de la bonne clé. |
|
|
68
|
+
| `jti` | Identifiant unique d'un JWT — clé de la denylist de révocation ciblée. |
|
|
69
|
+
| Rotation | Émettre un nouveau refresh à chaque usage et révoquer l'ancien (RFC 9700). |
|
|
70
|
+
| Famille | Chaîne de refresh liés par rotation ; un rejeu coupe toute la famille. |
|
|
71
|
+
| Downscoping | Les scopes ne **montent** jamais le long d'une chaîne de refresh. |
|
|
72
|
+
| `invalidBefore` | Seuil par porteur : tout access émis avant cet instant est rejeté (révocation en masse). |
|
|
73
|
+
|
|
74
|
+
## Qu'est-ce que ce système résout — la faille
|
|
75
|
+
|
|
76
|
+
Un JWT est **auto-porté** : le serveur peut le vérifier sans état. Génial pour la scalabilité,
|
|
77
|
+
dangereux pour la révocation — un jeton volé reste valide jusqu'à son expiration si rien ne le suit
|
|
78
|
+
côté serveur. Deux attaques concrètes :
|
|
79
|
+
|
|
80
|
+
- le **vol de refresh token** — l'attaquant le rejoue pour obtenir des access frais indéfiniment ;
|
|
81
|
+
- l'**absence de révocation** — bannir un compte ne coupe pas ses jetons déjà émis.
|
|
82
|
+
|
|
83
|
+
Nodefony répond par un **store de vérité côté serveur** (denylist `jti` + `invalidBefore` + rotation
|
|
84
|
+
avec détection de rejeu) et une **gestion de clé** qui ne génère jamais de secret en clair « par
|
|
85
|
+
défaut » en prod.
|
|
86
|
+
|
|
87
|
+
## La vision Nodefony — un service propriétaire, des endpoints minces
|
|
88
|
+
|
|
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
|
|
93
|
+
**gc** via `GcScheduler` (timer `unref` + **jitter** de phase pour étaler les balayages entre pods,
|
|
94
|
+
`tokenService.ts:175-181`).
|
|
95
|
+
|
|
96
|
+
Les endpoints HTTP sont des **adaptateurs minces** portés par `@nodefony/framework`, couplés **par
|
|
97
|
+
nom de service** via le contrat structurel `ITokenIssuer` — framework n'importe jamais security
|
|
98
|
+
(`TokenAuthController.ts:11-22`).
|
|
99
|
+
|
|
100
|
+
Constat clé de cohérence : `iss`/`aud`/`ttl` sont dérivés **une seule fois** par
|
|
101
|
+
`resolveJwtRuntime()` (`jwtRuntime.ts:30-41`) et **partagés** entre l'émetteur et le vérificateur —
|
|
102
|
+
une divergence ferait tout rejeter. Fonction pure : les deux côtés obtiennent la même valeur sans la
|
|
103
|
+
partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts:31`), à surcharger en prod.
|
|
104
|
+
|
|
105
|
+
### Être DÉCOUVRABLE — publier ses clés (RFC 8414)
|
|
106
|
+
|
|
107
|
+
Tant que Nodefony émet **et** vérifie ses propres jetons, `iss` n'est qu'une chaîne comparée à
|
|
108
|
+
elle-même. Dès qu'un **tiers** doit valider une signature émise ici (une autre application
|
|
109
|
+
Nodefony, un agent, un service), il lui faut deux documents publics :
|
|
110
|
+
|
|
111
|
+
| Route | Contenu |
|
|
112
|
+
| --------------------------------------------- | -------------------------------------------------------------- |
|
|
113
|
+
| `GET /.well-known/oauth-authorization-server` | `issuer`, `jwks_uri` — RFC 8414 §2 (chemin **non négociable**) |
|
|
114
|
+
| `GET /.well-known/jwks.json` | clés publiques de signature (jamais `d`) |
|
|
115
|
+
|
|
116
|
+
Les deux sont montées par `@nodefony/framework` (`IssuerMetadataController.ts`) **uniquement si**
|
|
117
|
+
`TokenService.publishedIssuer()` répond — soit `jwt.enabled`, `jwt.jwks`, **et** `jwt.issuer` écrit
|
|
118
|
+
sous forme d'URL https. Sinon : aucune route (`404`) et un avertissement au boot.
|
|
119
|
+
|
|
120
|
+
🔴 **L'URL ne se devine pas.** Derrière un relais (HAProxy, ingress, CDN), `Host` et
|
|
121
|
+
`X-Forwarded-*` viennent de la requête, donc du client : un document dérivé de l'en-tête ferait
|
|
122
|
+
servir, par le vrai serveur, l'identité d'un attaquant — et empoisonnerait tout cache mutualisé.
|
|
123
|
+
L'exploitant l'écrit, comme il écrit son domaine (`NF_JWT_ISSUER` dans `env.ts`).
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
use("@nodefony/security", { jwt: { issuer: ctx.env.NF_JWT_ISSUER } });
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Le document publié n'annonce **aucun** flux d'autorisation (`response_types_supported: []`,
|
|
130
|
+
`grant_types_supported: []`) : Nodefony n'est pas un serveur d'autorisation OAuth 2.1, elle rend
|
|
131
|
+
seulement ses signatures vérifiables. Omettre `grant_types_supported` annoncerait
|
|
132
|
+
`["authorization_code", "implicit"]` par défaut (RFC 8414 §2) — deux flux inexistants.
|
|
133
|
+
|
|
134
|
+
## 🚀 Démarrage rapide
|
|
135
|
+
|
|
136
|
+
### Les endpoints d'émission sont FOURNIS
|
|
137
|
+
|
|
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`) :
|
|
140
|
+
|
|
141
|
+
- `POST /nodefony/security/api/token` — body `{username, password, scope?}` → couple access/refresh ;
|
|
142
|
+
- `POST /nodefony/security/api/token/refresh` — body `{refresh_token}` → rotation.
|
|
143
|
+
|
|
144
|
+
> [!IMPORTANT]
|
|
145
|
+
> Ces routes n'existent **que si** le service `tokenService` est présent — sinon 404, zéro surface
|
|
146
|
+
> (`TokenAuthController.ts:46-48`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts:145`) :
|
|
147
|
+
> elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
|
|
148
|
+
> authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
|
|
149
|
+
|
|
150
|
+
### La config : une zone protégée par `jwt` + une clé qui survit au redémarrage
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
// nodefony.config.ts (extrait) — la zone API machine + la source de clé
|
|
154
|
+
use("@nodefony/security", {
|
|
155
|
+
jwt: {
|
|
156
|
+
// dev/VPS : persiste la clé Ed25519 (sinon clé ÉPHÉMÈRE + warning au boot).
|
|
157
|
+
// prod cloud : préférer keystore.keySetJson injecté depuis l'env (même clé
|
|
158
|
+
// sur tous les pods) — voir la section keystore.
|
|
159
|
+
keystore: { dir: "./config/jwt" },
|
|
160
|
+
},
|
|
161
|
+
areas: {
|
|
162
|
+
// Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
|
|
163
|
+
api: { pattern: "^/api/v1", authenticators: ["jwt"] },
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Ce que TU écris : le controller scopé
|
|
169
|
+
|
|
170
|
+
```typescript
|
|
171
|
+
// nodefony/controllers/OrdersController.ts — complet, compile tel quel
|
|
172
|
+
import {
|
|
173
|
+
controller,
|
|
174
|
+
Controller,
|
|
175
|
+
Get,
|
|
176
|
+
RequireScope,
|
|
177
|
+
CurrentUser,
|
|
178
|
+
} from "@nodefony/framework";
|
|
179
|
+
import type { IUser } from "@nodefony/user";
|
|
180
|
+
|
|
181
|
+
@controller("/api/v1/orders")
|
|
182
|
+
class OrdersController extends Controller {
|
|
183
|
+
// Zone `api` : le firewall a déjà validé le JWT (signature, exp, aud/iss,
|
|
184
|
+
// denylist, sujet actif). @RequireScope borne ce que la CLÉ a le droit de
|
|
185
|
+
// faire — un token émis sans `orders:read` reçoit 403, même sujet valide.
|
|
186
|
+
@RequireScope("orders:read")
|
|
187
|
+
@Get("/list")
|
|
188
|
+
async list(@CurrentUser() user: IUser) {
|
|
189
|
+
return this.renderJson({ subject: user.identifier, orders: [] });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export default OrdersController;
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Ce qu'on observe
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
# 0) Un compte (mot de passe demandé MASQUÉ — jamais en dur dans un script)
|
|
200
|
+
npx nodefony security:user:add ci-bot
|
|
201
|
+
|
|
202
|
+
# 1) Grant : credential → couple access/refresh (réponse RFC 6749 §5.1)
|
|
203
|
+
curl -s -H 'Content-Type: application/json' \
|
|
204
|
+
-d "{\"username\":\"ci-bot\",\"password\":\"$NF_PASS\",\"scope\":\"orders:read\"}" \
|
|
205
|
+
http://localhost:5151/nodefony/security/api/token
|
|
206
|
+
# {"access_token":"eyJ…","refresh_token":"nfr_…","token_type":"Bearer",
|
|
207
|
+
# "expires_in":900,"scope":"orders:read"}
|
|
208
|
+
|
|
209
|
+
# 2) L'access token en Bearer → 200 (zone api, scope vérifié)
|
|
210
|
+
curl -s -H "Authorization: Bearer $ACCESS" \
|
|
211
|
+
http://localhost:5151/api/v1/orders/list
|
|
212
|
+
# {"subject":"ci-bot","orders":[]}
|
|
213
|
+
|
|
214
|
+
# 3) Rotation : le refresh → NOUVEAU couple (l'ancien refresh est révoqué)
|
|
215
|
+
curl -s -H 'Content-Type: application/json' \
|
|
216
|
+
-d "{\"refresh_token\":\"$REFRESH\"}" \
|
|
217
|
+
http://localhost:5151/nodefony/security/api/token/refresh
|
|
218
|
+
|
|
219
|
+
# 4) Rejouer l'ANCIEN refresh → 401 {"error":"invalid_grant"} + famille coupée
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts:108-120`) :
|
|
223
|
+
401 message uniforme `invalid_grant` (anti-énumération), 429 avec `Retry-After` du throttler NIST.
|
|
224
|
+
Émission indisponible (JWT désactivé, store absent) → 503 `isEnabled()`
|
|
225
|
+
(`TokenAuthController.ts:67-69`).
|
|
226
|
+
|
|
227
|
+
## 🏗️ Architecture interne — la vie d'un couple access/refresh
|
|
228
|
+
|
|
229
|
+
### Émission (grant M2M/CLI)
|
|
230
|
+
|
|
231
|
+
`issueForCredentials()` (`tokenService.ts:317`) vérifie l'identifiant/mot de passe via le
|
|
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`)
|
|
235
|
+
produit :
|
|
236
|
+
|
|
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`) ;
|
|
239
|
+
- un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
|
|
240
|
+
`sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
|
|
241
|
+
(`tokenService.ts:674-709`).
|
|
242
|
+
|
|
243
|
+
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts:43-51`). Tout succès est audité
|
|
244
|
+
`token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts:312-318`).
|
|
245
|
+
|
|
246
|
+
### Rotation & détection de rejeu (RFC 9700 §4.14)
|
|
247
|
+
|
|
248
|
+
`refresh()` (`tokenService.ts:555`) est le cœur défensif, dans l'ordre :
|
|
249
|
+
|
|
250
|
+
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts:564`).
|
|
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`).
|
|
254
|
+
4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
|
|
255
|
+
`#resolveUserForRefresh()` (`tokenService.ts:745`).
|
|
256
|
+
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
257
|
+
(`tokenService.ts:592`).
|
|
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`).
|
|
261
|
+
|
|
262
|
+
### Mise en situation — ton refresh token a été volé
|
|
263
|
+
|
|
264
|
+
Besoin vécu : le refresh d'un poste compromis est exfiltré. Rotation active (défaut) — voici ce que
|
|
265
|
+
chacun vit, requête par requête :
|
|
266
|
+
|
|
267
|
+
| # | Qui présente quoi | Ce que fait `refresh()` | Résultat client |
|
|
268
|
+
| --- | ------------------------------- | ----------------------------------------------------------- | ----------------------------------- |
|
|
269
|
+
| 1 | Client légitime → refresh R1 | rotation : R2 émis, R1 révoqué `rotated` | 200, nouveau couple |
|
|
270
|
+
| 2 | Voleur → R1 (volé, déjà tourné) | R1 révoqué re-présenté = **rejeu** → famille entière coupée | 401 `invalid_grant` |
|
|
271
|
+
| 3 | Client légitime → R2 | famille coupée : R2 est révoqué aussi | 401 → se reconnecte (nouveau grant) |
|
|
272
|
+
|
|
273
|
+
Si le **voleur joue en premier**, la rotation lui répond normalement (le serveur ne peut pas encore
|
|
274
|
+
le distinguer) — mais dès que le légitime rejoue son vieux refresh, le rejeu est détecté et le
|
|
275
|
+
voleur perd aussi son couple. Dans les deux ordres, **l'attaque est bornée à une fenêtre courte** et
|
|
276
|
+
la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéfini.
|
|
277
|
+
|
|
278
|
+
## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
|
|
279
|
+
|
|
280
|
+
`JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts:97-128`), pensée
|
|
281
|
+
pour ne jamais auto-générer une clé en clair silencieusement en prod :
|
|
282
|
+
|
|
283
|
+
1. **env** — `keySetJson` (JWK Set injecté depuis le catalogue d'env) : prod cloud, secret géré
|
|
284
|
+
hors-app, même clé sur tous les pods (`JwtKeystore.ts:100-106`).
|
|
285
|
+
2. **fichier** — `dir/keyset.json`, généré si absent, écriture atomique tmp+rename en mode 600 —
|
|
286
|
+
`#writeAtomic()` (`JwtKeystore.ts:208-217`) : opt-in dev/VPS mono-machine.
|
|
287
|
+
3. **mémoire** — aucune source → clé **éphémère + WARNING** explicite : perdue au redémarrage =
|
|
288
|
+
refresh invalidés, incohérente en cluster (`JwtKeystore.ts:121-127`).
|
|
289
|
+
|
|
290
|
+
Le JWKS servi par `getPublicJWKS()` (`JwtKeystore.ts:87-90`) est **public** : la composante privée
|
|
291
|
+
`d` est retirée à l'import par `#importKeyset()` (`JwtKeystore.ts:156-158`, RFC 8037/7517) — c'est
|
|
292
|
+
lui qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts:174`), jamais
|
|
293
|
+
une clé venue du token. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts:93-95`).
|
|
294
|
+
|
|
295
|
+
> [!WARNING]
|
|
296
|
+
> **Race au 1ᵉʳ boot d'un cluster sans clé pré-provisionnée** : deux workers peuvent générer des
|
|
297
|
+
> clés différentes — le dernier `rename` gagne (`JwtKeystore.ts:61-64`). En prod, provisionner
|
|
298
|
+
> `keySetJson` hors-bande élimine ce cas : c'est la source recommandée.
|
|
299
|
+
|
|
300
|
+
## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
|
|
301
|
+
|
|
302
|
+
### Le contrat et l'enregistrement
|
|
303
|
+
|
|
304
|
+
Le `tokenStore` héberge **trois structures** : les records (refresh + PAT), la denylist `jti` et le
|
|
305
|
+
seuil `invalidBefore` par porteur (`ITokenStore.ts:11-15`). Les backends s'enregistrent par
|
|
306
|
+
fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts:41-46`) : les adapters lourds importent
|
|
307
|
+
`import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
|
|
308
|
+
|
|
309
|
+
Sa résolution au boot (`tokenService.ts:112-163`) suit la doctrine `store:"auto"` du framework :
|
|
310
|
+
|
|
311
|
+
- `auto` (défaut) → suit l'infra database déclarée via `resolveAutoStore` — **borné aux backends
|
|
312
|
+
réellement enregistrés**, repli memory **annoncé** (`tokenService.ts:116-124`) ;
|
|
313
|
+
- store explicite **inconnu** → en prod, **boot avorté** (fail-loud) ; en dev, brique désactivée et
|
|
314
|
+
annoncée avec la liste `listTokenStores()` (`tokenService.ts:127-138`) — jamais de fallback
|
|
315
|
+
memory silencieux pour du durable ;
|
|
316
|
+
- store `memory` **en prod** → `WARNING` nommant l'impact : denylist/refresh/clés API per-pod et
|
|
317
|
+
volatils, révocation non partagée (`tokenService.ts:142-149`).
|
|
318
|
+
|
|
319
|
+
La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
|
|
320
|
+
(`tokenService.ts:151-160`) — visible dans Studio.
|
|
321
|
+
|
|
322
|
+
### Mise en situation — quel store pour quelle app ?
|
|
323
|
+
|
|
324
|
+
| Ta situation | Store | Pourquoi |
|
|
325
|
+
| --------------------------------------------------- | ---------------- | --------------------------------------------------------------------- |
|
|
326
|
+
| Dev / tests mono-process | `memory` (auto) | 0 dépendance ; volatil — un redémarrage déconnecte tout le monde |
|
|
327
|
+
| Prod, base SQL déclarée (`NF_DATABASE_URL`) | `auto` → drizzle | durable + partagé entre pods : révocation et rejeu vus PARTOUT |
|
|
328
|
+
| Prod, MongoDB | `mongoose` | même contrat, même banc, sur Mongo |
|
|
329
|
+
| Flotte de pods, Redis déjà présent, denylist chaude | `redis` | TTL natif, lecture O(1) ; listing par curseur SCAN (capacité réduite) |
|
|
330
|
+
| Prod **sans** infra durable | ❌ `memory` | ça boote, mais WARNING mérité : la révocation ne traverse pas un pod |
|
|
331
|
+
|
|
332
|
+
### Les backends (catalogue)
|
|
333
|
+
|
|
334
|
+
### `memory` — la référence 0 dépendance
|
|
335
|
+
|
|
336
|
+
- Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts:63-68`).
|
|
337
|
+
- `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
|
|
338
|
+
(`MemoryTokenStore.ts:122-142`).
|
|
339
|
+
- Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
|
|
340
|
+
(`MemoryTokenStore.ts:360`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
|
|
341
|
+
- `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
|
|
342
|
+
(`MemoryTokenStore.ts:253-283`).
|
|
343
|
+
- Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
|
|
344
|
+
|
|
345
|
+
### `drizzle` — SQL, le défaut durable
|
|
346
|
+
|
|
347
|
+
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts:220`).
|
|
348
|
+
- Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
|
|
349
|
+
est chargé (`config.ts:394-399`).
|
|
350
|
+
- Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
|
|
351
|
+
|
|
352
|
+
### `mongoose` — MongoDB
|
|
353
|
+
|
|
354
|
+
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts:119`).
|
|
355
|
+
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts:163-173`).
|
|
356
|
+
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts:196`).
|
|
357
|
+
|
|
358
|
+
### `redis` — cluster, TTL natif
|
|
359
|
+
|
|
360
|
+
- Enregistré par le module redis (`redis/nodefony/registerStores.ts:49`).
|
|
361
|
+
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts:254`) — l'expiration ne
|
|
362
|
+
dépend pas du gc.
|
|
363
|
+
- Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
|
|
364
|
+
(`RedisTokenStore.ts:35-44`) — sans ordre global ni total, capacité réduite **assumée**.
|
|
365
|
+
- `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
|
|
366
|
+
(`RedisTokenStore.ts:432`).
|
|
367
|
+
|
|
368
|
+
### Le record — une seule table pour PAT et refresh
|
|
369
|
+
|
|
370
|
+
`IAccessTokenRecord` (`ITokenStore.ts:69`) est **single-table** : un même schéma porte PAT et
|
|
371
|
+
refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts:60-64`). Deux
|
|
372
|
+
constats de design :
|
|
373
|
+
|
|
374
|
+
- `subjectId` est une **référence logique souple, PAS une FK SQL** : porteur polymorphe
|
|
375
|
+
(user/service), store multi-backend, découplage des modules — intégrité assurée à l'usage
|
|
376
|
+
(`ITokenStore.ts:80-95`) ;
|
|
377
|
+
- slots prêts sans migration : `resources` (permissions fine-grained façon GitHub) et `cnf`
|
|
378
|
+
(sender-constrained DPoP/mTLS) (`ITokenStore.ts:101-119`).
|
|
379
|
+
|
|
380
|
+
Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-triple-vérité).
|
|
381
|
+
|
|
382
|
+
### Lister pour l'admin — pagination native
|
|
383
|
+
|
|
384
|
+
- `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts:233`) — capacité par
|
|
385
|
+
backend : **offset + total** (SQL/Mongo/mémoire) ou **curseur** (`nextCursor`, Redis).
|
|
386
|
+
- `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts:238`).
|
|
387
|
+
- Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts:154-161`) —
|
|
388
|
+
prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts:11-26`).
|
|
389
|
+
- `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
|
|
390
|
+
(`ITokenStore.ts:222`).
|
|
391
|
+
- Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
|
|
392
|
+
(`apiKeys.ts:216`), jamais un listAll matérialisé.
|
|
393
|
+
|
|
394
|
+
### Révoquer — trois portées
|
|
395
|
+
|
|
396
|
+
- **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts:251`).
|
|
397
|
+
- **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
|
|
398
|
+
(`ITokenStore.ts:242`).
|
|
399
|
+
- **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
|
|
400
|
+
`iat < invalidBefore` est rejeté (`ITokenStore.ts:226-234`) ; le seuil est **monotone**, deux
|
|
401
|
+
logouts successifs ne le reculent pas (`MemoryTokenStore.ts:229`).
|
|
402
|
+
|
|
403
|
+
### La maintenance (gc)
|
|
404
|
+
|
|
405
|
+
Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
|
|
406
|
+
la rétention (`ITokenStore.ts:237-251`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
|
|
407
|
+
un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts:293`).
|
|
408
|
+
|
|
409
|
+
> [!TIP]
|
|
410
|
+
> Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
|
|
411
|
+
> `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
|
|
412
|
+
> (`MemoryTokenStore.ts:239-248`). Un store **local** (memory) est par-process : seul SON process
|
|
413
|
+
> peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
|
|
414
|
+
|
|
415
|
+
## ⚙️ Configuration
|
|
416
|
+
|
|
417
|
+
Tables dérivées du schéma Zod — `jwtSchema` (`config.ts:334-390`) et `tokenStoreSchema`
|
|
418
|
+
(`config.ts:392-425`), défauts inclus.
|
|
419
|
+
|
|
420
|
+
### `jwt.*`
|
|
421
|
+
|
|
422
|
+
<!-- prettier-ignore -->
|
|
423
|
+
| Option | Type | Défaut | Effet |
|
|
424
|
+
| --- | --- | --- | --- |
|
|
425
|
+
| `enabled` | boolean | `true` | Active signature + refresh (`config.ts:317`) |
|
|
426
|
+
| `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts:21`) |
|
|
427
|
+
| `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts:364`) |
|
|
428
|
+
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts:369`) |
|
|
429
|
+
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts:374`) |
|
|
430
|
+
| `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
|
|
431
|
+
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts:384`) |
|
|
432
|
+
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
433
|
+
| `keystore.keySetJson` | string? | — | JWK Set privé injecté depuis l'env — source prod, SECRET (`security/nodefony/config/config.ts:398`) |
|
|
434
|
+
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 — source dev/VPS (`config.ts:376-381`) |
|
|
435
|
+
|
|
436
|
+
### `tokenStore.*`
|
|
437
|
+
|
|
438
|
+
| Option | Type | Défaut | Effet |
|
|
439
|
+
| ---------------------- | ---------- | -------- | ------------------------------------------------------------------------------------- |
|
|
440
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts:394-399`) |
|
|
441
|
+
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts:428`) |
|
|
442
|
+
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts:436`) |
|
|
443
|
+
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts:442`) |
|
|
444
|
+
|
|
445
|
+
## 📜 Normes appliquées
|
|
446
|
+
|
|
447
|
+
| Domaine | Norme | Ancrage |
|
|
448
|
+
| -------------------------------- | --------------- | ------------------------------------------------------- |
|
|
449
|
+
| Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts:43-51`) |
|
|
450
|
+
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts:555`) |
|
|
451
|
+
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts:402-415`) |
|
|
452
|
+
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts:406-414`) |
|
|
453
|
+
| Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts:156-158`) |
|
|
454
|
+
| Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts:104-105`) |
|
|
455
|
+
| 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts:108-115`) |
|
|
456
|
+
| Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts:346`) |
|
|
457
|
+
|
|
458
|
+
## ⚡ Performance & mémoire
|
|
459
|
+
|
|
460
|
+
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts:648`)
|
|
461
|
+
— le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
|
|
462
|
+
- **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
|
|
463
|
+
vérification (hot path) vit chez le `JwtAuthenticator`.
|
|
464
|
+
- **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
|
|
465
|
+
simultanés (`tokenService.ts:175-181`) ; denylist mémoire bornée par purge amortie
|
|
466
|
+
(`MemoryTokenStore.ts:331-341`).
|
|
467
|
+
- **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts:233`).
|
|
468
|
+
|
|
469
|
+
## 📡 Observabilité — Studio
|
|
470
|
+
|
|
471
|
+
- **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
|
|
472
|
+
raison) publiée par `registerStoreResolution()` (`tokenService.ts:151-160`).
|
|
473
|
+
- **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
|
|
474
|
+
`login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
|
|
475
|
+
- **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.
|
|
476
|
+
|
|
477
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
478
|
+
|
|
479
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
480
|
+
| --------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------- |
|
|
481
|
+
| 404 sur `/nodefony/security/api/token` | Routes montées seulement si `tokenService` existe | Charger `@nodefony/security` + `jwt.enabled: true` |
|
|
482
|
+
| 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
|
|
483
|
+
| Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `jwt.keystore.keySetJson` (prod) ou `dir` (dev) |
|
|
484
|
+
| JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | Provisionner `keySetJson` hors-bande (même clé partout) |
|
|
485
|
+
| Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
|
|
486
|
+
| Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
|
|
487
|
+
| Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
|
|
488
|
+
| Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
|
|
489
|
+
| Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
|
|
490
|
+
| Listing admin sans `total` sur Redis | `countTokens()` = `-1` (comptage O(N) refusé), curseur SCAN | Attendu — capacité réduite annoncée, paginer par curseur |
|
|
491
|
+
|
|
492
|
+
## 🧪 Tests & couverture
|
|
493
|
+
|
|
494
|
+
Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
|
|
495
|
+
(régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
|
|
496
|
+
|
|
497
|
+
- **unit** : `tokenService` (émission, rotation, rejeu, downscoping), `tokenStore` (révocation,
|
|
498
|
+
denylist, gc, rétention), `jwtKeystore` (les 3 priorités de source), `jwtPipeline` (bout en bout
|
|
499
|
+
signature → vérification), `tokenPagination` (le banc de contrat piloté par le store mémoire) ;
|
|
500
|
+
- **banc de contrat** : `tokenPaginationContract` — invariants `listPage`/`countTokens` tenus par
|
|
501
|
+
**tous** les backends (tri, offset, filtres, curseur) ;
|
|
502
|
+
- **intégration adapters** : token-store + token-pagination chez drizzle (sqlite), mongoose et
|
|
503
|
+
redis — le MÊME banc rejoué sur chaque backend ;
|
|
504
|
+
- **e2e base réelle** : drizzle sur PostgreSQL et MySQL ;
|
|
505
|
+
- **manque assumé** : pas de banc d'attaque dédié à l'émission (le rejeu est couvert en unit) ni de
|
|
506
|
+
test de charge sur le grant.
|
|
507
|
+
|
|
508
|
+
Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — un skip compte comme
|
|
509
|
+
vert : lire le bloc gates (`vitest.gates.ts`, affiché en fin de run) avant de conclure.
|
|
510
|
+
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
511
|
+
|
|
512
|
+
## 🔗 Pour aller plus loin
|
|
513
|
+
|
|
514
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
515
|
+
- 🧭 **Pages sœurs** : [api-keys](api-keys.md) · [Authenticators](authenticators.md)
|
|
516
|
+
|
|
517
|
+
- La vérification de ces jetons (JWT/PAT) → [authenticators](./authenticators.md)
|
|
518
|
+
- Le firewall qui applique la zone → [firewall](./firewall.md) · L'autorisation par scopes → [authorization](./authorization.md)
|
|
519
|
+
- La doctrine `store:"auto"` → [configuration](../../../../../docs/architecture/configuration.md)
|
|
520
|
+
- Vue d'ensemble sécurité → [index](./index.md)
|