@nodefony/security 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +544 -0
- package/README.md +182 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/index.js +151 -0
- package/dist/nodefony/command/security-secrets.js +158 -0
- package/dist/nodefony/command/security-token.js +335 -0
- package/dist/nodefony/command/security-user-add.js +131 -0
- package/dist/nodefony/command/security-user-delete.js +102 -0
- package/dist/nodefony/command/security-user-list.js +77 -0
- package/dist/nodefony/config/config.js +366 -0
- package/dist/nodefony/config/defineModuleConfig.js +35 -0
- package/dist/nodefony/contracts/IAccessVoter.js +13 -0
- package/dist/nodefony/contracts/IApiKey.js +1 -0
- package/dist/nodefony/contracts/IAuditEvent.js +1 -0
- package/dist/nodefony/contracts/IAuditStore.js +1 -0
- package/dist/nodefony/contracts/IAuthenticator.js +1 -0
- package/dist/nodefony/contracts/IAuthorizationService.js +1 -0
- package/dist/nodefony/contracts/IFirewall.js +1 -0
- package/dist/nodefony/contracts/IFirewallDescription.js +1 -0
- package/dist/nodefony/contracts/IJwtKeystore.js +1 -0
- package/dist/nodefony/contracts/IOAuthProvider.js +1 -0
- package/dist/nodefony/contracts/ISecuredArea.js +1 -0
- package/dist/nodefony/contracts/IToken.js +1 -0
- package/dist/nodefony/contracts/ITokenStore.js +1 -0
- package/dist/nodefony/contracts/ITotpSecret.js +1 -0
- package/dist/nodefony/contracts/ITotpSecretStore.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredential.js +1 -0
- package/dist/nodefony/contracts/IWebAuthnCredentialStore.js +1 -0
- package/dist/nodefony/contracts/IWebhookEndpoint.js +1 -0
- package/dist/nodefony/contracts/IWebhookStore.js +1 -0
- package/dist/nodefony/contracts/index.js +2 -0
- package/dist/nodefony/errors/AccessDeniedError.js +14 -0
- package/dist/nodefony/errors/ApiKeyError.js +21 -0
- package/dist/nodefony/errors/AuthenticationError.js +14 -0
- package/dist/nodefony/errors/CsrfError.js +23 -0
- package/dist/nodefony/errors/InvalidTargetError.js +39 -0
- package/dist/nodefony/errors/SsrfError.js +17 -0
- package/dist/nodefony/errors/ThrottledError.js +21 -0
- package/dist/nodefony/errors/UnverifiableTokenError.js +42 -0
- package/dist/nodefony/errors/WebAuthnError.js +21 -0
- package/dist/nodefony/errors/index.js +9 -0
- package/dist/nodefony/service/accessTokenVerifier.js +77 -0
- package/dist/nodefony/service/apiKeys.js +310 -0
- package/dist/nodefony/service/auditService.js +145 -0
- package/dist/nodefony/service/authFlow.js +332 -0
- package/dist/nodefony/service/authorization.js +95 -0
- package/dist/nodefony/service/cors.js +81 -0
- package/dist/nodefony/service/csrf.js +97 -0
- package/dist/nodefony/service/firewall.js +699 -0
- package/dist/nodefony/service/oauth2.js +153 -0
- package/dist/nodefony/service/securityHeaders.js +80 -0
- package/dist/nodefony/service/tokenService.js +486 -0
- package/dist/nodefony/service/totp.js +209 -0
- package/dist/nodefony/service/webAuthn.js +343 -0
- package/dist/nodefony/service/webhooks.js +539 -0
- package/dist/nodefony/src/RoleHierarchyWalker.js +77 -0
- package/dist/nodefony/src/SecuredArea.js +51 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +495 -0
- package/dist/nodefony/src/admin/WebhookAdminApi.js +378 -0
- package/dist/nodefony/src/admin/adminAudit.js +37 -0
- package/dist/nodefony/src/admin/userRevocationCascade.js +40 -0
- package/dist/nodefony/src/apikey/apiKeyFormat.js +107 -0
- package/dist/nodefony/src/audit/MemoryAuditStore.js +121 -0
- package/dist/nodefony/src/audit/auditBridge.js +82 -0
- package/dist/nodefony/src/audit/auditFilters.js +60 -0
- package/dist/nodefony/src/audit/auditStoreRegistry.js +25 -0
- package/dist/nodefony/src/audit/readAuditContext.js +24 -0
- package/dist/nodefony/src/audit/recordAudit.js +16 -0
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +36 -0
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +164 -0
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +224 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +174 -0
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +176 -0
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +92 -0
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +95 -0
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +63 -0
- package/dist/nodefony/src/authenticator/bearer.js +2 -0
- package/dist/nodefony/src/authenticator/externalSubject.js +36 -0
- package/dist/nodefony/src/authenticator/peekIssuer.js +56 -0
- package/dist/nodefony/src/crypto/secretCipher.js +79 -0
- package/dist/nodefony/src/csp.js +54 -0
- package/dist/nodefony/src/csrfToken.js +65 -0
- package/dist/nodefony/src/net/ssrfGuard.js +130 -0
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +37 -0
- package/dist/nodefony/src/oauth/providers/github.js +65 -0
- package/dist/nodefony/src/oauth/providers/oidc.js +48 -0
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +94 -0
- package/dist/nodefony/src/realtime/frameAuthorizer.js +279 -0
- package/dist/nodefony/src/realtime/realtimeContracts.js +1 -0
- package/dist/nodefony/src/sessionIdentity.js +35 -0
- package/dist/nodefony/src/throttle/LoginThrottler.js +97 -0
- package/dist/nodefony/src/token/AnonymousToken.js +40 -0
- package/dist/nodefony/src/token/JwtKeystore.js +160 -0
- package/dist/nodefony/src/token/MemoryTokenStore.js +236 -0
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +231 -0
- package/dist/nodefony/src/token/UserToken.js +67 -0
- package/dist/nodefony/src/token/jwtRuntime.js +19 -0
- package/dist/nodefony/src/token/secretFile.js +134 -0
- package/dist/nodefony/src/token/tokenCriteria.js +35 -0
- package/dist/nodefony/src/token/tokenFilters.js +72 -0
- package/dist/nodefony/src/token/tokenSort.js +40 -0
- package/dist/nodefony/src/token/tokenStatus.js +35 -0
- package/dist/nodefony/src/token/tokenStoreRegistry.js +25 -0
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +97 -0
- package/dist/nodefony/src/totp/totpCipher.js +30 -0
- package/dist/nodefony/src/totp/totpCrypto.js +226 -0
- package/dist/nodefony/src/totp/totpOperations.js +129 -0
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +18 -0
- package/dist/nodefony/src/voter/RoleVoter.js +32 -0
- package/dist/nodefony/src/voter/ScopeVoter.js +52 -0
- package/dist/nodefony/src/voter/voterRegistry.js +20 -0
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +121 -0
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +18 -0
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +87 -0
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +208 -0
- package/dist/nodefony/src/webhook/webhookCipher.js +27 -0
- package/dist/nodefony/src/webhook/webhookDelivery.js +102 -0
- package/dist/nodefony/src/webhook/webhookFilters.js +56 -0
- package/dist/nodefony/src/webhook/webhookSignature.js +51 -0
- package/dist/nodefony/src/webhook/webhookSort.js +48 -0
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +18 -0
- package/dist/types/index.d.ts +157 -0
- package/dist/types/nodefony/command/security-secrets.d.ts +24 -0
- package/dist/types/nodefony/command/security-token.d.ts +44 -0
- package/dist/types/nodefony/command/security-user-add.d.ts +28 -0
- package/dist/types/nodefony/command/security-user-delete.d.ts +25 -0
- package/dist/types/nodefony/command/security-user-list.d.ts +28 -0
- package/dist/types/nodefony/config/config.d.ts +295 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/contracts/IAccessVoter.d.ts +23 -0
- package/dist/types/nodefony/contracts/IApiKey.d.ts +75 -0
- package/dist/types/nodefony/contracts/IAuditEvent.d.ts +94 -0
- package/dist/types/nodefony/contracts/IAuditStore.d.ts +80 -0
- package/dist/types/nodefony/contracts/IAuthenticator.d.ts +66 -0
- package/dist/types/nodefony/contracts/IAuthorizationService.d.ts +28 -0
- package/dist/types/nodefony/contracts/IFirewall.d.ts +64 -0
- package/dist/types/nodefony/contracts/IFirewallDescription.d.ts +120 -0
- package/dist/types/nodefony/contracts/IJwtKeystore.d.ts +40 -0
- package/dist/types/nodefony/contracts/IOAuthProvider.d.ts +51 -0
- package/dist/types/nodefony/contracts/ISecuredArea.d.ts +57 -0
- package/dist/types/nodefony/contracts/IToken.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITokenStore.d.ts +240 -0
- package/dist/types/nodefony/contracts/ITotpSecret.d.ts +41 -0
- package/dist/types/nodefony/contracts/ITotpSecretStore.d.ts +88 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredential.d.ts +56 -0
- package/dist/types/nodefony/contracts/IWebAuthnCredentialStore.d.ts +118 -0
- package/dist/types/nodefony/contracts/IWebhookEndpoint.d.ts +82 -0
- package/dist/types/nodefony/contracts/IWebhookStore.d.ts +85 -0
- package/dist/types/nodefony/contracts/index.d.ts +9 -0
- package/dist/types/nodefony/errors/AccessDeniedError.d.ts +10 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +17 -0
- package/dist/types/nodefony/errors/AuthenticationError.d.ts +10 -0
- package/dist/types/nodefony/errors/CsrfError.d.ts +19 -0
- package/dist/types/nodefony/errors/InvalidTargetError.d.ts +34 -0
- package/dist/types/nodefony/errors/SsrfError.d.ts +13 -0
- package/dist/types/nodefony/errors/ThrottledError.d.ts +16 -0
- package/dist/types/nodefony/errors/UnverifiableTokenError.d.ts +37 -0
- package/dist/types/nodefony/errors/WebAuthnError.d.ts +17 -0
- package/dist/types/nodefony/errors/index.d.ts +8 -0
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +29 -0
- package/dist/types/nodefony/service/apiKeys.d.ts +103 -0
- package/dist/types/nodefony/service/auditService.d.ts +30 -0
- package/dist/types/nodefony/service/authFlow.d.ts +123 -0
- package/dist/types/nodefony/service/authorization.d.ts +33 -0
- package/dist/types/nodefony/service/cors.d.ts +48 -0
- package/dist/types/nodefony/service/csrf.d.ts +57 -0
- package/dist/types/nodefony/service/firewall.d.ts +148 -0
- package/dist/types/nodefony/service/oauth2.d.ts +66 -0
- package/dist/types/nodefony/service/securityHeaders.d.ts +66 -0
- package/dist/types/nodefony/service/tokenService.d.ts +103 -0
- package/dist/types/nodefony/service/totp.d.ts +58 -0
- package/dist/types/nodefony/service/webAuthn.d.ts +123 -0
- package/dist/types/nodefony/service/webhooks.d.ts +160 -0
- package/dist/types/nodefony/src/RoleHierarchyWalker.d.ts +21 -0
- package/dist/types/nodefony/src/SecuredArea.d.ts +31 -0
- package/dist/types/nodefony/src/admin/SecurityAdminApi.d.ts +82 -0
- package/dist/types/nodefony/src/admin/WebhookAdminApi.d.ts +30 -0
- package/dist/types/nodefony/src/admin/adminAudit.d.ts +27 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +31 -0
- package/dist/types/nodefony/src/apikey/apiKeyFormat.d.ts +43 -0
- package/dist/types/nodefony/src/audit/MemoryAuditStore.d.ts +33 -0
- package/dist/types/nodefony/src/audit/auditBridge.d.ts +49 -0
- package/dist/types/nodefony/src/audit/auditFilters.d.ts +56 -0
- package/dist/types/nodefony/src/audit/auditStoreRegistry.d.ts +37 -0
- package/dist/types/nodefony/src/audit/readAuditContext.d.ts +17 -0
- package/dist/types/nodefony/src/audit/recordAudit.d.ts +13 -0
- package/dist/types/nodefony/src/authenticator/AnonymousAuthenticator.d.ts +26 -0
- package/dist/types/nodefony/src/authenticator/ApiKeyAuthenticator.d.ts +74 -0
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +132 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +78 -0
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +69 -0
- package/dist/types/nodefony/src/authenticator/SessionAuthenticator.d.ts +70 -0
- package/dist/types/nodefony/src/authenticator/UserPasswordAuthenticator.d.ts +53 -0
- package/dist/types/nodefony/src/authenticator/authenticatorRegistry.d.ts +39 -0
- package/dist/types/nodefony/src/authenticator/bearer.d.ts +22 -0
- package/dist/types/nodefony/src/authenticator/externalSubject.d.ts +27 -0
- package/dist/types/nodefony/src/authenticator/peekIssuer.d.ts +31 -0
- package/dist/types/nodefony/src/crypto/secretCipher.d.ts +31 -0
- package/dist/types/nodefony/src/csp.d.ts +39 -0
- package/dist/types/nodefony/src/csrfToken.d.ts +36 -0
- package/dist/types/nodefony/src/net/ssrfGuard.d.ts +43 -0
- package/dist/types/nodefony/src/oauth/oauthProviderRegistry.d.ts +45 -0
- package/dist/types/nodefony/src/oauth/providers/github.d.ts +9 -0
- package/dist/types/nodefony/src/oauth/providers/oidc.d.ts +35 -0
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +62 -0
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +171 -0
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +139 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +20 -0
- package/dist/types/nodefony/src/throttle/LoginThrottler.d.ts +68 -0
- package/dist/types/nodefony/src/token/AnonymousToken.d.ts +23 -0
- package/dist/types/nodefony/src/token/JwtKeystore.d.ts +43 -0
- package/dist/types/nodefony/src/token/MemoryTokenStore.d.ts +66 -0
- package/dist/types/nodefony/src/token/RemoteJwtVerifier.d.ts +149 -0
- package/dist/types/nodefony/src/token/UserToken.d.ts +41 -0
- package/dist/types/nodefony/src/token/jwtRuntime.d.ts +28 -0
- package/dist/types/nodefony/src/token/secretFile.d.ts +70 -0
- package/dist/types/nodefony/src/token/tokenCriteria.d.ts +20 -0
- package/dist/types/nodefony/src/token/tokenFilters.d.ts +76 -0
- package/dist/types/nodefony/src/token/tokenSort.d.ts +33 -0
- package/dist/types/nodefony/src/token/tokenStatus.d.ts +38 -0
- package/dist/types/nodefony/src/token/tokenStoreRegistry.d.ts +38 -0
- package/dist/types/nodefony/src/totp/MemoryTotpSecretStore.d.ts +43 -0
- package/dist/types/nodefony/src/totp/totpCipher.d.ts +9 -0
- package/dist/types/nodefony/src/totp/totpCrypto.d.ts +164 -0
- package/dist/types/nodefony/src/totp/totpOperations.d.ts +73 -0
- package/dist/types/nodefony/src/totp/totpSecretStoreRegistry.d.ts +27 -0
- package/dist/types/nodefony/src/voter/RoleVoter.d.ts +25 -0
- package/dist/types/nodefony/src/voter/ScopeVoter.d.ts +30 -0
- package/dist/types/nodefony/src/voter/voterRegistry.d.ts +33 -0
- package/dist/types/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.d.ts +39 -0
- package/dist/types/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.d.ts +26 -0
- package/dist/types/nodefony/src/webhook/MemoryWebhookStore.d.ts +37 -0
- package/dist/types/nodefony/src/webhook/WebhookDispatcher.d.ts +69 -0
- package/dist/types/nodefony/src/webhook/webhookCipher.d.ts +8 -0
- package/dist/types/nodefony/src/webhook/webhookDelivery.d.ts +28 -0
- package/dist/types/nodefony/src/webhook/webhookFilters.d.ts +64 -0
- package/dist/types/nodefony/src/webhook/webhookSignature.d.ts +20 -0
- package/dist/types/nodefony/src/webhook/webhookSort.d.ts +39 -0
- package/dist/types/nodefony/src/webhook/webhookStoreRegistry.d.ts +31 -0
- package/docs/api-keys.md +691 -0
- package/docs/audit.md +751 -0
- package/docs/authenticators.md +487 -0
- package/docs/authorization.md +497 -0
- package/docs/cors.md +497 -0
- package/docs/csrf.md +392 -0
- package/docs/external-jwt.md +181 -0
- package/docs/firewall.md +546 -0
- package/docs/headers.md +616 -0
- package/docs/index.md +207 -0
- package/docs/lexique.md +190 -0
- package/docs/oauth2.md +575 -0
- package/docs/obtenir-un-jeton.md +225 -0
- package/docs/tokens.md +520 -0
- package/docs/totp.md +804 -0
- package/docs/webauthn.md +733 -0
- package/docs/webhooks.md +1016 -0
- package/package.json +83 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { PLATFORM_CHANNELS } from "nodefony";
|
|
2
|
+
//#region nodefony/src/audit/auditBridge.ts
|
|
3
|
+
/**
|
|
4
|
+
* Canal WS du flux live d'audit (P6.14 lot 4). Le préfixe `security:` le place
|
|
5
|
+
* sous le plancher `SECURITY_CHANNEL_POLICY` (ROLE_NODEFONY_ADMIN) du verrou de
|
|
6
|
+
* frame — un user lambda ne peut pas s'y abonner (refus audité `frame.denied`).
|
|
7
|
+
*/
|
|
8
|
+
const SECURITY_AUDIT_CHANNEL = PLATFORM_CHANNELS.audit;
|
|
9
|
+
/**
|
|
10
|
+
* Pont journal d'audit → canal `nodefony:audit`, **coalescé** (P6.14 lot 4).
|
|
11
|
+
*
|
|
12
|
+
* Calque {@link createSyslogBridge} (studio) : au lieu de 1 frame WS par
|
|
13
|
+
* événement (un pic d'`auth.failure` sous brute-force noierait la console
|
|
14
|
+
* auditeur), on accumule dans un **ring buffer borné** et on flush **1 frame
|
|
15
|
+
* agrégée toutes les `flushMs`** : `{ events, dropped }`. Sous surcharge, le ring
|
|
16
|
+
* écrase les plus vieux et `dropped` indique combien ont été omis → la console
|
|
17
|
+
* affiche un récap au lieu de se figer (budget borné, dégradable — règle
|
|
18
|
+
* observabilité « superviser ≠ tomber la prod »).
|
|
19
|
+
*
|
|
20
|
+
* **Lazy par construction** (créé par le hub au 1ᵉʳ abonné, `dispose` au dernier) :
|
|
21
|
+
* tant qu'aucun auditeur n'écoute `nodefony:audit`, ce pont N'EXISTE PAS — aucun
|
|
22
|
+
* listener sur l'`AuditService`, aucun timer. Au repos avec auditeur connecté mais
|
|
23
|
+
* sans événement : ring `null`, 0 timer (armé au 1ᵉʳ événement, `unref`).
|
|
24
|
+
*
|
|
25
|
+
* @param source - l'`AuditService` (slot `subscribe`).
|
|
26
|
+
* @param publish - publication hub (le canal est fourni par la factory).
|
|
27
|
+
* @param channel - canal de publication (`nodefony:audit`).
|
|
28
|
+
* @returns dispose() — détache le listener `AuditService` ET désarme le timer.
|
|
29
|
+
* OBLIGATOIRE (aucun listener/timer sans cleanup, sinon fuite à chaque
|
|
30
|
+
* dernier désabonnement).
|
|
31
|
+
*/
|
|
32
|
+
function createAuditBridge(source, publish, channel, opts = {}) {
|
|
33
|
+
const flushMs = opts.flushMs ?? 250;
|
|
34
|
+
const maxBatch = opts.maxBatch ?? 200;
|
|
35
|
+
let ring = null;
|
|
36
|
+
let head = 0;
|
|
37
|
+
let count = 0;
|
|
38
|
+
let dropped = 0;
|
|
39
|
+
let timer = null;
|
|
40
|
+
const flush = () => {
|
|
41
|
+
timer = null;
|
|
42
|
+
if (count === 0 && dropped === 0) return;
|
|
43
|
+
const events = new Array(count);
|
|
44
|
+
for (let i = 0; i < count; i++) events[i] = ring[(head + i) % maxBatch];
|
|
45
|
+
const d = dropped;
|
|
46
|
+
for (let i = 0; i < maxBatch; i++) ring[i] = void 0;
|
|
47
|
+
head = 0;
|
|
48
|
+
count = 0;
|
|
49
|
+
dropped = 0;
|
|
50
|
+
publish(channel, {
|
|
51
|
+
events,
|
|
52
|
+
dropped: d
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
const onEvent = (event) => {
|
|
56
|
+
if (ring === null) ring = new Array(maxBatch);
|
|
57
|
+
if (count === maxBatch) {
|
|
58
|
+
ring[head] = event;
|
|
59
|
+
head = (head + 1) % maxBatch;
|
|
60
|
+
dropped++;
|
|
61
|
+
} else {
|
|
62
|
+
ring[(head + count) % maxBatch] = event;
|
|
63
|
+
count++;
|
|
64
|
+
}
|
|
65
|
+
if (timer === null) {
|
|
66
|
+
timer = setTimeout(flush, flushMs);
|
|
67
|
+
timer.unref?.();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const unsubscribe = source.subscribe(onEvent);
|
|
71
|
+
return () => {
|
|
72
|
+
if (timer) {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
timer = null;
|
|
75
|
+
}
|
|
76
|
+
ring = null;
|
|
77
|
+
head = count = dropped = 0;
|
|
78
|
+
unsubscribe();
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
//#endregion
|
|
82
|
+
export { SECURITY_AUDIT_CHANNEL, createAuditBridge, createAuditBridge as default };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
//#region nodefony/src/audit/auditFilters.ts
|
|
2
|
+
/**
|
|
3
|
+
* **Le vocabulaire de filtre du journal d'audit**, en noms PUBLICS — ceux qu'un
|
|
4
|
+
* auditeur écrit dans l'URL (`?category=authz&outcome=denied&since=…`).
|
|
5
|
+
*
|
|
6
|
+
* Les deux énumérations y sont écrites en toutes lettres, et
|
|
7
|
+
* {@link AUDIT_FILTER_VOCABULARY_IS_COMPLETE} vérifie **à la compilation**
|
|
8
|
+
* qu'elles couvrent exactement `AuditCategory` et `AuditOutcome`.
|
|
9
|
+
*
|
|
10
|
+
* Ce contrôle n'est pas décoratif : la liste qu'il remplace avait DÉJÀ dérivé.
|
|
11
|
+
* Un `Set` recopié à la main dans le data plane portait dix catégories quand le
|
|
12
|
+
* type en déclarait onze — `?category=config` tombait donc hors de l'allowlist,
|
|
13
|
+
* était ignoré en silence, et l'auditeur recevait le journal ENTIER en croyant
|
|
14
|
+
* lire les seules mutations de configuration. Une liste recopiée ne diverge
|
|
15
|
+
* jamais bruyamment.
|
|
16
|
+
*
|
|
17
|
+
* `actor`, `action` et `requestId` restent des chaînes libres : ce sont des
|
|
18
|
+
* identifiants produits à l'exécution, aucune allowlist ne peut les connaître.
|
|
19
|
+
* `since`/`until` sont des horodatages en millisecondes (bornes incluses).
|
|
20
|
+
*/
|
|
21
|
+
const AUDIT_FILTERS = {
|
|
22
|
+
/** Famille d'événement — la liste EST le type `AuditCategory`. */
|
|
23
|
+
category: [
|
|
24
|
+
"auth",
|
|
25
|
+
"authz",
|
|
26
|
+
"token",
|
|
27
|
+
"session",
|
|
28
|
+
"oauth",
|
|
29
|
+
"webauthn",
|
|
30
|
+
"csrf",
|
|
31
|
+
"cors",
|
|
32
|
+
"ws",
|
|
33
|
+
"webhook",
|
|
34
|
+
"config"
|
|
35
|
+
],
|
|
36
|
+
/** Issue — `denied` est le signal d'accès non autorisé. */
|
|
37
|
+
outcome: [
|
|
38
|
+
"success",
|
|
39
|
+
"failure",
|
|
40
|
+
"denied"
|
|
41
|
+
],
|
|
42
|
+
/** Identité de l'acteur (égalité stricte). */
|
|
43
|
+
actor: "string",
|
|
44
|
+
/** Nom de l'action auditée (égalité stricte). */
|
|
45
|
+
action: "string",
|
|
46
|
+
/** Corrèle toutes les traces d'une même requête. */
|
|
47
|
+
requestId: "string",
|
|
48
|
+
/** Borne basse, horodatage en millisecondes. */
|
|
49
|
+
since: "int",
|
|
50
|
+
/** Borne haute, horodatage en millisecondes. */
|
|
51
|
+
until: "int"
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* Preuve **à la compilation** que le vocabulaire ci-dessus est exactement celui
|
|
55
|
+
* des types du contrat. Elle remplace la discipline humaine « penser à mettre
|
|
56
|
+
* les deux à jour », qui avait échoué en silence.
|
|
57
|
+
*/
|
|
58
|
+
const AUDIT_FILTER_VOCABULARY_IS_COMPLETE = [true, true];
|
|
59
|
+
//#endregion
|
|
60
|
+
export { AUDIT_FILTERS, AUDIT_FILTER_VOCABULARY_IS_COMPLETE };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { MemoryAuditStore } from "./MemoryAuditStore.js";
|
|
2
|
+
//#region nodefony/src/audit/auditStoreRegistry.ts
|
|
3
|
+
const factories = /* @__PURE__ */ new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Enregistre (ou remplace) la fabrique d'un store d'audit. Appelée par le builtin
|
|
6
|
+
* `memory` au chargement, et par les adapters (drizzle/mongoose/redis) pour les leurs.
|
|
7
|
+
*/
|
|
8
|
+
function registerAuditStore(name, factory) {
|
|
9
|
+
factories.set(name, factory);
|
|
10
|
+
}
|
|
11
|
+
/** Fabrique d'un store par nom, ou `undefined` si inconnu. */
|
|
12
|
+
function getAuditStoreFactory(name) {
|
|
13
|
+
return factories.get(name);
|
|
14
|
+
}
|
|
15
|
+
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
16
|
+
function listAuditStores() {
|
|
17
|
+
return [...factories.keys()];
|
|
18
|
+
}
|
|
19
|
+
const MS_PER_DAY = 864e5;
|
|
20
|
+
registerAuditStore("memory", (ctx) => {
|
|
21
|
+
const days = ctx?.config?.audit?.retentionDays;
|
|
22
|
+
return typeof days === "number" ? new MemoryAuditStore(Date.now, days * MS_PER_DAY) : new MemoryAuditStore();
|
|
23
|
+
});
|
|
24
|
+
//#endregion
|
|
25
|
+
export { getAuditStoreFactory, listAuditStores, registerAuditStore };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region nodefony/src/audit/readAuditContext.ts
|
|
2
|
+
/**
|
|
3
|
+
* Extrait IP / User-Agent / requestId + drapeaux de présence (jamais la valeur)
|
|
4
|
+
* d'un contexte de requête — pour enrichir un événement d'audit avec la
|
|
5
|
+
* provenance (« d'où vient cette tentative »). Calque {@link JsonAuditLogger}.
|
|
6
|
+
*
|
|
7
|
+
* @param context - contexte HTTP/WS courant (typé `unknown` à la frontière).
|
|
8
|
+
* @returns provenance normalisée ; champs `null` si l'info est absente.
|
|
9
|
+
*/
|
|
10
|
+
function readAuditContext(context) {
|
|
11
|
+
const ctx = context ?? {};
|
|
12
|
+
const headers = ctx.request?.headers ?? {};
|
|
13
|
+
return {
|
|
14
|
+
ip: ctx.remoteAddress ?? null,
|
|
15
|
+
userAgent: ctx.getUserAgent?.() ?? null,
|
|
16
|
+
requestId: ctx.requestId ?? null,
|
|
17
|
+
flags: {
|
|
18
|
+
hasAuthorization: Boolean(headers["authorization"]),
|
|
19
|
+
hasCookie: Boolean(headers["cookie"])
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
export { readAuditContext };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region nodefony/src/audit/recordAudit.ts
|
|
2
|
+
/**
|
|
3
|
+
* Émet un événement d'audit **si** le service est présent — no-op sinon. La
|
|
4
|
+
* résolution se fait par le container (`auditService`) sur le **cold-path**
|
|
5
|
+
* (login, refus, révocation) : le coût d'un `Map.get` y est négligeable, et le
|
|
6
|
+
* journal reste **découplé** (module audit absent ou désactivé → aucun effet,
|
|
7
|
+
* jamais d'exception qui remonterait dans le flux métier).
|
|
8
|
+
*
|
|
9
|
+
* @param container - container du service émetteur (`this.container`).
|
|
10
|
+
* @param event - brouillon d'événement (l'`AuditService` pose `id` + `ts`).
|
|
11
|
+
*/
|
|
12
|
+
function recordAudit(container, event) {
|
|
13
|
+
(container?.get("auditService"))?.record(event);
|
|
14
|
+
}
|
|
15
|
+
//#endregion
|
|
16
|
+
export { recordAudit };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AnonymousToken } from "../token/AnonymousToken.js";
|
|
2
|
+
//#region nodefony/src/authenticator/AnonymousAuthenticator.ts
|
|
3
|
+
/**
|
|
4
|
+
* Acceptation EXPLICITE de l'anonymat dans une zone — le seul authenticator
|
|
5
|
+
* autorisé à produire un token non authentifié sans déclencher le Zero Trust.
|
|
6
|
+
*
|
|
7
|
+
* Ne le lister que volontairement : une zone `authenticators: ["jwt", "anonymous"]`
|
|
8
|
+
* (mode `first`) signifie « identifié si preuve présente, sinon visiteur anonyme
|
|
9
|
+
* accepté ». Sans lui, zone protégée + aucune preuve → 401. En mode `all` il
|
|
10
|
+
* reste utile en DERNIER : « le canal doit être prouvé (ex. mtls), l'identité
|
|
11
|
+
* utilisateur est optionnelle ».
|
|
12
|
+
*
|
|
13
|
+
* Zéro coût : `supports()` accepte tout, le token porte le singleton gelé
|
|
14
|
+
* `anonymousUser` (aucune allocation d'utilisateur).
|
|
15
|
+
*/
|
|
16
|
+
var AnonymousAuthenticator = class {
|
|
17
|
+
name = "anonymous";
|
|
18
|
+
supports() {
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
createToken() {
|
|
22
|
+
return Promise.resolve(new AnonymousToken());
|
|
23
|
+
}
|
|
24
|
+
/** Toujours un succès — accepter l'anonymat ne vérifie rien. */
|
|
25
|
+
authenticate(token) {
|
|
26
|
+
return Promise.resolve(token);
|
|
27
|
+
}
|
|
28
|
+
onSuccess(_context, _token) {
|
|
29
|
+
return Promise.resolve();
|
|
30
|
+
}
|
|
31
|
+
onFailure(_context, _error) {
|
|
32
|
+
return Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { AnonymousAuthenticator, AnonymousAuthenticator as default };
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { UserToken } from "../token/UserToken.js";
|
|
3
|
+
import { bearerToken } from "./bearer.js";
|
|
4
|
+
import { looksLikeApiKey, parseApiKey } from "../apikey/apiKeyFormat.js";
|
|
5
|
+
//#region nodefony/src/authenticator/ApiKeyAuthenticator.ts
|
|
6
|
+
const INVALID_TOKEN = "Invalid token";
|
|
7
|
+
/**
|
|
8
|
+
* Marqueur interne posé par `authenticate()` et consommé par `onSuccess()` :
|
|
9
|
+
* l'horodatage à inscrire, quand la fenêtre de throttle est dépassée. Il ne
|
|
10
|
+
* traverse jamais le pipeline — l'attribut vit sur le token de la requête.
|
|
11
|
+
*/
|
|
12
|
+
const MARK_USED_AT = "apiKeyMarkUsedAt";
|
|
13
|
+
/**
|
|
14
|
+
* Authentification par **clé API personnelle (PAT, P6.12)** présentée en
|
|
15
|
+
* `Authorization: Bearer <prefix>_…` (RFC 6750). Réservée API/CI/scripts — le web
|
|
16
|
+
* utilise la session BFF.
|
|
17
|
+
*
|
|
18
|
+
* Un PAT est un **bearer opaque** (≠ JWT auto-porté) : sa vérité vit côté serveur
|
|
19
|
+
* (`ITokenStore`), donc il est **révocable immédiatement**. Discrimination du
|
|
20
|
+
* JWT : le PAT porte le préfixe `<prefix>_` (le JWT a la structure compacte
|
|
21
|
+
* `a.b.c`) → les deux authenticators cohabitent dans une même zone.
|
|
22
|
+
*
|
|
23
|
+
* Défenses :
|
|
24
|
+
* - **forme + CRC validés AVANT tout accès au store** ({@link parseApiKey}) →
|
|
25
|
+
* une valeur malformée n'atteint jamais la base (anti-DoS) ;
|
|
26
|
+
* - lookup par **hash** (`sha256`) — le secret n'existe nulle part au repos ;
|
|
27
|
+
* - **révocation** immédiate (`revokedAt`) + **expiration** (`expiresAt`) +
|
|
28
|
+
* **ban en masse** du porteur (`invalidBefore` vs `createdAt`) ;
|
|
29
|
+
* - **sujet revérifié** à chaque requête (`loadUserByIdentifier` → disparu/
|
|
30
|
+
* inactif/verrouillé = rejet) — rôles **frais** (révocation effective) ;
|
|
31
|
+
* - message d'échec **uniforme** (anti-énumération).
|
|
32
|
+
*
|
|
33
|
+
* Dépendances (store, userProvider) résolues **paresseusement** du container.
|
|
34
|
+
*/
|
|
35
|
+
var ApiKeyAuthenticator = class {
|
|
36
|
+
name = "apikey";
|
|
37
|
+
#container;
|
|
38
|
+
#prefix;
|
|
39
|
+
#throttleMs;
|
|
40
|
+
#store = null;
|
|
41
|
+
#userProvider = null;
|
|
42
|
+
/**
|
|
43
|
+
* @param container - container DI (résolution lazy de `tokenStore`/`users`).
|
|
44
|
+
* @param runtime - préfixe + throttle effectifs (dérivés de `config.apiKeys`).
|
|
45
|
+
*/
|
|
46
|
+
constructor(container, runtime) {
|
|
47
|
+
this.#container = container;
|
|
48
|
+
this.#prefix = runtime.prefix;
|
|
49
|
+
this.#throttleMs = Math.max(0, runtime.lastUsedThrottleS) * 1e3;
|
|
50
|
+
}
|
|
51
|
+
/** La requête porte-t-elle un `Authorization: Bearer <prefix>_…` ? (test bon marché) */
|
|
52
|
+
supports(context) {
|
|
53
|
+
const auth = context.request?.headers?.authorization;
|
|
54
|
+
if (typeof auth !== "string") return false;
|
|
55
|
+
const token = bearerToken(auth);
|
|
56
|
+
return token !== null && looksLikeApiKey(token, this.#prefix);
|
|
57
|
+
}
|
|
58
|
+
/** Extrait la valeur brute (non vérifiée) → portée par un `UserToken` type `"apikey"`. */
|
|
59
|
+
createToken(context) {
|
|
60
|
+
const auth = context.request?.headers?.authorization;
|
|
61
|
+
return Promise.resolve(new UserToken("apikey", bearerToken(auth) ?? ""));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Valide la clé (forme+CRC, puis store) et résout le sujet — ou lève un 401 au
|
|
65
|
+
* message uniforme.
|
|
66
|
+
*
|
|
67
|
+
* @throws AuthenticationError (401) — clé malformée/inconnue/révoquée/expirée,
|
|
68
|
+
* ou sujet disparu/banni.
|
|
69
|
+
* @throws Error (câblage : store/users absents) — loggée ERROR par le firewall
|
|
70
|
+
* puis 401 fail-closed (rien ne fuite au client).
|
|
71
|
+
*/
|
|
72
|
+
async authenticate(token) {
|
|
73
|
+
const raw = token.getCredentials();
|
|
74
|
+
if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
|
|
75
|
+
const parsed = parseApiKey(raw, this.#prefix);
|
|
76
|
+
if (parsed === null) throw new AuthenticationError(INVALID_TOKEN);
|
|
77
|
+
const store = this.#resolveStore();
|
|
78
|
+
const record = await store.findByHash(parsed.secretHash);
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (!record || record.kind !== "pat" || record.revokedAt !== null || record.expiresAt !== null && record.expiresAt <= now) throw new AuthenticationError(INVALID_TOKEN);
|
|
81
|
+
const invalidBefore = await store.getInvalidBefore(record.subjectId);
|
|
82
|
+
if (invalidBefore !== null && record.createdAt < invalidBefore) throw new AuthenticationError(INVALID_TOKEN);
|
|
83
|
+
const user = await this.#resolveUserOrReject(record.subjectId);
|
|
84
|
+
const last = record.lastUsedAt;
|
|
85
|
+
if (this.#throttleMs === 0 || last === null || now - last >= this.#throttleMs) token.setAttribute(MARK_USED_AT, now);
|
|
86
|
+
const ut = token;
|
|
87
|
+
ut.promote(user);
|
|
88
|
+
ut.setAttribute("scopes", [...record.scopes]);
|
|
89
|
+
ut.setAttribute("apiKeyId", record.id);
|
|
90
|
+
ut.setAttribute("tenantId", record.tenantId);
|
|
91
|
+
return ut;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Inscrit la trace d'usage de la clé — horodatage, IP et agent.
|
|
95
|
+
*
|
|
96
|
+
* C'est ici, et pas dans `authenticate()`, parce que c'est ici qu'on reçoit
|
|
97
|
+
* le contexte. La provenance se lit par les ACCESSEURS proxy-aware des
|
|
98
|
+
* contextes concrets (`getRemoteAddress()` dépouille `X-Forwarded-For` selon
|
|
99
|
+
* `trustProxy`), absents du type de base — duck-typing optionnel, même
|
|
100
|
+
* approche que `AuthFlow.#openSession()`.
|
|
101
|
+
*
|
|
102
|
+
* Rien n'est écrit si `authenticate()` n'a pas posé le marqueur : la fenêtre
|
|
103
|
+
* de throttle n'était pas dépassée, et le hot path reste sans écriture.
|
|
104
|
+
*/
|
|
105
|
+
async onSuccess(context, token) {
|
|
106
|
+
const at = token.getAttribute(MARK_USED_AT);
|
|
107
|
+
if (at === void 0) return;
|
|
108
|
+
const id = token.getAttribute("apiKeyId");
|
|
109
|
+
if (id === void 0) return;
|
|
110
|
+
const provenance = context;
|
|
111
|
+
let ip;
|
|
112
|
+
let userAgent;
|
|
113
|
+
try {
|
|
114
|
+
ip = provenance.getRemoteAddress?.() ?? void 0;
|
|
115
|
+
userAgent = provenance.getUserAgent?.();
|
|
116
|
+
} catch {}
|
|
117
|
+
try {
|
|
118
|
+
await this.#resolveStore().markUsed(id, {
|
|
119
|
+
at,
|
|
120
|
+
ip,
|
|
121
|
+
userAgent
|
|
122
|
+
});
|
|
123
|
+
} catch (e) {
|
|
124
|
+
context.log(`apikey: trace d'usage non enregistrée (${id}) — ${String(e)}`, "WARNING");
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Slot audit (P6.14) — le 401 + challenge sont posés par le firewall. */
|
|
128
|
+
onFailure(_context, _error) {
|
|
129
|
+
return Promise.resolve();
|
|
130
|
+
}
|
|
131
|
+
/** Challenge RFC 6750/7235 posé par le firewall sur les 401 de la zone. */
|
|
132
|
+
challenge() {
|
|
133
|
+
return "Bearer";
|
|
134
|
+
}
|
|
135
|
+
async #resolveUserOrReject(sub) {
|
|
136
|
+
const provider = this.#resolveUserProvider();
|
|
137
|
+
let user;
|
|
138
|
+
try {
|
|
139
|
+
user = await provider.loadUserByIdentifier(sub);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new AuthenticationError(INVALID_TOKEN);
|
|
142
|
+
}
|
|
143
|
+
if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
|
|
144
|
+
return user;
|
|
145
|
+
}
|
|
146
|
+
#resolveStore() {
|
|
147
|
+
if (this.#store === null) {
|
|
148
|
+
const store = this.#container.get("tokenStore");
|
|
149
|
+
if (!store) throw new Error("ApiKeyAuthenticator: service 'tokenStore' absent du container — le TokenService de @nodefony/security doit être chargé.");
|
|
150
|
+
this.#store = store;
|
|
151
|
+
}
|
|
152
|
+
return this.#store;
|
|
153
|
+
}
|
|
154
|
+
#resolveUserProvider() {
|
|
155
|
+
if (this.#userProvider === null) {
|
|
156
|
+
const provider = this.#container.get("users");
|
|
157
|
+
if (!provider) throw new Error("ApiKeyAuthenticator: aucun service 'users' (IUserProvider) dans le container — enregistrer un UserService au boot de l'application.");
|
|
158
|
+
this.#userProvider = provider;
|
|
159
|
+
}
|
|
160
|
+
return this.#userProvider;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
//#endregion
|
|
164
|
+
export { ApiKeyAuthenticator, ApiKeyAuthenticator as default };
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { UnverifiableTokenError } from "../../errors/UnverifiableTokenError.js";
|
|
3
|
+
import { UserToken } from "../token/UserToken.js";
|
|
4
|
+
import { bearerToken } from "./bearer.js";
|
|
5
|
+
import peekIssuer from "./peekIssuer.js";
|
|
6
|
+
import { localIdentifierFor } from "./externalSubject.js";
|
|
7
|
+
import { ACCESS_TOKEN_VERIFIER, buildBearerChallenge, canonicalIssuer, protectedResourceMetadataUrl } from "nodefony";
|
|
8
|
+
import { BaseUser } from "@nodefony/user";
|
|
9
|
+
//#region nodefony/src/authenticator/ExternalJwtAuthenticator.ts
|
|
10
|
+
/**
|
|
11
|
+
* Message UNIFORME de refus — la cause fine (expiré, audience, signature, sujet
|
|
12
|
+
* inconnu) part au journal, jamais au client : un refus détaillé est un oracle
|
|
13
|
+
* qui aide à fabriquer un jeton acceptable.
|
|
14
|
+
*/
|
|
15
|
+
const INVALID_TOKEN = "Invalid token";
|
|
16
|
+
/** Clé de transport de l'audience entre `createToken` et `authenticate`. */
|
|
17
|
+
const AUDIENCE = "audience";
|
|
18
|
+
/**
|
|
19
|
+
* Authentification par **jeton d'accès émis par un serveur d'autorisation
|
|
20
|
+
* TIERS** (Keycloak, Auth0, Entra, ou l'émetteur d'une flotte d'agents).
|
|
21
|
+
*
|
|
22
|
+
* C'est le chaînon qui relie deux pièces déjà en place : le vérificateur de
|
|
23
|
+
* jetons distants, qui sait lire un jeton dont on ne possède pas la clé, et le
|
|
24
|
+
* pare-feu, qui raisonne en utilisateurs et en rôles. Le vérificateur s'arrête
|
|
25
|
+
* à un sujet et des scopes — délibérément, car établir une identité
|
|
26
|
+
* applicative est une décision de l'application, pas du protocole. C'est cette
|
|
27
|
+
* décision-là que porte cette classe, et rien d'autre.
|
|
28
|
+
*
|
|
29
|
+
* ## Cohabitation avec les jetons maison
|
|
30
|
+
*
|
|
31
|
+
* `JwtAuthenticator` et celui-ci reconnaissent la même forme de credential.
|
|
32
|
+
* Chacun ne prend donc que les jetons dont l'émetteur revendiqué est le sien
|
|
33
|
+
* ({@link peekIssuer}) — lecture non vérifiée qui ne sert qu'à AIGUILLER. Sans
|
|
34
|
+
* cela, en mode `first`, le premier listé capturerait les deux familles et
|
|
35
|
+
* refuserait la moitié des jetons : l'ordre de la configuration deviendrait
|
|
36
|
+
* une décision de sécurité, dont l'erreur ne se verrait qu'en production.
|
|
37
|
+
*
|
|
38
|
+
* ## Ce qui vaut garantie
|
|
39
|
+
*
|
|
40
|
+
* - **L'audience vient de la ZONE**, jamais du jeton, et elle est obligatoire :
|
|
41
|
+
* sans elle l'authenticator refuse de démarrer ({@link validateArea}).
|
|
42
|
+
* - **Un refus est un 401 uniforme ; une PANNE est un 503** — un émetteur
|
|
43
|
+
* injoignable n'est pas un jeton invalide, et le dire autrement enverrait le
|
|
44
|
+
* client renouveler en boucle un jeton parfaitement bon.
|
|
45
|
+
* - **Le sujet est revérifié localement** en mode `require` : un compte
|
|
46
|
+
* supprimé, désactivé ou verrouillé ferme l'accès sans attendre l'expiration
|
|
47
|
+
* du jeton, que l'application ne contrôle pas.
|
|
48
|
+
*
|
|
49
|
+
* - **Le sujet n'entre jamais nu dans l'espace de noms local** : un `sub` n'est
|
|
50
|
+
* unique que chez son émetteur, et le rattachement passe donc par
|
|
51
|
+
* {@link localIdentifierFor}, piloté par le `subjectMapping` de CET émetteur.
|
|
52
|
+
* - **Le refus est apprenable** : le défi porte le pointeur `resource_metadata`
|
|
53
|
+
* (RFC 9728), qui dit au client où aller chercher de quoi obtenir un jeton.
|
|
54
|
+
*/
|
|
55
|
+
var ExternalJwtAuthenticator = class {
|
|
56
|
+
name = "external-jwt";
|
|
57
|
+
#container;
|
|
58
|
+
#issuers;
|
|
59
|
+
#policy;
|
|
60
|
+
#ephemeralRoles;
|
|
61
|
+
#userProvider = null;
|
|
62
|
+
/**
|
|
63
|
+
* @param container - container DI (résolution lazy du vérificateur et de `users`)
|
|
64
|
+
* @param options - émetteurs reconnus et politique de rattachement
|
|
65
|
+
*/
|
|
66
|
+
constructor(container, options) {
|
|
67
|
+
this.#container = container;
|
|
68
|
+
const issuers = /* @__PURE__ */ new Map();
|
|
69
|
+
for (const binding of options.issuers) try {
|
|
70
|
+
issuers.set(canonicalIssuer(binding.issuer), binding.subjectMapping);
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
this.#issuers = issuers;
|
|
75
|
+
this.#policy = options.subjectPolicy;
|
|
76
|
+
this.#ephemeralRoles = options.ephemeralRoles;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* La requête porte-t-elle un jeton se réclamant d'un émetteur de confiance ?
|
|
80
|
+
*
|
|
81
|
+
* Le contrôle est délibérément le MÊME que celui du vérificateur, et non un
|
|
82
|
+
* simple « c'est un JWT » : un jeton maison ne doit pas être capturé ici, et
|
|
83
|
+
* un jeton d'un émetteur inconnu n'a pas à provoquer le moindre travail.
|
|
84
|
+
*/
|
|
85
|
+
supports(context) {
|
|
86
|
+
const auth = context.request?.headers?.authorization;
|
|
87
|
+
if (typeof auth !== "string") return false;
|
|
88
|
+
const raw = bearerToken(auth);
|
|
89
|
+
if (raw === null) return false;
|
|
90
|
+
const issuer = peekIssuer(raw);
|
|
91
|
+
if (issuer === null) return false;
|
|
92
|
+
if (this.#issuers.has(issuer)) return true;
|
|
93
|
+
try {
|
|
94
|
+
return this.#issuers.has(canonicalIssuer(issuer));
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Extrait le jeton brut ET l'audience de la zone.
|
|
101
|
+
*
|
|
102
|
+
* L'audience transite par le token parce que `authenticate()` ne reçoit pas
|
|
103
|
+
* le contexte : c'est ici, et seulement ici, qu'on sait quelle ressource est
|
|
104
|
+
* visée.
|
|
105
|
+
*/
|
|
106
|
+
createToken(context) {
|
|
107
|
+
const auth = context.request?.headers?.authorization;
|
|
108
|
+
const token = new UserToken("external-jwt", bearerToken(auth) ?? "");
|
|
109
|
+
const area = context.security;
|
|
110
|
+
if (area?.resource) token.setAttribute(AUDIENCE, area.resource);
|
|
111
|
+
return Promise.resolve(token);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Vérifie le jeton auprès de son émetteur, puis rattache le sujet.
|
|
115
|
+
*
|
|
116
|
+
* @throws AuthenticationError (401) — jeton refusé, ou sujet sans compte
|
|
117
|
+
* local utilisable
|
|
118
|
+
* @throws UnverifiableTokenError (503) — rien ne peut vérifier ce jeton, ou
|
|
119
|
+
* l'émetteur est injoignable : on ne sait pas, et on le dit
|
|
120
|
+
*/
|
|
121
|
+
async authenticate(token) {
|
|
122
|
+
const raw = token.getCredentials();
|
|
123
|
+
if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
|
|
124
|
+
const audience = token.getAttribute(AUDIENCE);
|
|
125
|
+
if (!audience) throw new UnverifiableTokenError();
|
|
126
|
+
const verify = this.#container.get(ACCESS_TOKEN_VERIFIER);
|
|
127
|
+
if (typeof verify !== "function") throw new UnverifiableTokenError();
|
|
128
|
+
let principal;
|
|
129
|
+
try {
|
|
130
|
+
principal = await verify(raw, audience);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
throw new UnverifiableTokenError(error.message);
|
|
133
|
+
}
|
|
134
|
+
if (principal === null) throw new AuthenticationError(INVALID_TOKEN);
|
|
135
|
+
const subject = principal.subject;
|
|
136
|
+
if (!subject) throw new AuthenticationError(INVALID_TOKEN);
|
|
137
|
+
const mapping = this.#issuers.get(principal.issuer);
|
|
138
|
+
if (mapping === void 0) throw new UnverifiableTokenError(`émetteur « ${principal.issuer} » vérifié mais absent de la table de rattachement des sujets — configuration incohérente.`);
|
|
139
|
+
const user = await this.#resolveUser(principal.issuer, subject, mapping);
|
|
140
|
+
const ut = token;
|
|
141
|
+
ut.promote(user);
|
|
142
|
+
ut.setAttribute("scopes", [...principal.scopes]);
|
|
143
|
+
ut.setAttribute("subject", subject);
|
|
144
|
+
ut.setAttribute("issuer", principal.issuer);
|
|
145
|
+
if (principal.expiresAt !== void 0 || principal.issuedAt !== void 0) ut.setAttribute("claims", {
|
|
146
|
+
exp: principal.expiresAt,
|
|
147
|
+
iat: principal.issuedAt
|
|
148
|
+
});
|
|
149
|
+
if (principal.tokenId !== void 0) ut.setAttribute("jti", principal.tokenId);
|
|
150
|
+
return ut;
|
|
151
|
+
}
|
|
152
|
+
/** Slot audit — le firewall enregistre déjà succès et échec par zone. */
|
|
153
|
+
onSuccess(_context, _token) {
|
|
154
|
+
return Promise.resolve();
|
|
155
|
+
}
|
|
156
|
+
/** Slot audit — le 401 et le défi sont posés par le firewall. */
|
|
157
|
+
onFailure(_context, _error) {
|
|
158
|
+
return Promise.resolve();
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Défi RFC 6750 + pointeur RFC 9728, posé par le firewall sur les 401.
|
|
162
|
+
*
|
|
163
|
+
* ⭐ **C'est cet en-tête qui rend l'autorisation apprenable.** Un `Bearer` nu
|
|
164
|
+
* est un mur : le client sait qu'il lui faut un jeton, mais pas où le
|
|
165
|
+
* demander. `resource_metadata` nomme le document qui le lui dira — c'est le
|
|
166
|
+
* seul mécanisme normalisé pour ça, et celui qu'un client MCP conforme suit.
|
|
167
|
+
*
|
|
168
|
+
* Aucun `error` n'est joint : le firewall pose ce défi sur TOUT 401 de la
|
|
169
|
+
* zone, sans savoir si la requête portait un jeton. Or la RFC 6750 §3
|
|
170
|
+
* demande de ne PAS mettre de code d'erreur quand elle n'en portait aucun —
|
|
171
|
+
* un `invalid_token` ferait renouveler en boucle un jeton qui n'existe pas.
|
|
172
|
+
*
|
|
173
|
+
* @param area - zone refusante ; sans elle (ou sans ressource déclarée) le
|
|
174
|
+
* défi retombe sur `Bearer` nu, faute de ressource à nommer
|
|
175
|
+
*/
|
|
176
|
+
challenge(area) {
|
|
177
|
+
if (!area?.resource) return "Bearer";
|
|
178
|
+
return buildBearerChallenge({ resourceMetadataUrl: protectedResourceMetadataUrl(area.resource) });
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Refuse une zone sans ressource — au boot, pas à la première requête.
|
|
182
|
+
*
|
|
183
|
+
* Sans audience, la vérification accepterait un jeton émis pour un autre
|
|
184
|
+
* service : le seul verrou qui lie un jeton à CE service disparaîtrait, et
|
|
185
|
+
* l'application n'en saurait rien.
|
|
186
|
+
*/
|
|
187
|
+
validateArea(area) {
|
|
188
|
+
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.`);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Établit l'utilisateur applicatif à partir du sujet du jeton.
|
|
192
|
+
*
|
|
193
|
+
* Les deux politiques répondent à deux questions différentes : « qui, chez
|
|
194
|
+
* nous, est cette personne ? » et « quel pouvoir accorde-t-on à une machine
|
|
195
|
+
* que l'annuaire a authentifiée ? ».
|
|
196
|
+
*/
|
|
197
|
+
async #resolveUser(issuer, subject, mapping) {
|
|
198
|
+
const identifier = localIdentifierFor(issuer, subject, mapping);
|
|
199
|
+
if (this.#policy === "ephemeral") return new BaseUser({
|
|
200
|
+
id: identifier,
|
|
201
|
+
identifier,
|
|
202
|
+
roles: [...this.#ephemeralRoles]
|
|
203
|
+
});
|
|
204
|
+
const provider = this.#resolveUserProvider();
|
|
205
|
+
let user;
|
|
206
|
+
try {
|
|
207
|
+
user = await provider.loadUserByIdentifier(identifier);
|
|
208
|
+
} catch {
|
|
209
|
+
throw new AuthenticationError(INVALID_TOKEN);
|
|
210
|
+
}
|
|
211
|
+
if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
|
|
212
|
+
return user;
|
|
213
|
+
}
|
|
214
|
+
#resolveUserProvider() {
|
|
215
|
+
if (this.#userProvider === null) {
|
|
216
|
+
const provider = this.#container.get("users");
|
|
217
|
+
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.");
|
|
218
|
+
this.#userProvider = provider;
|
|
219
|
+
}
|
|
220
|
+
return this.#userProvider;
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
//#endregion
|
|
224
|
+
export { ExternalJwtAuthenticator, ExternalJwtAuthenticator as default };
|