@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +1 -2
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +127 -124
- package/package.json +13 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/apikey/apiKeyFormat.ts
|
|
3
3
|
/**
|
|
4
4
|
* Encodage / décodage / hachage des clés API (PAT) — logique **pure** (aucun I/O,
|
|
5
5
|
* aucun container) → testable sans serveur et partagée par l'émetteur
|
|
@@ -103,5 +103,4 @@ function parseApiKey(token, prefix) {
|
|
|
103
103
|
secretHash: hashApiKey(token)
|
|
104
104
|
};
|
|
105
105
|
}
|
|
106
|
-
//#endregion
|
|
107
106
|
export { generateApiKey, hashApiKey, looksLikeApiKey, parseApiKey };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { assertPageQuery } from "nodefony";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/audit/MemoryAuditStore.ts
|
|
3
3
|
const DEFAULT_MAX_ENTRIES = 1e4;
|
|
4
4
|
const MAX_LIMIT = 500;
|
|
5
5
|
/**
|
|
@@ -113,5 +113,4 @@ var MemoryAuditStore = class {
|
|
|
113
113
|
for (const event of snapshot.events) this.#events.push(event);
|
|
114
114
|
}
|
|
115
115
|
};
|
|
116
|
-
//#endregion
|
|
117
116
|
export { MemoryAuditStore };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { PLATFORM_CHANNELS } from "nodefony";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/audit/auditBridge.ts
|
|
3
3
|
/**
|
|
4
4
|
* Canal WS du flux live d'audit (P6.14 lot 4). Le préfixe `security:` le place
|
|
5
5
|
* sous le plancher `SECURITY_CHANNEL_POLICY` (ROLE_NODEFONY_ADMIN) du verrou de
|
|
@@ -82,5 +82,4 @@ function createAuditBridge(source, publish, channel, opts = {}) {
|
|
|
82
82
|
unsubscribe();
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
|
-
//#endregion
|
|
86
85
|
export { SECURITY_AUDIT_CHANNEL, createAuditBridge, createAuditBridge as default };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/audit/auditFilters.ts
|
|
2
2
|
/**
|
|
3
3
|
* **Le vocabulaire de filtre du journal d'audit**, en noms PUBLICS — ceux qu'un
|
|
4
4
|
* auditeur écrit dans l'URL (`?category=authz&outcome=denied&since=…`).
|
|
@@ -56,5 +56,4 @@ const AUDIT_FILTERS = {
|
|
|
56
56
|
* les deux à jour », qui avait échoué en silence.
|
|
57
57
|
*/
|
|
58
58
|
const AUDIT_FILTER_VOCABULARY_IS_COMPLETE = [true, true];
|
|
59
|
-
//#endregion
|
|
60
59
|
export { AUDIT_FILTERS, AUDIT_FILTER_VOCABULARY_IS_COMPLETE };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MemoryAuditStore } from "./MemoryAuditStore.js";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/audit/auditStoreRegistry.ts
|
|
3
3
|
const factories = /* @__PURE__ */ new Map();
|
|
4
4
|
/**
|
|
5
5
|
* Enregistre (ou remplace) la fabrique d'un store d'audit. Appelée par le builtin
|
|
@@ -21,5 +21,4 @@ registerAuditStore("memory", (ctx) => {
|
|
|
21
21
|
const days = ctx?.config?.audit?.retentionDays;
|
|
22
22
|
return typeof days === "number" ? new MemoryAuditStore(Date.now, days * MS_PER_DAY) : new MemoryAuditStore();
|
|
23
23
|
});
|
|
24
|
-
//#endregion
|
|
25
24
|
export { getAuditStoreFactory, listAuditStores, registerAuditStore };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/audit/readAuditContext.ts
|
|
2
2
|
/**
|
|
3
3
|
* Extrait IP / User-Agent / requestId + drapeaux de présence (jamais la valeur)
|
|
4
4
|
* d'un contexte de requête — pour enrichir un événement d'audit avec la
|
|
@@ -20,5 +20,4 @@ function readAuditContext(context) {
|
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
-
//#endregion
|
|
24
23
|
export { readAuditContext };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
// nodefony/src/audit/recordAudit.ts
|
|
2
2
|
/**
|
|
3
3
|
* Émet un événement d'audit **si** le service est présent — no-op sinon. La
|
|
4
4
|
* résolution se fait par le container (`auditService`) sur le **cold-path**
|
|
@@ -12,5 +12,4 @@
|
|
|
12
12
|
function recordAudit(container, event) {
|
|
13
13
|
(container?.get("auditService"))?.record(event);
|
|
14
14
|
}
|
|
15
|
-
//#endregion
|
|
16
15
|
export { recordAudit };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AnonymousToken } from "../token/AnonymousToken.js";
|
|
2
|
-
|
|
2
|
+
// nodefony/src/authenticator/AnonymousAuthenticator.ts
|
|
3
3
|
/**
|
|
4
4
|
* Acceptation EXPLICITE de l'anonymat dans une zone — le seul authenticator
|
|
5
5
|
* autorisé à produire un token non authentifié sans déclencher le Zero Trust.
|
|
@@ -32,5 +32,4 @@ var AnonymousAuthenticator = class {
|
|
|
32
32
|
return Promise.resolve();
|
|
33
33
|
}
|
|
34
34
|
};
|
|
35
|
-
//#endregion
|
|
36
35
|
export { AnonymousAuthenticator, AnonymousAuthenticator as default };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { isBoundToAccount } from "../accountBinding.js";
|
|
2
3
|
import { UserToken } from "../token/UserToken.js";
|
|
3
4
|
import { bearerToken } from "./bearer.js";
|
|
4
5
|
import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
|
|
5
|
-
|
|
6
|
+
// nodefony/src/authenticator/ApiKeyAuthenticator.ts
|
|
6
7
|
const INVALID_TOKEN = "Invalid token";
|
|
7
8
|
/**
|
|
8
9
|
* Marqueur interne posé par `authenticate()` et consommé par `onSuccess()` :
|
|
@@ -81,6 +82,7 @@ var ApiKeyAuthenticator = class {
|
|
|
81
82
|
const invalidBefore = await store.getInvalidBefore(record.subjectId);
|
|
82
83
|
if (invalidBefore !== null && record.createdAt < invalidBefore) throw new AuthenticationError(INVALID_TOKEN);
|
|
83
84
|
const user = await this.#resolveUserOrReject(record.subjectId);
|
|
85
|
+
if (!isBoundToAccount(user, record.metadata["accountId"], record.metadata["credentialsVersion"])) throw new AuthenticationError(INVALID_TOKEN);
|
|
84
86
|
const last = record.lastUsedAt;
|
|
85
87
|
if (this.#throttleMs === 0 || last === null || now - last >= this.#throttleMs) token.setAttribute(MARK_USED_AT, now);
|
|
86
88
|
const ut = token;
|
|
@@ -160,5 +162,4 @@ var ApiKeyAuthenticator = class {
|
|
|
160
162
|
return this.#userProvider;
|
|
161
163
|
}
|
|
162
164
|
};
|
|
163
|
-
//#endregion
|
|
164
165
|
export { ApiKeyAuthenticator, ApiKeyAuthenticator as default };
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
import { mapProviderRoles } from "../oauth/providerRoles.js";
|
|
2
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
3
2
|
import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
|
|
4
3
|
import { UserToken } from "../token/UserToken.js";
|
|
5
4
|
import { bearerToken } from "./bearer.js";
|
|
6
5
|
import peekIssuer from "./peekIssuer.js";
|
|
7
|
-
import {
|
|
8
|
-
import { ACCESS_TOKEN_VERIFIER, buildBearerChallenge,
|
|
9
|
-
|
|
10
|
-
//#region nodefony/src/authenticator/ExternalJwtAuthenticator.ts
|
|
6
|
+
import { ExternalSubjectResolver } from "./ExternalSubjectResolver.js";
|
|
7
|
+
import { ACCESS_TOKEN_VERIFIER, buildBearerChallenge, protectedResourceMetadataUrl } from "nodefony";
|
|
8
|
+
// nodefony/src/authenticator/ExternalJwtAuthenticator.ts
|
|
11
9
|
/**
|
|
12
10
|
* Message UNIFORME de refus — la cause fine (expiré, audience, signature, sujet
|
|
13
11
|
* inconnu) part au journal, jamais au client : un refus détaillé est un oracle
|
|
@@ -61,31 +59,15 @@ const SUBJECT_AREA = "subjectArea";
|
|
|
61
59
|
var ExternalJwtAuthenticator = class {
|
|
62
60
|
name = "external-jwt";
|
|
63
61
|
#container;
|
|
64
|
-
|
|
65
|
-
#
|
|
66
|
-
#ephemeralRoles;
|
|
67
|
-
/** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
|
|
68
|
-
#linkedProviders;
|
|
69
|
-
/** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
|
|
70
|
-
#roleMappings;
|
|
71
|
-
#userProvider = null;
|
|
62
|
+
/** Rattachement du sujet à un compte local — partagé, jamais recopié. */
|
|
63
|
+
#subjects;
|
|
72
64
|
/**
|
|
73
65
|
* @param container - container DI (résolution lazy du vérificateur et de `users`)
|
|
74
66
|
* @param options - émetteurs reconnus et politique de rattachement
|
|
75
67
|
*/
|
|
76
68
|
constructor(container, options) {
|
|
77
69
|
this.#container = container;
|
|
78
|
-
|
|
79
|
-
for (const binding of options.issuers) try {
|
|
80
|
-
issuers.set(canonicalIssuer(binding.issuer), binding.subjectMapping);
|
|
81
|
-
} catch {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
this.#issuers = issuers;
|
|
85
|
-
this.#policy = options.subjectPolicy;
|
|
86
|
-
this.#ephemeralRoles = options.ephemeralRoles;
|
|
87
|
-
this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
|
|
88
|
-
this.#roleMappings = collectRoleMappings(options.oauthProviders);
|
|
70
|
+
this.#subjects = new ExternalSubjectResolver(container, options);
|
|
89
71
|
}
|
|
90
72
|
/**
|
|
91
73
|
* La requête porte-t-elle un jeton se réclamant d'un émetteur de confiance ?
|
|
@@ -101,12 +83,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
101
83
|
if (raw === null) return false;
|
|
102
84
|
const issuer = peekIssuer(raw);
|
|
103
85
|
if (issuer === null) return false;
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
return this.#issuers.has(canonicalIssuer(issuer));
|
|
107
|
-
} catch {
|
|
108
|
-
return false;
|
|
109
|
-
}
|
|
86
|
+
return this.#subjects.recognizes(issuer);
|
|
110
87
|
}
|
|
111
88
|
/**
|
|
112
89
|
* Extrait le jeton brut ET l'audience de la zone.
|
|
@@ -147,9 +124,7 @@ var ExternalJwtAuthenticator = class {
|
|
|
147
124
|
if (principal === null) throw new AuthenticationError(INVALID_TOKEN);
|
|
148
125
|
const subject = principal.subject;
|
|
149
126
|
if (!subject) throw new AuthenticationError(INVALID_TOKEN);
|
|
150
|
-
const
|
|
151
|
-
if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${principal.issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
|
|
152
|
-
const user = await this.#resolveUser(principal.issuer, subject, mapping, principal.claims, token.getAttribute(SUBJECT_AREA));
|
|
127
|
+
const user = await this.#subjects.resolve(principal.issuer, subject, principal.claims, token.getAttribute(SUBJECT_AREA));
|
|
153
128
|
const ut = token;
|
|
154
129
|
ut.promote(user);
|
|
155
130
|
ut.setAttribute("scopes", [...principal.scopes]);
|
|
@@ -200,85 +175,5 @@ var ExternalJwtAuthenticator = class {
|
|
|
200
175
|
validateArea(area) {
|
|
201
176
|
if (!area.resource) throw new Error(`area "${area.name}": l'authenticator "${this.name}" exige que la zone déclare sa ressource (\`resource\`) — c'est l'audience que le jeton doit porter (RFC 8707 §2). Sans elle, un jeton valide délivré au même porteur pour un AUTRE service serait accepté ici.`);
|
|
202
177
|
}
|
|
203
|
-
/**
|
|
204
|
-
* Établit l'utilisateur applicatif à partir du sujet du jeton.
|
|
205
|
-
*
|
|
206
|
-
* Les deux politiques répondent à deux questions différentes : « qui, chez
|
|
207
|
-
* nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
|
|
208
|
-
* que l'annuaire a authentifiée ? ».
|
|
209
|
-
*/
|
|
210
|
-
async #resolveUser(issuer, subject, mapping, claims, area) {
|
|
211
|
-
const identifier = localIdentifierFor(issuer, subject, mapping);
|
|
212
|
-
if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
|
|
213
|
-
id: identifier,
|
|
214
|
-
identifier,
|
|
215
|
-
roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
|
|
216
|
-
});
|
|
217
|
-
const provider = this.#resolveUserProvider();
|
|
218
|
-
let user = null;
|
|
219
|
-
const linked = this.#linkedProviders?.get(issuer);
|
|
220
|
-
if (linked !== void 0) try {
|
|
221
|
-
const roleMapping = this.#roleMappings?.get(linked);
|
|
222
|
-
user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
|
|
223
|
-
} catch {}
|
|
224
|
-
if (user === null) try {
|
|
225
|
-
user = await provider.loadUserByIdentifier(identifier);
|
|
226
|
-
} catch {
|
|
227
|
-
throw new AuthenticationError(INVALID_TOKEN);
|
|
228
|
-
}
|
|
229
|
-
if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
|
|
230
|
-
return user;
|
|
231
|
-
}
|
|
232
|
-
#resolveUserProvider() {
|
|
233
|
-
if (this.#userProvider === null) {
|
|
234
|
-
const provider = this.#container.get("users");
|
|
235
|
-
if (!provider) throw new Error("ExternalJwtAuthenticator: aucun service \"users\" (IUserProvider) dans le container — enregistrer un UserService au boot, ou passer la politique de sujet à \"ephemeral\" si l'appelant est une machine sans compte local.");
|
|
236
|
-
this.#userProvider = provider;
|
|
237
|
-
}
|
|
238
|
-
return this.#userProvider;
|
|
239
|
-
}
|
|
240
178
|
};
|
|
241
|
-
/** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
|
|
242
|
-
function canSyncRoles(provider) {
|
|
243
|
-
return typeof provider.syncOAuthRoles === "function";
|
|
244
|
-
}
|
|
245
|
-
/** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
|
|
246
|
-
function collectRoleMappings(providers) {
|
|
247
|
-
if (!providers) return null;
|
|
248
|
-
let mappings = null;
|
|
249
|
-
for (const { name, roleMapping } of providers) {
|
|
250
|
-
if (roleMapping == null) continue;
|
|
251
|
-
mappings ??= /* @__PURE__ */ new Map();
|
|
252
|
-
mappings.set(name, roleMapping);
|
|
253
|
-
}
|
|
254
|
-
return mappings;
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Associe chaque émetteur de confiance au fournisseur de connexion qui partage
|
|
258
|
-
* son émetteur canonique.
|
|
259
|
-
*
|
|
260
|
-
* Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
|
|
261
|
-
* des deux ferait dépendre l'identité de l'ordre de la configuration.
|
|
262
|
-
*
|
|
263
|
-
* @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
|
|
264
|
-
*/
|
|
265
|
-
function linkProviders(trusted, providers) {
|
|
266
|
-
if (!providers || providers.length === 0) return null;
|
|
267
|
-
const links = /* @__PURE__ */ new Map();
|
|
268
|
-
const ambiguous = /* @__PURE__ */ new Set();
|
|
269
|
-
for (const { name, issuer } of providers) {
|
|
270
|
-
let canonical;
|
|
271
|
-
try {
|
|
272
|
-
canonical = canonicalIssuer(issuer);
|
|
273
|
-
} catch {
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
if (!trusted.has(canonical)) continue;
|
|
277
|
-
if (links.has(canonical)) ambiguous.add(canonical);
|
|
278
|
-
links.set(canonical, name);
|
|
279
|
-
}
|
|
280
|
-
for (const issuer of ambiguous) links.delete(issuer);
|
|
281
|
-
return links.size > 0 ? links : null;
|
|
282
|
-
}
|
|
283
|
-
//#endregion
|
|
284
179
|
export { ExternalJwtAuthenticator, ExternalJwtAuthenticator as default };
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { compileProviderRoleMapping, mapProviderRoles } from "../oauth/providerRoles.js";
|
|
2
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
3
|
+
import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
|
|
4
|
+
import { localIdentifierFor } from "./externalSubject.js";
|
|
5
|
+
import { canonicalIssuer } from "nodefony";
|
|
6
|
+
import { BaseUser } from "@nodefony/user";
|
|
7
|
+
// nodefony/src/authenticator/ExternalSubjectResolver.ts
|
|
8
|
+
/**
|
|
9
|
+
* Message UNIFORME de refus — la cause fine (sujet inconnu, compte verrouillé)
|
|
10
|
+
* part au journal, jamais au client : un refus détaillé est un oracle.
|
|
11
|
+
*/
|
|
12
|
+
const INVALID_TOKEN = "Invalid token";
|
|
13
|
+
/**
|
|
14
|
+
* Les options de rattachement des sujets externes, lues dans la configuration
|
|
15
|
+
* de sécurité — UNE lecture, pour la zone `external-jwt` comme pour le
|
|
16
|
+
* résolveur de compte d'une ressource protégée.
|
|
17
|
+
*
|
|
18
|
+
* @param config - configuration de sécurité validée
|
|
19
|
+
* @returns émetteurs, politique de sujet, fournisseurs de connexion liables
|
|
20
|
+
*/
|
|
21
|
+
function externalSubjectOptions(config) {
|
|
22
|
+
const rs = config.resourceServer;
|
|
23
|
+
return {
|
|
24
|
+
issuers: rs.issuers.map((i) => ({
|
|
25
|
+
issuer: i.issuer,
|
|
26
|
+
subjectMapping: i.subjectMapping
|
|
27
|
+
})),
|
|
28
|
+
subjectPolicy: rs.subjectPolicy,
|
|
29
|
+
ephemeralRoles: rs.ephemeralRoles,
|
|
30
|
+
oauthProviders: Object.entries(config.oauth2.providers).flatMap(([name, p]) => p.issuer ? [{
|
|
31
|
+
name,
|
|
32
|
+
issuer: p.issuer,
|
|
33
|
+
roleMapping: compileProviderRoleMapping(p)
|
|
34
|
+
}] : [])
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Établit l'utilisateur applicatif que désigne le sujet d'un jeton TIERS —
|
|
39
|
+
* la décision qui transforme « un annuaire a authentifié ce sujet » en « ce
|
|
40
|
+
* compte agit chez nous ».
|
|
41
|
+
*
|
|
42
|
+
* UNE implémentation, deux consommateurs : la zone `external-jwt` du pare-feu
|
|
43
|
+
* (`ExternalJwtAuthenticator`) et le résolveur de compte d'une ressource
|
|
44
|
+
* protégée (porte MCP). Recopiée, la règle d'espace de noms aurait divergé dès
|
|
45
|
+
* le premier émetteur ajouté — et c'est elle qui empêche un annuaire tiers de
|
|
46
|
+
* réclamer le compte local `admin`.
|
|
47
|
+
*/
|
|
48
|
+
var ExternalSubjectResolver = class {
|
|
49
|
+
#container;
|
|
50
|
+
#issuers;
|
|
51
|
+
#policy;
|
|
52
|
+
#ephemeralRoles;
|
|
53
|
+
/** Émetteur canonique → fournisseur de connexion ; `null` = aucun lien. */
|
|
54
|
+
#linkedProviders;
|
|
55
|
+
/** Fournisseur de connexion → table des rôles ; `null` = aucune table. */
|
|
56
|
+
#roleMappings;
|
|
57
|
+
#userProvider = null;
|
|
58
|
+
/**
|
|
59
|
+
* @param container - container DI (résolution lazy de `users`)
|
|
60
|
+
* @param options - émetteurs reconnus et politique de rattachement
|
|
61
|
+
*/
|
|
62
|
+
constructor(container, options) {
|
|
63
|
+
this.#container = container;
|
|
64
|
+
const issuers = /* @__PURE__ */ new Map();
|
|
65
|
+
for (const binding of options.issuers) try {
|
|
66
|
+
issuers.set(canonicalIssuer(binding.issuer), binding.subjectMapping);
|
|
67
|
+
} catch {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
this.#issuers = issuers;
|
|
71
|
+
this.#policy = options.subjectPolicy;
|
|
72
|
+
this.#ephemeralRoles = options.ephemeralRoles;
|
|
73
|
+
this.#linkedProviders = linkProviders(issuers, options.oauthProviders);
|
|
74
|
+
this.#roleMappings = collectRoleMappings(options.oauthProviders);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* L'émetteur revendiqué est-il l'un des nôtres ? Ne lève JAMAIS : la valeur
|
|
78
|
+
* vient d'un jeton que n'importe qui peut forger.
|
|
79
|
+
*
|
|
80
|
+
* @param issuer - émetteur lu dans le jeton, non vérifié
|
|
81
|
+
*/
|
|
82
|
+
recognizes(issuer) {
|
|
83
|
+
if (this.#issuers.has(issuer)) return true;
|
|
84
|
+
try {
|
|
85
|
+
return this.#issuers.has(canonicalIssuer(issuer));
|
|
86
|
+
} catch {
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Établit l'utilisateur applicatif à partir du sujet d'un jeton VÉRIFIÉ.
|
|
92
|
+
*
|
|
93
|
+
* Les deux politiques répondent à deux questions différentes : « qui, chez
|
|
94
|
+
* nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
|
|
95
|
+
* que l'annuaire a authentifiée ? ».
|
|
96
|
+
*
|
|
97
|
+
* @param issuer - émetteur canonique, tel que le vérificateur l'a établi
|
|
98
|
+
* @param subject - sujet brut (`sub`) chez cet émetteur
|
|
99
|
+
* @param claims - revendications du jeton (rôles d'annuaire)
|
|
100
|
+
* @param area - zone qui SURCHARGE la politique, s'il y en a une
|
|
101
|
+
* @throws AuthenticationError (401) — sujet sans compte local utilisable
|
|
102
|
+
* @throws UnverifiableTokenError (503) — émetteur vérifié mais absent de la
|
|
103
|
+
* table de rattachement : configuration incohérente
|
|
104
|
+
*/
|
|
105
|
+
async resolve(issuer, subject, claims, area) {
|
|
106
|
+
const mapping = this.#issuers.get(issuer);
|
|
107
|
+
if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
|
|
108
|
+
const identifier = localIdentifierFor(issuer, subject, mapping);
|
|
109
|
+
if ((area?.subjectPolicy ?? this.#policy) === "ephemeral") return new BaseUser({
|
|
110
|
+
id: identifier,
|
|
111
|
+
identifier,
|
|
112
|
+
roles: [...area?.ephemeralRoles ?? this.#ephemeralRoles]
|
|
113
|
+
});
|
|
114
|
+
const provider = this.#resolveUserProvider();
|
|
115
|
+
let user = null;
|
|
116
|
+
const linked = this.#linkedProviders?.get(issuer);
|
|
117
|
+
if (linked !== void 0) try {
|
|
118
|
+
const roleMapping = this.#roleMappings?.get(linked);
|
|
119
|
+
user = roleMapping !== void 0 && canSyncRoles(provider) ? await provider.syncOAuthRoles(linked, subject, mapProviderRoles(roleMapping, claims)) : await provider.loadUserByOAuth(linked, subject);
|
|
120
|
+
} catch {}
|
|
121
|
+
if (user === null) try {
|
|
122
|
+
user = await provider.loadUserByIdentifier(identifier);
|
|
123
|
+
} catch {
|
|
124
|
+
throw new AuthenticationError(INVALID_TOKEN);
|
|
125
|
+
}
|
|
126
|
+
if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
|
|
127
|
+
return user;
|
|
128
|
+
}
|
|
129
|
+
#resolveUserProvider() {
|
|
130
|
+
if (this.#userProvider === null) {
|
|
131
|
+
const provider = this.#container.get("users");
|
|
132
|
+
if (!provider) throw new Error("ExternalJwtAuthenticator: aucun service \"users\" (IUserProvider) dans le container — enregistrer un UserService au boot, ou passer la politique de sujet à \"ephemeral\" si l'appelant est une machine sans compte local.");
|
|
133
|
+
this.#userProvider = provider;
|
|
134
|
+
}
|
|
135
|
+
return this.#userProvider;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
/** Le fournisseur d'utilisateurs sait-il recalculer les rôles gérés par un annuaire ? */
|
|
139
|
+
function canSyncRoles(provider) {
|
|
140
|
+
return typeof provider.syncOAuthRoles === "function";
|
|
141
|
+
}
|
|
142
|
+
/** Tables de rôles par fournisseur de connexion — `null` quand aucun n'en déclare. */
|
|
143
|
+
function collectRoleMappings(providers) {
|
|
144
|
+
if (!providers) return null;
|
|
145
|
+
let mappings = null;
|
|
146
|
+
for (const { name, roleMapping } of providers) {
|
|
147
|
+
if (roleMapping == null) continue;
|
|
148
|
+
mappings ??= /* @__PURE__ */ new Map();
|
|
149
|
+
mappings.set(name, roleMapping);
|
|
150
|
+
}
|
|
151
|
+
return mappings;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Associe chaque émetteur de confiance au fournisseur de connexion qui partage
|
|
155
|
+
* son émetteur canonique.
|
|
156
|
+
*
|
|
157
|
+
* Un émetteur revendiqué par DEUX fournisseurs n'est lié à aucun : choisir l'un
|
|
158
|
+
* des deux ferait dépendre l'identité de l'ordre de la configuration.
|
|
159
|
+
*
|
|
160
|
+
* @returns la table, ou `null` quand aucun émetteur n'est lié (rien à chercher).
|
|
161
|
+
*/
|
|
162
|
+
function linkProviders(trusted, providers) {
|
|
163
|
+
if (!providers || providers.length === 0) return null;
|
|
164
|
+
const links = /* @__PURE__ */ new Map();
|
|
165
|
+
const ambiguous = /* @__PURE__ */ new Set();
|
|
166
|
+
for (const { name, issuer } of providers) {
|
|
167
|
+
let canonical;
|
|
168
|
+
try {
|
|
169
|
+
canonical = canonicalIssuer(issuer);
|
|
170
|
+
} catch {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!trusted.has(canonical)) continue;
|
|
174
|
+
if (links.has(canonical)) ambiguous.add(canonical);
|
|
175
|
+
links.set(canonical, name);
|
|
176
|
+
}
|
|
177
|
+
for (const issuer of ambiguous) links.delete(issuer);
|
|
178
|
+
return links.size > 0 ? links : null;
|
|
179
|
+
}
|
|
180
|
+
export { ExternalSubjectResolver, externalSubjectOptions };
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { isBoundToAccount } from "../accountBinding.js";
|
|
2
3
|
import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
|
|
3
4
|
import { Nodefony, RequestContext } from "nodefony";
|
|
4
5
|
import { authenticatedUser } from "@nodefony/user";
|
|
5
|
-
|
|
6
|
+
// nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
|
|
6
7
|
/**
|
|
7
8
|
* Authenticator realtime des identités résolues par le **firewall** — équivalent
|
|
8
9
|
* WS de tout ce que le pipeline HTTP sait authentifier.
|
|
@@ -57,12 +58,21 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
57
58
|
*/
|
|
58
59
|
#resolveStore;
|
|
59
60
|
/**
|
|
61
|
+
* Accès paresseux à l'annuaire des comptes, pour confirmer avant chaque
|
|
62
|
+
* `api.request` que le compte de la socket existe toujours sous le même `id`.
|
|
63
|
+
* Absent (`null`) → aucune confirmation (seul `isValid` fait foi).
|
|
64
|
+
*/
|
|
65
|
+
#resolveUsers;
|
|
66
|
+
/**
|
|
60
67
|
* @param resolveStore - fournit le store de révocation des jetons (le firewall
|
|
61
68
|
* passe une closure sur son container). Omis → mode dégradé documenté :
|
|
62
69
|
* seules les bornes portées par le jeton lui-même sont vérifiables.
|
|
70
|
+
* @param resolveUsers - fournit l'annuaire des comptes (`users`). Omis → pas
|
|
71
|
+
* de confirmation du compte avant `api.request`.
|
|
63
72
|
*/
|
|
64
|
-
constructor(resolveStore = null) {
|
|
73
|
+
constructor(resolveStore = null, resolveUsers = null) {
|
|
65
74
|
this.#resolveStore = resolveStore;
|
|
75
|
+
this.#resolveUsers = resolveUsers;
|
|
66
76
|
}
|
|
67
77
|
/** Une identité authentifiée a-t-elle été résolue (par le firewall) au handshake ? */
|
|
68
78
|
supports(_handshake) {
|
|
@@ -82,7 +92,7 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
82
92
|
const type = issuedType(issued);
|
|
83
93
|
const revalidate = this.#revalidatorFor(user, issued, type);
|
|
84
94
|
const scopes = issued ? issued.getScopes() : void 0;
|
|
85
|
-
return new UserRealtimeToken(user, revalidate, type, scopes);
|
|
95
|
+
return new UserRealtimeToken(user, revalidate, type, scopes, this.#accountConfirmationFor(user, type));
|
|
86
96
|
}
|
|
87
97
|
/**
|
|
88
98
|
* Le revalidateur de l'identité que le firewall a posée sur la requête
|
|
@@ -106,6 +116,29 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
106
116
|
* Choisit la preuve de révocation selon le mode d'authentification : la
|
|
107
117
|
* session se relit, un jeton porteur se borne par `exp` et la denylist.
|
|
108
118
|
*/
|
|
119
|
+
/**
|
|
120
|
+
* Confirmation du compte, pour les modes dont l'APPLICATION émet l'artefact
|
|
121
|
+
* (session, JWT maison, clé d'API) : le compte relu doit exister, être actif
|
|
122
|
+
* et porter le même `id` immuable qu'au handshake — sinon il a disparu ou un
|
|
123
|
+
* autre compte a repris son identifiant hors du framework (#139). Un sujet
|
|
124
|
+
* `external-jwt` peut être éphémère, sans compte local : il n'est pas
|
|
125
|
+
* confirmé. `null` = rien à confirmer.
|
|
126
|
+
*/
|
|
127
|
+
#accountConfirmationFor(user, type) {
|
|
128
|
+
const resolveUsers = this.#resolveUsers;
|
|
129
|
+
if (resolveUsers === null || !ACCOUNT_CONFIRMED_TYPES.has(type)) return null;
|
|
130
|
+
return async () => {
|
|
131
|
+
const users = resolveUsers();
|
|
132
|
+
if (users === null) return true;
|
|
133
|
+
let current;
|
|
134
|
+
try {
|
|
135
|
+
current = await users.loadUserByIdentifier(user.identifier);
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
return current.isActive() && !current.isLocked() && isBoundToAccount(current, user.id, user.credentialsVersion);
|
|
140
|
+
};
|
|
141
|
+
}
|
|
109
142
|
#revalidatorFor(user, issued, type) {
|
|
110
143
|
return type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
|
|
111
144
|
}
|
|
@@ -150,6 +183,12 @@ var FirewallRealtimeAuthenticator = class {
|
|
|
150
183
|
}
|
|
151
184
|
};
|
|
152
185
|
const EMPTY_CLAIMS = Object.freeze({});
|
|
186
|
+
/** Modes dont l'application émet l'artefact : leur compte est local, donc relisible. */
|
|
187
|
+
const ACCOUNT_CONFIRMED_TYPES = /* @__PURE__ */ new Set([
|
|
188
|
+
"session",
|
|
189
|
+
"jwt",
|
|
190
|
+
"apikey"
|
|
191
|
+
]);
|
|
153
192
|
/** Claim temporel JWT (secondes, RFC 7519) → epoch ms, ou `null` si absent/invalide. */
|
|
154
193
|
function secondsToMs(value) {
|
|
155
194
|
return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : null;
|
|
@@ -193,5 +232,4 @@ function buildSessionRevalidator(identifier) {
|
|
|
193
232
|
}
|
|
194
233
|
};
|
|
195
234
|
}
|
|
196
|
-
//#endregion
|
|
197
235
|
export { FirewallRealtimeAuthenticator, FirewallRealtimeAuthenticator as default };
|