@nodefony/security 10.0.0-beta.6 → 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.157.0 → _@oxc-project_runtime@0.158.0}/helpers/esm/decorate.js +1 -1
- package/dist/index.js +4 -2
- package/dist/nodefony/command/security-mcp-token.js +43 -5
- package/dist/nodefony/config/config.js +17 -6
- package/dist/nodefony/config/defineModuleConfig.js +52 -1
- 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 +3 -1
- package/dist/nodefony/keycloak/scaffold.js +31 -14
- package/dist/nodefony/service/accessTokenVerifier.js +1 -1
- package/dist/nodefony/service/auditService.js +48 -3
- package/dist/nodefony/service/authFlow.js +6 -3
- package/dist/nodefony/service/firewall.js +48 -3
- package/dist/nodefony/service/tokenService.js +8 -3
- package/dist/nodefony/service/totp.js +7 -2
- package/dist/nodefony/service/webAuthn.js +45 -6
- package/dist/nodefony/src/audit/AuditRefusalAggregator.js +139 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +24 -4
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +21 -8
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +1 -1
- package/dist/nodefony/src/net/fetchPublic.js +244 -0
- package/dist/nodefony/src/net/ssrfGuard.js +2 -2
- package/dist/nodefony/src/realtime/frameAuthorizer.js +130 -39
- package/dist/nodefony/src/token/jwtRuntime.js +22 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +37 -86
- package/dist/types/index.d.ts +4 -1
- package/dist/types/nodefony/command/security-mcp-token.d.ts +12 -0
- package/dist/types/nodefony/config/config.d.ts +6 -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/authFlow.d.ts +2 -2
- package/dist/types/nodefony/service/webAuthn.d.ts +13 -0
- package/dist/types/nodefony/src/audit/AuditRefusalAggregator.d.ts +55 -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/net/fetchPublic.d.ts +73 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +10 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +19 -3
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +21 -2
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +5 -2
- package/docs/audit.md +112 -28
- package/docs/keycloak.md +4 -4
- package/docs/obtenir-un-jeton.md +17 -10
- package/docs/tokens.md +1 -1
- package/docs/webhooks.md +15 -7
- package/package.json +13 -13
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
|
@@ -28,7 +28,7 @@ import SecurityOAuthDoctor from "./nodefony/command/security-oauth-doctor.js";
|
|
|
28
28
|
import { createSecurityAdminApi, parseAuditQuery, registerSecurityAdminApi } from "./nodefony/src/admin/SecurityAdminApi.js";
|
|
29
29
|
import { registerUserRevocationCascade, usersEventBusNotice } from "./nodefony/src/admin/userRevocationCascade.js";
|
|
30
30
|
import { defineSecurityConfig, securityConfigJsonSchema } from "./nodefony/config/defineModuleConfig.js";
|
|
31
|
-
import __decorate from "./_virtual/_@oxc-project_runtime@0.
|
|
31
|
+
import __decorate from "./_virtual/_@oxc-project_runtime@0.158.0/helpers/esm/decorate.js";
|
|
32
32
|
import { RemoteJwtVerifier } from "./nodefony/src/token/RemoteJwtVerifier.js";
|
|
33
33
|
import { amrForFactor } from "./nodefony/src/sessionAuthentication.js";
|
|
34
34
|
import { ApiKeyError } from "./nodefony/errors/ApiKeyError.js";
|
|
@@ -73,6 +73,7 @@ import { KeycloakAdminClient, KeycloakAdminError, keycloakAdminTarget, keycloakL
|
|
|
73
73
|
import { createDiscoveredOidcProvider, createOidcProvider, readLogoutTokenClaims } from "./nodefony/src/oauth/providers/oidc.js";
|
|
74
74
|
import { SsrfError } from "./nodefony/errors/SsrfError.js";
|
|
75
75
|
import { createGithubProvider } from "./nodefony/src/oauth/providers/github.js";
|
|
76
|
+
import { FetchPublicError } from "./nodefony/errors/FetchPublicError.js";
|
|
76
77
|
import { MemoryAuditStore } from "./nodefony/src/audit/MemoryAuditStore.js";
|
|
77
78
|
import { getAuditStoreFactory, listAuditStores, registerAuditStore } from "./nodefony/src/audit/auditStoreRegistry.js";
|
|
78
79
|
import { recordAudit } from "./nodefony/src/audit/recordAudit.js";
|
|
@@ -86,6 +87,7 @@ import { MemoryWebhookStore } from "./nodefony/src/webhook/MemoryWebhookStore.js
|
|
|
86
87
|
import { WEBHOOK_DEFAULT_ORDER, WEBHOOK_SORTABLE_FIELDS } from "./nodefony/src/webhook/webhookSort.js";
|
|
87
88
|
import { getWebhookStoreFactory, listWebhookStores, registerWebhookStore } from "./nodefony/src/webhook/webhookStoreRegistry.js";
|
|
88
89
|
import { assertPublicUrl, isBlockedAddress } from "./nodefony/src/net/ssrfGuard.js";
|
|
90
|
+
import { fetchPublic } from "./nodefony/src/net/fetchPublic.js";
|
|
89
91
|
import { decryptSecret, deriveKey, encryptSecret, generateEphemeralKey } from "./nodefony/src/crypto/secretCipher.js";
|
|
90
92
|
import { LoginThrottler } from "./nodefony/src/throttle/LoginThrottler.js";
|
|
91
93
|
import { CredentialTransportPolicy, shouldEnforceCredentialTransport } from "./nodefony/src/transport/CredentialTransportPolicy.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.")
|
|
@@ -191,9 +191,9 @@ const tokenStoreSchema = z.strictObject({
|
|
|
191
191
|
}).describe("Store de jetons serveur (refresh, PAT, denylist `jti`, seuil de révocation par porteur) + maintenance gc.");
|
|
192
192
|
const passkeysSchema = z.strictObject({
|
|
193
193
|
enabled: z.boolean().default(true).describe("Active WebAuthn/passkeys (MFA phishing-resistant, NIST AAL2)."),
|
|
194
|
-
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."),
|
|
195
195
|
rpName: z.string().optional().describe("Nom lisible de la Relying Party affiché dans l'invite OS/navigateur. Omis = nom de l'app."),
|
|
196
|
-
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."),
|
|
197
197
|
userVerification: z.enum([
|
|
198
198
|
"required",
|
|
199
199
|
"preferred",
|
|
@@ -321,7 +321,18 @@ const auditSchema = z.strictObject({
|
|
|
321
321
|
reserved: true,
|
|
322
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."
|
|
323
323
|
}),
|
|
324
|
-
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
|
+
}))
|
|
325
336
|
}).describe("Journal d'audit sécurité (login, accès refusé, clés, webhooks).");
|
|
326
337
|
const studioSchema = z.strictObject({
|
|
327
338
|
enabled: z.boolean().default(false).describe("Console admin Studio. OFF par défaut (surtout en prod)."),
|
|
@@ -425,7 +436,7 @@ const grantableScopesSchema = z.record(z.string(), z.array(z.string().min(1)).mi
|
|
|
425
436
|
* cet axe. Étend OU surcharge les défauts système (placée AVANT eux à l'évaluation).
|
|
426
437
|
*/
|
|
427
438
|
const realtimeChannelRuleSchema = z.strictObject({
|
|
428
|
-
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."),
|
|
429
440
|
authenticated: z.boolean().optional().describe("Exige une connexion authentifiée (token non anonyme)."),
|
|
430
441
|
roles: z.array(z.string()).optional().describe("Un de ces rôles suffit (évalué AVEC la hiérarchie de rôles)."),
|
|
431
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).")
|
|
@@ -435,7 +446,7 @@ const securityConfigSchema = z.strictObject({
|
|
|
435
446
|
roleHierarchy: z.record(z.string(), z.array(z.string())).default({}).describe("Hiérarchie de rôles (ROLE_ADMIN: ['ROLE_USER'])."),
|
|
436
447
|
grantableScopes: grantableScopesSchema,
|
|
437
448
|
areas: z.record(z.string(), areaSchema).default({}).describe("Zones sécurisées (firewall) par nom."),
|
|
438
|
-
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."),
|
|
439
450
|
cors: corsSchema.default(() => corsSchema.parse({})),
|
|
440
451
|
csrf: csrfSchema.default(() => csrfSchema.parse({})),
|
|
441
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,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
1
|
import { AuthenticationError } from "./AuthenticationError.js";
|
|
2
|
+
import { StaleSessionError } from "./StaleSessionError.js";
|
|
2
3
|
import { AccessDeniedError } from "./AccessDeniedError.js";
|
|
3
4
|
import { ThrottledError } from "./ThrottledError.js";
|
|
4
5
|
import { UnverifiableTokenError } from "./UnverifiableTokenError.js";
|
|
5
6
|
import { InvalidTargetError } from "./InvalidTargetError.js";
|
|
6
7
|
import { CsrfError } from "./CsrfError.js";
|
|
7
8
|
import { SsrfError } from "./SsrfError.js";
|
|
9
|
+
import { FetchPublicError } from "./FetchPublicError.js";
|
|
8
10
|
import { WebAuthnError } from "./WebAuthnError.js";
|
|
9
11
|
import { InsecureTransportError } from "./InsecureTransportError.js";
|
|
10
|
-
export { AccessDeniedError, AuthenticationError, CsrfError, InsecureTransportError, InvalidTargetError, SsrfError, ThrottledError, UnverifiableTokenError, WebAuthnError };
|
|
12
|
+
export { AccessDeniedError, AuthenticationError, CsrfError, FetchPublicError, InsecureTransportError, InvalidTargetError, SsrfError, StaleSessionError, ThrottledError, UnverifiableTokenError, WebAuthnError };
|
|
@@ -25,15 +25,19 @@ const APP_KEYCLOAK_REALM_FILE = "docker/keycloak/import/realm.json";
|
|
|
25
25
|
const APP_KEYCLOAK_THEMES_DIR = "docker/keycloak/themes";
|
|
26
26
|
const APP_KEYCLOAK_THEME_DIR = `${APP_KEYCLOAK_THEMES_DIR}/${KEYCLOAK_THEME_NAME}`;
|
|
27
27
|
/** Fichier de configuration de sécurité d'une application générée. */
|
|
28
|
-
const APP_SECURITY_CONFIG_FILE = "nodefony/config/security.ts";
|
|
29
28
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* ni `NF_PORT_HTTPS` — le port HTTPS par défaut, comme les URL de retour du
|
|
33
|
-
* realm. Deux paquets, une valeur : le test de contribution compare le
|
|
34
|
-
* gabarit rendu à cette constante.
|
|
29
|
+
* Le fichier où la démo de `create app --demo` (module privé `modules/demo`)
|
|
30
|
+
* déclare sa zone à jeton — la surcharge `module-security` qu'elle apporte.
|
|
35
31
|
*/
|
|
36
|
-
const
|
|
32
|
+
const DEMO_SECURITY_CONFIG_FILE = "modules/demo/nodefony/config/security.ts";
|
|
33
|
+
/**
|
|
34
|
+
* Ressource de l'API partenaire de la démo en développement : le chemin de sa
|
|
35
|
+
* zone (`/demo/api/partner`) rendu absolu contre l'émetteur de développement
|
|
36
|
+
* — le port HTTPS par défaut, comme les URL de retour du realm. Deux paquets,
|
|
37
|
+
* une valeur : le test de contribution la confronte à une application
|
|
38
|
+
* `--demo` réellement rendue.
|
|
39
|
+
*/
|
|
40
|
+
const APP_PARTNER_DEV_RESOURCE = "https://localhost:5152/demo/api/partner";
|
|
37
41
|
/** Les types de thème que le realm sait déclarer, et la clé qui les porte. */
|
|
38
42
|
const THEME_TYPES = [
|
|
39
43
|
"login",
|
|
@@ -124,6 +128,12 @@ function devMachineSecret(appName) {
|
|
|
124
128
|
* rôle client `admin` (`roleMapping: { admin: "ROLE_ADMIN" }`), et les
|
|
125
129
|
* audiences des zones `external-jwt` que la configuration rendue déclare.
|
|
126
130
|
*
|
|
131
|
+
* Le client « machine » (`<app>-machine`, `client_credentials`) n'existe que
|
|
132
|
+
* s'il a une audience à recevoir : une application neuve n'ouvre aucune zone à
|
|
133
|
+
* jeton tiers (#746), et un client que rien n'accepte serait un secret de plus
|
|
134
|
+
* à garder pour rien. La démo, qui apporte l'API entre programmes, le fait
|
|
135
|
+
* naître en déclarant sa ressource.
|
|
136
|
+
*
|
|
127
137
|
* @param appName - nom de l'application (= realm = identifiant de client)
|
|
128
138
|
* @param themes - types de thème à déclarer
|
|
129
139
|
* @param ids - `sub` des comptes de démonstration, FIXÉS dans le fichier :
|
|
@@ -150,7 +160,7 @@ function appDevRealmInput(appName, themes, ids, audiences = []) {
|
|
|
150
160
|
description: "Administrateur de l'application — traduit en ROLE_ADMIN par la table roleMapping du fournisseur keycloak."
|
|
151
161
|
}],
|
|
152
162
|
realmRoles: [],
|
|
153
|
-
machine: {
|
|
163
|
+
machine: audiences.length === 0 ? null : {
|
|
154
164
|
clientId: `${appName}-machine`,
|
|
155
165
|
secret: devMachineSecret(appName),
|
|
156
166
|
name: `${appName} — appelant machine (compte de service)`,
|
|
@@ -191,17 +201,24 @@ function mountsKeycloak(context) {
|
|
|
191
201
|
].some((file) => context.read(file)?.includes("./docker/keycloak/") === true);
|
|
192
202
|
}
|
|
193
203
|
/**
|
|
194
|
-
* Les audiences que la configuration RENDUE de l'application déclare.
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
*
|
|
204
|
+
* Les audiences que la configuration RENDUE de l'application déclare. Une
|
|
205
|
+
* application neuve n'en déclare aucune ; la démo ouvre la zone
|
|
206
|
+
* `demo-partner` (`jwt` + `external-jwt`) dans son propre module : sans son
|
|
207
|
+
* audience dans le realm, le jeton du client machine y serait refusé dès le
|
|
208
|
+
* premier essai, et `security:keycloak:realm --check` trouverait le realm né
|
|
209
|
+
* en désaccord avec la configuration.
|
|
210
|
+
*
|
|
211
|
+
* Le fichier de zone de la démo doit écrire le CHEMIN de la ressource : un
|
|
212
|
+
* module qui l'aurait changé n'a pas l'audience de cette constante, et
|
|
213
|
+
* mieux vaut ne rien inscrire qu'une audience que la zone refuserait.
|
|
199
214
|
*
|
|
200
215
|
* @param context - contexte de contribution
|
|
201
216
|
* @returns les ressources à inscrire dans les jetons du realm
|
|
202
217
|
*/
|
|
203
218
|
function renderedAudiences(context) {
|
|
204
|
-
|
|
219
|
+
const demo = context.read(DEMO_SECURITY_CONFIG_FILE);
|
|
220
|
+
const resourcePath = new URL(APP_PARTNER_DEV_RESOURCE).pathname;
|
|
221
|
+
return demo?.includes(`"${resourcePath}"`) === true ? [APP_PARTNER_DEV_RESOURCE] : [];
|
|
205
222
|
}
|
|
206
223
|
/**
|
|
207
224
|
* Recopie les thèmes livrés dans l'application — `nodefony` et ses thèmes
|
|
@@ -67,7 +67,7 @@ var AccessTokenVerifierService = class extends Service {
|
|
|
67
67
|
} catch {
|
|
68
68
|
return;
|
|
69
69
|
}
|
|
70
|
-
const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt) : null;
|
|
70
|
+
const runtime = config.jwt.enabled ? resolveJwtRuntime(config.jwt, config.areas) : null;
|
|
71
71
|
const local = runtime === null ? null : new JwtAuthenticator(this.container, runtime);
|
|
72
72
|
const { issuers, ...tuning } = config.resourceServer;
|
|
73
73
|
let remote = null;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { defineSecurityConfig } from "../config/defineModuleConfig.js";
|
|
2
2
|
import { getAuditStoreFactory, listAuditStores } from "../src/audit/auditStoreRegistry.js";
|
|
3
|
+
import { AuditRefusalAggregator } from "../src/audit/AuditRefusalAggregator.js";
|
|
3
4
|
import { AUTO_STORE, EMPTY_INFRA, GcScheduler, Service, durableStoreRemedy, readStoreConnector, readStoreLocation, resolveAutoStore, storeConnectivity } from "nodefony";
|
|
4
5
|
import { randomBytes } from "node:crypto";
|
|
5
6
|
// nodefony/service/auditService.ts
|
|
@@ -30,11 +31,21 @@ var AuditService = class extends Service {
|
|
|
30
31
|
#idPrefix = "";
|
|
31
32
|
#seq = 0;
|
|
32
33
|
#gc = null;
|
|
34
|
+
/** Agrégation des refus — `null` si désactivée par la config. */
|
|
35
|
+
#aggregator = null;
|
|
36
|
+
/** Balayage des fenêtres échues — armé au premier refus suivi, coupé à vide. */
|
|
37
|
+
#sweep = null;
|
|
38
|
+
#windowMs = 0;
|
|
33
39
|
constructor(module) {
|
|
34
40
|
super(serviceName, module.container, module.notificationsCenter, module.options);
|
|
35
41
|
this.module = module;
|
|
36
|
-
this.kernel?.once("onBoot", () =>
|
|
37
|
-
|
|
42
|
+
this.kernel?.once("onBoot", () => {
|
|
43
|
+
try {
|
|
44
|
+
this.#build();
|
|
45
|
+
} finally {
|
|
46
|
+
this.kernel?.once("onTerminate", () => this.#shutdown());
|
|
47
|
+
}
|
|
48
|
+
});
|
|
38
49
|
}
|
|
39
50
|
#build() {
|
|
40
51
|
let config;
|
|
@@ -91,9 +102,17 @@ var AuditService = class extends Service {
|
|
|
91
102
|
onError: (e) => this.log(e, "WARNING")
|
|
92
103
|
});
|
|
93
104
|
this.#gc.start();
|
|
105
|
+
const aggregate = config.audit.aggregate;
|
|
106
|
+
if (aggregate.enabled) {
|
|
107
|
+
this.#windowMs = aggregate.windowMs;
|
|
108
|
+
this.#aggregator = new AuditRefusalAggregator(aggregate, (draft) => this.#write(draft, Date.now()));
|
|
109
|
+
}
|
|
94
110
|
this.log(`audit service ready — store "${config.audit.store}", rétention ${config.audit.retentionDays}j`, "DEBUG");
|
|
95
111
|
}
|
|
96
112
|
#shutdown() {
|
|
113
|
+
this.#stopSweep();
|
|
114
|
+
this.#aggregator?.flush();
|
|
115
|
+
this.#aggregator = null;
|
|
97
116
|
this.#gc?.stop();
|
|
98
117
|
this.#gc = null;
|
|
99
118
|
this.#listeners = null;
|
|
@@ -102,10 +121,21 @@ var AuditService = class extends Service {
|
|
|
102
121
|
}
|
|
103
122
|
record(draft) {
|
|
104
123
|
if (!this.#enabled || this.#store === null) return;
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
if (draft.outcome !== "success" && this.#aggregator !== null) {
|
|
126
|
+
const written = this.#aggregator.admit(draft, now);
|
|
127
|
+
if (this.#sweep === null) this.#startSweep();
|
|
128
|
+
if (!written) return;
|
|
129
|
+
}
|
|
130
|
+
this.#write(draft, now);
|
|
131
|
+
}
|
|
132
|
+
/** Persiste (fire-and-forget) et diffuse un événement horodaté `ts`. */
|
|
133
|
+
#write(draft, ts) {
|
|
134
|
+
if (this.#store === null) return;
|
|
105
135
|
const event = {
|
|
106
136
|
...draft,
|
|
107
137
|
id: `${this.#idPrefix}-${(this.#seq++).toString(36)}`,
|
|
108
|
-
ts
|
|
138
|
+
ts
|
|
109
139
|
};
|
|
110
140
|
this.#store.append(event).catch((error) => this.log(error, "ERROR"));
|
|
111
141
|
if (this.#listeners !== null) for (const listener of this.#listeners) try {
|
|
@@ -114,6 +144,21 @@ var AuditService = class extends Service {
|
|
|
114
144
|
this.log(error, "ERROR");
|
|
115
145
|
}
|
|
116
146
|
}
|
|
147
|
+
/** Arme le balayage des fenêtres échues (une fois par fenêtre, `unref`). */
|
|
148
|
+
#startSweep() {
|
|
149
|
+
this.#sweep = setInterval(() => {
|
|
150
|
+
const aggregator = this.#aggregator;
|
|
151
|
+
if (aggregator === null) return this.#stopSweep();
|
|
152
|
+
aggregator.sweep(Date.now());
|
|
153
|
+
if (aggregator.size === 0) this.#stopSweep();
|
|
154
|
+
}, this.#windowMs);
|
|
155
|
+
this.#sweep.unref();
|
|
156
|
+
}
|
|
157
|
+
#stopSweep() {
|
|
158
|
+
if (this.#sweep === null) return;
|
|
159
|
+
clearInterval(this.#sweep);
|
|
160
|
+
this.#sweep = null;
|
|
161
|
+
}
|
|
117
162
|
subscribe(listener) {
|
|
118
163
|
this.#listeners ??= [];
|
|
119
164
|
this.#listeners.push(listener);
|
|
@@ -284,8 +284,8 @@ var AuthFlow = class extends Service {
|
|
|
284
284
|
* (mêmes contrôles que le `SessionAuthenticator` — source unique).
|
|
285
285
|
*
|
|
286
286
|
* @returns la projection publique, ou `null` (pas de session, session
|
|
287
|
-
* orpheline, compte verrouillé/désactivé
|
|
288
|
-
* `{ user: null }`.
|
|
287
|
+
* orpheline, compte verrouillé/désactivé — la session périmée est alors
|
|
288
|
+
* détruite, cookie compris) — le handler répond `200` `{ user: null }`.
|
|
289
289
|
*/
|
|
290
290
|
async me(context) {
|
|
291
291
|
const identifier = context.session?.user;
|
|
@@ -295,7 +295,10 @@ var AuthFlow = class extends Service {
|
|
|
295
295
|
try {
|
|
296
296
|
return toSafeUser(await resolveBoundSessionIdentity(this.#resolveUsers(), identifier, context.session?.getMetaBag(ACCOUNT_ID_KEY), context.session?.getMetaBag(CREDENTIALS_VERSION_KEY)));
|
|
297
297
|
} catch (error) {
|
|
298
|
-
if (error instanceof AuthenticationError)
|
|
298
|
+
if (error instanceof AuthenticationError) {
|
|
299
|
+
await context.session?.destroy(true);
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
299
302
|
throw error;
|
|
300
303
|
}
|
|
301
304
|
}
|