@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,174 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { UserRealtimeToken } from "../realtime/UserRealtimeToken.js";
|
|
3
|
+
import { Nodefony, RequestContext } from "nodefony";
|
|
4
|
+
import { anonymousUser } from "@nodefony/user";
|
|
5
|
+
//#region nodefony/src/authenticator/FirewallRealtimeAuthenticator.ts
|
|
6
|
+
/**
|
|
7
|
+
* Authenticator realtime des identités résolues par le **firewall** — équivalent
|
|
8
|
+
* WS de tout ce que le pipeline HTTP sait authentifier.
|
|
9
|
+
*
|
|
10
|
+
* ── Pourquoi il NE re-lit PAS la base ──────────────────────────────────────
|
|
11
|
+
* Un handshake WebSocket est une requête upgrade HTTP qui traverse le MÊME
|
|
12
|
+
* pipeline : `startSession` (reprise L1 du cookie) **puis** `firewall.handleSecurity`
|
|
13
|
+
* tournent AVANT que le `RealtimeController` ne fasse son handshake. Sur une zone
|
|
14
|
+
* data plane, le firewall a donc DÉJÀ : (1) authentifié (session, JWT, clé API…),
|
|
15
|
+
* (2) re-résolu l'identité via le provider `users` (rôles frais), (3) posé
|
|
16
|
+
* l'`IUser` **et le jeton** dans l'ALS et appliqué le Zero Trust (un anonyme est
|
|
17
|
+
* fermé AVANT d'arriver ici). Re-décoder le credential ici referait des lectures
|
|
18
|
+
* base **redondantes** par connexion — sur le différenciateur temps réel, un coût
|
|
19
|
+
* évitable. → on **réutilise** l'identité déjà en ALS.
|
|
20
|
+
*
|
|
21
|
+
* Le `RealtimeController.onHandshake` s'exécute dans la même bulle ALS que le
|
|
22
|
+
* firewall (un seul `RequestContext.run` enveloppe handshake + frames) → la
|
|
23
|
+
* lecture est sûre et synchrone.
|
|
24
|
+
*
|
|
25
|
+
* ── Il n'est PAS l'authenticator « de la session » ──────────────────────────
|
|
26
|
+
* Son nom d'origine (`SessionRealtimeAuthenticator`) décrivait le premier mode
|
|
27
|
+
* branché, pas son rôle : il promeut **toute** identité que le firewall a posée,
|
|
28
|
+
* y compris un agent authentifié par jeton porteur, sans cookie ni session. La
|
|
29
|
+
* confusion a coûté cher — un durcissement pensé pour la session a été appliqué
|
|
30
|
+
* à toutes les identités, et une connexion JWT parfaitement valide se faisait
|
|
31
|
+
* révoquer au motif qu'elle n'avait pas de session. D'où le nom actuel : il dit
|
|
32
|
+
* d'où vient l'identité (le firewall), pas comment elle a été prouvée.
|
|
33
|
+
*
|
|
34
|
+
* ── Révocation : un invariant, deux preuves ────────────────────────────────
|
|
35
|
+
* L'invariant est unique — **une socket ne survit pas à l'identité qui l'a
|
|
36
|
+
* ouverte** — mais la preuve dépend du mode, parce que ce sont deux mécanismes
|
|
37
|
+
* de révocation différents :
|
|
38
|
+
*
|
|
39
|
+
* | Mode | Ce qui rend l'identité morte |
|
|
40
|
+
* | -------------------------- | ------------------------------------------------ |
|
|
41
|
+
* | session BFF (`session`) | session détruite, expirée, ou passée à un autre |
|
|
42
|
+
* | jeton porteur (JWT, clé…) | `exp` atteint · `jti` denylisté · `invalidBefore` |
|
|
43
|
+
*
|
|
44
|
+
* Le jeton est figé au handshake (les frames lisent un cache O(1), jamais la
|
|
45
|
+
* base) ; la re-validation tourne sur le tick du hub (`REVOCATION_REVALIDATE_MS`)
|
|
46
|
+
* et devant chaque `api.request`. Une révocation prend donc effet en une fenêtre,
|
|
47
|
+
* pas à la frame suivante — c'est l'état de l'art (Socket.IO/Phoenix figent aussi
|
|
48
|
+
* l'identité au handshake).
|
|
49
|
+
*/
|
|
50
|
+
var FirewallRealtimeAuthenticator = class {
|
|
51
|
+
name = "firewall-realtime";
|
|
52
|
+
/**
|
|
53
|
+
* Accès **paresseux** au store de révocation des jetons porteurs. Résolu au
|
|
54
|
+
* plus tôt à la première re-validation d'une socket à jeton — jamais au boot,
|
|
55
|
+
* jamais pour une session. Absent (`null`) → seule la borne `exp` du jeton
|
|
56
|
+
* fait foi.
|
|
57
|
+
*/
|
|
58
|
+
#resolveStore;
|
|
59
|
+
/**
|
|
60
|
+
* @param resolveStore - fournit le store de révocation des jetons (le firewall
|
|
61
|
+
* passe une closure sur son container). Omis → mode dégradé documenté :
|
|
62
|
+
* seules les bornes portées par le jeton lui-même sont vérifiables.
|
|
63
|
+
*/
|
|
64
|
+
constructor(resolveStore = null) {
|
|
65
|
+
this.#resolveStore = resolveStore;
|
|
66
|
+
}
|
|
67
|
+
/** Une identité authentifiée a-t-elle été résolue (par le firewall) au handshake ? */
|
|
68
|
+
supports(_handshake) {
|
|
69
|
+
return isAuthenticatedUser(RequestContext.getUser());
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Promeut l'identité déjà résolue (ALS) en jeton realtime — 0 lecture base.
|
|
73
|
+
*
|
|
74
|
+
* @throws AuthenticationError — aucune identité authentifiée en ALS (ne devrait
|
|
75
|
+
* pas arriver sur une zone data plane : le firewall ferme l'anonyme en amont ;
|
|
76
|
+
* filet défensif fail-closed → le hub ferme la socket en 4001).
|
|
77
|
+
*/
|
|
78
|
+
async authenticate(_handshake) {
|
|
79
|
+
const user = RequestContext.getUser();
|
|
80
|
+
if (!isAuthenticatedUser(user)) throw new AuthenticationError("Invalid realtime session");
|
|
81
|
+
const issued = readFirewallToken();
|
|
82
|
+
const type = typeof issued?.type === "string" && issued.type ? issued.type : "session";
|
|
83
|
+
const revalidate = type === "session" ? buildSessionRevalidator(user.identifier) : this.#buildBearerRevalidator(issued, user.identifier, type);
|
|
84
|
+
const scopes = issued ? issued.getScopes() : void 0;
|
|
85
|
+
return new UserRealtimeToken(user, revalidate, type, scopes);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Re-validation d'une identité portée par un **jeton** (JWT, clé API, OAuth) :
|
|
89
|
+
* rejoue les trois conditions qui tuent un jeton, sans jamais parler de session.
|
|
90
|
+
*
|
|
91
|
+
* `exp` est vérifié **sans personne** (la borne voyage dans le jeton) ; le store
|
|
92
|
+
* n'ajoute que la révocation *avant terme*. C'est pourquoi un store en panne ne
|
|
93
|
+
* coupe pas les sockets d'agents : ce serait une panne de disponibilité déguisée
|
|
94
|
+
* en mesure de sécurité, alors que la borne du jeton tient toujours.
|
|
95
|
+
*/
|
|
96
|
+
#buildBearerRevalidator(issued, identifier, type) {
|
|
97
|
+
const claims = issued.getAttribute("claims") ?? EMPTY_CLAIMS;
|
|
98
|
+
const expMs = secondsToMs(claims.exp);
|
|
99
|
+
const iatMs = secondsToMs(claims.iat);
|
|
100
|
+
const jti = issued.getAttribute("jti") ?? (typeof claims.jti === "string" ? claims.jti : null);
|
|
101
|
+
const resolveStore = this.#resolveStore;
|
|
102
|
+
if (expMs === null && (resolveStore === null || jti === null && iatMs === null)) {
|
|
103
|
+
Nodefony.getKernel()?.log?.(`Realtime ${type} token "${identifier}" carries neither an expiry nor any revocation handle ("jti"/"iat") — connection will be revoked (fail-closed). Ensure the authenticator forwards the token claims.`, "WARNING");
|
|
104
|
+
return () => Promise.resolve(false);
|
|
105
|
+
}
|
|
106
|
+
return async (nowMs = Date.now()) => {
|
|
107
|
+
if (expMs !== null && nowMs >= expMs) return false;
|
|
108
|
+
if (!resolveStore) return true;
|
|
109
|
+
let store;
|
|
110
|
+
try {
|
|
111
|
+
store = resolveStore();
|
|
112
|
+
} catch {
|
|
113
|
+
return expMs !== null;
|
|
114
|
+
}
|
|
115
|
+
if (!store) return expMs !== null;
|
|
116
|
+
try {
|
|
117
|
+
if (jti !== null && await store.isJtiDenied(jti)) return false;
|
|
118
|
+
const invalidBefore = await store.getInvalidBefore(identifier);
|
|
119
|
+
if (invalidBefore !== null && iatMs !== null && iatMs < invalidBefore) return false;
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return expMs !== null;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
const EMPTY_CLAIMS = Object.freeze({});
|
|
128
|
+
/** Claim temporel JWT (secondes, RFC 7519) → epoch ms, ou `null` si absent/invalide. */
|
|
129
|
+
function secondsToMs(value) {
|
|
130
|
+
return typeof value === "number" && Number.isFinite(value) ? value * 1e3 : null;
|
|
131
|
+
}
|
|
132
|
+
/** Le jeton posé dans l'ALS par `firewall.handleSecurity`, s'il y en a un. */
|
|
133
|
+
function readFirewallToken() {
|
|
134
|
+
return RequestContext.get()?.token;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Re-validation d'une identité portée par la **session BFF** : re-lit la session
|
|
138
|
+
* (par son id, capturé au handshake) et vérifie qu'elle est TOUJOURS vivante et
|
|
139
|
+
* TOUJOURS celle de `identifier` — ce qui détecte la déconnexion comme le
|
|
140
|
+
* changement de compte sur un navigateur partagé.
|
|
141
|
+
*
|
|
142
|
+
* Renvoie TOUJOURS un revalidateur (jamais `null`) : une identité authentifiée
|
|
143
|
+
* par session DOIT rester révocable. Une lecture qui throw (store down, session
|
|
144
|
+
* détruite) → invalide ; une session non re-lisible au handshake → invalide
|
|
145
|
+
* aussi (fail-closed, cf ci-dessous).
|
|
146
|
+
*/
|
|
147
|
+
function buildSessionRevalidator(identifier) {
|
|
148
|
+
const session = RequestContext.getContext()?.session;
|
|
149
|
+
const id = session?.id;
|
|
150
|
+
const storage = session?.storage;
|
|
151
|
+
if (typeof id !== "string" || !storage || typeof storage.read !== "function") {
|
|
152
|
+
Nodefony.getKernel()?.log?.(`Realtime session token "${identifier}" has no revalidatable session at handshake — connection will be revoked (fail-closed). Ensure the realtime zone runs after startSession.`, "WARNING");
|
|
153
|
+
return () => Promise.resolve(false);
|
|
154
|
+
}
|
|
155
|
+
return async () => {
|
|
156
|
+
try {
|
|
157
|
+
const serialized = await storage.read(id);
|
|
158
|
+
return !!serialized && serialized.user === identifier;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Vrai si `user` est un utilisateur AUTHENTIFIÉ (≠ anonyme). Le firewall pose
|
|
166
|
+
* `token.getUser()` dans l'ALS : soit l'`IUser` réel, soit `anonymousUser`
|
|
167
|
+
* (singleton). Tout ce qui n'est pas l'anonyme et porte un `identifier` est
|
|
168
|
+
* une identité valide.
|
|
169
|
+
*/
|
|
170
|
+
function isAuthenticatedUser(user) {
|
|
171
|
+
return !!user && user !== anonymousUser && typeof user.identifier === "string";
|
|
172
|
+
}
|
|
173
|
+
//#endregion
|
|
174
|
+
export { FirewallRealtimeAuthenticator, FirewallRealtimeAuthenticator as default };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { UserToken } from "../token/UserToken.js";
|
|
3
|
+
import { bearerToken } from "./bearer.js";
|
|
4
|
+
import peekIssuer from "./peekIssuer.js";
|
|
5
|
+
//#region nodefony/src/authenticator/JwtAuthenticator.ts
|
|
6
|
+
const COMPACT_JWS = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*$/;
|
|
7
|
+
const INVALID_TOKEN = "Invalid token";
|
|
8
|
+
/**
|
|
9
|
+
* Authentification par **JWT Bearer** (RFC 6750) — réservée API service↔service /
|
|
10
|
+
* agents (le web utilise la session BFF). Vérifie un access token EdDSA signé par
|
|
11
|
+
* le {@link IJwtKeystore} du serveur.
|
|
12
|
+
*
|
|
13
|
+
* Défenses **dures** (RFC 8725 JWT BCP, prouvées en test) :
|
|
14
|
+
* - **allowlist d'algorithmes** côté serveur (`["EdDSA"]`) — l'algo n'est JAMAIS
|
|
15
|
+
* choisi d'après l'en-tête du token (§3.1) ; `alg=none` jamais accepté par jose.
|
|
16
|
+
* - **clé par `kid` depuis le keyset LOCAL** (`createLocalJWKSet`) — jamais
|
|
17
|
+
* `jku`/`jwk` de l'en-tête (injection de clé / SSRF, §3.5).
|
|
18
|
+
* - **`aud` (§3.9) + `iss` (§3.8) obligatoires** + `typ:"at+jwt"` (§3.11, sépare
|
|
19
|
+
* access et refresh) + `exp`/`nbf` (jose).
|
|
20
|
+
* - **révocation** : denylist `jti` + seuil `invalidBefore` par porteur (le JWT
|
|
21
|
+
* est auto-porté et non révocable sans état serveur).
|
|
22
|
+
* - **sujet revérifié** (§3.10) : `loadUserByIdentifier(sub)` → compte disparu,
|
|
23
|
+
* inactif ou verrouillé = rejet.
|
|
24
|
+
*
|
|
25
|
+
* Dépendances (keystore, store, userProvider) résolues **paresseusement** du
|
|
26
|
+
* container au premier usage (cold path) ; jose importé **lazy** (dep lourde).
|
|
27
|
+
*/
|
|
28
|
+
var JwtAuthenticator = class {
|
|
29
|
+
name = "jwt";
|
|
30
|
+
#container;
|
|
31
|
+
#runtime;
|
|
32
|
+
#jose = null;
|
|
33
|
+
#getKey = null;
|
|
34
|
+
#keystore = null;
|
|
35
|
+
#store = null;
|
|
36
|
+
#userProvider = null;
|
|
37
|
+
/**
|
|
38
|
+
* @param container - container DI (résolution lazy de `jwtKeystore`/`tokenStore`/`users`).
|
|
39
|
+
* @param runtime - paramètres JWT effectifs (iss/aud/ttl) partagés avec l'émetteur.
|
|
40
|
+
*/
|
|
41
|
+
constructor(container, runtime) {
|
|
42
|
+
this.#container = container;
|
|
43
|
+
this.#runtime = runtime;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* La requête porte-t-elle un `Authorization: Bearer <jws>` émis par NOUS ?
|
|
47
|
+
*
|
|
48
|
+
* L'émetteur revendiqué est lu sans être vérifié ({@link peekIssuer}) et sert
|
|
49
|
+
* uniquement à AIGUILLER : `ExternalJwtAuthenticator` reconnaît la même forme
|
|
50
|
+
* de credential pour les jetons d'un serveur d'autorisation tiers. Sans ce
|
|
51
|
+
* discriminant, en mode `first`, le premier des deux listés dans la zone
|
|
52
|
+
* capturerait les deux familles et refuserait la moitié des jetons — l'ordre
|
|
53
|
+
* de la configuration deviendrait une décision de sécurité, dont l'erreur ne
|
|
54
|
+
* se verrait qu'en production.
|
|
55
|
+
*
|
|
56
|
+
* Un jeton dont l'émetteur est illisible reste pris en charge ici : c'est un
|
|
57
|
+
* jeton maison malformé, que la vérification refusera en le disant, plutôt
|
|
58
|
+
* qu'un credential qui disparaîtrait sans laisser de trace.
|
|
59
|
+
*/
|
|
60
|
+
supports(context) {
|
|
61
|
+
const auth = context.request?.headers?.authorization;
|
|
62
|
+
if (typeof auth !== "string") return false;
|
|
63
|
+
const token = bearerToken(auth);
|
|
64
|
+
if (token === null || !COMPACT_JWS.test(token)) return false;
|
|
65
|
+
const issuer = peekIssuer(token);
|
|
66
|
+
return issuer === null || issuer === this.#runtime.issuer;
|
|
67
|
+
}
|
|
68
|
+
/** Extrait le token brut (non vérifié) → porté par un `UserToken` type `"jwt"`. */
|
|
69
|
+
createToken(context) {
|
|
70
|
+
const auth = context.request?.headers?.authorization;
|
|
71
|
+
return Promise.resolve(new UserToken("jwt", bearerToken(auth) ?? ""));
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Vérifie la signature + les claims du JWT, applique la révocation et résout le
|
|
75
|
+
* sujet — ou lève un 401 au message uniforme.
|
|
76
|
+
*
|
|
77
|
+
* @throws AuthenticationError (401) — token absent/invalide/expiré/révoqué, ou
|
|
78
|
+
* sujet disparu/banni.
|
|
79
|
+
* @throws Error (câblage : keystore/store/users absents) — logguée ERROR par le
|
|
80
|
+
* firewall puis 401 fail-closed (rien ne fuite au client).
|
|
81
|
+
*/
|
|
82
|
+
async authenticate(token) {
|
|
83
|
+
const raw = token.getCredentials();
|
|
84
|
+
if (typeof raw !== "string" || raw.length === 0) throw new AuthenticationError(INVALID_TOKEN);
|
|
85
|
+
const jose = this.#jose ??= await import("jose");
|
|
86
|
+
const getKey = await this.#ensureGetKey(jose);
|
|
87
|
+
let payload;
|
|
88
|
+
try {
|
|
89
|
+
payload = (await jose.jwtVerify(raw, getKey, {
|
|
90
|
+
algorithms: ["EdDSA"],
|
|
91
|
+
issuer: this.#runtime.issuer,
|
|
92
|
+
audience: this.#runtime.audiences,
|
|
93
|
+
typ: "at+jwt"
|
|
94
|
+
})).payload;
|
|
95
|
+
} catch {
|
|
96
|
+
throw new AuthenticationError(INVALID_TOKEN);
|
|
97
|
+
}
|
|
98
|
+
const sub = payload.sub;
|
|
99
|
+
const jti = payload.jti;
|
|
100
|
+
if (typeof sub !== "string" || typeof jti !== "string") throw new AuthenticationError(INVALID_TOKEN);
|
|
101
|
+
const store = this.#resolveStore();
|
|
102
|
+
if (await store.isJtiDenied(jti)) throw new AuthenticationError(INVALID_TOKEN);
|
|
103
|
+
const invalidBefore = await store.getInvalidBefore(sub);
|
|
104
|
+
if (invalidBefore !== null) {
|
|
105
|
+
if ((typeof payload.iat === "number" ? payload.iat * 1e3 : 0) < invalidBefore) throw new AuthenticationError(INVALID_TOKEN);
|
|
106
|
+
}
|
|
107
|
+
const user = await this.#resolveUserOrReject(sub);
|
|
108
|
+
return this.#promote(token, user, payload);
|
|
109
|
+
}
|
|
110
|
+
/** Slot audit (J4b). */
|
|
111
|
+
onSuccess(_context, _token) {
|
|
112
|
+
return Promise.resolve();
|
|
113
|
+
}
|
|
114
|
+
/** Slot audit (J4b) — le 401 + challenge sont posés par le firewall. */
|
|
115
|
+
onFailure(_context, _error) {
|
|
116
|
+
return Promise.resolve();
|
|
117
|
+
}
|
|
118
|
+
/** Challenge RFC 6750/7235 posé par le firewall sur les 401 de la zone. */
|
|
119
|
+
challenge() {
|
|
120
|
+
return "Bearer";
|
|
121
|
+
}
|
|
122
|
+
/** Construit (une fois) le résolveur de clé par `kid` depuis le JWKS public. */
|
|
123
|
+
async #ensureGetKey(jose) {
|
|
124
|
+
if (this.#getKey !== null) return this.#getKey;
|
|
125
|
+
const jwks = await this.#resolveKeystore().getPublicJWKS();
|
|
126
|
+
return this.#getKey = jose.createLocalJWKSet(jwks);
|
|
127
|
+
}
|
|
128
|
+
/** Promeut le token : utilisateur vérifié + scopes/claims posés en attributs. */
|
|
129
|
+
#promote(token, user, payload) {
|
|
130
|
+
const ut = token;
|
|
131
|
+
ut.promote(user);
|
|
132
|
+
const scope = payload.scope;
|
|
133
|
+
const scopes = typeof scope === "string" && scope.length > 0 ? scope.split(" ") : [];
|
|
134
|
+
ut.setAttribute("scopes", scopes);
|
|
135
|
+
ut.setAttribute("jti", payload.jti);
|
|
136
|
+
ut.setAttribute("claims", payload);
|
|
137
|
+
return ut;
|
|
138
|
+
}
|
|
139
|
+
async #resolveUserOrReject(sub) {
|
|
140
|
+
const provider = this.#resolveUserProvider();
|
|
141
|
+
let user;
|
|
142
|
+
try {
|
|
143
|
+
user = await provider.loadUserByIdentifier(sub);
|
|
144
|
+
} catch {
|
|
145
|
+
throw new AuthenticationError(INVALID_TOKEN);
|
|
146
|
+
}
|
|
147
|
+
if (!user.isActive() || user.isLocked()) throw new AuthenticationError(INVALID_TOKEN);
|
|
148
|
+
return user;
|
|
149
|
+
}
|
|
150
|
+
#resolveKeystore() {
|
|
151
|
+
if (this.#keystore === null) {
|
|
152
|
+
const ks = this.#container.get("jwtKeystore");
|
|
153
|
+
if (!ks) throw new Error("JwtAuthenticator: service 'jwtKeystore' absent du container — le TokenService de @nodefony/security doit être chargé.");
|
|
154
|
+
this.#keystore = ks;
|
|
155
|
+
}
|
|
156
|
+
return this.#keystore;
|
|
157
|
+
}
|
|
158
|
+
#resolveStore() {
|
|
159
|
+
if (this.#store === null) {
|
|
160
|
+
const store = this.#container.get("tokenStore");
|
|
161
|
+
if (!store) throw new Error("JwtAuthenticator: service 'tokenStore' absent du container — le TokenService de @nodefony/security doit être chargé.");
|
|
162
|
+
this.#store = store;
|
|
163
|
+
}
|
|
164
|
+
return this.#store;
|
|
165
|
+
}
|
|
166
|
+
#resolveUserProvider() {
|
|
167
|
+
if (this.#userProvider === null) {
|
|
168
|
+
const provider = this.#container.get("users");
|
|
169
|
+
if (!provider) throw new Error("JwtAuthenticator: aucun service 'users' (IUserProvider) dans le container — enregistrer un UserService au boot de l'application.");
|
|
170
|
+
this.#userProvider = provider;
|
|
171
|
+
}
|
|
172
|
+
return this.#userProvider;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
//#endregion
|
|
176
|
+
export { JwtAuthenticator, JwtAuthenticator as default };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { resolveSessionIdentity } from "../sessionIdentity.js";
|
|
3
|
+
import { UserToken } from "../token/UserToken.js";
|
|
4
|
+
//#region nodefony/src/authenticator/SessionAuthenticator.ts
|
|
5
|
+
/**
|
|
6
|
+
* Authentification par **session serveur** (cookie opaque, modèle BFF) — la
|
|
7
|
+
* preuve des requêtes qui SUIVENT le login (`AuthFlow.login`, qui a déjà posé
|
|
8
|
+
* l'identifiant dans le blob et régénéré l'ID anti-fixation).
|
|
9
|
+
*
|
|
10
|
+
* `supports()` exige une session REPRISE porteuse d'un utilisateur : le
|
|
11
|
+
* pipeline http démarre la session AVANT le firewall (point d'activation
|
|
12
|
+
* unique, lazy — cookie entrant ou intent de route), cet authenticator ne
|
|
13
|
+
* démarre jamais rien lui-même. L'identité est re-résolue à CHAQUE requête
|
|
14
|
+
* via {@link resolveSessionIdentity} (rôles frais, révocation immédiate).
|
|
15
|
+
*
|
|
16
|
+
* Pas de `challenge()` : une session absente/expirée donne un 401 nu — le
|
|
17
|
+
* client web redirige vers son écran de login, jamais de popup Basic. Si la
|
|
18
|
+
* zone liste aussi `userpassword`, le firewall pose SON challenge (RFC 7235).
|
|
19
|
+
*/
|
|
20
|
+
var SessionAuthenticator = class {
|
|
21
|
+
name = "session";
|
|
22
|
+
#provider = null;
|
|
23
|
+
#resolveProvider;
|
|
24
|
+
/**
|
|
25
|
+
* @param resolveProvider - résolution lazy de la source d'identité
|
|
26
|
+
* (typiquement `container.get("users")`) — appelée à la première requête.
|
|
27
|
+
*/
|
|
28
|
+
constructor(resolveProvider) {
|
|
29
|
+
this.#resolveProvider = resolveProvider;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Refuse une zone déclarée SANS REGISTRE — au boot, pas à la première requête.
|
|
33
|
+
*
|
|
34
|
+
* `stateless: true` annonce que l'identité tient tout entière dans la preuve
|
|
35
|
+
* portée par chaque requête, et que la session est ignorée « même si un
|
|
36
|
+
* cookie est présent ». Lister `session` dans une telle zone dit exactement
|
|
37
|
+
* l'inverse : {@link supports} y rendrait vrai dès qu'un cookie ramène une
|
|
38
|
+
* session porteuse d'un utilisateur, et la zone authentifierait par le
|
|
39
|
+
* registre qu'elle déclare ne pas tenir.
|
|
40
|
+
*
|
|
41
|
+
* Cette contradiction ne se voyait NULLE PART : l'application démarrait, la
|
|
42
|
+
* console d'administration affichait « aucun registre serveur », et le
|
|
43
|
+
* cookie authentifiait quand même. Elle se refuse donc au démarrage — le
|
|
44
|
+
* firewall en fait une erreur de configuration fail-closed, plutôt qu'une
|
|
45
|
+
* requête sur deux qui se comporte autrement que ce qui est écrit.
|
|
46
|
+
*
|
|
47
|
+
* @param area - la zone qui liste cet authenticator.
|
|
48
|
+
* @throws Error si la zone est `stateless` — le message la NOMME.
|
|
49
|
+
*/
|
|
50
|
+
validateArea(area) {
|
|
51
|
+
if (area.stateless) throw new Error(`area "${area.name}": l'authenticator "${this.name}" est incompatible avec \`stateless: true\` — une zone sans registre ne peut pas tirer son identité d'une session serveur. Retirer "session" de cette zone (l'appelant porte sa preuve : \`apikey\`, \`jwt\`, \`external-jwt\`), ou passer la zone à \`stateless: false\` si elle sert un navigateur.`);
|
|
52
|
+
}
|
|
53
|
+
/** La requête porte-t-elle une session reprise avec un utilisateur ? */
|
|
54
|
+
supports(context) {
|
|
55
|
+
const user = context.session?.user;
|
|
56
|
+
return typeof user === "string" && user.length > 0;
|
|
57
|
+
}
|
|
58
|
+
/** Extrait l'identifiant du blob de session (jamais de secret en jeu). */
|
|
59
|
+
createToken(context) {
|
|
60
|
+
const credentials = { identifier: context.session?.user ?? "" };
|
|
61
|
+
return Promise.resolve(new UserToken(this.name, credentials));
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Re-résout l'identifiant de session en utilisateur vivant et promeut le
|
|
65
|
+
* token. Les contrôles d'état (existe, actif, non verrouillé) vivent dans
|
|
66
|
+
* {@link resolveSessionIdentity} — partagés avec `AuthFlow.me()`.
|
|
67
|
+
*
|
|
68
|
+
* @throws AuthenticationError (401, message uniforme) — session orpheline,
|
|
69
|
+
* compte verrouillé ou désactivé.
|
|
70
|
+
*/
|
|
71
|
+
async authenticate(token) {
|
|
72
|
+
const credentials = token.getCredentials();
|
|
73
|
+
if (!credentials?.identifier) throw new AuthenticationError("Invalid session");
|
|
74
|
+
const provider = this.#provider ??= this.#resolveProvider();
|
|
75
|
+
const user = await resolveSessionIdentity(provider, credentials.identifier);
|
|
76
|
+
return token.promote(user);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Pose l'identifiant sur le contexte : la persistance de session du pipeline
|
|
80
|
+
* (`saveSession`) lie le blob au principal courant (string attendu).
|
|
81
|
+
*/
|
|
82
|
+
onSuccess(context, token) {
|
|
83
|
+
context.user = token.getUser().identifier;
|
|
84
|
+
return Promise.resolve();
|
|
85
|
+
}
|
|
86
|
+
/** Slot audit (P6.14). Le 401 est posé par le firewall. */
|
|
87
|
+
onFailure(_context, _error) {
|
|
88
|
+
return Promise.resolve();
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
//#endregion
|
|
92
|
+
export { SessionAuthenticator, SessionAuthenticator as default };
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { AuthenticationError } from "../../errors/AuthenticationError.js";
|
|
2
|
+
import { ThrottledError } from "../../errors/ThrottledError.js";
|
|
3
|
+
import { UserToken } from "../token/UserToken.js";
|
|
4
|
+
//#region nodefony/src/authenticator/UserPasswordAuthenticator.ts
|
|
5
|
+
const BASIC_SCHEME = /^basic\s+/i;
|
|
6
|
+
const INVALID_CREDENTIALS = "Invalid credentials";
|
|
7
|
+
/**
|
|
8
|
+
* Authentification par identifiant + mot de passe — schéma **HTTP Basic**
|
|
9
|
+
* (RFC 7617) : `Authorization: Basic base64(identifiant:motdepasse)`, charset
|
|
10
|
+
* UTF-8, split au PREMIER `:` (le mot de passe peut en contenir).
|
|
11
|
+
*
|
|
12
|
+
* La vérification est déléguée au {@link IPasswordVerifier} (`UserService` par
|
|
13
|
+
* défaut) : hash, comparaison, leurre anti-timing et re-hash transparent restent
|
|
14
|
+
* derrière la frontière user — cet authenticator ne voit que le verdict.
|
|
15
|
+
*
|
|
16
|
+
* Le verifier est résolu **paresseusement** au premier login (cold path) : le
|
|
17
|
+
* boot ne paie rien et l'ordre de chargement des modules est indifférent.
|
|
18
|
+
*
|
|
19
|
+
* @remarks Le login par formulaire (body JSON) n'est PAS ici : il arrive avec la
|
|
20
|
+
* session BFF (`AuthController`, J3) qui appelle le verifier directement.
|
|
21
|
+
*/
|
|
22
|
+
var UserPasswordAuthenticator = class {
|
|
23
|
+
name = "userpassword";
|
|
24
|
+
#verifier = null;
|
|
25
|
+
#resolveVerifier;
|
|
26
|
+
#throttler;
|
|
27
|
+
/**
|
|
28
|
+
* @param resolveVerifier - résolution lazy de la source de vérification
|
|
29
|
+
* (typiquement `container.get("users")`) — appelée au premier login.
|
|
30
|
+
* @param throttler - limiteur de tentatives (backoff NIST), `null` = désactivé.
|
|
31
|
+
*/
|
|
32
|
+
constructor(resolveVerifier, throttler = null) {
|
|
33
|
+
this.#resolveVerifier = resolveVerifier;
|
|
34
|
+
this.#throttler = throttler;
|
|
35
|
+
}
|
|
36
|
+
/** La requête porte-t-elle un en-tête `Authorization: Basic ...` ? */
|
|
37
|
+
supports(context) {
|
|
38
|
+
const auth = context.request?.headers?.authorization;
|
|
39
|
+
return typeof auth === "string" && BASIC_SCHEME.test(auth);
|
|
40
|
+
}
|
|
41
|
+
/** Décode l'enveloppe Basic — un contenu malformé donne un credential vide (échec uniforme). */
|
|
42
|
+
createToken(context) {
|
|
43
|
+
const auth = context.request?.headers?.authorization;
|
|
44
|
+
const decoded = Buffer.from(auth.replace(BASIC_SCHEME, ""), "base64").toString("utf8");
|
|
45
|
+
const sep = decoded.indexOf(":");
|
|
46
|
+
const credentials = sep > 0 ? {
|
|
47
|
+
identifier: decoded.slice(0, sep),
|
|
48
|
+
password: decoded.slice(sep + 1)
|
|
49
|
+
} : {
|
|
50
|
+
identifier: "",
|
|
51
|
+
password: ""
|
|
52
|
+
};
|
|
53
|
+
return Promise.resolve(new UserToken(this.name, credentials));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Vérifie le credential via le verifier ou lève un 401 au message uniforme.
|
|
57
|
+
* Au succès le token est promu : utilisateur posé, credential effacé.
|
|
58
|
+
*
|
|
59
|
+
* Throttling NIST (si activé) : l'identifiant SAISI est vérifié AVANT le
|
|
60
|
+
* verifier (un identifiant bloqué ne coûte aucun hash → le throttle protège
|
|
61
|
+
* aussi le serveur du DoS argon2), échec compté, succès remis à zéro.
|
|
62
|
+
*
|
|
63
|
+
* @throws ThrottledError (429 + `Retry-After`) — backoff encore actif.
|
|
64
|
+
* @throws AuthenticationError (401) — credential absent ou invalide.
|
|
65
|
+
*/
|
|
66
|
+
async authenticate(token) {
|
|
67
|
+
const credentials = token.getCredentials();
|
|
68
|
+
if (!credentials?.identifier || !credentials.password) throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
69
|
+
if (this.#throttler !== null) {
|
|
70
|
+
const retryAfterS = this.#throttler.check(credentials.identifier);
|
|
71
|
+
if (retryAfterS > 0) throw new ThrottledError(retryAfterS);
|
|
72
|
+
}
|
|
73
|
+
const user = await (this.#verifier ??= this.#resolveVerifier()).authenticate(credentials.identifier, credentials.password);
|
|
74
|
+
if (user === null) {
|
|
75
|
+
this.#throttler?.recordFailure(credentials.identifier);
|
|
76
|
+
throw new AuthenticationError(INVALID_CREDENTIALS);
|
|
77
|
+
}
|
|
78
|
+
this.#throttler?.recordSuccess(credentials.identifier);
|
|
79
|
+
return token.promote(user);
|
|
80
|
+
}
|
|
81
|
+
/** Slot J3 (session BFF au login) — rien à poser pour du Basic pur. */
|
|
82
|
+
onSuccess(_context, _token) {
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
}
|
|
85
|
+
/** Slot J3+ (audit events). Le throttling vit dans `authenticate` (clé = identifiant) ; le 401 + challenge sont posés par le firewall. */
|
|
86
|
+
onFailure(_context, _error) {
|
|
87
|
+
return Promise.resolve();
|
|
88
|
+
}
|
|
89
|
+
/** Challenge RFC 7235 posé par le firewall sur les 401 de la zone. */
|
|
90
|
+
challenge() {
|
|
91
|
+
return "Basic realm=\"nodefony\", charset=\"UTF-8\"";
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
//#endregion
|
|
95
|
+
export { UserPasswordAuthenticator, UserPasswordAuthenticator as default };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AnonymousAuthenticator } from "./AnonymousAuthenticator.js";
|
|
2
|
+
import { SessionAuthenticator } from "./SessionAuthenticator.js";
|
|
3
|
+
import { UserPasswordAuthenticator } from "./UserPasswordAuthenticator.js";
|
|
4
|
+
import { JwtAuthenticator } from "./JwtAuthenticator.js";
|
|
5
|
+
import { ApiKeyAuthenticator } from "./ApiKeyAuthenticator.js";
|
|
6
|
+
import { ExternalJwtAuthenticator } from "./ExternalJwtAuthenticator.js";
|
|
7
|
+
import { resolveJwtRuntime } from "../token/jwtRuntime.js";
|
|
8
|
+
//#region nodefony/src/authenticator/authenticatorRegistry.ts
|
|
9
|
+
const factories = /* @__PURE__ */ new Map();
|
|
10
|
+
/**
|
|
11
|
+
* Enregistre (ou remplace) la fabrique d'un authenticator. Appelé par les
|
|
12
|
+
* builtins au chargement du module, et par les plugins pour les leurs
|
|
13
|
+
* (LDAP, SSO maison...).
|
|
14
|
+
*/
|
|
15
|
+
function registerAuthenticatorFactory(name, factory) {
|
|
16
|
+
factories.set(name, factory);
|
|
17
|
+
}
|
|
18
|
+
/** Fabrique d'un authenticator par nom, ou `undefined` si inconnu. */
|
|
19
|
+
function getAuthenticatorFactory(name) {
|
|
20
|
+
return factories.get(name);
|
|
21
|
+
}
|
|
22
|
+
/** Noms enregistrés (validation boot, introspection Studio, tests). */
|
|
23
|
+
function listAuthenticatorFactories() {
|
|
24
|
+
return [...factories.keys()];
|
|
25
|
+
}
|
|
26
|
+
registerAuthenticatorFactory("anonymous", () => new AnonymousAuthenticator());
|
|
27
|
+
registerAuthenticatorFactory("userpassword", ({ container }) => {
|
|
28
|
+
const throttler = container.get("loginThrottler") ?? null;
|
|
29
|
+
return new UserPasswordAuthenticator(() => {
|
|
30
|
+
const verifier = container.get("users");
|
|
31
|
+
if (!verifier) throw new Error("UserPasswordAuthenticator: aucun service \"users\" (IPasswordVerifier) dans le container — enregistrer un UserService au boot de l'application.");
|
|
32
|
+
return verifier;
|
|
33
|
+
}, throttler);
|
|
34
|
+
});
|
|
35
|
+
registerAuthenticatorFactory("session", ({ container }) => {
|
|
36
|
+
return new SessionAuthenticator(() => {
|
|
37
|
+
const provider = container.get("users");
|
|
38
|
+
if (!provider) throw new Error("SessionAuthenticator: aucun service \"users\" (IUserProvider) dans le container — enregistrer un UserService au boot de l'application.");
|
|
39
|
+
return provider;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
registerAuthenticatorFactory("jwt", ({ container, config }) => {
|
|
43
|
+
return new JwtAuthenticator(container, resolveJwtRuntime(config.jwt));
|
|
44
|
+
});
|
|
45
|
+
registerAuthenticatorFactory("external-jwt", ({ container, config }) => {
|
|
46
|
+
const rs = config.resourceServer;
|
|
47
|
+
return new ExternalJwtAuthenticator(container, {
|
|
48
|
+
issuers: rs.issuers.map((i) => ({
|
|
49
|
+
issuer: i.issuer,
|
|
50
|
+
subjectMapping: i.subjectMapping
|
|
51
|
+
})),
|
|
52
|
+
subjectPolicy: rs.subjectPolicy,
|
|
53
|
+
ephemeralRoles: rs.ephemeralRoles
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
registerAuthenticatorFactory("apikey", ({ container, config }) => {
|
|
57
|
+
return new ApiKeyAuthenticator(container, {
|
|
58
|
+
prefix: config.apiKeys.prefix,
|
|
59
|
+
lastUsedThrottleS: config.apiKeys.lastUsedThrottleS
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
//#endregion
|
|
63
|
+
export { getAuthenticatorFactory, listAuthenticatorFactories, registerAuthenticatorFactory };
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region nodefony/src/authenticator/externalSubject.ts
|
|
2
|
+
/**
|
|
3
|
+
* Séparateur entre l'émetteur et le sujet.
|
|
4
|
+
*
|
|
5
|
+
* `#` est choisi parce qu'un identifiant d'émetteur ne peut PAS en contenir :
|
|
6
|
+
* la RFC 8414 §2 interdit le fragment dans un `issuer`. La composition est donc
|
|
7
|
+
* **injective** — deux paires `(iss, sub)` distinctes ne peuvent jamais produire
|
|
8
|
+
* le même identifiant local, et c'est précisément la propriété qui empêche un
|
|
9
|
+
* émetteur d'usurper le sujet d'un autre.
|
|
10
|
+
*/
|
|
11
|
+
const SEPARATOR = "#";
|
|
12
|
+
/**
|
|
13
|
+
* Compose l'identifiant local qui désigne le sujet d'un émetteur externe.
|
|
14
|
+
*
|
|
15
|
+
* 🔴 **Un `sub` seul ne désigne personne.** OpenID Connect Core §2 ne garantit
|
|
16
|
+
* son unicité et sa non-réattribution que *dans l'espace de son émetteur*.
|
|
17
|
+
* Chercher un compte local directement par `sub` verse donc des identifiants
|
|
18
|
+
* étrangers dans l'espace local : il suffit d'un annuaire où l'utilisateur
|
|
19
|
+
* choisit son identifiant — beaucoup le permettent — pour présenter
|
|
20
|
+
* `sub: "admin"` et se voir rattacher au compte local du même nom.
|
|
21
|
+
*
|
|
22
|
+
* C'est pour cela que `prefixed` est le défaut et que `subject` se déclare :
|
|
23
|
+
* le mode sûr ne doit rien demander, le mode qui fait confiance doit être écrit.
|
|
24
|
+
*
|
|
25
|
+
* @param issuer - émetteur VÉRIFIÉ, sous sa forme canonique (jamais la valeur
|
|
26
|
+
* brute lue dans le jeton — elle est choisie par le porteur)
|
|
27
|
+
* @param subject - sujet du jeton (`sub`)
|
|
28
|
+
* @param mapping - politique déclarée pour CET émetteur
|
|
29
|
+
* @returns l'identifiant à chercher dans l'annuaire local
|
|
30
|
+
*/
|
|
31
|
+
function localIdentifierFor(issuer, subject, mapping) {
|
|
32
|
+
if (mapping === "subject") return subject;
|
|
33
|
+
return `${issuer}${SEPARATOR}${subject}`;
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
export { localIdentifierFor };
|