@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
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import { JwtKeystore } from "../src/token/JwtKeystore.js";
|
|
2
|
+
import { ScopeGrantPolicy, refusedScopes } from "../src/token/scopeGrants.js";
|
|
3
|
+
import { InsecureTransportError } from "../errors/InsecureTransportError.js";
|
|
1
4
|
import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
2
5
|
import { ThrottledError } from "../errors/ThrottledError.js";
|
|
3
6
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
7
|
+
import { ACCOUNT_ID_KEY, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount } from "../src/accountBinding.js";
|
|
4
8
|
import { defaultAudience, resolveJwtRuntime } from "../src/token/jwtRuntime.js";
|
|
5
9
|
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
10
|
+
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
6
11
|
import { InvalidTargetError } from "../errors/InvalidTargetError.js";
|
|
7
12
|
import { getTokenStoreFactory, listTokenStores } from "../src/token/tokenStoreRegistry.js";
|
|
8
|
-
import {
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, refusedAdminScopes, resolveAutoStore, runNeedsExternalServices } from "nodefony";
|
|
13
|
+
import { AUTO_STORE, BootConfigurationError, EMPTY_INFRA, GcScheduler, Service, canonicalIssuer, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
10
14
|
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
|
-
|
|
15
|
+
// nodefony/service/tokenService.ts
|
|
12
16
|
const serviceName = "tokenService";
|
|
13
17
|
/**
|
|
14
18
|
* Orchestrateur des jetons longue durée (P6 J4) — émission/refresh des JWT +
|
|
@@ -36,13 +40,48 @@ var TokenService = class extends Service {
|
|
|
36
40
|
#users = null;
|
|
37
41
|
#throttler = null;
|
|
38
42
|
#throttlerResolved = false;
|
|
43
|
+
#transport = null;
|
|
44
|
+
/** `grantableScopes` compilé au boot ; `null` tant que la config n'est pas lue. */
|
|
45
|
+
#scopeGrants = null;
|
|
39
46
|
#gc = null;
|
|
40
47
|
constructor(module) {
|
|
41
48
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
42
49
|
this.module = module;
|
|
43
|
-
|
|
50
|
+
const tokenServiceBoot = () => this.#build();
|
|
51
|
+
this.kernel?.once("onBoot", tokenServiceBoot);
|
|
44
52
|
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
45
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Refuse de SERVIR en production sans clé de signature partagée.
|
|
56
|
+
*
|
|
57
|
+
* Sans `keySetJson` ni `dir`, le keystore tire une clé ÉPHÉMÈRE propre au
|
|
58
|
+
* process : derrière plusieurs pods ou workers, un jeton signé par l'un est
|
|
59
|
+
* refusé par l'autre (401 au hasard de la répartition), et chaque
|
|
60
|
+
* redémarrage invalide tous les jetons. Un avertissement au premier jeton ne
|
|
61
|
+
* suffisait pas — il tombait en production, loin de celui qui pouvait agir.
|
|
62
|
+
*
|
|
63
|
+
* Seul un run qui SERT est concerné (`runProfile.servers`) : une commande de
|
|
64
|
+
* console (`orm:migrate`, `security:user:add`) ne signe aucun jeton, et la
|
|
65
|
+
* bloquer casserait le job de migration pour rien.
|
|
66
|
+
*
|
|
67
|
+
* @throws BootConfigurationError - production, serveur, et aucune source.
|
|
68
|
+
*/
|
|
69
|
+
#requireSharedSigningKey(keystore) {
|
|
70
|
+
if (keystore.keySetJson || keystore.dir) return;
|
|
71
|
+
if (this.kernel?.environment !== "production") return;
|
|
72
|
+
if (!this.kernel.runProfile.servers) return;
|
|
73
|
+
throw new BootConfigurationError([
|
|
74
|
+
"[@nodefony/security] DÉMARRAGE REFUSÉ — aucune clé de signature des jetons partagée (jwt.keystore).",
|
|
75
|
+
" Pourquoi : chaque process signerait avec la sienne. Un jeton émis par un pod ou un worker",
|
|
76
|
+
" serait refusé par les autres (401 au hasard), et tous seraient perdus au redémarrage.",
|
|
77
|
+
" Que faire — une des trois :",
|
|
78
|
+
" 1. Poser NF_JWT_KEYSET (câblée sur jwt.keystore.keySetJson) : valeur générée UNE fois par",
|
|
79
|
+
" `npx nodefony security:secrets --jwt-keyset`, rangée dans le gestionnaire de secrets,",
|
|
80
|
+
" injectée dans chaque pod.",
|
|
81
|
+
" 2. Serveur unique à disque persistant : jwt.keystore.dir.",
|
|
82
|
+
" 3. L'application n'émet aucun jeton : jwt.enabled: false."
|
|
83
|
+
].join("\n"));
|
|
84
|
+
}
|
|
46
85
|
#build() {
|
|
47
86
|
let config;
|
|
48
87
|
try {
|
|
@@ -52,6 +91,7 @@ var TokenService = class extends Service {
|
|
|
52
91
|
}
|
|
53
92
|
const jwtEnabled = config.jwt.enabled;
|
|
54
93
|
const apiKeysEnabled = config.apiKeys.enabled;
|
|
94
|
+
this.#scopeGrants = new ScopeGrantPolicy(config.grantableScopes);
|
|
55
95
|
if (!jwtEnabled && !apiKeysEnabled) {
|
|
56
96
|
this.log("token service idle — JWT et clés API désactivés", "DEBUG");
|
|
57
97
|
return;
|
|
@@ -59,7 +99,7 @@ var TokenService = class extends Service {
|
|
|
59
99
|
let storeName = config.tokenStore.store;
|
|
60
100
|
let reason = `store explicitement configuré ("${storeName}")`;
|
|
61
101
|
if (storeName === AUTO_STORE) {
|
|
62
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory",
|
|
102
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTokenStores(), "memory", storeConnectivity(this.kernel));
|
|
63
103
|
storeName = auto.store;
|
|
64
104
|
reason = auto.reason;
|
|
65
105
|
this.log(`tokenStore "auto" → "${storeName}" (${auto.reason})`, "INFO");
|
|
@@ -71,7 +111,7 @@ var TokenService = class extends Service {
|
|
|
71
111
|
this.log(`${msg} — JWT/clés API indisponibles`, "CRITIC");
|
|
72
112
|
return;
|
|
73
113
|
}
|
|
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. " + durableStoreRemedy(
|
|
114
|
+
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. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
75
115
|
this.#store = factory({
|
|
76
116
|
container: this.container,
|
|
77
117
|
config
|
|
@@ -89,6 +129,7 @@ var TokenService = class extends Service {
|
|
|
89
129
|
});
|
|
90
130
|
this.container?.set("tokenStore", this.#store);
|
|
91
131
|
if (jwtEnabled) {
|
|
132
|
+
this.#requireSharedSigningKey(config.jwt.keystore);
|
|
92
133
|
this.#runtime = resolveJwtRuntime(config.jwt);
|
|
93
134
|
this.#keystore = new JwtKeystore(config.jwt.keystore, (m, s) => this.log(m, s));
|
|
94
135
|
this.container?.set("jwtKeystore", this.#keystore);
|
|
@@ -193,10 +234,24 @@ var TokenService = class extends Service {
|
|
|
193
234
|
* Émet un couple access/refresh après vérification d'un credential
|
|
194
235
|
* identifiant/mot de passe (grant M2M/CLI). Throttling NIST partagé si activé.
|
|
195
236
|
*
|
|
237
|
+
* @param context - requête HTTP qui porte le credential. Présente, elle
|
|
238
|
+
* soumet l'appel au refus du clair en production ; absente (CLI, appel
|
|
239
|
+
* programmatique), il n'y a pas de transport à juger. Un endpoint HTTP DOIT
|
|
240
|
+
* la passer.
|
|
241
|
+
* @throws InsecureTransportError (403) — credential reçu en clair en production.
|
|
196
242
|
* @throws ThrottledError (429) — backoff actif.
|
|
197
243
|
* @throws AuthenticationError (401, message uniforme) — credential invalide.
|
|
198
244
|
*/
|
|
199
|
-
async issueForCredentials(identifier, password, requestedScopes, resource) {
|
|
245
|
+
async issueForCredentials(identifier, password, requestedScopes, resource, context) {
|
|
246
|
+
if (context !== void 0) {
|
|
247
|
+
this.#transport ??= this.get("credentialTransport") ?? null;
|
|
248
|
+
try {
|
|
249
|
+
this.#transport?.assert(context, "token");
|
|
250
|
+
} catch (error) {
|
|
251
|
+
if (error instanceof InsecureTransportError) this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "insecure_transport", readAuditContext(context));
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
200
255
|
if (typeof identifier !== "string" || identifier.length === 0 || typeof password !== "string" || password.length === 0) {
|
|
201
256
|
this.#auditGrant("login.failure", typeof identifier === "string" && identifier.length > 0 ? identifier : null, "invalid_credentials");
|
|
202
257
|
throw new AuthenticationError("Invalid credentials");
|
|
@@ -218,13 +273,14 @@ var TokenService = class extends Service {
|
|
|
218
273
|
throttler?.recordSuccess(identifier);
|
|
219
274
|
return this.issueTokens(user, requestedScopes, resource);
|
|
220
275
|
}
|
|
221
|
-
#auditGrant(action, actor, reason) {
|
|
276
|
+
#auditGrant(action, actor, reason, info) {
|
|
222
277
|
recordAudit(this.container, {
|
|
223
278
|
category: "auth",
|
|
224
279
|
action,
|
|
225
280
|
outcome: "failure",
|
|
226
281
|
actor,
|
|
227
|
-
reason
|
|
282
|
+
reason,
|
|
283
|
+
...info
|
|
228
284
|
});
|
|
229
285
|
}
|
|
230
286
|
/**
|
|
@@ -289,26 +345,30 @@ var TokenService = class extends Service {
|
|
|
289
345
|
* d'informer le client — c'est ce que fait le champ `scope` de la réponse,
|
|
290
346
|
* déjà rempli avec les scopes réellement accordés.
|
|
291
347
|
*
|
|
292
|
-
* Les scopes d'une APPLICATION
|
|
293
|
-
* qui peut obtenir `shop:
|
|
348
|
+
* Les scopes d'une APPLICATION se bornent par `grantableScopes` : c'est elle
|
|
349
|
+
* qui dit qui peut obtenir `shop:write`. Un scope qu'elle ne déclare pas
|
|
350
|
+
* reste accordé à tout porteur. Les deux familles passent par le MÊME juge
|
|
351
|
+
* ({@link refusedScopes}), que les clés API appellent aussi.
|
|
294
352
|
*
|
|
295
353
|
* @param user - le porteur, déjà authentifié.
|
|
296
354
|
* @param requested - ce qu'il demande.
|
|
297
355
|
* @returns ce qui lui est accordé.
|
|
298
356
|
*/
|
|
299
357
|
#grantableScopes(user, requested) {
|
|
358
|
+
if (requested.length === 0) return requested;
|
|
300
359
|
const roles = Array.isArray(user.roles) ? user.roles.filter((r) => typeof r === "string") : [];
|
|
301
|
-
const
|
|
302
|
-
if (
|
|
303
|
-
|
|
304
|
-
|
|
360
|
+
const refusals = refusedScopes(requested, roles, this.#scopeGrants ?? new ScopeGrantPolicy(), this.container?.get("roleHierarchy") ?? null);
|
|
361
|
+
if (refusals.length === 0) return requested;
|
|
362
|
+
const named = refusals.map((r) => `${r.scope} (${r.rule === "admin" ? "réservé au rôle d'administration" : "réservé par grantableScopes"})`).join(", ");
|
|
363
|
+
this.log(`grant : scope(s) NON accordé(s) à « ${user.identifier} » — ${named}. Le jeton est émis avec les scopes restants (RFC 6749 §3.3).`, "WARNING");
|
|
364
|
+
return requested.filter((scope) => !refusals.some((r) => r.scope === scope));
|
|
305
365
|
}
|
|
306
366
|
async issueTokens(user, requestedScopes, resource, accessTtlS) {
|
|
307
367
|
const { store, runtime } = this.#ensureReady();
|
|
308
368
|
const scopes = this.#grantableScopes(user, requestedScopes && requestedScopes.length > 0 ? [...requestedScopes] : []);
|
|
309
369
|
const audience = this.#resolveAudience(runtime, resource);
|
|
310
|
-
const access = await this.#signAccess(user
|
|
311
|
-
const { record, raw } = this.#buildRefresh(user
|
|
370
|
+
const access = await this.#signAccess(user, scopes, audience, accessTtlS);
|
|
371
|
+
const { record, raw } = this.#buildRefresh(user, scopes, this.#randomId(), audience);
|
|
312
372
|
await store.put(record);
|
|
313
373
|
recordAudit(this.container, {
|
|
314
374
|
category: "token",
|
|
@@ -367,10 +427,11 @@ var TokenService = class extends Service {
|
|
|
367
427
|
const now = Date.now();
|
|
368
428
|
if (record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError("Invalid token");
|
|
369
429
|
const user = await this.#resolveUserForRefresh(record.subjectId);
|
|
370
|
-
|
|
430
|
+
if (!isBoundToAccount(user, record.metadata["accountId"], record.metadata["credentialsVersion"])) throw new AuthenticationError("Invalid token");
|
|
431
|
+
const scopes = this.#grantableScopes(user, [...record.scopes]);
|
|
371
432
|
const audience = record.audience?.at(0) ?? defaultAudience(runtime);
|
|
372
433
|
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
|
|
434
|
+
const access = await this.#signAccess(user, scopes, audience);
|
|
374
435
|
if (!runtime.rotateRefresh) return {
|
|
375
436
|
access_token: access,
|
|
376
437
|
refresh_token: rawRefresh,
|
|
@@ -379,7 +440,7 @@ var TokenService = class extends Service {
|
|
|
379
440
|
scope: scopes.join(" ")
|
|
380
441
|
};
|
|
381
442
|
const family = record.family ?? this.#randomId();
|
|
382
|
-
const next = this.#buildRefresh(user
|
|
443
|
+
const next = this.#buildRefresh(user, scopes, family, audience);
|
|
383
444
|
await store.put(next.record);
|
|
384
445
|
record.replacedBy = next.record.id;
|
|
385
446
|
record.revokedAt = now;
|
|
@@ -393,15 +454,19 @@ var TokenService = class extends Service {
|
|
|
393
454
|
scope: scopes.join(" ")
|
|
394
455
|
};
|
|
395
456
|
}
|
|
396
|
-
async #signAccess(
|
|
457
|
+
async #signAccess(user, scopes, audience, ttlS) {
|
|
397
458
|
const { keystore, runtime: rt } = this.#ensureReady();
|
|
398
459
|
const jose = await this.#ensureJose();
|
|
399
460
|
const { key, kid } = await keystore.getSigningKey();
|
|
400
|
-
return new jose.SignJWT({
|
|
461
|
+
return new jose.SignJWT({
|
|
462
|
+
scope: scopes.join(" "),
|
|
463
|
+
["uid"]: user.id,
|
|
464
|
+
["cv"]: credentialsVersionOf(user)
|
|
465
|
+
}).setProtectedHeader({
|
|
401
466
|
alg: "EdDSA",
|
|
402
467
|
kid,
|
|
403
468
|
typ: "at+jwt"
|
|
404
|
-
}).setIssuedAt().setIssuer(rt.issuer).setSubject(
|
|
469
|
+
}).setIssuedAt().setIssuer(rt.issuer).setSubject(user.identifier).setAudience(audience).setExpirationTime(`${ttlS ?? rt.accessTtlS}s`).setJti(randomUUID()).sign(key);
|
|
405
470
|
}
|
|
406
471
|
/**
|
|
407
472
|
* Construit (sans persister) un record refresh + son secret opaque en clair.
|
|
@@ -411,7 +476,7 @@ var TokenService = class extends Service {
|
|
|
411
476
|
* silencieusement l'accès à l'audience par défaut — un downscoping
|
|
412
477
|
* qui s'annule au bout de quelques minutes n'en est pas un.
|
|
413
478
|
*/
|
|
414
|
-
#buildRefresh(
|
|
479
|
+
#buildRefresh(user, scopes, family, audience) {
|
|
415
480
|
const raw = `nfr_${randomBytes(32).toString("base64url")}`;
|
|
416
481
|
const now = Date.now();
|
|
417
482
|
return {
|
|
@@ -420,7 +485,7 @@ var TokenService = class extends Service {
|
|
|
420
485
|
kind: "refresh",
|
|
421
486
|
name: "refresh token",
|
|
422
487
|
prefix: null,
|
|
423
|
-
subjectId:
|
|
488
|
+
subjectId: user.identifier,
|
|
424
489
|
subjectType: "user",
|
|
425
490
|
tenantId: null,
|
|
426
491
|
scopes: [...scopes],
|
|
@@ -439,7 +504,10 @@ var TokenService = class extends Service {
|
|
|
439
504
|
lastUsedUserAgent: null,
|
|
440
505
|
revokedAt: null,
|
|
441
506
|
revokedReason: null,
|
|
442
|
-
metadata: {
|
|
507
|
+
metadata: {
|
|
508
|
+
[ACCOUNT_ID_KEY]: user.id,
|
|
509
|
+
[CREDENTIALS_VERSION_KEY]: credentialsVersionOf(user)
|
|
510
|
+
}
|
|
443
511
|
},
|
|
444
512
|
raw
|
|
445
513
|
};
|
|
@@ -492,5 +560,4 @@ var TokenService = class extends Service {
|
|
|
492
560
|
return this.#throttler;
|
|
493
561
|
}
|
|
494
562
|
};
|
|
495
|
-
//#endregion
|
|
496
563
|
export { TokenService, TokenService as default };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
|
+
import { recordAudit } from "../src/audit/recordAudit.js";
|
|
3
|
+
import { readAuditContext } from "../src/audit/readAuditContext.js";
|
|
2
4
|
import { getTotpStoreFactory, listTotpStores } from "../src/totp/totpSecretStoreRegistry.js";
|
|
3
5
|
import { generateEphemeralKey } from "../src/crypto/secretCipher.js";
|
|
4
6
|
import { deriveTotpKey } from "../src/totp/totpCipher.js";
|
|
5
7
|
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "../src/totp/totpOperations.js";
|
|
6
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
7
|
-
|
|
8
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
9
|
+
// nodefony/service/totp.ts
|
|
8
10
|
const serviceName = "totp";
|
|
9
11
|
function isFlushable(s) {
|
|
10
12
|
return s !== null && typeof s.flushNow === "function";
|
|
@@ -91,7 +93,7 @@ var TotpService = class extends Service {
|
|
|
91
93
|
let driver = config.totp.store;
|
|
92
94
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
93
95
|
if (driver === AUTO_STORE) {
|
|
94
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory",
|
|
96
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listTotpStores(), "memory", storeConnectivity(this.kernel));
|
|
95
97
|
driver = auto.store;
|
|
96
98
|
reason = auto.reason;
|
|
97
99
|
this.log(`totp.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -103,7 +105,7 @@ var TotpService = class extends Service {
|
|
|
103
105
|
this.log(`${msg} — 2FA indisponible`, "CRITIC");
|
|
104
106
|
return null;
|
|
105
107
|
}
|
|
106
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(
|
|
108
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("totp.store \"memory\" en PRODUCTION — secrets 2FA volatils : perdus au redémarrage (utilisateurs verrouillés). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
107
109
|
const store = factory({
|
|
108
110
|
container: this.container,
|
|
109
111
|
config
|
|
@@ -157,17 +159,60 @@ var TotpService = class extends Service {
|
|
|
157
159
|
beginEnrollment(userId, account) {
|
|
158
160
|
return beginTotpEnrollment(this.#ensureReady(), userId, account);
|
|
159
161
|
}
|
|
160
|
-
/**
|
|
161
|
-
|
|
162
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Confirme l'enrôlement par un 1ᵉʳ code → active + codes de récupération clairs.
|
|
164
|
+
*
|
|
165
|
+
* Activer un second facteur est un geste de SÉCURITÉ : il est audité ici
|
|
166
|
+
* (`user.totp_enabled`), avec la requête qui l'a porté — sans trace, un facteur
|
|
167
|
+
* posé à l'insu du titulaire ne se retrouve pas.
|
|
168
|
+
*
|
|
169
|
+
* @param userId - compte dont on active le 2FA (le porteur de la session).
|
|
170
|
+
* @param code - premier code de l'application d'authentification.
|
|
171
|
+
* @param context - contexte de la requête (IP, navigateur, identifiant de
|
|
172
|
+
* requête) recopié dans l'événement d'audit ; absent, l'événement part sans.
|
|
173
|
+
* @returns les codes de récupération, affichés une seule fois.
|
|
174
|
+
* @throws quand le code est faux, l'enrôlement absent ou déjà confirmé.
|
|
175
|
+
*/
|
|
176
|
+
async confirmEnrollment(userId, code, context) {
|
|
177
|
+
const activation = await confirmTotpEnrollment(this.#ensureReady(), userId, code);
|
|
178
|
+
recordAudit(this.container, {
|
|
179
|
+
category: "auth",
|
|
180
|
+
action: "user.totp_enabled",
|
|
181
|
+
outcome: "success",
|
|
182
|
+
actor: userId,
|
|
183
|
+
resource: userId,
|
|
184
|
+
...readAuditContext(context)
|
|
185
|
+
});
|
|
186
|
+
return activation;
|
|
163
187
|
}
|
|
164
188
|
/** Vérifie un second facteur au login (code TOTP ou code de récupération). */
|
|
165
189
|
verifyLogin(userId, code) {
|
|
166
190
|
return verifyTotpLogin(this.#ensureReady(), userId, code);
|
|
167
191
|
}
|
|
168
|
-
/**
|
|
169
|
-
|
|
170
|
-
|
|
192
|
+
/**
|
|
193
|
+
* Désactive le 2FA d'un utilisateur.
|
|
194
|
+
*
|
|
195
|
+
* Avec un `context`, c'est le TITULAIRE qui retire son facteur (porte
|
|
196
|
+
* libre-service) : le geste est audité ici (`user.totp_disabled`). Sans
|
|
197
|
+
* contexte, l'appelant porte lui-même l'audit — le plan d'administration le
|
|
198
|
+
* fait avec l'identité de l'administrateur (`viaAdmin`), qu'ici on ignore.
|
|
199
|
+
* Un seul événement par geste, jamais deux.
|
|
200
|
+
*
|
|
201
|
+
* @param userId - compte dont on retire le 2FA.
|
|
202
|
+
* @param context - contexte de la requête du titulaire (audit libre-service).
|
|
203
|
+
*/
|
|
204
|
+
async disable(userId, context) {
|
|
205
|
+
await disableTotp(this.#ensureReady(), userId);
|
|
206
|
+
if (context === void 0) return;
|
|
207
|
+
recordAudit(this.container, {
|
|
208
|
+
category: "auth",
|
|
209
|
+
action: "user.totp_disabled",
|
|
210
|
+
outcome: "success",
|
|
211
|
+
actor: userId,
|
|
212
|
+
resource: userId,
|
|
213
|
+
metadata: { viaAdmin: false },
|
|
214
|
+
...readAuditContext(context)
|
|
215
|
+
});
|
|
171
216
|
}
|
|
172
217
|
/** État 2FA d'un utilisateur (absent / pending / activé + codes restants). */
|
|
173
218
|
status(userId) {
|
|
@@ -207,5 +252,4 @@ var TotpService = class extends Service {
|
|
|
207
252
|
return this.#deps;
|
|
208
253
|
}
|
|
209
254
|
};
|
|
210
|
-
//#endregion
|
|
211
255
|
export { TotpService, TotpService as default };
|
|
@@ -2,13 +2,31 @@ import { AuthenticationError } from "../errors/AuthenticationError.js";
|
|
|
2
2
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
3
3
|
import { WebAuthnError } from "../errors/WebAuthnError.js";
|
|
4
4
|
import { getWebAuthnStoreFactory, listWebAuthnStores } from "../src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
5
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
5
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
6
7
|
import { Buffer } from "node:buffer";
|
|
7
|
-
|
|
8
|
+
// nodefony/service/webAuthn.ts
|
|
8
9
|
const serviceName = "webauthn";
|
|
9
10
|
function isFlushable(s) {
|
|
10
11
|
return s !== null && typeof s.flushNow === "function";
|
|
11
12
|
}
|
|
13
|
+
/** Taille maximale d'un `userHandle` (WebAuthn L3 §4, « User Handle »). */
|
|
14
|
+
const USER_HANDLE_MAX_BYTES = 64;
|
|
15
|
+
/**
|
|
16
|
+
* Octets du `userHandle` d'une cérémonie d'enregistrement — opaques, bornés, sans
|
|
17
|
+
* donnée personnelle (cf {@link IWebAuthnUser.handle}).
|
|
18
|
+
*
|
|
19
|
+
* @param user - sujet de la cérémonie.
|
|
20
|
+
* @returns `handle` encodé s'il est fourni et tient en 64 octets, sinon le
|
|
21
|
+
* condensat SHA-256 de l'`id`.
|
|
22
|
+
*/
|
|
23
|
+
function userHandleBytes(user) {
|
|
24
|
+
if (user.handle !== void 0 && user.handle.length > 0) {
|
|
25
|
+
const bytes = Buffer.from(user.handle, "utf8");
|
|
26
|
+
if (bytes.length <= USER_HANDLE_MAX_BYTES) return new Uint8Array(bytes);
|
|
27
|
+
}
|
|
28
|
+
return new Uint8Array(createHash("sha256").update(user.id, "utf8").digest());
|
|
29
|
+
}
|
|
12
30
|
/**
|
|
13
31
|
* **WebAuthn / passkeys** (P6 J9) — orchestrateur des deux cérémonies FIDO2
|
|
14
32
|
* (WebAuthn L3 §7.1 enregistrement, §7.2 authentification).
|
|
@@ -74,7 +92,7 @@ var WebAuthnService = class extends Service {
|
|
|
74
92
|
let driver = config.passkeys.store;
|
|
75
93
|
reason = `store explicitement configuré ("${driver}")`;
|
|
76
94
|
if (driver === AUTO_STORE) {
|
|
77
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory",
|
|
95
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebAuthnStores(), "memory", storeConnectivity(this.kernel));
|
|
78
96
|
driver = auto.store;
|
|
79
97
|
reason = auto.reason;
|
|
80
98
|
this.log(`passkeys.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -86,7 +104,7 @@ var WebAuthnService = class extends Service {
|
|
|
86
104
|
this.log(`${msg} — passkeys indisponibles`, "CRITIC");
|
|
87
105
|
return;
|
|
88
106
|
}
|
|
89
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(
|
|
107
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("passkeys.store \"memory\" en PRODUCTION — credentials WebAuthn volatils : tous les passkeys enregistrés sont perdus au redémarrage (utilisateurs verrouillés hors de leur compte). " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
90
108
|
this.#store = factory({
|
|
91
109
|
container: this.container,
|
|
92
110
|
config
|
|
@@ -143,7 +161,7 @@ var WebAuthnService = class extends Service {
|
|
|
143
161
|
rpName: this.#rpName,
|
|
144
162
|
rpID: this.#rpID,
|
|
145
163
|
userName: user.name,
|
|
146
|
-
userID:
|
|
164
|
+
userID: userHandleBytes(user),
|
|
147
165
|
userDisplayName: user.displayName ?? user.name,
|
|
148
166
|
attestationType: pk.attestation,
|
|
149
167
|
timeout: pk.timeoutMs,
|
|
@@ -347,5 +365,4 @@ var WebAuthnService = class extends Service {
|
|
|
347
365
|
};
|
|
348
366
|
}
|
|
349
367
|
};
|
|
350
|
-
|
|
351
|
-
export { WebAuthnService, WebAuthnService as default };
|
|
368
|
+
export { WebAuthnService, WebAuthnService as default, userHandleBytes };
|
|
@@ -6,11 +6,11 @@ import { deriveWebhookKey } from "../src/webhook/webhookCipher.js";
|
|
|
6
6
|
import { assertPublicUrl } from "../src/net/ssrfGuard.js";
|
|
7
7
|
import { WebhookDispatcher } from "../src/webhook/WebhookDispatcher.js";
|
|
8
8
|
import { deliverWebhook } from "../src/webhook/webhookDelivery.js";
|
|
9
|
-
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore,
|
|
9
|
+
import { AUTO_STORE, EMPTY_INFRA, Service, countFacets, deriveStoreBackend, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { schemaMismatchOf } from "@nodefony/http";
|
|
13
|
-
|
|
13
|
+
// nodefony/service/webhooks.ts
|
|
14
14
|
const serviceName = "webhooks";
|
|
15
15
|
/** Historique de livraisons gardé PAR endpoint (ring borné, RAM, par pod). */
|
|
16
16
|
const MAX_DELIVERIES_PER_ENDPOINT = 20;
|
|
@@ -155,7 +155,7 @@ var WebhookService = class extends Service {
|
|
|
155
155
|
let driver = config.webhooks.store;
|
|
156
156
|
let reason = `store explicitement configuré ("${driver}")`;
|
|
157
157
|
if (driver === AUTO_STORE) {
|
|
158
|
-
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory",
|
|
158
|
+
const auto = resolveAutoStore("durable", this.kernel?.infra ?? EMPTY_INFRA, listWebhookStores(), "memory", storeConnectivity(this.kernel));
|
|
159
159
|
driver = auto.store;
|
|
160
160
|
reason = auto.reason;
|
|
161
161
|
this.log(`webhooks.store "auto" → "${driver}" (${auto.reason})`, "INFO");
|
|
@@ -167,7 +167,7 @@ var WebhookService = class extends Service {
|
|
|
167
167
|
this.log(`${msg} — webhooks indisponibles`, "CRITIC");
|
|
168
168
|
return null;
|
|
169
169
|
}
|
|
170
|
-
if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(
|
|
170
|
+
if (driver === "memory" && this.kernel?.environment === "production") this.log("webhooks.store \"memory\" en PRODUCTION — abonnements volatils et per-pod : perdus au redémarrage, non partagés entre pods. " + durableStoreRemedy(storeConnectivity(this.kernel)), "WARNING");
|
|
171
171
|
const store = factory({
|
|
172
172
|
container: this.container,
|
|
173
173
|
config
|
|
@@ -546,5 +546,4 @@ var WebhookService = class extends Service {
|
|
|
546
546
|
return addresses;
|
|
547
547
|
}
|
|
548
548
|
};
|
|
549
|
-
//#endregion
|
|
550
549
|
export { WebhookService, WebhookService as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/RoleHierarchyWalker.ts
|
|
2
2
|
/**
|
|
3
3
|
* Résout la hiérarchie de rôles — `ROLE_ADMIN` hérite `ROLE_USER`, etc.
|
|
4
4
|
*
|
|
@@ -73,5 +73,4 @@ var RoleHierarchyWalker = class {
|
|
|
73
73
|
for (const node of Object.keys(hierarchy)) if ((color.get(node) ?? WHITE) === WHITE) visit(node);
|
|
74
74
|
}
|
|
75
75
|
};
|
|
76
|
-
//#endregion
|
|
77
76
|
export { RoleHierarchyWalker, RoleHierarchyWalker as default };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { stripTrailingSlashes } from "nodefony";
|
|
2
|
+
// nodefony/src/SecuredArea.ts
|
|
2
3
|
/**
|
|
3
4
|
* Zone sécurisée concrète — pattern d'URL compilé + métadonnées d'authentification.
|
|
4
5
|
*
|
|
@@ -16,22 +17,30 @@ var SecuredArea = class {
|
|
|
16
17
|
host;
|
|
17
18
|
realtime;
|
|
18
19
|
resource;
|
|
20
|
+
entryPoint;
|
|
21
|
+
subjectPolicy;
|
|
22
|
+
ephemeralRoles;
|
|
19
23
|
/**
|
|
20
24
|
* Rôles exigés par défaut dans la zone, ou `null` quand elle n'en exige
|
|
21
25
|
* aucun. `null` plutôt qu'un tableau vide : le hot path teste une référence,
|
|
22
26
|
* et aucune zone ne porte de tableau qu'elle n'utilise pas.
|
|
23
27
|
*/
|
|
24
28
|
roles;
|
|
29
|
+
#hostKey;
|
|
25
30
|
constructor(name, config) {
|
|
26
31
|
this.name = name;
|
|
27
|
-
this.pattern = new RegExp(config.pattern, "
|
|
32
|
+
this.pattern = new RegExp(config.pattern, "iu");
|
|
28
33
|
this.security = config.security;
|
|
29
34
|
this.stateless = config.stateless;
|
|
30
35
|
this.mode = config.mode;
|
|
31
36
|
this.authenticators = config.authenticators;
|
|
32
37
|
this.host = config.host;
|
|
38
|
+
this.#hostKey = config.host ? config.host.toLowerCase() : null;
|
|
33
39
|
this.realtime = config.realtime;
|
|
34
40
|
this.resource = config.resource;
|
|
41
|
+
this.entryPoint = config.entryPoint;
|
|
42
|
+
this.subjectPolicy = config.subjectPolicy;
|
|
43
|
+
this.ephemeralRoles = config.subjectPolicy === "ephemeral" ? config.ephemeralRoles ?? [] : void 0;
|
|
35
44
|
this.roles = config.roles.length > 0 ? config.roles : null;
|
|
36
45
|
}
|
|
37
46
|
/**
|
|
@@ -40,8 +49,10 @@ var SecuredArea = class {
|
|
|
40
49
|
* UNIQUE de la décision de zone (invariant `api.request {path}` ≤ `GET {path}`).
|
|
41
50
|
*/
|
|
42
51
|
matchPath(pathname, host) {
|
|
43
|
-
if (this
|
|
44
|
-
|
|
52
|
+
if (this.#hostKey !== null && this.#hostKey !== host?.toLowerCase()) return false;
|
|
53
|
+
if (this.pattern.test(pathname)) return true;
|
|
54
|
+
const bare = stripTrailingSlashes(pathname);
|
|
55
|
+
return bare !== pathname && this.pattern.test(bare);
|
|
45
56
|
}
|
|
46
57
|
/** La requête tombe-t-elle dans cette zone ? (host éventuel + pathname). */
|
|
47
58
|
match(context) {
|
|
@@ -54,5 +65,4 @@ var SecuredArea = class {
|
|
|
54
65
|
return this.matchPath(pathname, context.domain);
|
|
55
66
|
}
|
|
56
67
|
};
|
|
57
|
-
//#endregion
|
|
58
68
|
export { SecuredArea, SecuredArea as default };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// nodefony/src/accountBinding.ts
|
|
2
|
+
/**
|
|
3
|
+
* Claim d'un jeton d'accès émis par l'application qui porte l'identifiant
|
|
4
|
+
* IMMUABLE du compte (`IUser.id`) — `sub` porte l'identifiant fonctionnel.
|
|
5
|
+
*/
|
|
6
|
+
const ACCOUNT_ID_CLAIM = "uid";
|
|
7
|
+
/**
|
|
8
|
+
* Clé, dans `metadata` d'un enregistrement de jeton (refresh, clé d'API) comme
|
|
9
|
+
* dans le `metaBag` d'une session, qui porte l'identifiant immuable du compte.
|
|
10
|
+
*/
|
|
11
|
+
const ACCOUNT_ID_KEY = "accountId";
|
|
12
|
+
/**
|
|
13
|
+
* Claim d'un jeton d'accès émis par l'application qui porte la version des
|
|
14
|
+
* identifiants du compte à l'émission (`IUser.credentialsVersion`).
|
|
15
|
+
*/
|
|
16
|
+
const CREDENTIALS_VERSION_CLAIM = "cv";
|
|
17
|
+
/**
|
|
18
|
+
* Clé, dans `metadata` d'un enregistrement de jeton comme dans le `metaBag`
|
|
19
|
+
* d'une session, qui porte la version des identifiants lue à l'émission.
|
|
20
|
+
*/
|
|
21
|
+
const CREDENTIALS_VERSION_KEY = "credentialsVersion";
|
|
22
|
+
/**
|
|
23
|
+
* La version des identifiants à inscrire dans un artefact qu'on émet pour ce
|
|
24
|
+
* compte — 0 quand le compte n'en porte pas encore.
|
|
25
|
+
*
|
|
26
|
+
* @param user - compte pour lequel l'artefact est émis.
|
|
27
|
+
* @returns la version courante, jamais `null`.
|
|
28
|
+
*/
|
|
29
|
+
function credentialsVersionOf(user) {
|
|
30
|
+
return user.credentialsVersion ?? 0;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Dit si un artefact d'accès (jeton, session, clé) appartient bien au compte
|
|
34
|
+
* qu'on vient de recharger par son identifiant fonctionnel, ET s'il a été émis
|
|
35
|
+
* depuis le dernier changement de mot de passe.
|
|
36
|
+
*
|
|
37
|
+
* Les artefacts sont rangés sous l'`identifier`, qu'un compte RECRÉÉ peut
|
|
38
|
+
* reprendre : supprimé hors du framework (`DELETE` SQL, restauration, import)
|
|
39
|
+
* puis revenu sous le même nom, l'identité se rechargerait et l'artefact de
|
|
40
|
+
* l'ancien titulaire ouvrirait le compte du nouveau. L'identifiant immuable
|
|
41
|
+
* lève l'ambiguïté sans dépendre d'aucun événement ni d'aucun stockage : deux
|
|
42
|
+
* comptes successifs portent deux `id` différents. SEULE règle de
|
|
43
|
+
* comparaison — chaque authentificateur l'appelle, aucun ne la recopie.
|
|
44
|
+
*
|
|
45
|
+
* Un artefact SANS liaison est refusé : il a été émis avant que la règle
|
|
46
|
+
* existe, et rien ne dit à qui il appartient.
|
|
47
|
+
*
|
|
48
|
+
* La version des identifiants fait de la coupure au changement de mot de passe
|
|
49
|
+
* un ÉTAT du compte, et non une suppression : si la cascade de révocation
|
|
50
|
+
* échoue, ou si une requête en vol sur un autre processus réécrit la session
|
|
51
|
+
* qu'on vient d'effacer, l'artefact porte toujours l'ancienne version, et il
|
|
52
|
+
* tombe ici. Un artefact émis avant cette règle n'en porte pas : il vaut 0,
|
|
53
|
+
* comme un compte qui n'a jamais changé de mot de passe — le déploiement ne
|
|
54
|
+
* déconnecte personne. Une valeur d'un autre type est refusée.
|
|
55
|
+
*
|
|
56
|
+
* ⚠️ Chemin chaud — appelée sur CHAQUE requête authentifiée : deux comparaisons
|
|
57
|
+
* de chaîne et d'entier, aucune allocation.
|
|
58
|
+
*
|
|
59
|
+
* @param user - compte rechargé par l'identifiant que porte l'artefact.
|
|
60
|
+
* @param boundAccountId - identifiant immuable inscrit dans l'artefact.
|
|
61
|
+
* @param boundCredentialsVersion - version des identifiants inscrite dans
|
|
62
|
+
* l'artefact ({@link CREDENTIALS_VERSION_KEY}), `undefined` si absente.
|
|
63
|
+
* Obligatoire dans la signature pour qu'aucune porte ne l'oublie.
|
|
64
|
+
* @returns `true` si l'artefact a été émis pour CE compte, à sa version courante.
|
|
65
|
+
*/
|
|
66
|
+
function isBoundToAccount(user, boundAccountId, boundCredentialsVersion) {
|
|
67
|
+
return typeof boundAccountId === "string" && boundAccountId.length > 0 && boundAccountId === user.id && (boundCredentialsVersion ?? 0) === (user.credentialsVersion ?? 0);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Recharge le compte qu'un jeton d'accès de CETTE application désigne, ou
|
|
71
|
+
* `null` : sujet inconnu, compte inactif ou verrouillé, jeton émis pour un
|
|
72
|
+
* autre compte du même nom ou avant un changement de mot de passe.
|
|
73
|
+
*
|
|
74
|
+
* SEULE implémentation de la règle « ce jeton ouvre-t-il encore ce compte ? »
|
|
75
|
+
* pour un jeton maison : la zone `jwt` (`JwtAuthenticator`) et le résolveur de
|
|
76
|
+
* compte d'une ressource protégée l'appellent, aucun ne la recopie. Un refus
|
|
77
|
+
* est `null`, jamais une exception : chaque appelant le traduit dans son
|
|
78
|
+
* propre contrat (401 uniforme, outil en `isError`).
|
|
79
|
+
*
|
|
80
|
+
* @param provider - annuaire des comptes de l'application.
|
|
81
|
+
* @param identifier - identifiant fonctionnel porté par le jeton (`sub`).
|
|
82
|
+
* @param boundAccountId - identifiant immuable inscrit dans le jeton ({@link ACCOUNT_ID_CLAIM}).
|
|
83
|
+
* @param boundCredentialsVersion - version des identifiants inscrite ({@link CREDENTIALS_VERSION_CLAIM}).
|
|
84
|
+
* @returns le compte, ou `null` s'il n'est pas (ou plus) celui du jeton.
|
|
85
|
+
*/
|
|
86
|
+
async function loadBoundAccount(provider, identifier, boundAccountId, boundCredentialsVersion) {
|
|
87
|
+
let user;
|
|
88
|
+
try {
|
|
89
|
+
user = await provider.loadUserByIdentifier(identifier);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
if (!user.isActive() || user.isLocked()) return null;
|
|
94
|
+
return isBoundToAccount(user, boundAccountId, boundCredentialsVersion) ? user : null;
|
|
95
|
+
}
|
|
96
|
+
export { ACCOUNT_ID_CLAIM, ACCOUNT_ID_KEY, CREDENTIALS_VERSION_CLAIM, CREDENTIALS_VERSION_KEY, credentialsVersionOf, isBoundToAccount, loadBoundAccount };
|