@nodefony/security 10.0.0-beta.5 → 10.0.0-beta.7
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/README.md +24 -0
- package/dist/_virtual/{_@oxc-project_runtime@0.155.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +63 -61
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +30 -11
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- package/dist/nodefony/contracts/IWebhookPayload.js +1 -0
- package/dist/nodefony/errors/FetchPublicError.js +18 -0
- package/dist/nodefony/errors/SsrfError.js +9 -1
- package/dist/nodefony/errors/StaleSessionError.js +20 -0
- package/dist/nodefony/errors/index.js +7 -5
- package/dist/nodefony/keycloak/keycloakAdmin.js +3 -3
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/keycloak/security-keycloak-realm.js +1 -1
- package/dist/nodefony/service/accessTokenVerifier.js +6 -6
- package/dist/nodefony/service/apiKeys.js +8 -4
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +9 -6
- package/dist/nodefony/service/authorization.js +1 -1
- package/dist/nodefony/service/firewall.js +281 -44
- package/dist/nodefony/service/keycloak.js +1 -1
- package/dist/nodefony/service/oauth2.js +8 -4
- package/dist/nodefony/service/securityHeaders.js +340 -47
- package/dist/nodefony/service/tokenService.js +16 -11
- package/dist/nodefony/service/totp.js +10 -5
- package/dist/nodefony/service/webAuthn.js +46 -7
- package/dist/nodefony/service/webhooks.js +11 -4
- package/dist/nodefony/src/admin/SecurityAdminApi.js +19 -0
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +1 -1
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +31 -7
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +62 -20
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +29 -6
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +22 -9
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +2 -2
- package/dist/nodefony/src/csp.js +265 -10
- package/dist/nodefony/src/cspReports.js +307 -0
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -1
- package/dist/nodefony/src/oauth/metadata.js +1 -1
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +17 -4
- package/dist/nodefony/src/oauth/providers/github.js +1 -1
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +59 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +136 -44
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +8 -2
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/token/tokenTimeClaims.js +34 -0
- package/dist/nodefony/src/totp/totpOperations.js +1 -1
- package/dist/nodefony/src/voter/RoleVoter.js +1 -1
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +71 -17
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/nodefony/testing/index.js +26 -36
- package/dist/types/index.d.ts +5 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +8 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +8 -2
- package/dist/types/nodefony/contracts/IFirewall.d.ts +15 -2
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +7 -3
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +13 -3
- package/dist/types/nodefony/contracts/IWebhookPayload.d.ts +24 -0
- package/dist/types/nodefony/errors/FetchPublicError.d.ts +15 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +8 -1
- package/dist/types/nodefony/errors/StaleSessionError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +3 -0
- package/dist/types/nodefony/keycloak/scaffold.d.ts +12 -6
- package/dist/types/nodefony/service/apiKeys.d.ts +3 -0
- package/dist/types/nodefony/service/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/firewall.d.ts +28 -7
- package/dist/types/nodefony/service/securityHeaders.d.ts +82 -11
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/service/webhooks.d.ts +7 -1
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +3 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -0
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +11 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +17 -3
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +11 -4
- package/dist/types/nodefony/src/csp.d.ts +142 -0
- package/dist/types/nodefony/src/cspReports.d.ts +73 -0
- package/dist/types/nodefony/src/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +15 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +35 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +42 -18
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/token/tokenTimeClaims.d.ts +32 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +36 -1
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/dist/types/nodefony/testing/index.d.ts +6 -0
- package/docs/api-keys.md +3 -1
- package/docs/audit.md +112 -28
- package/docs/authenticators.md +6 -4
- package/docs/csrf.md +3 -2
- package/docs/external-jwt.md +5 -1
- package/docs/firewall.md +3 -2
- package/docs/headers.md +427 -71
- package/docs/keycloak.md +4 -4
- package/docs/login-page.md +4 -3
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/totp.md +2 -2
- package/docs/webauthn.md +2 -2
- package/docs/webhooks.md +63 -18
- package/package.json +17 -17
package/README.md
CHANGED
|
@@ -148,6 +148,11 @@ le trafic courant ne passe pas par l'audit (cold-path)
|
|
|
148
148
|
→ aucun coût ajouté au hot-path nominal. Activé par défaut (`audit.enabled`, OWASP A09), coût nul si
|
|
149
149
|
désactivé. Lecture : `GET /nodefony/security/api/audit/events` (RBAC `ROLE_NODEFONY_ADMIN`).
|
|
150
150
|
|
|
151
|
+
Un **flot de refus** d'une même source (même IP, même motif) ne s'écrit pas ligne à ligne : les
|
|
152
|
+
10 premiers refus par fenêtre de 60 s sont journalisés tels quels, les suivants comptés, puis une ligne
|
|
153
|
+
de synthèse porte le total (`metadata.aggregated`). Les succès ne sont jamais agrégés. Réglage :
|
|
154
|
+
`audit.aggregate` — détail dans [docs/audit.md](docs/audit.md).
|
|
155
|
+
|
|
151
156
|
**Flux temps réel** (lot 4) : canal WS **`nodefony:audit`**, réservé `ROLE_NODEFONY_ADMIN`
|
|
152
157
|
(plancher système, un cran au-dessus de l'observabilité générique `ROLE_ADMIN`). Enregistré comme
|
|
153
158
|
**canal système** sur le hub realtime (servable par tout endpoint, sans couplage à Studio) ;
|
|
@@ -178,6 +183,25 @@ Un **secret n'entre jamais** dans un événement — seule sa _présence_ est tr
|
|
|
178
183
|
|
|
179
184
|
- `AuthenticationError` → `401` (non authentifié).
|
|
180
185
|
- `AccessDeniedError` → `403` (authentifié mais non autorisé).
|
|
186
|
+
- `SsrfError` → `422` (adresse sortante interdite, redirection comprise).
|
|
187
|
+
- `FetchPublicError` → `504` (délai total) ou `502` (trop de redirections, réseau).
|
|
188
|
+
|
|
189
|
+
## Requêtes sortantes sûres
|
|
190
|
+
|
|
191
|
+
`fetchPublic(url, options)` lit une adresse fournie par un humain sans atteindre le réseau interne : IP vérifiée
|
|
192
|
+
puis épinglée à chaque saut, redirections comptées et revérifiées (`maxRedirects`, 0 par défaut), délai TOTAL
|
|
193
|
+
(`timeoutMs`), corps coupé à `maxBytes` et non décodé, ni cookie ni `Authorization`. Les webhooks passent par elle.
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
import { fetchPublic } from "@nodefony/security";
|
|
197
|
+
|
|
198
|
+
const page = await fetchPublic(url, {
|
|
199
|
+
maxRedirects: 3,
|
|
200
|
+
maxBytes: 262_144,
|
|
201
|
+
timeoutMs: 4000,
|
|
202
|
+
});
|
|
203
|
+
console.log(page.status, new TextDecoder().decode(page.body));
|
|
204
|
+
```
|
|
181
205
|
|
|
182
206
|
## Licence
|
|
183
207
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// \0@oxc-project+runtime@0.
|
|
1
|
+
// \0@oxc-project+runtime@0.158.0/helpers/esm/decorate.js
|
|
2
2
|
function __decorate(decorators, target, key, desc) {
|
|
3
3
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
4
4
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
package/dist/index.js
CHANGED
|
@@ -1,94 +1,96 @@
|
|
|
1
|
-
import { JwtKeystore } from "./nodefony/src/token/JwtKeystore.js";
|
|
2
1
|
import config from "./nodefony/config/config.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
2
|
+
import { Firewall } from "./nodefony/service/firewall.js";
|
|
3
|
+
import { AuthFlow } from "./nodefony/service/authFlow.js";
|
|
4
|
+
import { TokenService } from "./nodefony/service/tokenService.js";
|
|
5
5
|
import { InsecureTransportError } from "./nodefony/errors/InsecureTransportError.js";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { AccessTokenVerifierService } from "./nodefony/service/accessTokenVerifier.js";
|
|
7
|
+
import { Authorization } from "./nodefony/service/authorization.js";
|
|
8
|
+
import { WebAuthnService } from "./nodefony/service/webAuthn.js";
|
|
9
9
|
import { CsrfError } from "./nodefony/errors/CsrfError.js";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import
|
|
10
|
+
import { OAuth2Service } from "./nodefony/service/oauth2.js";
|
|
11
|
+
import { ApiKeyService } from "./nodefony/service/apiKeys.js";
|
|
12
|
+
import AuditService from "./nodefony/service/auditService.js";
|
|
13
13
|
import { AuthenticationError } from "./nodefony/errors/AuthenticationError.js";
|
|
14
14
|
import { ThrottledError } from "./nodefony/errors/ThrottledError.js";
|
|
15
15
|
import { UnverifiableTokenError } from "./nodefony/errors/UnverifiableTokenError.js";
|
|
16
|
+
import { TotpService } from "./nodefony/service/totp.js";
|
|
17
|
+
import { WebhookService } from "./nodefony/service/webhooks.js";
|
|
18
|
+
import { KeycloakService } from "./nodefony/service/keycloak.js";
|
|
19
|
+
import SecuritySecrets from "./nodefony/command/security-secrets.js";
|
|
20
|
+
import SecurityUserAdd from "./nodefony/command/security-user-add.js";
|
|
21
|
+
import SecurityUserList from "./nodefony/command/security-user-list.js";
|
|
22
|
+
import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
|
|
23
|
+
import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
24
|
+
import { publicRoutesNotice } from "./nodefony/src/boot/publicRoutesNotice.js";
|
|
25
|
+
import SecurityMcpToken from "./nodefony/command/security-mcp-token.js";
|
|
26
|
+
import SecurityKeycloakRealm from "./nodefony/keycloak/security-keycloak-realm.js";
|
|
27
|
+
import SecurityOAuthDoctor from "./nodefony/command/security-oauth-doctor.js";
|
|
28
|
+
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
29
|
+
import { registerUserRevocationCascade, usersEventBusNotice } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
16
30
|
import { defineSecurityConfig, securityConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
|
|
31
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
|
|
32
|
+
import { RemoteJwtVerifier } from "./nodefony/src/token/RemoteJwtVerifier.js";
|
|
33
|
+
import { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
|
|
34
|
+
import { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
|
|
35
|
+
import { InvalidTargetError } from "./nodefony/errors/InvalidTargetError.js";
|
|
36
|
+
import { SecuredArea } from "./nodefony/src/SecuredArea.js";
|
|
37
|
+
import { Csrf } from "./nodefony/service/csrf.js";
|
|
38
|
+
import { CsrfTokenManager } from "./nodefony/src/csrfToken.js";
|
|
39
|
+
import { Cors } from "./nodefony/service/cors.js";
|
|
40
|
+
import { SecurityHeaders } from "./nodefony/service/securityHeaders.js";
|
|
41
|
+
import { RoleHierarchyWalker } from "./nodefony/src/RoleHierarchyWalker.js";
|
|
42
|
+
import { VoterVote } from "./nodefony/contracts/IAccessVoter.js";
|
|
17
43
|
import { AnonymousToken } from "./nodefony/src/token/AnonymousToken.js";
|
|
18
|
-
import { AnonymousAuthenticator } from "./nodefony/src/authenticator/AnonymousAuthenticator.js";
|
|
19
44
|
import { UserToken } from "./nodefony/src/token/UserToken.js";
|
|
20
|
-
import {
|
|
45
|
+
import { RoleVoter } from "./nodefony/src/voter/RoleVoter.js";
|
|
46
|
+
import { ScopeVoter } from "./nodefony/src/voter/ScopeVoter.js";
|
|
47
|
+
import { listVoterFactories, registerVoterFactory } from "./nodefony/src/voter/voterRegistry.js";
|
|
48
|
+
import { AnonymousAuthenticator } from "./nodefony/src/authenticator/AnonymousAuthenticator.js";
|
|
21
49
|
import { UserPasswordAuthenticator } from "./nodefony/src/authenticator/UserPasswordAuthenticator.js";
|
|
22
|
-
import
|
|
50
|
+
import { SessionAuthenticator } from "./nodefony/src/authenticator/SessionAuthenticator.js";
|
|
23
51
|
import { JwtAuthenticator } from "./nodefony/src/authenticator/JwtAuthenticator.js";
|
|
24
|
-
import { generateApiKey, hashApiKey, looksLikeApiKey, parseApiKey } from "./nodefony/src/apikey/apiKeyFormat.js";
|
|
25
52
|
import { ApiKeyAuthenticator } from "./nodefony/src/authenticator/ApiKeyAuthenticator.js";
|
|
26
53
|
import { ExternalJwtAuthenticator } from "./nodefony/src/authenticator/ExternalJwtAuthenticator.js";
|
|
27
|
-
import
|
|
54
|
+
import peekIssuer from "./nodefony/src/authenticator/peekIssuer.js";
|
|
55
|
+
import { generateApiKey, hashApiKey, looksLikeApiKey, parseApiKey } from "./nodefony/src/apikey/apiKeyFormat.js";
|
|
28
56
|
import { getAuthenticatorFactory, listAuthenticatorFactories, registerAuthenticatorFactory } from "./nodefony/src/authenticator/authenticatorRegistry.js";
|
|
29
|
-
import {
|
|
30
|
-
import { readAuditContext } from "./nodefony/src/audit/readAuditContext.js";
|
|
31
|
-
import { Firewall } from "./nodefony/service/firewall.js";
|
|
32
|
-
import { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
|
|
33
|
-
import { AuthFlow } from "./nodefony/service/authFlow.js";
|
|
34
|
-
import { InvalidTargetError } from "./nodefony/errors/InvalidTargetError.js";
|
|
57
|
+
import { MemoryTokenStore } from "./nodefony/src/token/MemoryTokenStore.js";
|
|
35
58
|
import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS } from "./nodefony/src/token/tokenSort.js";
|
|
59
|
+
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
36
60
|
import { matchesTokenStatus, tokenStatusOf } from "./nodefony/src/token/tokenStatus.js";
|
|
37
|
-
import {
|
|
61
|
+
import { TOKEN_FACETS } from "./nodefony/src/token/tokenFilters.js";
|
|
62
|
+
import { JwtKeystore } from "./nodefony/src/token/JwtKeystore.js";
|
|
63
|
+
import { resolveJwtRuntime } from "./nodefony/src/token/jwtRuntime.js";
|
|
38
64
|
import { getTokenStoreFactory, listTokenStores, registerTokenStore } from "./nodefony/src/token/tokenStoreRegistry.js";
|
|
39
|
-
import { TokenService } from "./nodefony/service/tokenService.js";
|
|
40
|
-
import { RemoteJwtVerifier } from "./nodefony/src/token/RemoteJwtVerifier.js";
|
|
41
|
-
import { AccessTokenVerifierService } from "./nodefony/service/accessTokenVerifier.js";
|
|
42
|
-
import { VoterVote } from "./nodefony/contracts/IAccessVoter.js";
|
|
43
|
-
import { RoleVoter } from "./nodefony/src/voter/RoleVoter.js";
|
|
44
|
-
import { ScopeVoter } from "./nodefony/src/voter/ScopeVoter.js";
|
|
45
|
-
import { listVoterFactories, registerVoterFactory } from "./nodefony/src/voter/voterRegistry.js";
|
|
46
|
-
import { Authorization } from "./nodefony/service/authorization.js";
|
|
47
65
|
import { MemoryWebAuthnCredentialStore } from "./nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js";
|
|
48
66
|
import { getWebAuthnStoreFactory, listWebAuthnStores, registerWebAuthnStore } from "./nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js";
|
|
49
|
-
import {
|
|
50
|
-
import { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme } from "./nodefony/keycloak/keycloakAdmin.js";
|
|
67
|
+
import { getOAuthProviderFactory, listOAuthProviders, oauthProviderRequiresIssuer, registerOAuthProvider } from "./nodefony/src/oauth/oauthProviderRegistry.js";
|
|
51
68
|
import { OAuth2Client, OAuth2RequestError, OAuth2Tokens, createCodeChallenge, generateCodeVerifier, generateState } from "./nodefony/src/oauth/oauth2Client.js";
|
|
52
69
|
import { discoverAuthorizationServer } from "./nodefony/src/oauth/metadata.js";
|
|
70
|
+
import { requestClientCredentialsToken } from "./nodefony/src/oauth/clientCredentials.js";
|
|
71
|
+
import { diagnoseOAuthProvider } from "./nodefony/src/oauth/providerDiagnosis.js";
|
|
72
|
+
import { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakLoginThemeCheck, readKeycloakLoginTheme } from "./nodefony/keycloak/keycloakAdmin.js";
|
|
53
73
|
import { createDiscoveredOidcProvider, createOidcProvider, readLogoutTokenClaims } from "./nodefony/src/oauth/providers/oidc.js";
|
|
74
|
+
import { SsrfError } from "./nodefony/errors/SsrfError.js";
|
|
54
75
|
import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
|
|
55
|
-
import {
|
|
56
|
-
import { diagnoseOAuthProvider } from "./nodefony/src/oauth/providerDiagnosis.js";
|
|
57
|
-
import { OAuth2Service } from "./nodefony/service/oauth2.js";
|
|
58
|
-
import { TOKEN_FACETS } from "./nodefony/src/token/tokenFilters.js";
|
|
59
|
-
import { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
|
|
60
|
-
import { ApiKeyService } from "./nodefony/service/apiKeys.js";
|
|
76
|
+
import { FetchPublicError } from "./nodefony/errors/FetchPublicError.js";
|
|
61
77
|
import { MemoryAuditStore } from "./nodefony/src/audit/MemoryAuditStore.js";
|
|
62
78
|
import { getAuditStoreFactory, listAuditStores, registerAuditStore } from "./nodefony/src/audit/auditStoreRegistry.js";
|
|
63
|
-
import
|
|
79
|
+
import { recordAudit } from "./nodefony/src/audit/recordAudit.js";
|
|
80
|
+
import { readAuditContext } from "./nodefony/src/audit/readAuditContext.js";
|
|
64
81
|
import { MemoryTotpSecretStore } from "./nodefony/src/totp/MemoryTotpSecretStore.js";
|
|
65
82
|
import { getTotpStoreFactory, listTotpStores, registerTotpStore } from "./nodefony/src/totp/totpSecretStoreRegistry.js";
|
|
66
|
-
import { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey } from "./nodefony/src/crypto/secretCipher.js";
|
|
67
|
-
import { deriveTotpKey } from "./nodefony/src/totp/totpCipher.js";
|
|
68
|
-
import { base32Decode, totpCode } from "./nodefony/src/totp/totpCrypto.js";
|
|
69
83
|
import { beginTotpEnrollment, confirmTotpEnrollment, disableTotp, totpStatus, verifyTotpLogin } from "./nodefony/src/totp/totpOperations.js";
|
|
70
|
-
import {
|
|
71
|
-
import {
|
|
84
|
+
import { base32Decode, totpCode } from "./nodefony/src/totp/totpCrypto.js";
|
|
85
|
+
import { deriveTotpKey } from "./nodefony/src/totp/totpCipher.js";
|
|
72
86
|
import { MemoryWebhookStore } from "./nodefony/src/webhook/MemoryWebhookStore.js";
|
|
87
|
+
import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "./nodefony/src/webhook/webhookSort.js";
|
|
73
88
|
import { getWebhookStoreFactory, listWebhookStores, registerWebhookStore } from "./nodefony/src/webhook/webhookStoreRegistry.js";
|
|
74
|
-
import { SsrfError } from "./nodefony/errors/SsrfError.js";
|
|
75
89
|
import { assertPublicUrl, isBlockedAddress } from "./nodefony/src/net/ssrfGuard.js";
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
78
|
-
import
|
|
79
|
-
import
|
|
80
|
-
import SecurityUserList from "./nodefony/command/security-user-list.js";
|
|
81
|
-
import SecurityUserDelete from "./nodefony/command/security-user-delete.js";
|
|
82
|
-
import SecurityUserPassword from "./nodefony/command/security-user-password.js";
|
|
83
|
-
import { publicRoutesNotice } from "./nodefony/src/boot/publicRoutesNotice.js";
|
|
84
|
-
import SecurityMcpToken from "./nodefony/command/security-mcp-token.js";
|
|
85
|
-
import SecurityKeycloakRealm from "./nodefony/keycloak/security-keycloak-realm.js";
|
|
86
|
-
import SecurityOAuthDoctor from "./nodefony/command/security-oauth-doctor.js";
|
|
87
|
-
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
88
|
-
import { registerUserRevocationCascade, usersEventBusNotice } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
89
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.155.0/helpers/esm/decorate.js";
|
|
90
|
-
import { tokenStatusCriteria } from "./nodefony/src/token/tokenCriteria.js";
|
|
91
|
-
import { requestClientCredentialsToken } from "./nodefony/src/oauth/clientCredentials.js";
|
|
90
|
+
import { fetchPublic } from "./nodefony/src/net/fetchPublic.js";
|
|
91
|
+
import { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey } from "./nodefony/src/crypto/secretCipher.js";
|
|
92
|
+
import { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
|
|
93
|
+
import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "./nodefony/src/transport/CredentialTransportPolicy.js";
|
|
92
94
|
import { AccessDeniedError } from "./nodefony/errors/AccessDeniedError.js";
|
|
93
95
|
import "./nodefony/errors/index.js";
|
|
94
96
|
import "./nodefony/contracts/index.js";
|
|
@@ -186,4 +188,4 @@ Security = __decorate([services([
|
|
|
186
188
|
KeycloakService
|
|
187
189
|
])], Security);
|
|
188
190
|
var security_default = Security;
|
|
189
|
-
export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, CredentialTransportPolicy, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, Firewall, InsecureTransportError, InvalidTargetError, JwtAuthenticator, JwtKeystore, KeycloakAdminClient, KeycloakAdminError, KeycloakService, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, amrForFactor, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, diagnoseOAuthProvider, disableTotp, discoverAuthorizationServer, encryptSecret, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, keycloakAdminTarget, keycloakLoginThemeCheck, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, oauthProviderRequiresIssuer, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, readKeycloakLoginTheme, readLogoutTokenClaims, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, requestClientCredentialsToken, resolveJwtRuntime, securityConfigJsonSchema, shouldEnforceCredentialTransport, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
|
|
191
|
+
export { AccessDeniedError, AccessTokenVerifierService, AnonymousAuthenticator, AnonymousToken, ApiKeyAuthenticator, ApiKeyError, ApiKeyService, AuditService, AuthFlow, AuthenticationError, Authorization, Cors, CredentialTransportPolicy, Csrf, CsrfError, CsrfTokenManager, ExternalJwtAuthenticator, FetchPublicError, Firewall, InsecureTransportError, InvalidTargetError, JwtAuthenticator, JwtKeystore, KeycloakAdminClient, KeycloakAdminError, KeycloakService, LoginThrottler, MemoryAuditStore, MemoryTokenStore, MemoryTotpSecretStore, MemoryWebAuthnCredentialStore, MemoryWebhookStore, OAuth2Client, OAuth2RequestError, OAuth2Service, OAuth2Tokens, RemoteJwtVerifier, RoleHierarchyWalker, RoleVoter, ScopeVoter, SecuredArea, SecurityHeaders, SessionAuthenticator, SsrfError, TOKEN_DEFAULT_ORDER, TOKEN_FACETS, TOKEN_SORTABLE_FIELDS, ThrottledError, TokenService, TotpService, UnverifiableTokenError, UserPasswordAuthenticator, UserToken, VoterVote, WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS, WebAuthnService, WebhookService, amrForFactor, assertPublicUrl, base32Decode, beginTotpEnrollment, confirmTotpEnrollment, createCodeChallenge, createDiscoveredOidcProvider, createGithubProvider, createOidcProvider, createSecurityAdminApi, decryptSecret, security_default as default, defineSecurityConfig, deriveKey, deriveTotpKey, diagnoseOAuthProvider, disableTotp, discoverAuthorizationServer, encryptSecret, fetchPublic, generateApiKey, generateCodeVerifier, generateEphemeralKey, generateState, getAuditStoreFactory, getAuthenticatorFactory, getOAuthProviderFactory, getTokenStoreFactory, getTotpStoreFactory, getWebAuthnStoreFactory, getWebhookStoreFactory, hashApiKey, isBlockedAddress, keycloakAdminTarget, keycloakLoginThemeCheck, listAuditStores, listAuthenticatorFactories, listOAuthProviders, listTokenStores, listTotpStores, listVoterFactories, listWebAuthnStores, listWebhookStores, looksLikeApiKey, matchesTokenStatus, oauthProviderRequiresIssuer, parseApiKey, parseAuditQuery, peekIssuer, readAuditContext, readKeycloakLoginTheme, readLogoutTokenClaims, recordAudit, registerAuditStore, registerAuthenticatorFactory, registerOAuthProvider, registerSecurityAdminApi, registerTokenStore, registerTotpStore, registerVoterFactory, registerWebAuthnStore, registerWebhookStore, requestClientCredentialsToken, resolveJwtRuntime, securityConfigJsonSchema, shouldEnforceCredentialTransport, tokenStatusCriteria, tokenStatusOf, totpCode, totpStatus, verifyTotpLogin };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { messageNonRestreint, modeNonRestreint, readIfPresentSync, writeSecretSync } from "../src/token/secretFile.js";
|
|
2
|
-
import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents } from "nodefony";
|
|
2
|
+
import { ADMIN_SCOPE_READ, ADMIN_SCOPE_WRITE, AGENT_TARGETS, CONSOLE_DATA_RUN_PROFILE, Command, MCP_ENDPOINT_PATH, MCP_TOKEN_ENV, agentRoot, agentsPresents, alreadyHasKey, chargePrompts, poseVariable, requestedAgents, stripTrailingSlashes } from "nodefony";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
@@ -61,6 +61,30 @@ function tokenTtlSeconds(raw, write) {
|
|
|
61
61
|
if (raw !== void 0) return ttlSeconds(raw);
|
|
62
62
|
return write ? AGENT_TTL_MINUTES * 60 : void 0;
|
|
63
63
|
}
|
|
64
|
+
/** Une URI de ressource sous une forme comparable : sans barre finale. */
|
|
65
|
+
function comparableResource(uri) {
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(uri);
|
|
68
|
+
return `${url.origin}${stripTrailingSlashes(url.pathname)}`;
|
|
69
|
+
} catch {
|
|
70
|
+
return stripTrailingSlashes(uri.trim());
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Le jeton vise-t-il une AUTRE porte que celle de l'outillage ?
|
|
75
|
+
*
|
|
76
|
+
* `NF_MCP_TOKEN` et `--write` servent la porte par défaut, et elle seule : un
|
|
77
|
+
* jeton d'une autre audience (RFC 8707) y prendrait sa place, et cette porte le
|
|
78
|
+
* refuserait — ses outils réservés disparaîtraient sans un mot.
|
|
79
|
+
*
|
|
80
|
+
* @param resource - la valeur de `--resource`, ou rien
|
|
81
|
+
* @param defaultResource - la porte par défaut de l'application
|
|
82
|
+
* @returns `true` quand `--resource` désigne une autre porte
|
|
83
|
+
*/
|
|
84
|
+
function targetsOtherGate(resource, defaultResource) {
|
|
85
|
+
if (resource === void 0) return false;
|
|
86
|
+
return comparableResource(resource) !== comparableResource(defaultResource);
|
|
87
|
+
}
|
|
64
88
|
/** « 8 h », « 1 h 30 », « 15 min » — la durée telle qu'on la dit. */
|
|
65
89
|
function humanDuration(seconds) {
|
|
66
90
|
const minutes = Math.round(seconds / 60);
|
|
@@ -274,11 +298,20 @@ var SecurityMcpToken = class extends Command {
|
|
|
274
298
|
process.exitCode = 1;
|
|
275
299
|
return this;
|
|
276
300
|
}
|
|
277
|
-
const
|
|
301
|
+
const defaultResource = this.#defaultResource();
|
|
302
|
+
const resource = opts.resource ?? defaultResource;
|
|
303
|
+
const otherGate = targetsOtherGate(opts.resource, defaultResource);
|
|
304
|
+
if (otherGate && opts.write === true) {
|
|
305
|
+
this.log(`--write refusé pour cette porte.
|
|
306
|
+
|
|
307
|
+
--write pose ${MCP_TOKEN_ENV}, le jeton de la porte de l'outillage\n (${defaultResource}). Ce jeton-ci a pour audience ${resource} :\n il y prendrait sa place, cette porte le refuserait, et ses outils\n réservés disparaîtraient sans un mot.\n\n → relance sans --write, puis pose le jeton dans l'en-tête de\n l'agent qui parle à ${resource}.`, "ERROR");
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
return this;
|
|
310
|
+
}
|
|
278
311
|
const requestedScopes = (opts.scope ?? "").split(/\s+/u).filter(Boolean);
|
|
279
312
|
const scopes = requestedScopes.length > 0 ? requestedScopes : [ADMIN_SCOPE_READ];
|
|
280
313
|
let write = opts.write === true;
|
|
281
|
-
if (!write && !opts.json && process.stdin.isTTY) {
|
|
314
|
+
if (!write && !opts.json && !otherGate && process.stdin.isTTY) {
|
|
282
315
|
await this.loadPrompts();
|
|
283
316
|
write = await this.prompts.confirm({
|
|
284
317
|
message: `Poser ${MCP_TOKEN_ENV} dans la configuration des agents présents ?`,
|
|
@@ -295,7 +328,7 @@ var SecurityMcpToken = class extends Command {
|
|
|
295
328
|
try {
|
|
296
329
|
issued = await tokens.issueTokens(user, scopes, resource, ttlS);
|
|
297
330
|
} catch (e) {
|
|
298
|
-
if (e.oauthError === "invalid_target") {
|
|
331
|
+
if ((typeof e === "object" && e !== null && "oauthError" in e ? e.oauthError : void 0) === "invalid_target") {
|
|
299
332
|
const env = this.kernel?.environment ?? "?";
|
|
300
333
|
const enDev = env === "development";
|
|
301
334
|
this.log(`impossible d'émettre un jeton pour cette porte ici.
|
|
@@ -366,9 +399,14 @@ var SecurityMcpToken = class extends Command {
|
|
|
366
399
|
w(`${DIM} Vibe et Codex prennent le NOM de la variable, pas le secret — déclare la porte une fois :${RESET}\n\n ${DIM}vibe mcp add nodefony --transport streamable-http \\${RESET}\n ${DIM} --url ${resource} --api-key-env ${MCP_TOKEN_ENV}${RESET}\n ${DIM}codex mcp add nodefony --url ${resource} \\${RESET}\n ${DIM} --bearer-token-env-var ${MCP_TOKEN_ENV}${RESET}\n\n`);
|
|
367
400
|
return this;
|
|
368
401
|
}
|
|
402
|
+
if (otherGate) {
|
|
403
|
+
w(` ${token}\n\n`);
|
|
404
|
+
w(`${DIM} Ce jeton ne vaut que pour ${resource} : pose-le dans l'en-tête\n de l'agent qui parle à cette porte, par exemple :\n claude mcp add --transport http <nom> ${resource} \\\n --header "Authorization: Bearer <ce jeton>"\n Pas dans ${MCP_TOKEN_ENV} : elle porte le jeton de ${defaultResource}.${RESET}\n\n`);
|
|
405
|
+
return this;
|
|
406
|
+
}
|
|
369
407
|
w(` export ${MCP_TOKEN_ENV}=${token}\n\n`);
|
|
370
408
|
w(`${DIM} --write pose la valeur chez les agents présents · nodefony ai:mcp --auth câble .mcp.json${RESET}\n\n`);
|
|
371
409
|
return this;
|
|
372
410
|
}
|
|
373
411
|
};
|
|
374
|
-
export { SecurityMcpToken as default, tokenTtlSeconds, ttlSeconds };
|
|
412
|
+
export { SecurityMcpToken as default, targetsOtherGate, tokenTtlSeconds, ttlSeconds };
|
|
@@ -51,7 +51,7 @@ const areaSchema = z.strictObject({
|
|
|
51
51
|
roles: z.array(z.string()).default([]).describe("Rôle(s) exigés PAR DÉFAUT dans la zone, en OU (l'un suffit ; hiérarchie comprise). Vide (défaut) = la zone n'exige qu'une identité, et toute route qu'elle couvre est ouverte à n'importe quel compte authentifié — c'est un défaut OUVERT, qu'une surface d'administration ne doit pas garder. Renseigné, le rôle s'applique à toute route de la zone qui ne décide PAS elle-même de son autorisation : une route gardée (`@IsGranted`, `@RequireScope`) et le pont du plan d'administration (qui résout un rôle par point d'entrée) gardent la main — sans quoi une zone fermée à l'administrateur écraserait un point d'entrée voulu accessible à son propriétaire (`me`, `sessions/mine`). Un refus ici est un 403 (authentifié, pas autorisé), jamais un 401."),
|
|
52
52
|
entryPoint: z.string().refine((value) => safeRedirectPath(value, "") === value, { message: "entryPoint doit être un chemin de CETTE origine (`/login`), jamais une adresse externe" }).optional().describe("Page de connexion vers laquelle conduire un visiteur anonyme qui NAVIGUE (GET ou HEAD, `Accept: text/html`, `Sec-Fetch-Mode: navigate` s'il est présent) : 302 vers `<entryPoint>?from=<chemin demandé>`, et la page de connexion ramène au chemin demandé. Tout le reste — appel d'API, `fetch`, POST, WebSocket — garde son 401 et son `WWW-Authenticate`. Le refus reste journalisé (`auth.denied`). Absent (défaut) : 401 pour tous."),
|
|
53
53
|
host: z.string().optional().describe("Domaine/vhost de la zone (ex. admin.exemple.com). Omis = tous domaines."),
|
|
54
|
-
resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. Ex. « https://api.example.com/mcp »."),
|
|
54
|
+
resource: z.string().optional().describe("URI canonique de la ressource protégée — l'AUDIENCE qu'un jeton doit porter pour être accepté ici (RFC 8707 §2). Obligatoire dès que la zone liste un authenticator de jetons TIERS (`external-jwt`), qui refuse de démarrer sans elle. C'est ce qui empêche un jeton valide, délivré au même porteur pour un AUTRE service, d'être rejoué sur celui-ci. Elle s'ÉCRIT et ne se dérive pas du `Host` : derrière un relais, ce que le processus croit être son adresse n'est pas ce que le client a demandé, et l'audience doit être exactement celle que le serveur d'autorisation a inscrite dans le jeton. L'application peut aussi ÉMETTRE des jetons pour cette ressource (`resource` à l'émission, RFC 8707) sans la recopier dans `jwt.audiences` : elle y est ajoutée après les audiences déclarées, qui gardent l'audience par défaut. Un CHEMIN (« /api/partner ») est rendu absolu contre `jwt.issuer`, l'adresse publique de l'application — la forme d'un module qui apporte sa zone sans connaître cette adresse ; le démarrage refuse si l'émetteur n'est pas une URL http(s). Ex. « https://api.example.com/mcp »."),
|
|
55
55
|
realtime: z.boolean().default(true).describe("Zone valable AUSSI pour les frames WebSocket (api.request + subscribe), pas seulement HTTP. Défaut `true` (Zero Trust : une zone protégée ferme TOUS ses transports — un opt-IN laisserait le WS anonyme par omission = fail-open). `false` = opt-out explicite pour une zone strictement HTTP. Le verrou WS consulte la MÊME zone que HTTP — invariant : `api.request {path}` n'accorde jamais plus que `GET {path}`."),
|
|
56
56
|
subjectPolicy: z.enum(["require", "ephemeral"]).optional().describe("Surcharge, pour CETTE zone, de `resourceServer.subjectPolicy` — comment le sujet d'un jeton TIERS devient un utilisateur. Omis (défaut) = la politique globale. Sert à faire cohabiter une zone d'appelants machine (« ephemeral » : aucun compte local exigé) et une zone de personnes (« require ») dans la même application ; une politique globale ne sait pas l'exprimer. N'a de sens que si la zone liste `external-jwt` : déclarée ailleurs, le boot la refuse."),
|
|
57
57
|
ephemeralRoles: z.array(z.string().min(1)).optional().describe("Rôles accordés à l'appelant accepté par CETTE zone en mode « ephemeral ». Exige `subjectPolicy: \"ephemeral\"` écrit dans la zone même : un pouvoir local ne s'hérite pas d'un réglage écrit ailleurs. Une zone qui déclare « ephemeral » sans ses rôles n'en accorde AUCUN — jamais ceux de `resourceServer.ephemeralRoles`, qui protègent une autre surface.")
|
|
@@ -96,20 +96,28 @@ const csrfSchema = z.strictObject({
|
|
|
96
96
|
secret: z.string().min(16).optional().describe("Secret HMAC du token synchronizer (`@CsrfProtect`, défense en profondeur opt-in). PROD : fixer via env (≥16 car.) — DOIT être PARTAGÉ entre process (cluster) sinon les tokens d'un pod sont rejetés par un autre. DEV : si absent, un secret éphémère est généré au boot (re-généré à chaque restart → invalide les tokens en cours). Sans valeur, `@CsrfProtect` fonctionne en dev mais N'EST PAS sûr en cluster.")
|
|
97
97
|
}).describe("Cross-Site Request Forgery — Fetch Metadata d'abord (OWASP 2025) ; token synchronizer = opt-in @CsrfProtect.");
|
|
98
98
|
const headersSchema = z.strictObject({
|
|
99
|
-
enabled: z.boolean().default(true).describe("
|
|
99
|
+
enabled: z.boolean().default(true).describe("Couche APPLICATIVE des en-têtes de sécurité : CSP, Referrer-Policy, COOP/COEP/CORP, Origin-Agent-Cluster, Permissions-Policy. `false` les coupe tous. Le transport (X-Content-Type-Options, X-Frame-Options, HSTS) n'en dépend pas : il se règle dans `http.securityHeaders`. Couper la seule CSP : `csp: \"\"`."),
|
|
100
100
|
hsts: z.boolean().default(true).meta({
|
|
101
101
|
reserved: true,
|
|
102
|
-
description: "INERTE ICI — l'en-tête est posé par @nodefony/http
|
|
102
|
+
description: "INERTE ICI — l'en-tête est posé par @nodefony/http sur toute réponse (couvre statics, erreurs et serveur nu ; une seule source par en-tête). Pour le régler : `http.securityHeaders.strictTransportSecurity` (`null` pour ne pas émettre l'en-tête)."
|
|
103
103
|
}),
|
|
104
104
|
hstsMaxAgeS: z.number().int().default(31536e3).meta({
|
|
105
105
|
reserved: true,
|
|
106
106
|
description: "INERTE ICI — pour régler la durée : `http.securityHeaders.strictTransportSecurity.maxAge`."
|
|
107
107
|
}),
|
|
108
|
-
csp: z.string().default("default-src 'self'; script-src '
|
|
108
|
+
csp: z.string().default("default-src 'self'; script-src 'nonce-{{nonce}}' 'strict-dynamic' 'self'; style-src 'self' 'unsafe-inline' 'nonce-{{nonce}}'; style-src-attr 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'; upgrade-insecure-requests").describe("Content-Security-Policy « secure-but-usable ». `script-src` : `{{nonce}}` (substitué par requête si `cspNonces`) et `'strict-dynamic'` — un navigateur CSP3 ignore alors `'self'`, gardé en repli CSP2. `style-src` : le nonce ferme l'exfiltration par `<style>` injecté ; `'unsafe-inline'` n'y joue que là où le nonce est retiré (CSP2+ l'ignore en présence d'un nonce), et `style-src-attr 'unsafe-inline'` tolère les attributs `style=` (ADR-0024 §C2) ; un document servi tel quel (fichier statique) reçoit la politique sans nonce ni `'strict-dynamic'` (ADR-0024 §C1) = défense XSS ; le reste couvre les besoins réels (img/font inline, blobs, fetch/WS same-origin, workers) + durcissements (object 'none', base-uri/form-action 'self'). `upgrade-insecure-requests` n’est posé que sur une page servie en https (proxy de confiance compris) ; le retirer de cette chaîne le coupe partout."),
|
|
109
|
+
cspSources: z.record(z.string(), z.record(z.string(), z.array(z.string()))).default({}).describe("Sources AJOUTÉES à la politique `csp` sans la réécrire, par contribution nommée : `{ cdn: { \"img-src\": [\"https://cdn.example.com\"] } }`. Indexé par NOM et non par directive : la fusion de configuration remplace un tableau case par case, deux contributions nommées coexistent (une application et un module, par exemple) ; un même nom écrit deux fois est un remplacement. Fusionné par la même règle que les contributions des modules ; une contribution malformée est ignorée et journalisée, une source ouverte à tout hôte est gardée (choix de l'application) et dite. Sans effet quand `csp` vaut `\"\"`."),
|
|
109
110
|
cspNonces: z.boolean().default(true).describe("Nonce CSP par requête (bloque l'inline non signé)."),
|
|
111
|
+
cspReport: z.union([z.boolean(), z.string()]).default(true).describe("Rapports de violation CSP (ADR-0024 §C7). `true` : la politique les demande (`report-uri` + `report-to`, en-tête `Reporting-Endpoints`) et l'application les reçoit sur `/nodefony/security/api/csp-report` — corps borné, quota par adresse, URL expurgées, mémoire seule —, lus par `GET /nodefony/security/api/csp-reports` (administrateur). Une chaîne : l'URL de votre propre collecteur (chemin absolu ou https). `false` : aucun rapport. Sans effet quand `csp` vaut `\"\"` ou déclare déjà `report-uri`/`report-to`. Une URL malformée est retirée et journalisée."),
|
|
112
|
+
/**
|
|
113
|
+
* @deprecated Inerte : régler `http.securityHeaders.frameOptions`, qui pilote
|
|
114
|
+
* `X-Frame-Options` ET `frame-ancestors` (ADR-0024 §C5). Retrait à la
|
|
115
|
+
* prochaine version majeure.
|
|
116
|
+
*/
|
|
110
117
|
frameguard: z.enum(["deny", "sameorigin"]).default("deny").meta({
|
|
111
118
|
reserved: true,
|
|
112
|
-
|
|
119
|
+
deprecated: true,
|
|
120
|
+
description: "DÉPRÉCIÉ, INERTE — remplacé par `http.securityHeaders.frameOptions` (`DENY` | `SAMEORIGIN` | `null`), qui pilote `X-Frame-Options` ET `frame-ancestors` et couvre aussi les statiques et les erreurs. Retrait à la prochaine version majeure."
|
|
113
121
|
}),
|
|
114
122
|
noSniff: z.boolean().default(true).meta({
|
|
115
123
|
reserved: true,
|
|
@@ -183,9 +191,9 @@ const tokenStoreSchema = z.strictObject({
|
|
|
183
191
|
}).describe("Store de jetons serveur (refresh, PAT, denylist `jti`, seuil de révocation par porteur) + maintenance gc.");
|
|
184
192
|
const passkeysSchema = z.strictObject({
|
|
185
193
|
enabled: z.boolean().default(true).describe("Active WebAuthn/passkeys (MFA phishing-resistant, NIST AAL2)."),
|
|
186
|
-
rpId: z.string().optional().describe("Relying Party ID (domaine). Omis = domaine de l'app au boot."),
|
|
194
|
+
rpId: z.string().optional().describe("Relying Party ID (domaine). Omis = domaine de l'app au boot. Un domaine international s'écrit tel quel : il est converti en punycode (`xn--…`) au démarrage, forme que présente le navigateur."),
|
|
187
195
|
rpName: z.string().optional().describe("Nom lisible de la Relying Party affiché dans l'invite OS/navigateur. Omis = nom de l'app."),
|
|
188
|
-
origins: z.array(z.string()).default([]).describe("Origines autorisées aux
|
|
196
|
+
origins: z.array(z.string()).default([]).describe("Origines autorisées aux cérémonies (`scheme://hôte[:port]`). Vide = origine de l'app. Converties en punycode au démarrage ; une entrée qui n'est pas une origine est écartée et nommée en ERROR."),
|
|
189
197
|
userVerification: z.enum([
|
|
190
198
|
"required",
|
|
191
199
|
"preferred",
|
|
@@ -260,7 +268,7 @@ const trustedIssuerSchema = z.strictObject({
|
|
|
260
268
|
"EdDSA"
|
|
261
269
|
]).describe("Algorithmes de signature acceptés — allowlist SERVEUR (RFC 8725 §3.1) : l'algorithme n'est jamais déduit de l'en-tête du jeton. Restreindre à ce que l'émetteur utilise réellement."),
|
|
262
270
|
typ: z.string().optional().describe("Valeur exigée de l'en-tête `typ` — « at+jwt » pour un émetteur conforme RFC 9068. Non exigé par défaut : le parc réel est inégal, et la séparation entre jetons est déjà portée par l'audience, qui n'est pas facultative."),
|
|
263
|
-
requiredClaims: z.array(z.string()).default([]).describe("Claims dont la PRÉSENCE est exigée, en plus de `iss
|
|
271
|
+
requiredClaims: z.array(z.string()).default([]).describe("Claims dont la PRÉSENCE est exigée, en plus de `iss`, `aud`, `exp` et `iat` (toujours obligatoires, RFC 9068 §2.2 ; un `iat` daté du futur est refusé). Ex. `[\"sub\"]` pour refuser un jeton sans sujet identifiable."),
|
|
264
272
|
subjectMapping: z.enum(["prefixed", "subject"]).default("prefixed").describe("Comment le sujet (`sub`) de CET émetteur devient un identifiant dans l'espace de noms local. « prefixed » (défaut) : l'identifiant cherché est « <issuer>#<sub> » — deux émetteurs ne peuvent pas se disputer un compte, et surtout aucun sujet étranger ne peut tomber par hasard sur un identifiant local existant. « subject » : le `sub` est cherché TEL QUEL, l'ancien comportement — à ne déclarer que si l'on maîtrise l'espace de noms de cet émetteur, typiquement parce qu'il EST cette application (jetons maison) ou parce que ses sujets sont déjà des identifiants locaux. 🔴 Le défaut n'est pas un réglage de confort : un annuaire où l'utilisateur choisit son identifiant permet de présenter `sub: \"admin\"`, et « subject » rattache alors le porteur au compte local du même nom. `sub` n'est unique que dans l'espace de son émetteur (OIDC Core §2) — l'identité est la paire, jamais le sujet seul.")
|
|
265
273
|
});
|
|
266
274
|
const resourceServerSchema = z.strictObject({
|
|
@@ -313,7 +321,18 @@ const auditSchema = z.strictObject({
|
|
|
313
321
|
reserved: true,
|
|
314
322
|
description: "INERTE — la diffusion realtime dépend de la présence d'abonnés WS (canal `nodefony:audit`), pas de ce drapeau. Le journal est diffusé dès qu'un client s'abonne."
|
|
315
323
|
}),
|
|
316
|
-
retentionDays: z.number().int().default(365)
|
|
324
|
+
retentionDays: z.number().int().default(365),
|
|
325
|
+
aggregate: z.strictObject({
|
|
326
|
+
enabled: z.boolean().default(true).describe("Agrège les REFUS (failure/denied) d'une même source : un flot ne s'écrit pas d'une ligne par requête. Les succès ne sont jamais agrégés."),
|
|
327
|
+
windowMs: z.number().int().positive().default(6e4).describe("Durée d'une fenêtre, en ms, ouverte au premier refus d'une clé (catégorie, action, issue, motif, ressource, IP)."),
|
|
328
|
+
burst: z.number().int().nonnegative().default(10).describe("Refus d'une clé écrits TELS QUELS par fenêtre (acteur compris) ; les suivants sont comptés, puis UNE ligne de synthèse (`metadata.aggregated`) clôt la fenêtre."),
|
|
329
|
+
maxKeys: z.number().int().positive().default(1e4).describe("Clés suivies au plus (mémoire bornée) ; au-delà, les refus se regroupent sous l'IP `*`.")
|
|
330
|
+
}).default(() => ({
|
|
331
|
+
enabled: true,
|
|
332
|
+
windowMs: 6e4,
|
|
333
|
+
burst: 10,
|
|
334
|
+
maxKeys: 1e4
|
|
335
|
+
}))
|
|
317
336
|
}).describe("Journal d'audit sécurité (login, accès refusé, clés, webhooks).");
|
|
318
337
|
const studioSchema = z.strictObject({
|
|
319
338
|
enabled: z.boolean().default(false).describe("Console admin Studio. OFF par défaut (surtout en prod)."),
|
|
@@ -417,7 +436,7 @@ const grantableScopesSchema = z.record(z.string(), z.array(z.string().min(1)).mi
|
|
|
417
436
|
* cet axe. Étend OU surcharge les défauts système (placée AVANT eux à l'évaluation).
|
|
418
437
|
*/
|
|
419
438
|
const realtimeChannelRuleSchema = z.strictObject({
|
|
420
|
-
pattern: z.string().describe("Préfixe de canal (match `startsWith`) : ex. 'orm:' couvre nodefony:orm:health/nodefony:orm:flow. Placé AVANT les défauts système → permet de surcharger (
|
|
439
|
+
pattern: z.string().describe("Préfixe de canal (match `startsWith`) : ex. 'nodefony:orm:' couvre nodefony:orm:health/nodefony:orm:flow, 'net:' couvre net:health. Placé AVANT les défauts système → permet de surcharger un namespace réservé (le durcir, le re-cibler sur un autre rôle — jamais l'ouvrir à l'anonyme) ou d'ouvrir à l'application un canal à suffixe :health/:stats."),
|
|
421
440
|
authenticated: z.boolean().optional().describe("Exige une connexion authentifiée (token non anonyme)."),
|
|
422
441
|
roles: z.array(z.string()).optional().describe("Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles)."),
|
|
423
442
|
scopes: z.array(z.string()).optional().describe("Un de ces scopes suffit (axe API : JWT/clé API ; une session BFF n'en porte pas).")
|
|
@@ -427,7 +446,7 @@ const securityConfigSchema = z.strictObject({
|
|
|
427
446
|
roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
|
|
428
447
|
grantableScopes: grantableScopesSchema,
|
|
429
448
|
areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
|
|
430
|
-
realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (
|
|
449
|
+
realtimeChannels: z.array(realtimeChannelRuleSchema).default([]).describe("Politiques d'autorisation par préfixe sur le temps réel. Couvre les canaux WS (subscribe/inbound) **ET les actions RPC** : le nom de méthode d'une frame entrante est soumis aux mêmes règles de préfixe — une règle `admin:` garde donc aussi l'action `admin:purge`. Étend/surcharge les défauts système (espace nodefony: + suffixes :health/:stats dans TOUT espace de noms = ROLE_NODEFONY_ADMIN, l'échelle PLATEFORME : ces canaux exposent l'état du processus, pas celui d'une application). Ce qui n'est couvert par aucune règle reste libre, sauf gating `@RealtimeChannel` côté controller."),
|
|
431
450
|
cors: corsSchema.default(() => corsSchema.parse({})),
|
|
432
451
|
csrf: csrfSchema.default(() => csrfSchema.parse({})),
|
|
433
452
|
headers: headersSchema.default(() => headersSchema.parse({})),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { securityConfigSchema } from "./config.js";
|
|
2
|
-
import { BootConfigurationError, parseModuleConfig } from "nodefony";
|
|
2
|
+
import { BootConfigurationError, parseModuleConfig, stripTrailingSlashes } from "nodefony";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
// nodefony/config/defineModuleConfig.ts
|
|
5
5
|
/**
|
|
@@ -12,9 +12,60 @@ import { z } from "zod";
|
|
|
12
12
|
function defineSecurityConfig(config = {}) {
|
|
13
13
|
const validated = parseModuleConfig(securityConfigSchema, config, "@nodefony/security");
|
|
14
14
|
detectConflicts(validated.areas);
|
|
15
|
+
resolveAreaResources(validated.areas, validated.jwt.issuer);
|
|
15
16
|
return Object.freeze(validated);
|
|
16
17
|
}
|
|
17
18
|
/**
|
|
19
|
+
* Rend absolue la `resource` d'une zone écrite comme un CHEMIN (`/api/partner`),
|
|
20
|
+
* contre l'émetteur des jetons de l'application (`jwt.issuer`) — son URL
|
|
21
|
+
* publique, celle que voient les clients.
|
|
22
|
+
*
|
|
23
|
+
* Pourquoi : un MODULE qui apporte sa zone à jeton (surcharge
|
|
24
|
+
* `module-security`) ne connaît pas l'adresse publique de l'application — elle
|
|
25
|
+
* vient de l'environnement, que seul `env.ts` lit. Le chemin est ce qu'il
|
|
26
|
+
* connaît ; l'origine est ce que l'application a déjà ÉCRIT une fois. La valeur
|
|
27
|
+
* reste donc écrite, jamais dérivée du `Host`.
|
|
28
|
+
*
|
|
29
|
+
* Concaténation, pas `new URL(chemin, base)` : un émetteur qui porte un chemin
|
|
30
|
+
* (`https://exemple.com/auth`) le garde, comme le fait déjà l'adresse publique
|
|
31
|
+
* des gabarits (`${publicBaseUrl}/api/…`).
|
|
32
|
+
*
|
|
33
|
+
* @param areas - zones validées (mutées sur place, avant le gel)
|
|
34
|
+
* @param issuer - `jwt.issuer` validé
|
|
35
|
+
* @throws BootConfigurationError si un chemin est relatif au protocole (`//…`),
|
|
36
|
+
* ou si l'émetteur n'est pas une URL http(s) — retirer la zone ouvrirait ses
|
|
37
|
+
* routes à la zone voisine, donc on refuse de démarrer en nommant le geste.
|
|
38
|
+
*/
|
|
39
|
+
function resolveAreaResources(areas, issuer) {
|
|
40
|
+
for (const name in areas) {
|
|
41
|
+
const area = areas[name];
|
|
42
|
+
const resource = area?.resource;
|
|
43
|
+
if (area === void 0 || resource === void 0) continue;
|
|
44
|
+
if (!resource.startsWith("/")) continue;
|
|
45
|
+
if (resource.startsWith("//")) throw new BootConfigurationError(`[@nodefony/security] areas.${name}.resource « ${resource} » : un chemin commence par UN seul « / » — « // » désigne un autre hôte.`);
|
|
46
|
+
const base = publicIssuerBase(issuer);
|
|
47
|
+
if (base === null) throw new BootConfigurationError(`[@nodefony/security] areas.${name}.resource « ${resource} » est un chemin, rendu absolu contre jwt.issuer — qui n'est pas une URL http(s) (« ${issuer ?? "nodefony"} »). Pose jwt.issuer à l'adresse publique de l'application (une application générée la lit dans NF_JWT_ISSUER), ou écris la ressource en entier (https://…).`);
|
|
48
|
+
area.resource = `${base}${resource}`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* L'émetteur, sans `/` final, s'il est une URL http(s) — sinon `null`.
|
|
53
|
+
*
|
|
54
|
+
* @param issuer - `jwt.issuer` validé
|
|
55
|
+
* @returns la base contre laquelle résoudre un chemin, ou `null`
|
|
56
|
+
*/
|
|
57
|
+
function publicIssuerBase(issuer) {
|
|
58
|
+
if (issuer === void 0) return null;
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(issuer);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return null;
|
|
66
|
+
return stripTrailingSlashes(issuer);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
18
69
|
* JSON Schema introspectable de la config sécurité — **Studio génère son
|
|
19
70
|
* formulaire d'édition depuis ça** (labels/types/défauts/descriptions),
|
|
20
71
|
* sans UI hardcodée. Surface du data plane `/nodefony/security/api/config/schema`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { nodefonyError } from "nodefony";
|
|
2
|
+
// nodefony/errors/FetchPublicError.ts
|
|
3
|
+
/**
|
|
4
|
+
* Échec d'une requête sortante vers un site tiers (`fetchPublic`) qui n'est PAS
|
|
5
|
+
* un refus de cible : délai total dépassé (`504`), trop de redirections ou
|
|
6
|
+
* échec réseau (`502`). Une cible interdite lève `SsrfError` (422), pas
|
|
7
|
+
* celle-ci — l'appelant distingue ainsi « tu n'as pas le droit d'y aller » de
|
|
8
|
+
* « le site n'a pas répondu ».
|
|
9
|
+
*/
|
|
10
|
+
var FetchPublicError = class extends nodefonyError {
|
|
11
|
+
/** Cause de l'échec, stable et exploitable sans lire le message. */
|
|
12
|
+
reason;
|
|
13
|
+
constructor(reason, message) {
|
|
14
|
+
super(message, reason === "timeout" ? 504 : 502);
|
|
15
|
+
this.reason = reason;
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export { FetchPublicError };
|
|
@@ -9,8 +9,16 @@ import { nodefonyError } from "nodefony";
|
|
|
9
9
|
* d'un webhook invalide).
|
|
10
10
|
*/
|
|
11
11
|
var SsrfError = class extends nodefonyError {
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `forbidden` : la cible est interdite (protocole, identifiants, IP non
|
|
14
|
+
* publique). `unresolvable` : l'hôte n'a aucune adresse — rien d'interne n'est
|
|
15
|
+
* visé, le site est simplement injoignable ; un appelant qui rend ces deux cas
|
|
16
|
+
* à l'utilisateur les distingue par ce champ, jamais par le texte du message.
|
|
17
|
+
*/
|
|
18
|
+
reason;
|
|
19
|
+
constructor(message = "URL sortante interdite (SSRF)", reason = "forbidden") {
|
|
13
20
|
super(message, 422);
|
|
21
|
+
this.reason = reason;
|
|
14
22
|
}
|
|
15
23
|
};
|
|
16
24
|
export { SsrfError, SsrfError as default };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { AuthenticationError } from "./AuthenticationError.js";
|
|
2
|
+
// nodefony/errors/StaleSessionError.ts
|
|
3
|
+
/**
|
|
4
|
+
* Session PÉRIMÉE — elle ne désigne plus personne de valide : compte supprimé,
|
|
5
|
+
* verrouillé, désactivé, recréé sous le même identifiant, mot de passe changé
|
|
6
|
+
* depuis son ouverture, ou blob vidé. C'est un ÉTAT à purger, pas une preuve
|
|
7
|
+
* fausse présentée par un tiers : le `SessionAuthenticator` la détruit côté
|
|
8
|
+
* serveur ET côté navigateur (cookie expiré, RFC 6265 §5.3), puis le firewall
|
|
9
|
+
* poursuit comme si aucune session n'avait été présentée — une page ouverte
|
|
10
|
+
* aux anonymes s'affiche, une zone fermée redemande la connexion.
|
|
11
|
+
*
|
|
12
|
+
* Jamais levée pour une PANNE de la source d'identité : une base injoignable
|
|
13
|
+
* ne dit rien de la session, qui doit survivre à l'incident.
|
|
14
|
+
*/
|
|
15
|
+
var StaleSessionError = class extends AuthenticationError {
|
|
16
|
+
constructor(message = "Invalid session") {
|
|
17
|
+
super(message);
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
export { StaleSessionError, StaleSessionError as default };
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { InsecureTransportError } from "./InsecureTransportError.js";
|
|
2
|
-
import { CsrfError } from "./CsrfError.js";
|
|
3
1
|
import { AuthenticationError } from "./AuthenticationError.js";
|
|
2
|
+
import { StaleSessionError } from "./StaleSessionError.js";
|
|
3
|
+
import { AccessDeniedError } from "./AccessDeniedError.js";
|
|
4
4
|
import { ThrottledError } from "./ThrottledError.js";
|
|
5
5
|
import { UnverifiableTokenError } from "./UnverifiableTokenError.js";
|
|
6
6
|
import { InvalidTargetError } from "./InvalidTargetError.js";
|
|
7
|
-
import {
|
|
7
|
+
import { CsrfError } from "./CsrfError.js";
|
|
8
8
|
import { SsrfError } from "./SsrfError.js";
|
|
9
|
-
import {
|
|
10
|
-
|
|
9
|
+
import { FetchPublicError } from "./FetchPublicError.js";
|
|
10
|
+
import { WebAuthnError } from "./WebAuthnError.js";
|
|
11
|
+
import { InsecureTransportError } from "./InsecureTransportError.js";
|
|
12
|
+
export { AccessDeniedError, AuthenticationError, CsrfError, FetchPublicError, InsecureTransportError, InvalidTargetError, SsrfError, StaleSessionError, ThrottledError, UnverifiableTokenError, WebAuthnError };
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isNodefonyKeycloakTheme, keycloakLoginTheme, realmNameFromIssuer } from "./keycloakRealmInput.js";
|
|
2
|
+
import { ownField } from "nodefony";
|
|
2
3
|
// nodefony/keycloak/keycloakAdmin.ts
|
|
3
4
|
/** Refus ou échec d'un appel ; le message nomme la cause et le geste suivant. */
|
|
4
5
|
var KeycloakAdminError = class extends Error {
|
|
@@ -183,9 +184,8 @@ var KeycloakAdminClient = class {
|
|
|
183
184
|
throw new KeycloakAdminError(`compte administrateur refusé par Keycloak (HTTP ${response.status}, realm master) — vérifier NF_KEYCLOAK_ADMIN_USERNAME et NF_KEYCLOAK_ADMIN_PASSWORD`, response.status);
|
|
184
185
|
}
|
|
185
186
|
const payload = await response.json();
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const expiresIn = field("expires_in");
|
|
187
|
+
const accessToken = ownField(payload, "access_token");
|
|
188
|
+
const expiresIn = ownField(payload, "expires_in");
|
|
189
189
|
if (typeof accessToken !== "string" || accessToken === "") throw new KeycloakAdminError("Keycloak n'a rendu aucun jeton d'administration");
|
|
190
190
|
const lifetimeMs = typeof expiresIn === "number" ? expiresIn * 1e3 : 0;
|
|
191
191
|
this.#token = {
|