@nodefony/security 10.0.0-beta.2 → 10.0.0-beta.4
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.152.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +48 -16
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +62 -24
- package/dist/nodefony/command/security-oauth-doctor.js +68 -0
- package/dist/nodefony/command/security-secrets.js +52 -29
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +10 -7
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +9 -14
- package/dist/nodefony/config/config.js +99 -8
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/contracts/index.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/errors/index.js +2 -1
- package/dist/nodefony/keycloak/keycloakAdmin.js +254 -0
- package/dist/nodefony/keycloak/keycloakRealm.js +259 -0
- package/dist/nodefony/keycloak/keycloakRealmInput.js +123 -0
- package/dist/nodefony/keycloak/scaffold.js +231 -0
- package/dist/nodefony/keycloak/security-keycloak-realm.js +130 -0
- package/dist/nodefony/scaffold/contribute.js +19 -0
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +118 -16
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +116 -25
- package/dist/nodefony/service/keycloak.js +208 -0
- package/dist/nodefony/service/oauth2.js +403 -28
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +94 -27
- package/dist/nodefony/service/totp.js +55 -11
- package/dist/nodefony/service/webAuthn.js +24 -7
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +15 -5
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +71 -42
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -3
- package/dist/nodefony/src/admin/userRevocationCascade.js +148 -20
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +14 -59
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +80 -19
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +92 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +14 -5
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +5 -12
- package/dist/nodefony/src/authenticator/bearer.js +1 -2
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +26 -0
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +38 -0
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +39 -0
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +28 -12
- package/dist/nodefony/src/oauth/oauth2Client.js +61 -35
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +40 -8
- package/dist/nodefony/src/oauth/providerDiagnosis.js +282 -0
- package/dist/nodefony/src/oauth/providerRoles.js +78 -0
- package/dist/nodefony/src/oauth/providers/github.js +48 -12
- package/dist/nodefony/src/oauth/providers/oidc.js +90 -8
- package/dist/nodefony/src/openToSignup.js +46 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +25 -0
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +89 -48
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +46 -16
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +60 -4
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +50 -0
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/index.d.ts +26 -6
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +16 -6
- package/dist/types/nodefony/command/security-oauth-doctor.d.ts +23 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +2 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +4 -3
- package/dist/types/nodefony/command/security-user-password.d.ts +4 -4
- package/dist/types/nodefony/config/config.d.ts +64 -1
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +1 -1
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +42 -1
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +48 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +22 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +4 -1
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +7 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +4 -1
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +6 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/errors/InsecureTransportError.d.ts +20 -0
- package/dist/types/nodefony/errors/index.d.ts +1 -0
- package/dist/types/nodefony/keycloak/keycloakAdmin.d.ts +154 -0
- package/dist/types/nodefony/keycloak/keycloakRealm.d.ts +138 -0
- package/dist/types/nodefony/keycloak/keycloakRealmInput.d.ts +92 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +98 -0
- package/dist/types/nodefony/keycloak/security-keycloak-realm.d.ts +43 -0
- package/dist/types/nodefony/scaffold/contribute.d.ts +15 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/authFlow.d.ts +35 -3
- package/dist/types/nodefony/service/firewall.d.ts +17 -3
- package/dist/types/nodefony/service/keycloak.d.ts +115 -0
- package/dist/types/nodefony/service/oauth2.d.ts +145 -9
- package/dist/types/nodefony/service/tokenService.d.ts +7 -1
- package/dist/types/nodefony/service/totp.d.ts +28 -4
- package/dist/types/nodefony/service/webAuthn.d.ts +23 -1
- package/dist/types/nodefony/src/SecuredArea.d.ts +4 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +72 -12
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -26
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +17 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +11 -3
- package/dist/types/nodefony/src/boot/publicRoutesNotice.d.ts +16 -0
- package/dist/types/nodefony/src/entryPoint.d.ts +37 -0
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/clientCredentials.d.ts +54 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/oauth/metadata.d.ts +24 -1
- package/dist/types/nodefony/src/oauth/oauth2Client.d.ts +50 -1
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +46 -2
- package/dist/types/nodefony/src/oauth/providerDiagnosis.d.ts +117 -0
- package/dist/types/nodefony/src/oauth/providerRoles.d.ts +58 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +34 -1
- package/dist/types/nodefony/src/openToSignup.d.ts +36 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionAuthentication.d.ts +14 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +47 -5
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +29 -2
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +22 -0
- package/dist/types/nodefony/src/transport/CredentialTransportPolicy.d.ts +47 -0
- package/docs/api-keys.md +182 -175
- package/docs/audit.md +141 -141
- package/docs/authenticators.md +129 -96
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +77 -72
- package/docs/external-jwt.md +83 -11
- package/docs/firewall.md +119 -83
- package/docs/headers.md +82 -82
- package/docs/index.md +16 -8
- package/docs/keycloak.md +835 -0
- package/docs/login-page.md +361 -0
- package/docs/oauth2.md +291 -166
- package/docs/obtenir-un-jeton.md +131 -34
- package/docs/tokens.md +197 -140
- package/docs/totp.md +177 -136
- package/docs/webauthn.md +157 -154
- package/docs/webhooks.md +128 -125
- package/keycloak/themes/nodefony/account/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/account/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/account/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/account/theme.properties +14 -0
- package/keycloak/themes/nodefony/admin/resources/css/nodefony.css +66 -0
- package/keycloak/themes/nodefony/admin/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/admin/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/admin/theme.properties +12 -0
- package/keycloak/themes/nodefony/email/html/template.ftl +73 -0
- package/keycloak/themes/nodefony/email/messages/messages_en.properties +2 -0
- package/keycloak/themes/nodefony/email/messages/messages_fr.properties +3 -0
- package/keycloak/themes/nodefony/email/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/email/theme.properties +8 -0
- package/keycloak/themes/nodefony/login/messages/messages_en.properties +22 -0
- package/keycloak/themes/nodefony/login/messages/messages_fr.properties +25 -0
- package/keycloak/themes/nodefony/login/resources/css/login.css +1141 -0
- package/keycloak/themes/nodefony/login/resources/css/nodefony.css +688 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/blueprint.css +70 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/console.css +57 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/dots.css +67 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/enterprise.css +84 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/horizon.css +158 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/ledger.css +224 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/minimal.css +63 -0
- package/keycloak/themes/nodefony/login/resources/css/skins/photo-card.css +72 -0
- package/keycloak/themes/nodefony/login/resources/img/favicon.ico +0 -0
- package/keycloak/themes/nodefony/login/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/login/template.ftl +423 -0
- package/keycloak/themes/nodefony/login/theme.properties +17 -0
- package/keycloak/themes/nodefony/welcome/resources/css/nodefony.css +32 -0
- package/keycloak/themes/nodefony/welcome/resources/img/logo.png +0 -0
- package/keycloak/themes/nodefony/welcome/theme.properties +11 -0
- package/keycloak/themes/nodefony-blueprint/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-console/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-dots/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-enterprise/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-horizon/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-ledger/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-minimal/login/theme.properties +12 -0
- package/keycloak/themes/nodefony-photo-card/login/theme.properties +12 -0
- package/package.json +19 -17
- package/dist/_virtual/_@oxc-project_runtime@0.152.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/tokens.md
CHANGED
|
@@ -25,7 +25,7 @@ tags:
|
|
|
25
25
|
]
|
|
26
26
|
version: "doc"
|
|
27
27
|
status: stable
|
|
28
|
-
updated: 2026-10-
|
|
28
|
+
updated: 2026-10-05
|
|
29
29
|
source: "src/packages/@nodefony/security/docs/tokens.md"
|
|
30
30
|
---
|
|
31
31
|
|
|
@@ -87,20 +87,20 @@ 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#TokenService.#build`), si `jwt.enabled` ou `apiKeys.enabled`, il résout le store pluggable, pose
|
|
91
|
+
`tokenStore` au container (`tokenService.ts#TokenService.#build`) puis crée le keystore et pose `jwtKeystore`
|
|
92
|
+
(`tokenService.ts#TokenService.#build`) — 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#TokenService.#build`).
|
|
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
|
|
98
|
-
(`TokenAuthController.ts
|
|
98
|
+
(`TokenAuthController.ts#ITokenIssuer`).
|
|
99
99
|
|
|
100
100
|
Constat clé de cohérence : `iss`/`aud`/`ttl` sont dérivés **une seule fois** par
|
|
101
|
-
`resolveJwtRuntime()` (`jwtRuntime.ts
|
|
101
|
+
`resolveJwtRuntime()` (`jwtRuntime.ts#resolveJwtRuntime`) et **partagés** entre l'émetteur et le vérificateur —
|
|
102
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
|
|
103
|
+
partager par référence. `issuer` omis → `"nodefony"` (`jwtRuntime.ts#resolveJwtRuntime`), à surcharger en prod.
|
|
104
104
|
|
|
105
105
|
### Être DÉCOUVRABLE — publier ses clés (RFC 8414)
|
|
106
106
|
|
|
@@ -136,34 +136,41 @@ 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#mountTokenAuthRoutes`) :
|
|
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.
|
|
143
143
|
|
|
144
144
|
> [!IMPORTANT]
|
|
145
145
|
> Ces routes n'existent **que si** `@nodefony/security` est chargé — sinon 404, zéro surface
|
|
146
|
-
> (`framework/index.ts
|
|
147
|
-
> `503 Token issuance unavailable` (`TokenAuthController.ts
|
|
146
|
+
> (`framework/index.ts#Framework.onKernelReady`). Module chargé mais JWT désactivé : elles existent et répondent
|
|
147
|
+
> `503 Token issuance unavailable` (`TokenAuthController.ts#TokenAuthController.token`). Elles sont `bypassFirewall: true` (`TokenAuthController.ts#mountTokenAuthRoutes`) :
|
|
148
148
|
> elles SONT le mécanisme d'émission — protégées, obtenir un token exigerait d'être déjà
|
|
149
149
|
> authentifié (deadlock). Le JWT part en **réponse JSON** (Bearer), jamais en cookie ni en URL.
|
|
150
150
|
|
|
151
151
|
### La config : une zone protégée par `jwt` + une clé qui survit au redémarrage
|
|
152
152
|
|
|
153
|
-
```typescript
|
|
154
|
-
// nodefony
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
153
|
+
```typescript ignore
|
|
154
|
+
// nodefony/config/security.ts — la zone API machine + la source de clé
|
|
155
|
+
import type { ConfigContext } from "nodefony";
|
|
156
|
+
import type { ISecurityConfigInput } from "@nodefony/security";
|
|
157
|
+
import type { env } from "../../env";
|
|
158
|
+
|
|
159
|
+
export const securityConfig = (ctx: ConfigContext<typeof env>) =>
|
|
160
|
+
({
|
|
161
|
+
jwt: {
|
|
162
|
+
// dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
|
|
163
|
+
// prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
|
|
164
|
+
keystore: {
|
|
165
|
+
keySetJson: ctx.env.NF_JWT_KEYSET,
|
|
166
|
+
dir: "var/keys",
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
areas: {
|
|
170
|
+
// Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
|
|
171
|
+
api: { pattern: "^/api/v1", authenticators: ["jwt"] },
|
|
172
|
+
},
|
|
173
|
+
}) satisfies ISecurityConfigInput;
|
|
167
174
|
```
|
|
168
175
|
|
|
169
176
|
### Ce que TU écris : le controller scopé
|
|
@@ -220,45 +227,46 @@ curl -s -H 'Content-Type: application/json' \
|
|
|
220
227
|
# 4) Rejouer l'ANCIEN refresh → 401 {"error":"invalid_grant"} + famille coupée
|
|
221
228
|
```
|
|
222
229
|
|
|
223
|
-
Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts
|
|
230
|
+
Erreurs mappées par duck-typing dans `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) :
|
|
224
231
|
401 message uniforme `invalid_grant` (anti-énumération), 429 avec `Retry-After` du throttler NIST.
|
|
225
232
|
Émission indisponible (JWT désactivé, store absent) → 503 `isEnabled()`
|
|
226
|
-
(`TokenAuthController.ts
|
|
233
|
+
(`TokenAuthController.ts#ITokenIssuer.isEnabled`).
|
|
227
234
|
|
|
228
235
|
## 🏗️ Architecture interne — la vie d'un couple access/refresh
|
|
229
236
|
|
|
230
237
|
### Émission (grant M2M/CLI)
|
|
231
238
|
|
|
232
|
-
`issueForCredentials()` (`tokenService.ts
|
|
239
|
+
`issueForCredentials()` (`tokenService.ts#TokenService.issueForCredentials`) vérifie l'identifiant/mot de passe via le
|
|
233
240
|
service `users`, avec le **throttling NIST partagé** — `ThrottledError` avant tout hachage
|
|
234
|
-
(`tokenService.ts
|
|
235
|
-
par `#auditGrant()` (`tokenService.ts
|
|
241
|
+
(`tokenService.ts#TokenService.issueForCredentials`). Chaque tentative échouée est auditée `login.failure`/`login.throttled`
|
|
242
|
+
par `#auditGrant()` (`tokenService.ts#TokenService.#auditGrant`). Puis `issueTokens()` (`tokenService.ts#TokenService.issueTokens`)
|
|
236
243
|
produit :
|
|
237
244
|
|
|
238
245
|
- un **access token** : JWT signé EdDSA, en-tête `typ:"at+jwt"` + `kid`, claims
|
|
239
|
-
`iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts
|
|
246
|
+
`iss`/`sub`/`aud`/`exp` (15 min) + `jti` — `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) ;
|
|
240
247
|
- un **refresh token** : secret opaque haute entropie `nfr_<32 octets base64url>`, **stocké haché**
|
|
241
248
|
`sha256` (le clair n'existe qu'en réponse, jamais au repos) — `#buildRefresh()`
|
|
242
|
-
(`tokenService.ts
|
|
249
|
+
(`tokenService.ts#TokenService.#buildRefresh`).
|
|
243
250
|
|
|
244
|
-
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts
|
|
245
|
-
`token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts
|
|
251
|
+
La réponse suit RFC 6749 §5.1 — `ITokenResponse` (`tokenService.ts#ITokenResponse`). Tout succès est audité
|
|
252
|
+
`token.issued` via `recordAudit` avec le `tokenId` corrélable (`tokenService.ts#TokenService.runGc`).
|
|
246
253
|
|
|
247
254
|
### Rotation & détection de rejeu (RFC 9700 §4.14)
|
|
248
255
|
|
|
249
|
-
`refresh()` (`tokenService.ts
|
|
256
|
+
`refresh()` (`tokenService.ts#TokenService.refresh`) est le cœur défensif, dans l'ordre :
|
|
250
257
|
|
|
251
|
-
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts
|
|
258
|
+
1. Lookup par hash — `findByHash`, refus uniforme si inconnu/mauvais type (`tokenService.ts#TokenService.refresh`).
|
|
252
259
|
2. **Détection de rejeu** : refresh **déjà révoqué** re-présenté → `revokeFamily` coupe toute la
|
|
253
|
-
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts
|
|
254
|
-
3. Expiration `expiresAt` vérifiée (`tokenService.ts
|
|
260
|
+
famille + audit `token.reuse_detected`, signal d'attaque fort (`tokenService.ts#TokenService.refresh`).
|
|
261
|
+
3. Expiration `expiresAt` vérifiée (`tokenService.ts#TokenService.refresh`).
|
|
255
262
|
4. **Sujet revérifié** — compte disparu/inactif/verrouillé rejeté sans attendre l'exp,
|
|
256
|
-
`#resolveUserForRefresh()` (`tokenService.ts
|
|
257
|
-
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus
|
|
258
|
-
(`
|
|
263
|
+
`#resolveUserForRefresh()` (`tokenService.ts#TokenService.#resolveUserForRefresh`).
|
|
264
|
+
5. **Downscoping** : les `scopes` du nouveau couple sont ceux de l'ancien, jamais plus — et ils
|
|
265
|
+
sont REJUGÉS sur les rôles du compte relu (`grantableScopes`, règle d'administration) : un rôle
|
|
266
|
+
retiré retire son scope au prochain renouvellement (`tokenService.ts#TokenService.#grantableScopes`).
|
|
259
267
|
6. **Rotation** : nouveau refresh (même famille), l'ancien chaîné `replacedBy` + révoqué
|
|
260
|
-
`"rotated"` (`tokenService.ts
|
|
261
|
-
et le refresh courant reste valide (`tokenService.ts
|
|
268
|
+
`"rotated"` (`tokenService.ts#TokenService.refresh`). Si `rotateRefresh` est désactivé, l'access est réémis
|
|
269
|
+
et le refresh courant reste valide (`tokenService.ts#TokenService.refresh`).
|
|
262
270
|
|
|
263
271
|
### Mise en situation — ton refresh token a été volé
|
|
264
272
|
|
|
@@ -278,48 +286,95 @@ la victime est déconnectée (signal visible) au lieu d'un vol silencieux indéf
|
|
|
278
286
|
|
|
279
287
|
## 🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod
|
|
280
288
|
|
|
281
|
-
`JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts
|
|
289
|
+
`JwtKeystore.#load()` résout la source de clé par **priorité** (`JwtKeystore.ts#JwtKeystore.#load`), pensée
|
|
282
290
|
pour ne jamais auto-générer une clé en clair silencieusement en prod :
|
|
283
291
|
|
|
284
|
-
1. **env** — `keySetJson` (
|
|
285
|
-
|
|
286
|
-
2. **fichier** — `dir/keyset.json`, généré si absent,
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
+
1. **env** — `keySetJson` (`NF_JWT_KEYSET` dans une application générée) : la MÊME clé pour tous
|
|
293
|
+
les process qui servent l'application (`JwtKeystore.ts#JwtKeystore.#load`).
|
|
294
|
+
2. **fichier** — `dir/keyset.json`, généré si absent, en mode 600, par une création **exclusive**
|
|
295
|
+
(`createSecretExclusive`, `secretFile.ts#createSecretExclusive`) : quand plusieurs workers de `nodefony cluster`
|
|
296
|
+
démarrent ensemble, un seul crée la clé et les autres relisent la sienne (`JwtKeystore.ts#JwtKeystore.#load`).
|
|
297
|
+
Source de développement, ou d'un serveur unique à disque persistant.
|
|
298
|
+
3. **mémoire** — aucune source → clé **éphémère**, propre au process (`JwtKeystore.ts#JwtKeystore.#load`). En
|
|
299
|
+
développement : un WARNING au premier jeton. En production, un process qui sert **refuse de
|
|
300
|
+
démarrer** (§ suivant).
|
|
301
|
+
|
|
302
|
+
Le JWKS servi par `getPublicJWKS()` est **public** — `JwtKeystore.ts#JwtKeystore.getPublicJWKS`.
|
|
292
303
|
La composante privée `d` en est retirée à l'import, par liste BLANCHE de paramètres
|
|
293
|
-
(`#importKeyset()`, `JwtKeystore.ts
|
|
294
|
-
C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts
|
|
295
|
-
jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts
|
|
304
|
+
(`#importKeyset()`, `JwtKeystore.ts#JwtKeystore.#importKeyset`, RFC 8037/7517).
|
|
305
|
+
C'est ce JWKS qu'utilise le vérificateur local (`createLocalJWKSet`, `JwtAuthenticator.ts#JwtAuthenticator.#ensureGetKey`),
|
|
306
|
+
jamais une clé venue du jeton. Le chargement est mémoïsé — `#ensureLoaded()` (`JwtKeystore.ts#JwtKeystore.#ensureLoaded`).
|
|
307
|
+
|
|
308
|
+
### Plusieurs pods ou workers — UNE clé pour tous
|
|
309
|
+
|
|
310
|
+
Chaque jeton porte le `kid` de la clé qui l'a signé. Deux process qui ont chacun la leur refusent
|
|
311
|
+
les jetons l'un de l'autre : derrière un répartiteur, l'utilisateur est déconnecté au hasard (401),
|
|
312
|
+
et tout redémarrage invalide les jetons en vol.
|
|
313
|
+
|
|
314
|
+
**En production, un process qui SERT sans source partagée refuse de démarrer**
|
|
315
|
+
(`#requireSharedSigningKey`, `tokenService.ts#TokenService.#requireSharedSigningKey`), avec le motif et les trois issues dans le
|
|
316
|
+
journal. Une commande de console (`orm:migrate`, `security:user:add`) n'est pas concernée : elle
|
|
317
|
+
ne signe aucun jeton. Sous `nodefony cluster`, un worker refusé ne se relance pas — le master
|
|
318
|
+
arrête le cluster et sort en **78** (`ClusterManager.ts#ClusterManager.#onExit`), le code d'une faute de configuration.
|
|
319
|
+
|
|
320
|
+
Le déploiement, de bout en bout :
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
# 1. Générer la valeur UNE fois par environnement (staging ≠ production)
|
|
324
|
+
npx nodefony security:secrets --jwt-keyset > keyset.json
|
|
325
|
+
|
|
326
|
+
# 2. La ranger dans le gestionnaire de secrets — ici un Secret Kubernetes
|
|
327
|
+
kubectl create secret generic app-jwt --from-file=NF_JWT_KEYSET=keyset.json
|
|
328
|
+
rm keyset.json # elle porte la clé PRIVÉE : aucun exemplaire ne traîne
|
|
329
|
+
|
|
330
|
+
# 3. L'injecter dans CHAQUE pod (extrait du Deployment)
|
|
331
|
+
# env:
|
|
332
|
+
# - name: NF_JWT_KEYSET
|
|
333
|
+
# valueFrom: { secretKeyRef: { name: app-jwt, key: NF_JWT_KEYSET } }
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
Le câblage, que le gabarit d'application écrit déjà :
|
|
337
|
+
|
|
338
|
+
```typescript
|
|
339
|
+
jwt: {
|
|
340
|
+
keystore: {
|
|
341
|
+
keySetJson: ctx.env.NF_JWT_KEYSET, // présente : l'emporte partout
|
|
342
|
+
dir: ctx.isProd ? undefined : "var/keys", // développement
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
```
|
|
296
346
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
347
|
+
- **Jamais dans un fichier du poste, jamais dans git** : en développement la clé vit dans `var/keys/` ;
|
|
348
|
+
une clé privée de production sur un poste n'apporte que le risque de fuir.
|
|
349
|
+
- **Dans un fichier `.env`** (Docker Compose `env_file`), l'entourer de quotes simples :
|
|
350
|
+
`NF_JWT_KEYSET='{"active":"…","keys":[…]}'`.
|
|
351
|
+
- **Valeur illisible** : la configuration security est refusée au démarrage, le chemin
|
|
352
|
+
`jwt.keystore.keySetJson` nommé, la valeur **jamais** recopiée dans le message.
|
|
353
|
+
- **Rotation** : la valeur est STABLE — la remplacer refuse les jetons en vol. Ajouter la nouvelle
|
|
354
|
+
clé au tableau `keys`, la désigner dans `active`, garder l'ancienne le temps que ses jetons
|
|
355
|
+
expirent (`accessTtlS`, `refreshTtlS`), puis la retirer.
|
|
301
356
|
|
|
302
357
|
## 🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux
|
|
303
358
|
|
|
304
359
|
### Le contrat et l'enregistrement
|
|
305
360
|
|
|
306
361
|
Le `tokenStore` héberge **trois structures** : les records (refresh + PAT), la denylist `jti` et le
|
|
307
|
-
seuil `invalidBefore` par porteur (`ITokenStore.ts
|
|
308
|
-
fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts
|
|
362
|
+
seuil `invalidBefore` par porteur (`ITokenStore.ts`). Les backends s'enregistrent par
|
|
363
|
+
fabrique — `registerTokenStore()` (`tokenStoreRegistry.ts#registerTokenStore`) : les adapters lourds importent
|
|
309
364
|
`import type { ITokenStore }` (effacé à la compilation), zéro couplage runtime.
|
|
310
365
|
|
|
311
|
-
Sa résolution au boot (`tokenService.ts
|
|
366
|
+
Sa résolution au boot (`tokenService.ts#TokenService.#build`) suit la doctrine `store:"auto"` du framework :
|
|
312
367
|
|
|
313
368
|
- `auto` (défaut) → suit l'infra database déclarée via `resolveAutoStore` — **borné aux backends
|
|
314
|
-
réellement enregistrés**, repli memory **annoncé** (`tokenService.ts
|
|
369
|
+
réellement enregistrés**, repli memory **annoncé** (`tokenService.ts#TokenService.#build`) ;
|
|
315
370
|
- store explicite **inconnu** → en prod, **boot avorté** (fail-loud) ; en dev, brique désactivée et
|
|
316
|
-
annoncée avec la liste `listTokenStores()` (`tokenService.ts
|
|
371
|
+
annoncée avec la liste `listTokenStores()` (`tokenService.ts#TokenService.#build`) — jamais de fallback
|
|
317
372
|
memory silencieux pour du durable ;
|
|
318
373
|
- store `memory` **en prod** → `WARNING` nommant l'impact : denylist/refresh/clés API per-pod et
|
|
319
|
-
volatils, révocation non partagée (`tokenService.ts
|
|
374
|
+
volatils, révocation non partagée (`tokenService.ts#TokenService.#build`).
|
|
320
375
|
|
|
321
376
|
La décision (configuré → résolu, raison) est publiée au kernel par `registerStoreResolution()`
|
|
322
|
-
(`tokenService.ts
|
|
377
|
+
(`tokenService.ts#TokenService.#build`) — visible dans Studio.
|
|
323
378
|
|
|
324
379
|
### Mise en situation — quel store pour quelle app ?
|
|
325
380
|
|
|
@@ -335,161 +390,163 @@ La décision (configuré → résolu, raison) est publiée au kernel par `regist
|
|
|
335
390
|
|
|
336
391
|
### `memory` — la référence 0 dépendance
|
|
337
392
|
|
|
338
|
-
- Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts
|
|
393
|
+
- Builtin, enregistré à l'import du module via `registerTokenStore` (`tokenStoreRegistry.ts#registerTokenStore`).
|
|
339
394
|
- `listPage` : tri `createdAt` DESC + tiebreaker `id`, déterministe pour l'offset — parité SQL
|
|
340
|
-
(`MemoryTokenStore.ts
|
|
395
|
+
(`MemoryTokenStore.ts#MemoryTokenStore.listPage`).
|
|
341
396
|
- Denylist bornée : purge **amortie** tous les 256 ajouts — `#maybeSweep()`
|
|
342
|
-
(`MemoryTokenStore.ts
|
|
397
|
+
(`MemoryTokenStore.ts#MemoryTokenStore.#maybeSweep`) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite.
|
|
343
398
|
- `snapshot()`/`restore()` sérialisables — base d'une persistance fichier, index reconstruits
|
|
344
|
-
(`MemoryTokenStore.ts
|
|
399
|
+
(`MemoryTokenStore.ts#MemoryTokenStore.restore`).
|
|
345
400
|
- Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
|
|
346
401
|
|
|
347
402
|
### `drizzle` — SQL, le défaut durable
|
|
348
403
|
|
|
349
|
-
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts
|
|
404
|
+
- Enregistré par le module drizzle (`drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores`).
|
|
350
405
|
- Élu par `store:"auto"` dès qu'une infra database SQL est déclarée ; sinon sqlite local si drizzle
|
|
351
|
-
est chargé (`config.ts
|
|
406
|
+
est chargé (`config.ts#jwtSchema`).
|
|
352
407
|
- Pagination **offset + total** (helper `paginate()` d'orm-core) ; e2e sur PostgreSQL et MySQL réels.
|
|
353
408
|
|
|
354
409
|
### `mongoose` — MongoDB
|
|
355
410
|
|
|
356
|
-
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts
|
|
357
|
-
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts
|
|
358
|
-
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts
|
|
411
|
+
- Enregistré par le module mongoose (`mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores`).
|
|
412
|
+
- Pagination **offset + total** via `listPage` (`MongooseTokenStore.ts#MongooseTokenStore.listPage`).
|
|
413
|
+
- Purge par `gc()` explicite sur `expiresAt` (`MongooseTokenStore.ts#MongooseTokenStore.#toRecord`).
|
|
359
414
|
|
|
360
415
|
### `redis` — cluster, TTL natif
|
|
361
416
|
|
|
362
|
-
- Enregistré par le module redis (`redis/nodefony/registerStores.ts
|
|
363
|
-
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts
|
|
417
|
+
- Enregistré par le module redis (`redis/nodefony/registerStores.ts#registerRedisFrameworkStores`).
|
|
418
|
+
- TTL natif : `expire()` posé à l'écriture du record (`RedisTokenStore.ts#RedisClientLike.expire`) — l'expiration ne
|
|
364
419
|
dépend pas du gc.
|
|
365
420
|
- Listing par `SCAN` : curseur opaque `skip:scanCursor`, `decodeCursor()`
|
|
366
|
-
(`RedisTokenStore.ts
|
|
421
|
+
(`RedisTokenStore.ts#RedisTokenStore.listAll`) — sans ordre global ni total, capacité réduite **assumée**.
|
|
367
422
|
- `countTokens()` renvoie `-1` : un comptage exact exigerait un SCAN complet O(N), refusé
|
|
368
|
-
(`RedisTokenStore.ts
|
|
423
|
+
(`RedisTokenStore.ts#RedisTokenStore.countTokens`).
|
|
369
424
|
|
|
370
425
|
### Le record — une seule table pour PAT et refresh
|
|
371
426
|
|
|
372
|
-
`IAccessTokenRecord` (`ITokenStore.ts
|
|
373
|
-
refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts
|
|
427
|
+
`IAccessTokenRecord` (`ITokenStore.ts#IAccessTokenRecord`) est **single-table** : un même schéma porte PAT et
|
|
428
|
+
refresh, champs non pertinents à `null`, horodatages epoch ms (`ITokenStore.ts#IAccessTokenRecord`). Deux
|
|
374
429
|
constats de design :
|
|
375
430
|
|
|
376
431
|
- `subjectId` est une **référence logique souple, PAS une FK SQL** : porteur polymorphe
|
|
377
432
|
(user/service), store multi-backend, découplage des modules — intégrité assurée à l'usage
|
|
378
|
-
(`ITokenStore.ts
|
|
433
|
+
(`ITokenStore.ts#IAccessTokenRecord.subjectId`) ;
|
|
379
434
|
- slots prêts sans migration : `resources` (permissions fine-grained façon GitHub) et `cnf`
|
|
380
|
-
(sender-constrained DPoP/mTLS) (`ITokenStore.ts
|
|
435
|
+
(sender-constrained DPoP/mTLS) (`ITokenStore.ts#IAccessTokenRecord.cnf`).
|
|
381
436
|
|
|
382
437
|
Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-triple-vérité).
|
|
383
438
|
|
|
384
439
|
### Lister pour l'admin — pagination native
|
|
385
440
|
|
|
386
|
-
- `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts
|
|
441
|
+
- `listPage()` ne matérialise **jamais** plus d'une page (`ITokenStore.ts#ITokenStore.listPage`) — capacité par
|
|
387
442
|
backend : **offset + total** (SQL/Mongo/mémoire) ou **curseur** (`nextCursor`, Redis).
|
|
388
|
-
- `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts
|
|
389
|
-
- Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts
|
|
390
|
-
prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts
|
|
443
|
+
- `countTokens()` donne le `total` ; Redis répond `-1` (`ITokenStore.ts#ITokenStore.countTokens`).
|
|
444
|
+
- Filtres portables `ITokenListQuery` : `subjectId`, `kind`, `revoked` (`ITokenStore.ts#ITokenListQuery`) —
|
|
445
|
+
prédicat partagé `matchesTokenQuery()` (`MemoryTokenStore.ts#matchesTokenQuery`).
|
|
391
446
|
- `listAll()` reste réservé au **dump d'incident** cross-porteur, cold-path admin
|
|
392
|
-
(`ITokenStore.ts
|
|
447
|
+
(`ITokenStore.ts#ITokenStore.listAll`).
|
|
393
448
|
- Consommateur type : le data plane des clés API — `ApiKeyService.listPagePat()`
|
|
394
|
-
(`apiKeys.ts
|
|
449
|
+
(`apiKeys.ts#ApiKeyService.listPagePat`), jamais un listAll matérialisé.
|
|
395
450
|
|
|
396
451
|
### Révoquer — trois portées
|
|
397
452
|
|
|
398
|
-
- **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts
|
|
453
|
+
- **Un access** avant son `exp` : denylist `denyJti()`/`isJtiDenied()` (`ITokenStore.ts#ITokenStore.isJtiDenied`).
|
|
399
454
|
- **Un refresh/PAT** : `revoke()` idempotent, `revokeFamily()` pour la chaîne de rotation
|
|
400
|
-
(`ITokenStore.ts
|
|
455
|
+
(`ITokenStore.ts#ITokenStore.revokeFamily`).
|
|
401
456
|
- **Tout un porteur** (logout global, ban) : seuil `revokeAllForSubject()` — tout access dont
|
|
402
|
-
`iat < invalidBefore` est rejeté (`ITokenStore.ts
|
|
403
|
-
logouts successifs ne le reculent pas (`MemoryTokenStore.ts
|
|
457
|
+
`iat < invalidBefore` est rejeté (`ITokenStore.ts#ITokenStore`) ; le seuil est **monotone**, deux
|
|
458
|
+
logouts successifs ne le reculent pas (`MemoryTokenStore.ts#MemoryTokenStore.revokeAllForSubject`).
|
|
404
459
|
|
|
405
460
|
### La maintenance (gc)
|
|
406
461
|
|
|
407
462
|
Le `gc()` du store purge la `denylist` expirée, les records à terme, les PAT révoqués au-delà de
|
|
408
|
-
la rétention (`ITokenStore.ts
|
|
409
|
-
un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts
|
|
463
|
+
la rétention (`ITokenStore.ts#ITokenStore.gc`). Orchestré par le `GcScheduler` du service ; `runGc()` reste public pour
|
|
464
|
+
un futur worker cron — poser alors `gcIntervalS: 0` (`tokenService.ts#TokenService.getPublicJWKS`).
|
|
410
465
|
|
|
411
466
|
> [!TIP]
|
|
412
467
|
> Un refresh révoqué **par rotation** n'est PAS purgé tout de suite : il est conservé jusqu'à son
|
|
413
468
|
> `expiresAt` — c'est la **fenêtre de détection de rejeu** — puis tombe au gc, `#isPurgeable()`
|
|
414
|
-
> (`MemoryTokenStore.ts
|
|
469
|
+
> (`MemoryTokenStore.ts#MemoryTokenStore.#isPurgeable`). Un store **local** (memory) est par-process : seul SON process
|
|
415
470
|
> peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
|
|
416
471
|
|
|
417
472
|
## ⚙️ Configuration
|
|
418
473
|
|
|
419
|
-
Tables dérivées du schéma Zod — `jwtSchema` (`config.ts
|
|
420
|
-
(`config.ts
|
|
474
|
+
Tables dérivées du schéma Zod — `jwtSchema` (`config.ts#jwtSchema`) et `tokenStoreSchema`
|
|
475
|
+
(`config.ts#tokenStoreSchema`), défauts inclus.
|
|
421
476
|
|
|
422
477
|
### `jwt.*`
|
|
423
478
|
|
|
424
479
|
<!-- prettier-ignore -->
|
|
425
480
|
| Option | Type | Défaut | Effet |
|
|
426
481
|
| --- | --- | --- | --- |
|
|
427
|
-
| `enabled` | boolean | `true` | Active signature + refresh (`config.ts
|
|
428
|
-
| `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts
|
|
429
|
-
| `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts
|
|
430
|
-
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts
|
|
431
|
-
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts
|
|
482
|
+
| `enabled` | boolean | `true` | Active signature + refresh (`config.ts#rateLimitSchema`) |
|
|
483
|
+
| `alg` | `EdDSA` \| `RS256` | `EdDSA` | `RS256` = slot non câblé (`jwtRuntime.ts#IJwtRuntime.alg`) |
|
|
484
|
+
| `accessTtlS` | number (s) | `900` | TTL de l'access token — 15 min (`config.ts#jwtSchema`) |
|
|
485
|
+
| `refreshTtlS` | number (s) | `604800` | TTL du refresh — 7 jours (`config.ts#jwtSchema`) |
|
|
486
|
+
| `rotateRefresh` | boolean | `true` | Rotation du refresh à chaque usage, OWASP (`config.ts#jwtSchema`) |
|
|
432
487
|
| `jwks` | boolean | `true` | Publie `/.well-known/jwks.json` + les métadonnées RFC 8414 — sans `issuer` en URL https, rien n'est publié |
|
|
433
|
-
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts
|
|
488
|
+
| `audiences` | string[] | `[]` | `aud` acceptées (RFC 8707) ; vide = `[issuer]` (`config.ts#jwtSchema`) |
|
|
434
489
|
| `issuer` | string? | — | Claim `iss`, **STABLE** après émission ; omis → repli `"nodefony"`, qui n'est PAS publiable (RFC 8414 §2 exige une URL https) |
|
|
435
|
-
| `keystore.keySetJson` | string? | — | JWK Set privé
|
|
436
|
-
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600 —
|
|
490
|
+
| `keystore.keySetJson` | string? | — | JWK Set privé partagé par tous les process (`NF_JWT_KEYSET`) — SECRET, forme vérifiée au démarrage (`security/nodefony/config/config.ts#jwtSchema`) |
|
|
491
|
+
| `keystore.dir` | string? | — | Dossier `keyset.json` chmod 600, création exclusive — dev, ou serveur unique à disque persistant (`config.ts#jwtSchema`) |
|
|
437
492
|
|
|
438
493
|
### `tokenStore.*`
|
|
439
494
|
|
|
440
|
-
| Option | Type | Défaut | Effet
|
|
441
|
-
| ---------------------- | ---------- | -------- |
|
|
442
|
-
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts
|
|
443
|
-
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts
|
|
444
|
-
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts
|
|
445
|
-
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts
|
|
495
|
+
| Option | Type | Défaut | Effet |
|
|
496
|
+
| ---------------------- | ---------- | -------- | -------------------------------------------------------------------------------------------------- |
|
|
497
|
+
| `store` | string | `"auto"` | `auto`\|`memory`\|`drizzle`\|`mongoose`\|`redis` — pluggable (`config.ts#jwtSchema`) |
|
|
498
|
+
| `gcIntervalS` | number (s) | `600` | Purge périodique ; `0` = désactivé — chaque process purge SON store (`config.ts#tokenStoreSchema`) |
|
|
499
|
+
| `gcJitter` | boolean | `true` | Étale le gc d'un délai aléatoire par process — cluster (`config.ts#tokenStoreSchema`) |
|
|
500
|
+
| `retentionRevokedDays` | number (j) | `30` | Rétention d'un PAT révoqué SANS expiration avant purge (`config.ts#tokenStoreSchema`) |
|
|
446
501
|
|
|
447
502
|
## 📜 Normes appliquées
|
|
448
503
|
|
|
449
|
-
| Domaine | Norme | Ancrage
|
|
450
|
-
| -------------------------------- | --------------- |
|
|
451
|
-
| Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts
|
|
452
|
-
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts
|
|
453
|
-
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts
|
|
454
|
-
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts
|
|
455
|
-
| Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts
|
|
456
|
-
| Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts
|
|
457
|
-
| 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts
|
|
458
|
-
| Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts
|
|
504
|
+
| Domaine | Norme | Ancrage |
|
|
505
|
+
| -------------------------------- | --------------- | ------------------------------------------------------------------------------------ |
|
|
506
|
+
| Réponse d'émission | RFC 6749 §5.1 | `ITokenResponse` (`tokenService.ts#ITokenResponse`) |
|
|
507
|
+
| Rotation + détection de rejeu | RFC 9700 §4.14 | `refresh()` (`tokenService.ts#TokenService.refresh`) |
|
|
508
|
+
| Profil access token `typ:at+jwt` | RFC 9068 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
|
|
509
|
+
| Claims JWT (`iss/sub/aud/exp`) | RFC 7519 | `#signAccess()` (`tokenService.ts#TokenService.#signAccess`) |
|
|
510
|
+
| Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | `#importKeyset()` (`JwtKeystore.ts#JwtKeystore.#importKeyset`) |
|
|
511
|
+
| Audiences liées à la ressource | RFC 8707 | `audience` du record (`ITokenStore.ts#IAccessTokenRecord.audience`) |
|
|
512
|
+
| 429 + `Retry-After` | RFC 6585 | `#renderAuthError()` (`TokenAuthController.ts#TokenAuthController.#renderAuthError`) |
|
|
513
|
+
| Backoff de login | NIST SP 800-63B | `ThrottledError` avant hachage (`tokenService.ts#TokenService.issueForCredentials`) |
|
|
459
514
|
|
|
460
515
|
## ⚡ Performance & mémoire
|
|
461
516
|
|
|
462
|
-
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts
|
|
517
|
+
- **`jose` importé lazy** (dep lourde) : `#ensureJose()` au premier usage (`tokenService.ts#TokenService.#ensureJose`)
|
|
463
518
|
— le boot ne paie rien si le JWT n'est jamais sollicité ; keystore mémoïsé pareil.
|
|
464
519
|
- **Rien sur le hot path requête** : émission et rotation sont des endpoints cold-path ; la
|
|
465
520
|
vérification (hot path) vit chez le `JwtAuthenticator`.
|
|
466
521
|
- **Timers civilisés** : `GcScheduler` `unref` (n'empêche pas l'arrêt) + jitter anti-balayages
|
|
467
|
-
simultanés (`tokenService.ts
|
|
468
|
-
(`MemoryTokenStore.ts
|
|
469
|
-
- **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts
|
|
522
|
+
simultanés (`tokenService.ts#TokenService.#build`) ; denylist mémoire bornée par purge amortie
|
|
523
|
+
(`MemoryTokenStore.ts#MemoryTokenStore.restore`).
|
|
524
|
+
- **Jamais N en RAM** : `listPage()` borne toute lecture admin à une page (`ITokenStore.ts#ITokenStore.listPage`).
|
|
470
525
|
|
|
471
526
|
## 📡 Observabilité — Studio
|
|
472
527
|
|
|
473
528
|
- **Écran Stores** (`/nodefony/stores`) : la résolution du store de jetons (configuré → résolu +
|
|
474
|
-
raison) publiée par `registerStoreResolution()` (`tokenService.ts
|
|
529
|
+
raison) publiée par `registerStoreResolution()` (`tokenService.ts#TokenService.#build`).
|
|
475
530
|
- **Écran Audit** (`/nodefony/audit`) : événements `token.issued`, `token.reuse_detected` (signal d'attaque),
|
|
476
531
|
`login.failure`/`login.throttled` du grant — corrélables par `tokenId`.
|
|
477
532
|
- **Écran ApiKeys** : le même store côté PAT, listing paginé serveur.
|
|
478
533
|
|
|
479
534
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
480
535
|
|
|
481
|
-
| Symptôme
|
|
482
|
-
|
|
|
483
|
-
| 404 sur `/nodefony/security/api/token`
|
|
484
|
-
| 503 « Token issuance unavailable »
|
|
485
|
-
| Refresh tokens invalidés à chaque redémarrage
|
|
486
|
-
|
|
|
487
|
-
|
|
|
488
|
-
|
|
|
489
|
-
|
|
|
490
|
-
|
|
|
491
|
-
|
|
|
492
|
-
|
|
|
536
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
537
|
+
| ---------------------------------------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------- |
|
|
538
|
+
| 404 sur `/nodefony/security/api/token` | Module `@nodefony/security` non chargé | Charger `@nodefony/security` + `jwt.enabled: true` |
|
|
539
|
+
| 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config `jwt`/`tokenStore` + logs de boot |
|
|
540
|
+
| Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | `NF_JWT_KEYSET` (prod) ou `jwt.keystore.dir` (dev) |
|
|
541
|
+
| Boot refusé « DÉMARRAGE REFUSÉ — aucune clé de signature » | Production, process qui sert, ni `keySetJson` ni `dir` | `security:secrets --jwt-keyset` → secret → `NF_JWT_KEYSET` |
|
|
542
|
+
| Cluster arrêté « cluster master down (exit 78) » | Un worker a refusé de démarrer (faute de configuration) | Lire le motif du worker juste au-dessus, le corriger |
|
|
543
|
+
| JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | La même `NF_JWT_KEYSET` injectée dans chaque pod |
|
|
544
|
+
| Tout rejeté après changement de config | `issuer`/`audiences` divergents entre émission et vérification | `issuer` STABLE — ne pas le changer après émission |
|
|
545
|
+
| Révocation sans effet entre pods | `tokenStore:"memory"` en prod (per-pod) | Store durable (`NF_DATABASE_URL` → drizzle, ou redis) |
|
|
546
|
+
| Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
|
|
547
|
+
| Boot avorté « token store inconnu » | `tokenStore.store` explicite introuvable (fail-loud prod) | Corriger le nom / enregistrer le store |
|
|
548
|
+
| Scopes qui n'augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
|
|
549
|
+
| Listing admin sans `total` sur Redis | `countTokens()` = `-1` (comptage O(N) refusé), curseur SCAN | Attendu — capacité réduite annoncée, paginer par curseur |
|
|
493
550
|
|
|
494
551
|
## 🧪 Tests & couverture
|
|
495
552
|
|
|
@@ -508,7 +565,7 @@ Cinq familles couvrent la brique — les **chiffres exacts vivent dans la carte
|
|
|
508
565
|
test de charge sur le grant.
|
|
509
566
|
|
|
510
567
|
Les bancs sur serveur réel se **skippent sans leurs variables d'infra** — un skip compte comme
|
|
511
|
-
vert : lire le bloc gates (`vitest
|
|
568
|
+
vert : lire le bloc gates (`scripts/test/vitest/gates.ts`, affiché en fin de run) avant de conclure.
|
|
512
569
|
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
513
570
|
|
|
514
571
|
## 🔗 Pour aller plus loin
|