@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
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
|
+
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
|
+
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
4
|
+
import { resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
5
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
6
|
+
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
|
+
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
8
|
+
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, readStoreLocation, refusedAdminScopes, resolveAutoStore } from "nodefony";
|
|
10
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
|
+
//#region nodefony/service/tokenService.ts
|
|
12
|
+
const serviceName = "tokenService";
|
|
13
|
+
/**
|
|
14
|
+
* Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
|
|
15
|
+
* **maintenance du store** (le seam `ITokenStore.gc()` n'a pas d'autre appelant).
|
|
16
|
+
*
|
|
17
|
+
* Au boot (si `jwt.enabled`) : résout le store pluggable (`tokenStore.store`),
|
|
18
|
+
* crée le keystore Ed25519, les pose au container (`tokenStore`/`jwtKeystore`,
|
|
19
|
+
* consommés par le `JwtAuthenticator` et les endpoints framework), puis arme un
|
|
20
|
+
* **timer de gc** `unref` (n'empêche pas l'arrêt) avec **jitter** de phase
|
|
21
|
+
* (étale les balayages entre process d'un cluster sur un store partagé). À
|
|
22
|
+
* l'arrêt (`onTerminate`) : `clearInterval`/`clearTimeout`.
|
|
23
|
+
*
|
|
24
|
+
* Émission = « password grant » M2M/CLI : credential vérifié par le service
|
|
25
|
+
* `users` → access (JWT signé, 15 min) + refresh (secret opaque haute entropie,
|
|
26
|
+
* stocké **haché**). Refresh = rotation + détection de rejeu (RFC 9700 §4.14).
|
|
27
|
+
*/
|
|
28
|
+
var TokenService = class extends Service {
|
|
29
|
+
module;
|
|
30
|
+
#runtime = null;
|
|
31
|
+
/** Émetteur publiable, résolu une fois au boot (cf `#resolvePublication`). */
|
|
32
|
+
#published = null;
|
|
33
|
+
#store = null;
|
|
34
|
+
#keystore = null;
|
|
35
|
+
#jose = null;
|
|
36
|
+
#users = null;
|
|
37
|
+
#throttler = null;
|
|
38
|
+
#throttlerResolved = false;
|
|
39
|
+
#gc = null;
|
|
40
|
+
constructor(module) {
|
|
41
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
42
|
+
this.module = module;
|
|
43
|
+
this.kernel?.once("onBoot", () => this.#build());
|
|
44
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
45
|
+
}
|
|
46
|
+
#build() {
|
|
47
|
+
let config;
|
|
48
|
+
try {
|
|
49
|
+
config = defineSecurityConfig(this.options);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const jwtEnabled = config.jwt.enabled;
|
|
54
|
+
const apiKeysEnabled = config.apiKeys.enabled;
|
|
55
|
+
if (!jwtEnabled && !apiKeysEnabled) {
|
|
56
|
+
this.log("token service idle — JWT et clés API désactivés", "DEBUG");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
let storeName = config.tokenStore.store;
|
|
60
|
+
let reason = `store explicitement configuré ("${storeName}")`;
|
|
61
|
+
if (storeName === AUTO_STORE) {
|
|
62
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores());
|
|
63
|
+
storeName = auto.store;
|
|
64
|
+
reason = auto.reason;
|
|
65
|
+
this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
66
|
+
}
|
|
67
|
+
const factory = getTokenStoreFactory(storeName);
|
|
68
|
+
if (!factory) {
|
|
69
|
+
const msg = `token store "${storeName}" inconnu (enregistrés : ${listTokenStores().join(", ") || "aucun"})`;
|
|
70
|
+
if (this.kernel?.environment === "production") throw new Error(`${msg} — JWT/clés API indisponibles : boot avorté.`);
|
|
71
|
+
this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (storeName === "memory" && this.kernel?.environment === "production") this.log("tokenStore \"memory\" en PRODUCTION — denylist JWT, refresh tokens et clés API per-pod et volatils : révocation non partagée entre pods, tout est perdu au redémarrage. Déclarer une infra durable (NF_DATABASE_URL) ou un store persistant.", "WARNING");
|
|
75
|
+
this.#store = factory({
|
|
76
|
+
container: this.container,
|
|
77
|
+
config
|
|
78
|
+
});
|
|
79
|
+
this.kernel?.registerStoreResolution({
|
|
80
|
+
brick: "tokens",
|
|
81
|
+
nature: "durable",
|
|
82
|
+
configured: config.tokenStore.store,
|
|
83
|
+
resolved: storeName,
|
|
84
|
+
available: listTokenStores(),
|
|
85
|
+
reason,
|
|
86
|
+
configPath: "security.tokenStore.store",
|
|
87
|
+
location: readStoreLocation(this.#store)
|
|
88
|
+
});
|
|
89
|
+
this.container?.set("tokenStore", this.#store);
|
|
90
|
+
if (jwtEnabled) {
|
|
91
|
+
this.#runtime = resolveJwtRuntime(config.jwt);
|
|
92
|
+
this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
|
|
93
|
+
this.container?.set("jwtKeystore", this.#keystore);
|
|
94
|
+
this.#resolvePublication(config.jwt.jwks, this.#runtime.issuer);
|
|
95
|
+
}
|
|
96
|
+
this.#gc = new GcScheduler({
|
|
97
|
+
intervalS: config.tokenStore.gcIntervalS,
|
|
98
|
+
jitter: config.tokenStore.gcJitter,
|
|
99
|
+
run: () => this.runGc(),
|
|
100
|
+
onError: (e) => this.log(e, "ERROR")
|
|
101
|
+
});
|
|
102
|
+
this.#gc.start();
|
|
103
|
+
this.log(`token service ready — store "${config.tokenStore.store}", jwt=${jwtEnabled}, apiKeys=${apiKeysEnabled}, gc ${config.tokenStore.gcIntervalS}s`, "DEBUG");
|
|
104
|
+
}
|
|
105
|
+
#shutdown() {
|
|
106
|
+
this.#gc?.stop();
|
|
107
|
+
this.#gc = null;
|
|
108
|
+
}
|
|
109
|
+
/** `true` si l'émission JWT (signature + refresh) est opérationnelle. */
|
|
110
|
+
isEnabled() {
|
|
111
|
+
return this.#keystore !== null && this.#runtime !== null;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Décide UNE fois, au boot, si cette application peut se déclarer émetteur.
|
|
115
|
+
*
|
|
116
|
+
* Trois conditions, et la troisième est celle qui surprend : l'émetteur doit
|
|
117
|
+
* être une **URL https** (RFC 8414 §2). Le défaut `"nodefony"` de
|
|
118
|
+
* {@link resolveJwtRuntime} n'en est pas une — parfaitement inoffensif tant
|
|
119
|
+
* que Nodefony émet ET vérifie ses propres jetons (`iss` n'est alors qu'une
|
|
120
|
+
* chaîne comparée à elle-même), mais inutilisable comme identifiant public.
|
|
121
|
+
*
|
|
122
|
+
* 🔴 **On ne DEVINE pas cette URL.** Derrière un relais (HAProxy, ingress,
|
|
123
|
+
* CDN), le processus n'a aucun moyen fiable de connaître son adresse
|
|
124
|
+
* publique : `Host` et `X-Forwarded-*` viennent de la requête, donc du client
|
|
125
|
+
* en dernière analyse. La dériver ferait servir, par le VRAI serveur, un
|
|
126
|
+
* document `issuer: https://attaquant.example` — crédible, et empoisonnant
|
|
127
|
+
* tout cache mutualisé. Un argument non sécuritaire suffirait d'ailleurs :
|
|
128
|
+
* l'émetteur est gravé dans le `iss` de chaque jeton DÉJÀ émis ; variable
|
|
129
|
+
* selon l'hôte d'entrée, il ferait rejeter un jeton valide.
|
|
130
|
+
*
|
|
131
|
+
* Le refus est donc ANNONCÉ (avertissement au boot) plutôt que masqué
|
|
132
|
+
* derrière un document qui ne mènerait nulle part.
|
|
133
|
+
*/
|
|
134
|
+
#resolvePublication(wanted, issuer) {
|
|
135
|
+
if (!wanted) return;
|
|
136
|
+
try {
|
|
137
|
+
this.#published = canonicalIssuer(issuer);
|
|
138
|
+
} catch {
|
|
139
|
+
this.#published = null;
|
|
140
|
+
this.log(`JWKS non publié : \`security.jwt.issuer\` vaut « ${issuer} », qui n'est pas une URL https. Renseigner l'URL publique de cette application (RFC 8414 §2) — derrière un relais, elle ne peut pas être devinée. Les jetons restent émis et vérifiés normalement.`, "WARNING");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Émetteur sous lequel cette application accepte d'être DÉCOUVERTE, ou `null`.
|
|
145
|
+
*
|
|
146
|
+
* C'est la question que pose `@nodefony/framework` au moment de monter (ou
|
|
147
|
+
* non) `/.well-known/oauth-authorization-server` et `/.well-known/jwks.json` :
|
|
148
|
+
* il ne lit pas la configuration de sécurité, il obtient une réponse. `null`
|
|
149
|
+
* = aucune route, donc `404` — pas de document creux, pas de demi-mesure.
|
|
150
|
+
*
|
|
151
|
+
* @returns l'émetteur canonique publiable, ou `null` si rien ne doit l'être
|
|
152
|
+
*/
|
|
153
|
+
publishedIssuer() {
|
|
154
|
+
return this.#published;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Jeu de clés **publiques** de signature, tel qu'il doit être servi.
|
|
158
|
+
*
|
|
159
|
+
* Ne contient que des paramètres publics (RFC 8037/7517) — le keystore ne
|
|
160
|
+
* sérialise jamais `d`. Rien n'est calculé ici : la route est une porte, la
|
|
161
|
+
* matière vient du keystore.
|
|
162
|
+
*
|
|
163
|
+
* @returns le JWKS public
|
|
164
|
+
* @throws Error si la capacité JWT n'est pas active (garde de programmation :
|
|
165
|
+
* les routes ne sont montées que si {@link publishedIssuer} répond)
|
|
166
|
+
*/
|
|
167
|
+
async getPublicJWKS() {
|
|
168
|
+
if (!this.#keystore) throw new Error("JWKS demandé alors que la capacité JWT est inactive (`security.jwt.enabled: false`).");
|
|
169
|
+
return this.#keystore.getPublicJWKS();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Une passe de purge du store (`ITokenStore.gc()`) — point d'entrée public d'un
|
|
173
|
+
* ordonnanceur : le {@link GcScheduler} l'appelle, mais le futur worker cron
|
|
174
|
+
* (`security:token-gc` / k8s CronJob) peut l'appeler à sa place (poser alors
|
|
175
|
+
* `tokenStore.gcIntervalS: 0`). L'anti-empilement et la capture d'erreur vivent
|
|
176
|
+
* dans le GcScheduler (via `onError`) — ici, la passe métier nue.
|
|
177
|
+
*
|
|
178
|
+
* ⚠️ Un store **local** (`memory`/`file`) est par-process (mémoires disjointes) :
|
|
179
|
+
* seul SON process peut le purger → le timer in-process reste indispensable. Un
|
|
180
|
+
* store **partagé** (ORM) peut être délégué au worker cron (un seul balayage).
|
|
181
|
+
*
|
|
182
|
+
* @returns nombre d'entrées purgées.
|
|
183
|
+
*/
|
|
184
|
+
async runGc() {
|
|
185
|
+
if (!this.#store) return 0;
|
|
186
|
+
const t0 = performance.now();
|
|
187
|
+
const purged = await this.#store.gc();
|
|
188
|
+
if (purged > 0) this.log(`token gc: ${purged} jeton(s) purgé(s) en ${(performance.now() - t0).toFixed(1)}ms`, "DEBUG");
|
|
189
|
+
return purged;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Émet un couple access/refresh après vérification d'un credential
|
|
193
|
+
* identifiant/mot de passe (grant M2M/CLI). Throttling NIST partagé si activé.
|
|
194
|
+
*
|
|
195
|
+
* @throws ThrottledError (429) — backoff actif.
|
|
196
|
+
* @throws AuthenticationError (401, message uniforme) — credential invalide.
|
|
197
|
+
*/
|
|
198
|
+
async issueForCredentials(identifier, password, requestedScopes, resource) {
|
|
199
|
+
if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
|
|
200
|
+
this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "invalid_credentials");
|
|
201
|
+
throw new AuthenticationError("Invalid credentials");
|
|
202
|
+
}
|
|
203
|
+
const throttler = this.#resolveThrottler();
|
|
204
|
+
if (throttler !== null) {
|
|
205
|
+
const retryAfterS = throttler.check(identifier);
|
|
206
|
+
if (retryAfterS > 0) {
|
|
207
|
+
this.#auditGrant("login.throttled", identifier, "throttled");
|
|
208
|
+
throw new ThrottledError(retryAfterS);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const user = await this.#resolveUsers().authenticate(identifier, password);
|
|
212
|
+
if (user === null) {
|
|
213
|
+
throttler?.recordFailure(identifier);
|
|
214
|
+
this.#auditGrant("login.failure", identifier, "invalid_credentials");
|
|
215
|
+
throw new AuthenticationError("Invalid credentials");
|
|
216
|
+
}
|
|
217
|
+
throttler?.recordSuccess(identifier);
|
|
218
|
+
return this.issueTokens(user, requestedScopes, resource);
|
|
219
|
+
}
|
|
220
|
+
#auditGrant(action, actor, reason) {
|
|
221
|
+
recordAudit(this.container, {
|
|
222
|
+
category: "auth",
|
|
223
|
+
action,
|
|
224
|
+
outcome: "failure",
|
|
225
|
+
actor,
|
|
226
|
+
reason
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Résout l'audience (`aud`) d'un jeton à émettre — RFC 8707 §2.
|
|
231
|
+
*
|
|
232
|
+
* Le client dit POUR QUI il demande le jeton ; le serveur décide s'il accepte.
|
|
233
|
+
* La liste `security.jwt.audiences` est donc une **liste blanche de ressources
|
|
234
|
+
* demandables**, et non une simple valeur par défaut : sans elle, n'importe
|
|
235
|
+
* quel porteur d'un identifiant valide se ferait délivrer un jeton portant
|
|
236
|
+
* l'audience de son choix — y compris celle d'une ressource à laquelle il n'a
|
|
237
|
+
* rien à faire, dont la porte accepterait alors ce jeton sans sourciller.
|
|
238
|
+
*
|
|
239
|
+
* **Une seule ressource.** La RFC autorise plusieurs `resource` mais recommande
|
|
240
|
+
* l'inverse (§3) : « If a bearer token has multiple intended recipients […] the
|
|
241
|
+
* token is valid at more than one protected resource and can be used by any one
|
|
242
|
+
* of those resources to access any of the others », d'où « a high degree of
|
|
243
|
+
* trust between the involved parties is needed » — et elle prévoit qu'un
|
|
244
|
+
* serveur soit « unwilling or unable » de le faire. Nous le sommes : la portée
|
|
245
|
+
* minimale est le seul réglage qui ne se retourne pas contre l'application.
|
|
246
|
+
*
|
|
247
|
+
* @param requested - valeur `resource` telle que reçue, ou rien
|
|
248
|
+
* @returns l'audience à inscrire dans le jeton
|
|
249
|
+
* @throws InvalidTargetError (400) si la ressource est multiple, malformée ou
|
|
250
|
+
* non déclarée
|
|
251
|
+
*/
|
|
252
|
+
#resolveAudience(requested) {
|
|
253
|
+
const fallback = this.#runtime.audiences[0];
|
|
254
|
+
if (requested === void 0 || requested === null) return fallback;
|
|
255
|
+
if (Array.isArray(requested)) throw new InvalidTargetError("A single `resource` is accepted — a token valid at several resources lets each of them act at the others (RFC 8707 §3).");
|
|
256
|
+
if (typeof requested !== "string" || requested.length === 0) throw new InvalidTargetError("`resource` must be an absolute URI.");
|
|
257
|
+
let url;
|
|
258
|
+
try {
|
|
259
|
+
url = new URL(requested);
|
|
260
|
+
} catch {
|
|
261
|
+
throw new InvalidTargetError("`resource` must be an absolute URI (RFC 8707 §2).", requested);
|
|
262
|
+
}
|
|
263
|
+
if (url.hash) throw new InvalidTargetError("`resource` must not include a fragment (RFC 8707 §2).", requested);
|
|
264
|
+
if (!this.#runtime.audiences.includes(requested)) throw new InvalidTargetError("The requested resource is not available to this application.", requested);
|
|
265
|
+
return requested;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Émet un couple access/refresh pour un utilisateur déjà authentifié.
|
|
269
|
+
*
|
|
270
|
+
* @param user - porteur, déjà authentifié
|
|
271
|
+
* @param requestedScopes - scopes demandés (RFC 6749 §3.3)
|
|
272
|
+
* @param resource - ressource visée (RFC 8707) ; omise = audience par défaut
|
|
273
|
+
* @throws InvalidTargetError (400) si `resource` ne peut pas être servie
|
|
274
|
+
*/
|
|
275
|
+
/**
|
|
276
|
+
* Borne les scopes demandés à ce que le porteur peut légitimement obtenir.
|
|
277
|
+
*
|
|
278
|
+
* 🔴 Un scope se DEMANDE ; il ne s'accorde pas pour autant. Les scopes
|
|
279
|
+
* d'administration du framework (`admin:read`, `admin:write`) ouvrent le plan
|
|
280
|
+
* d'administration à un porteur de jeton : les signer sur simple demande
|
|
281
|
+
* ferait de n'importe quel compte un administrateur — il lui suffirait de le
|
|
282
|
+
* demander. La règle de ce qui est réservé vit au cœur
|
|
283
|
+
* ({@link refusedAdminScopes}), à côté de la traduction inverse qui les
|
|
284
|
+
* consomme ; ici on l'applique et on le DIT.
|
|
285
|
+
*
|
|
286
|
+
* Retirer plutôt que refuser tout le grant : RFC 6749 §3.3 prévoit
|
|
287
|
+
* explicitement qu'un serveur accorde MOINS que demandé, à condition
|
|
288
|
+
* d'informer le client — c'est ce que fait le champ `scope` de la réponse,
|
|
289
|
+
* déjà rempli avec les scopes réellement accordés.
|
|
290
|
+
*
|
|
291
|
+
* Les scopes d'une APPLICATION ne sont pas jugés ici : c'est à elle de dire
|
|
292
|
+
* qui peut obtenir `shop:read`.
|
|
293
|
+
*
|
|
294
|
+
* @param user - le porteur, déjà authentifié.
|
|
295
|
+
* @param requested - ce qu'il demande.
|
|
296
|
+
* @returns ce qui lui est accordé.
|
|
297
|
+
*/
|
|
298
|
+
#grantableScopes(user, requested) {
|
|
299
|
+
const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
|
|
300
|
+
const refuses = refusedAdminScopes(requested, roles);
|
|
301
|
+
if (refuses.length === 0) return requested;
|
|
302
|
+
this.log(`grant : scope(s) « ${refuses.join(" ")} » NON accordé(s) à « ${user.identifier} » — réservé(s) au rôle d'administration. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
|
|
303
|
+
return requested.filter((scope) => !refuses.includes(scope));
|
|
304
|
+
}
|
|
305
|
+
async issueTokens(user, requestedScopes, resource, accessTtlS) {
|
|
306
|
+
this.#ensureReady();
|
|
307
|
+
const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
|
|
308
|
+
const audience = this.#resolveAudience(resource);
|
|
309
|
+
const access = await this.#signAccess(user.identifier, scopes, audience, accessTtlS);
|
|
310
|
+
const { record, raw } = this.#buildRefresh(user.identifier, scopes, this.#randomId(), audience);
|
|
311
|
+
await this.#store.put(record);
|
|
312
|
+
recordAudit(this.container, {
|
|
313
|
+
category: "token",
|
|
314
|
+
action: "token.issued",
|
|
315
|
+
outcome: "success",
|
|
316
|
+
actor: user.identifier,
|
|
317
|
+
metadata: {
|
|
318
|
+
tokenId: record.id,
|
|
319
|
+
scopes
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return {
|
|
323
|
+
access_token: access,
|
|
324
|
+
refresh_token: raw,
|
|
325
|
+
token_type: "Bearer",
|
|
326
|
+
expires_in: accessTtlS ?? this.#runtime.accessTtlS,
|
|
327
|
+
scope: scopes.join(" ")
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Rotation d'un refresh token (RFC 9700 §4.14) : valide le refresh présenté,
|
|
332
|
+
* émet un nouveau couple, révoque l'ancien. Un refresh **déjà révoqué**
|
|
333
|
+
* re-présenté = rejeu → toute la famille est coupée.
|
|
334
|
+
*
|
|
335
|
+
* @param rawRefresh - le refresh token présenté, en clair
|
|
336
|
+
* @param resource - ressource visée (RFC 8707 §2.2). Sur un `refresh_token`,
|
|
337
|
+
* la politique « may limit the acceptable resources to those that
|
|
338
|
+
* were originally granted […] or a subset thereof » : un jeton ne
|
|
339
|
+
* portant qu'une seule audience, le seul sous-ensemble possible est
|
|
340
|
+
* elle-même. Demander autre chose est donc refusé, jamais ignoré —
|
|
341
|
+
* sinon la rotation devient le chemin par lequel on obtient une
|
|
342
|
+
* audience qu'on n'a pas su demander à l'émission.
|
|
343
|
+
* @throws AuthenticationError (401) — refresh inconnu/expiré/révoqué, sujet banni.
|
|
344
|
+
* @throws InvalidTargetError (400) — `resource` demandée ≠ celle accordée
|
|
345
|
+
*/
|
|
346
|
+
async refresh(rawRefresh, resource) {
|
|
347
|
+
this.#ensureReady();
|
|
348
|
+
if (typeof rawRefresh !== "string" || rawRefresh.length === 0) throw new AuthenticationError("Invalid token");
|
|
349
|
+
const store = this.#store;
|
|
350
|
+
const record = await store.findByHash(this.#hash(rawRefresh));
|
|
351
|
+
if (!record || record.kind !== "refresh") throw new AuthenticationError("Invalid token");
|
|
352
|
+
if (record.revokedAt !== null) {
|
|
353
|
+
if (record.family) await store.revokeFamily(record.family, "reuse_detected");
|
|
354
|
+
recordAudit(this.container, {
|
|
355
|
+
category: "token",
|
|
356
|
+
action: "token.reuse_detected",
|
|
357
|
+
outcome: "denied",
|
|
358
|
+
actor: record.subjectId,
|
|
359
|
+
reason: "reuse_detected",
|
|
360
|
+
metadata: {
|
|
361
|
+
tokenId: record.id,
|
|
362
|
+
family: record.family
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
throw new AuthenticationError("Invalid token");
|
|
366
|
+
}
|
|
367
|
+
const now = Date.now();
|
|
368
|
+
if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
|
|
369
|
+
const user = await this.#resolveUserForRefresh(record.subjectId);
|
|
370
|
+
const scopes = [...record.scopes];
|
|
371
|
+
const audience = record.audience?.[0] ?? this.#runtime.audiences[0];
|
|
372
|
+
if (resource !== void 0 && resource !== null && resource !== audience) throw new InvalidTargetError("The requested resource does not match the one granted to this token (RFC 8707 §2.2).", typeof resource === "string" ? resource : void 0);
|
|
373
|
+
const access = await this.#signAccess(user.identifier, scopes, audience);
|
|
374
|
+
if (!this.#runtime.rotateRefresh) return {
|
|
375
|
+
access_token: access,
|
|
376
|
+
refresh_token: rawRefresh,
|
|
377
|
+
token_type: "Bearer",
|
|
378
|
+
expires_in: this.#runtime.accessTtlS,
|
|
379
|
+
scope: scopes.join(" ")
|
|
380
|
+
};
|
|
381
|
+
const family = record.family ?? this.#randomId();
|
|
382
|
+
const next = this.#buildRefresh(user.identifier, scopes, family, audience);
|
|
383
|
+
await store.put(next.record);
|
|
384
|
+
record.replacedBy = next.record.id;
|
|
385
|
+
record.revokedAt = now;
|
|
386
|
+
record.revokedReason = "rotated";
|
|
387
|
+
await store.put(record);
|
|
388
|
+
return {
|
|
389
|
+
access_token: access,
|
|
390
|
+
refresh_token: next.raw,
|
|
391
|
+
token_type: "Bearer",
|
|
392
|
+
expires_in: this.#runtime.accessTtlS,
|
|
393
|
+
scope: scopes.join(" ")
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
async #signAccess(subject, scopes, audience, ttlS) {
|
|
397
|
+
const jose = await this.#ensureJose();
|
|
398
|
+
const { key, kid } = await this.#keystore.getSigningKey();
|
|
399
|
+
const rt = this.#runtime;
|
|
400
|
+
return new jose.SignJWT({ scope: scopes.join(" ") }).setProtectedHeader({
|
|
401
|
+
alg: "EdDSA",
|
|
402
|
+
kid,
|
|
403
|
+
typ: "at+jwt"
|
|
404
|
+
}).setIssuedAt().setIssuer(rt.issuer).setSubject(subject).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Construit (sans persister) un record refresh + son secret opaque en clair.
|
|
408
|
+
*
|
|
409
|
+
* @param audience - ressource ACCORDÉE, mémorisée pour que la rotation rende
|
|
410
|
+
* un jeton de même portée. Sans elle, le renouvellement élargirait
|
|
411
|
+
* silencieusement l'accès à l'audience par défaut — un downscoping
|
|
412
|
+
* qui s'annule au bout de quelques minutes n'en est pas un.
|
|
413
|
+
*/
|
|
414
|
+
#buildRefresh(subject, scopes, family, audience) {
|
|
415
|
+
const raw = `nfr_${randomBytes(32).toString("base64url")}`;
|
|
416
|
+
const now = Date.now();
|
|
417
|
+
return {
|
|
418
|
+
record: {
|
|
419
|
+
id: randomUUID(),
|
|
420
|
+
kind: "refresh",
|
|
421
|
+
name: "refresh token",
|
|
422
|
+
prefix: null,
|
|
423
|
+
subjectId: subject,
|
|
424
|
+
subjectType: "user",
|
|
425
|
+
tenantId: null,
|
|
426
|
+
scopes: [...scopes],
|
|
427
|
+
audience: [audience],
|
|
428
|
+
resources: null,
|
|
429
|
+
secretHash: this.#hash(raw),
|
|
430
|
+
hashAlg: "sha256",
|
|
431
|
+
clientId: null,
|
|
432
|
+
cnf: null,
|
|
433
|
+
family,
|
|
434
|
+
replacedBy: null,
|
|
435
|
+
createdAt: now,
|
|
436
|
+
expiresAt: now + this.#runtime.refreshTtlS * 1e3,
|
|
437
|
+
lastUsedAt: null,
|
|
438
|
+
lastUsedIp: null,
|
|
439
|
+
lastUsedUserAgent: null,
|
|
440
|
+
revokedAt: null,
|
|
441
|
+
revokedReason: null,
|
|
442
|
+
metadata: {}
|
|
443
|
+
},
|
|
444
|
+
raw
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
#ensureReady() {
|
|
448
|
+
if (!this.#store || !this.#keystore || !this.#runtime) throw new Error("TokenService: non initialisé (JWT désactivé ou store indisponible)");
|
|
449
|
+
}
|
|
450
|
+
async #ensureJose() {
|
|
451
|
+
return this.#jose ??= await import("jose");
|
|
452
|
+
}
|
|
453
|
+
#hash(secret) {
|
|
454
|
+
return createHash("sha256").update(secret).digest("hex");
|
|
455
|
+
}
|
|
456
|
+
#randomId() {
|
|
457
|
+
return randomBytes(16).toString("base64url");
|
|
458
|
+
}
|
|
459
|
+
#resolveUsers() {
|
|
460
|
+
if (this.#users === null) {
|
|
461
|
+
const users = this.get("users");
|
|
462
|
+
if (!users) throw new Error("TokenService: aucun service 'users' (IPasswordVerifier & IUserProvider) dans le container — enregistrer un UserService au boot.");
|
|
463
|
+
this.#users = users;
|
|
464
|
+
}
|
|
465
|
+
return this.#users;
|
|
466
|
+
}
|
|
467
|
+
async #resolveUserForRefresh(subjectId) {
|
|
468
|
+
let user;
|
|
469
|
+
try {
|
|
470
|
+
user = await this.#resolveUsers().loadUserByIdentifier(subjectId);
|
|
471
|
+
} catch {
|
|
472
|
+
throw new AuthenticationError("Invalid token");
|
|
473
|
+
}
|
|
474
|
+
if (!user.isActive() || user.isLocked()) throw new AuthenticationError("Invalid token");
|
|
475
|
+
return user;
|
|
476
|
+
}
|
|
477
|
+
#resolveThrottler() {
|
|
478
|
+
if (!this.#throttlerResolved) {
|
|
479
|
+
this.#throttler = this.get("loginThrottler") ?? null;
|
|
480
|
+
this.#throttlerResolved = true;
|
|
481
|
+
}
|
|
482
|
+
return this.#throttler;
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
//#endregion
|
|
486
|
+
export { TokenService, TokenService as default };
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
|
|
3
|
+
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
4
|
+
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
5
|
+
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
|
|
6
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, readStoreLocation, resolveAutoStore } from "nodefony";
|
|
7
|
+
//#region nodefony/service/totp.ts
|
|
8
|
+
const serviceName = "totp";
|
|
9
|
+
function isFlushable(s) {
|
|
10
|
+
return s !== null && typeof s.flushNow === "function";
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* **2FA TOTP** (P6.17, RFC 6238) — service d'orchestration du second facteur.
|
|
14
|
+
*
|
|
15
|
+
* Coquille fine : au boot (si `totp.enabled`) il résout le **store** de secrets
|
|
16
|
+
* pluggable + la **clé de chiffrement** AES-256-GCM, puis délègue toute la logique
|
|
17
|
+
* aux opérations pures `totpOperations` (testées sans serveur). Le TOTP est un
|
|
18
|
+
* facteur de **login step-up** (le code n'est présenté qu'à la connexion, calque
|
|
19
|
+
* WebAuthn/OAuth), pas un authenticator du firewall — `session.user` n'est posé
|
|
20
|
+
* qu'une fois le second facteur validé (Zero Trust 401 protège tout le reste).
|
|
21
|
+
*
|
|
22
|
+
* **Clé de chiffrement** : le secret TOTP est réversible (le serveur le relit pour
|
|
23
|
+
* calculer le code) → chiffré, jamais haché. La clé vient de `totp.encryptionKey`
|
|
24
|
+
* (dérivée HKDF). Absente : en dev une clé **éphémère** est générée + WARNING (les
|
|
25
|
+
* secrets ne survivent pas au redémarrage) ; en **production** c'est fatal — 2FA
|
|
26
|
+
* désactivé (une clé éphémère rendrait les secrets illisibles après redémarrage /
|
|
27
|
+
* sur les autres pods). Politique calquée sur RedisIdempotencyStore.
|
|
28
|
+
*/
|
|
29
|
+
var TotpService = class extends Service {
|
|
30
|
+
module;
|
|
31
|
+
#deps = null;
|
|
32
|
+
#store = null;
|
|
33
|
+
#ready = false;
|
|
34
|
+
constructor(module) {
|
|
35
|
+
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
36
|
+
this.module = module;
|
|
37
|
+
this.kernel?.once("onBoot", () => this.#build());
|
|
38
|
+
this.kernel?.once("onTerminate", () => void this.#shutdown());
|
|
39
|
+
}
|
|
40
|
+
#build() {
|
|
41
|
+
let config;
|
|
42
|
+
try {
|
|
43
|
+
config = defineSecurityConfig(this.options);
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
if (!config.totp.enabled) {
|
|
48
|
+
this.log("totp idle — 2FA désactivé en config", "DEBUG");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const store = this.#resolveStore(config);
|
|
52
|
+
if (!store) return;
|
|
53
|
+
const key = this.#resolveKey(config);
|
|
54
|
+
if (!key) return;
|
|
55
|
+
this.#store = store;
|
|
56
|
+
this.#deps = {
|
|
57
|
+
store,
|
|
58
|
+
key,
|
|
59
|
+
now: () => Date.now(),
|
|
60
|
+
issuer: config.totp.issuer ?? this.#defaultIssuer(),
|
|
61
|
+
algorithm: config.totp.algorithm,
|
|
62
|
+
digits: config.totp.digits,
|
|
63
|
+
period: config.totp.period,
|
|
64
|
+
window: config.totp.window,
|
|
65
|
+
recoveryCodesCount: config.totp.recoveryCodes
|
|
66
|
+
};
|
|
67
|
+
this.#ready = true;
|
|
68
|
+
this.log(`totp ready — store "${config.totp.store}", ${config.totp.digits} chiffres / ${config.totp.period}s`, "DEBUG");
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Résout le store de secrets : adapter posé au container (ORM/Redis) en priorité,
|
|
72
|
+
* sinon le driver configuré. `auto` (défaut) suit l'infra database déclarée puis
|
|
73
|
+
* un backend local persistant (sqlite/drizzle chargé), repli `memory` ANNONCÉ.
|
|
74
|
+
*/
|
|
75
|
+
#resolveStore(config) {
|
|
76
|
+
const existing = this.get("totpSecretStore");
|
|
77
|
+
if (existing) {
|
|
78
|
+
this.kernel?.registerStoreResolution({
|
|
79
|
+
brick: "totp",
|
|
80
|
+
nature: "durable",
|
|
81
|
+
configured: config.totp.store,
|
|
82
|
+
resolved: deriveStoreBackend(existing),
|
|
83
|
+
available: listTotpStores(),
|
|
84
|
+
reason: "adapter posé au container (infra database déclarée)",
|
|
85
|
+
configPath: "security.totp.store",
|
|
86
|
+
location: readStoreLocation(existing)
|
|
87
|
+
});
|
|
88
|
+
return existing;
|
|
89
|
+
}
|
|
90
|
+
let driver = config.totp.store;
|
|
91
|
+
let reason = `store explicitement configuré ("${driver}")`;
|
|
92
|
+
if (driver === AUTO_STORE) {
|
|
93
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores());
|
|
94
|
+
driver = auto.store;
|
|
95
|
+
reason = auto.reason;
|
|
96
|
+
this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
97
|
+
}
|
|
98
|
+
const factory = getTotpStoreFactory(driver);
|
|
99
|
+
if (!factory) {
|
|
100
|
+
const msg = `totp store "${driver}" inconnu (enregistrés : ${listTotpStores().join(", ") || "aucun"})`;
|
|
101
|
+
if (this.kernel?.environment === "production") throw new Error(`${msg} — 2FA indisponible : boot avorté.`);
|
|
102
|
+
this.log(`${msg} — 2FA indisponible`, "CRITIC");
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). Déclarer une infra durable (NF_DATABASE_URL) ou charger @nodefony/drizzle.", "WARNING");
|
|
106
|
+
const store = factory({
|
|
107
|
+
container: this.container,
|
|
108
|
+
config
|
|
109
|
+
});
|
|
110
|
+
this.container?.set("totpSecretStore", store);
|
|
111
|
+
this.kernel?.registerStoreResolution({
|
|
112
|
+
brick: "totp",
|
|
113
|
+
nature: "durable",
|
|
114
|
+
configured: config.totp.store,
|
|
115
|
+
resolved: driver,
|
|
116
|
+
available: listTotpStores(),
|
|
117
|
+
reason,
|
|
118
|
+
configPath: "security.totp.store",
|
|
119
|
+
location: readStoreLocation(store)
|
|
120
|
+
});
|
|
121
|
+
return store;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Résout la clé de chiffrement AES-256 du secret au repos. Clé de config →
|
|
125
|
+
* dérivée HKDF. Absente : dev = clé éphémère + WARNING ; prod = fatal (null →
|
|
126
|
+
* 2FA désactivé), pour ne jamais chiffrer un secret avec une clé non
|
|
127
|
+
* reproductible (illisible après redémarrage ou sur un autre pod).
|
|
128
|
+
*/
|
|
129
|
+
#resolveKey(config) {
|
|
130
|
+
const material = config.totp.encryptionKey;
|
|
131
|
+
if (material && material.length > 0) return deriveTotpKey(material);
|
|
132
|
+
if (this.kernel?.environment === "production") {
|
|
133
|
+
this.log("totp: AUCUNE clé de chiffrement (`security.totp.encryptionKey`) en PRODUCTION — 2FA désactivé (un secret chiffré par une clé éphémère serait illisible après redémarrage / sur les autres pods). Fournir une clé ≥ 32 octets depuis l'environnement.", "CRITIC");
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
this.log("totp: aucune clé de chiffrement configurée — clé ÉPHÉMÈRE générée (dev). Les secrets 2FA ne survivront pas au redémarrage. Définir `totp.encryptionKey` — générer la clé et le câblage : `npx nodefony security:secrets`.", "WARNING");
|
|
137
|
+
return generateEphemeralKey();
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Arrêt propre : flush immédiat du store s'il est persistant (driver `file`) →
|
|
141
|
+
* aucune écriture en attente n'est perdue. No-op pour un store mémoire/adapter.
|
|
142
|
+
*/
|
|
143
|
+
async #shutdown() {
|
|
144
|
+
if (isFlushable(this.#store)) try {
|
|
145
|
+
await this.#store.flushNow();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
this.log(e, "ERROR");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** `true` si le 2FA est opérationnel (activé en config, boot OK). */
|
|
151
|
+
isEnabled() {
|
|
152
|
+
return this.#ready;
|
|
153
|
+
}
|
|
154
|
+
/** Démarre l'enrôlement (secret + QR affichés 1×). */
|
|
155
|
+
beginEnrollment(userId, account) {
|
|
156
|
+
return beginTotpEnrollment(this.#ensureReady(), userId, account);
|
|
157
|
+
}
|
|
158
|
+
/** Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs. */
|
|
159
|
+
confirmEnrollment(userId, code) {
|
|
160
|
+
return confirmTotpEnrollment(this.#ensureReady(), userId, code);
|
|
161
|
+
}
|
|
162
|
+
/** Vérifie un second facteur au login (code TOTP ou code de récupération). */
|
|
163
|
+
verifyLogin(userId, code) {
|
|
164
|
+
return verifyTotpLogin(this.#ensureReady(), userId, code);
|
|
165
|
+
}
|
|
166
|
+
/** Désactive le 2FA d'un utilisateur. */
|
|
167
|
+
disable(userId) {
|
|
168
|
+
return disableTotp(this.#ensureReady(), userId);
|
|
169
|
+
}
|
|
170
|
+
/** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
|
|
171
|
+
status(userId) {
|
|
172
|
+
return totpStatus(this.#ensureReady(), userId);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Page d'enrôlements 2FA — pagination **native au store** (jamais un parcours
|
|
176
|
+
* complet). Vue sans secret ni condensats, garantie par le contrat du store.
|
|
177
|
+
*
|
|
178
|
+
* @param query - fenêtre + filtres ({@link ITotpListQuery}).
|
|
179
|
+
* @returns la page d'enrôlements.
|
|
180
|
+
*/
|
|
181
|
+
listPage(query) {
|
|
182
|
+
return this.#ensureReady().store.listPage(query);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Nombre d'enrôlements correspondant aux filtres — le KPI « couverture 2FA »
|
|
186
|
+
* sans énumérer.
|
|
187
|
+
*
|
|
188
|
+
* @param query - filtres ({@link ITotpListQuery}) ; `limit` ignoré.
|
|
189
|
+
* @returns le compte exact.
|
|
190
|
+
*/
|
|
191
|
+
countEnrollments(query) {
|
|
192
|
+
return this.#ensureReady().store.countEnrollments(query);
|
|
193
|
+
}
|
|
194
|
+
/** 2FA activé pour cet utilisateur ? (raccourci pour le flow de login). */
|
|
195
|
+
async isEnabledFor(userId) {
|
|
196
|
+
if (!this.#ready || !this.#deps) return false;
|
|
197
|
+
return (await totpStatus(this.#deps, userId)).enabled;
|
|
198
|
+
}
|
|
199
|
+
#defaultIssuer() {
|
|
200
|
+
const name = this.kernel?.projectName;
|
|
201
|
+
return name && name !== "NODEFONY" ? name : "Nodefony";
|
|
202
|
+
}
|
|
203
|
+
#ensureReady() {
|
|
204
|
+
if (!this.#ready || !this.#deps) throw new Error("TotpService: non initialisé (2FA désactivé ou boot échoué)");
|
|
205
|
+
return this.#deps;
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
209
|
+
export { TotpService, TotpService as default };
|