@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,236 @@
|
|
|
1
|
+
import { TOKEN_DEFAULT_ORDER, TOKEN_SORTABLE_FIELDS } from "./tokenSort.js";
|
|
2
|
+
import { matchesTokenStatus } from "./tokenStatus.js";
|
|
3
|
+
import { assertPageQuery, compareByOrder, pickOrder } from "nodefony";
|
|
4
|
+
//#region nodefony/src/token/MemoryTokenStore.ts
|
|
5
|
+
/**
|
|
6
|
+
* Filtre un record contre une requête de listing — prédicat de RÉFÉRENCE du
|
|
7
|
+
* contrat, réutilisé par les implémentations qui évaluent en mémoire.
|
|
8
|
+
*
|
|
9
|
+
* @param now - instant de référence pour l'état du jeton (`status`). Requis :
|
|
10
|
+
* « expiré » n'a pas de sens sans une horloge, et la lire ici ferait dépendre
|
|
11
|
+
* le résultat du moment du test plutôt que de la donnée.
|
|
12
|
+
*/
|
|
13
|
+
function matchesTokenQuery(record, query, now) {
|
|
14
|
+
if (query.subjectId !== void 0 && record.subjectId !== query.subjectId) return false;
|
|
15
|
+
if (query.kind !== void 0 && record.kind !== query.kind) return false;
|
|
16
|
+
if (!matchesTokenStatus(record, query.status, now)) return false;
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Store de jetons **en mémoire** — implémentation de référence d'{@link ITokenStore}.
|
|
21
|
+
*
|
|
22
|
+
* 0 dépendance, idéale pour le développement mono-process et les **tests**. NON
|
|
23
|
+
* partagée entre process (pas de cluster) et **volatile** (tout est perdu au
|
|
24
|
+
* redémarrage) → en production multi-process, utiliser un adapter ORM ou Redis.
|
|
25
|
+
*
|
|
26
|
+
* Perf/mémoire : les `Map` n'existent que si le store est instancié (JWT activé),
|
|
27
|
+
* jamais sur le hot path par requête. La denylist `jti` est bornée par un
|
|
28
|
+
* **balayage amorti** (purge des entrées expirées tous les 256 ajouts) doublé
|
|
29
|
+
* d'une expiration paresseuse à la lecture — pas de minuterie, pas de fuite.
|
|
30
|
+
*
|
|
31
|
+
* Horloge injectable (`now`) pour des tests déterministes (pattern `LoginThrottler`).
|
|
32
|
+
*/
|
|
33
|
+
var MemoryTokenStore = class {
|
|
34
|
+
/**
|
|
35
|
+
* {@inheritDoc ITokenStore.sortableFields}
|
|
36
|
+
*
|
|
37
|
+
* Le store mémoire porte l'enregistrement complet : il sait donc trier tout le
|
|
38
|
+
* vocabulaire public, sans réduction de capacité.
|
|
39
|
+
*/
|
|
40
|
+
sortableFields = TOKEN_SORTABLE_FIELDS;
|
|
41
|
+
/** id → enregistrement (source de vérité). */
|
|
42
|
+
#byId = /* @__PURE__ */ new Map();
|
|
43
|
+
/** hash de secret → id (recherche au login). */
|
|
44
|
+
#idByHash = /* @__PURE__ */ new Map();
|
|
45
|
+
/** famille de rotation → ids (révocation groupée, reuse detection). */
|
|
46
|
+
#idsByFamily = /* @__PURE__ */ new Map();
|
|
47
|
+
/** porteur → ids (console « mes jetons », révocation ciblée). */
|
|
48
|
+
#idsBySubject = /* @__PURE__ */ new Map();
|
|
49
|
+
/** jti d'access denylisté → expiration (epoch ms). */
|
|
50
|
+
#deniedJti = /* @__PURE__ */ new Map();
|
|
51
|
+
/** porteur → seuil `invalidBefore` (epoch ms) — révocation en masse. */
|
|
52
|
+
#invalidBefore = /* @__PURE__ */ new Map();
|
|
53
|
+
#now;
|
|
54
|
+
/** Fenêtre de conservation d'un PAT révoqué sans expiration (audit) avant purge. */
|
|
55
|
+
#retentionRevokedMs;
|
|
56
|
+
#sweepCounter = 0;
|
|
57
|
+
constructor(now = Date.now, retentionRevokedMs = 2592e6) {
|
|
58
|
+
this.#now = now;
|
|
59
|
+
this.#retentionRevokedMs = retentionRevokedMs;
|
|
60
|
+
}
|
|
61
|
+
put(record) {
|
|
62
|
+
this.#byId.set(record.id, record);
|
|
63
|
+
this.#idByHash.set(record.secretHash, record.id);
|
|
64
|
+
this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
|
|
65
|
+
if (record.family) this.#addToIndex(this.#idsByFamily, record.family, record.id);
|
|
66
|
+
return Promise.resolve();
|
|
67
|
+
}
|
|
68
|
+
findById(id) {
|
|
69
|
+
return Promise.resolve(this.#byId.get(id) ?? null);
|
|
70
|
+
}
|
|
71
|
+
findByHash(secretHash) {
|
|
72
|
+
const id = this.#idByHash.get(secretHash);
|
|
73
|
+
return Promise.resolve(id !== void 0 ? this.#byId.get(id) ?? null : null);
|
|
74
|
+
}
|
|
75
|
+
findBySubject(subjectId) {
|
|
76
|
+
const ids = this.#idsBySubject.get(subjectId);
|
|
77
|
+
if (!ids) return Promise.resolve([]);
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const id of ids) {
|
|
80
|
+
const record = this.#byId.get(id);
|
|
81
|
+
if (record) out.push(record);
|
|
82
|
+
}
|
|
83
|
+
return Promise.resolve(out);
|
|
84
|
+
}
|
|
85
|
+
listAll() {
|
|
86
|
+
return Promise.resolve([...this.#byId.values()]);
|
|
87
|
+
}
|
|
88
|
+
listPage(query) {
|
|
89
|
+
assertPageQuery(query, "offset");
|
|
90
|
+
const limit = Math.max(1, Math.floor(query.limit));
|
|
91
|
+
const offset = Math.max(0, Math.floor(query.offset ?? 0));
|
|
92
|
+
const now = this.#now();
|
|
93
|
+
const filtered = [...this.#byId.values()].filter((r) => matchesTokenQuery(r, query, now));
|
|
94
|
+
const order = pickOrder(query.order, this.sortableFields, TOKEN_DEFAULT_ORDER);
|
|
95
|
+
filtered.sort(compareByOrder(order, (r, field) => r[field]));
|
|
96
|
+
const items = filtered.slice(offset, offset + limit);
|
|
97
|
+
const total = query.withTotal === false ? void 0 : filtered.length;
|
|
98
|
+
return Promise.resolve({
|
|
99
|
+
items,
|
|
100
|
+
total,
|
|
101
|
+
limit,
|
|
102
|
+
offset,
|
|
103
|
+
hasNext: offset + items.length < filtered.length
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
countTokens(query) {
|
|
107
|
+
const now = this.#now();
|
|
108
|
+
let n = 0;
|
|
109
|
+
for (const r of this.#byId.values()) if (matchesTokenQuery(r, query, now)) n += 1;
|
|
110
|
+
return Promise.resolve(n);
|
|
111
|
+
}
|
|
112
|
+
markUsed(id, usage) {
|
|
113
|
+
const record = this.#byId.get(id);
|
|
114
|
+
if (record) {
|
|
115
|
+
record.lastUsedAt = usage.at;
|
|
116
|
+
record.lastUsedIp = usage.ip ?? null;
|
|
117
|
+
record.lastUsedUserAgent = usage.userAgent ?? null;
|
|
118
|
+
}
|
|
119
|
+
return Promise.resolve();
|
|
120
|
+
}
|
|
121
|
+
revoke(id, reason) {
|
|
122
|
+
this.#revokeRecord(this.#byId.get(id), reason);
|
|
123
|
+
return Promise.resolve();
|
|
124
|
+
}
|
|
125
|
+
revokeFamily(family, reason) {
|
|
126
|
+
const ids = this.#idsByFamily.get(family);
|
|
127
|
+
if (ids) for (const id of ids) this.#revokeRecord(this.#byId.get(id), reason);
|
|
128
|
+
return Promise.resolve();
|
|
129
|
+
}
|
|
130
|
+
denyJti(jti, expiresAt) {
|
|
131
|
+
this.#deniedJti.set(jti, expiresAt);
|
|
132
|
+
this.#maybeSweep();
|
|
133
|
+
return Promise.resolve();
|
|
134
|
+
}
|
|
135
|
+
isJtiDenied(jti) {
|
|
136
|
+
const expiresAt = this.#deniedJti.get(jti);
|
|
137
|
+
if (expiresAt === void 0) return Promise.resolve(false);
|
|
138
|
+
if (expiresAt <= this.#now()) {
|
|
139
|
+
this.#deniedJti.delete(jti);
|
|
140
|
+
return Promise.resolve(false);
|
|
141
|
+
}
|
|
142
|
+
return Promise.resolve(true);
|
|
143
|
+
}
|
|
144
|
+
revokeAllForSubject(subjectId, invalidBefore) {
|
|
145
|
+
const current = this.#invalidBefore.get(subjectId);
|
|
146
|
+
if (current === void 0 || invalidBefore > current) this.#invalidBefore.set(subjectId, invalidBefore);
|
|
147
|
+
return Promise.resolve();
|
|
148
|
+
}
|
|
149
|
+
getInvalidBefore(subjectId) {
|
|
150
|
+
return Promise.resolve(this.#invalidBefore.get(subjectId) ?? null);
|
|
151
|
+
}
|
|
152
|
+
gc(now = this.#now()) {
|
|
153
|
+
let purged = 0;
|
|
154
|
+
for (const [jti, expiresAt] of this.#deniedJti) if (expiresAt <= now) {
|
|
155
|
+
this.#deniedJti.delete(jti);
|
|
156
|
+
purged++;
|
|
157
|
+
}
|
|
158
|
+
for (const [id, record] of this.#byId) if (this.#isPurgeable(record, now)) {
|
|
159
|
+
this.#removeRecord(id, record);
|
|
160
|
+
purged++;
|
|
161
|
+
}
|
|
162
|
+
return Promise.resolve(purged);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Un record est purgeable s'il est **expiré** (`expiresAt` passé — couvre les
|
|
166
|
+
* refresh, y compris révoqués par rotation : conservés jusqu'à leur `exp` =
|
|
167
|
+
* fenêtre de détection de rejeu, puis tombent ici), OU s'il est un PAT
|
|
168
|
+
* **révoqué sans expiration** au-delà de la fenêtre de rétention (audit) —
|
|
169
|
+
* sinon il resterait éternellement.
|
|
170
|
+
*/
|
|
171
|
+
#isPurgeable(record, now) {
|
|
172
|
+
if (record.expiresAt !== null && record.expiresAt <= now) return true;
|
|
173
|
+
return record.revokedAt !== null && record.expiresAt === null && record.revokedAt + this.#retentionRevokedMs <= now;
|
|
174
|
+
}
|
|
175
|
+
/** Instantané sérialisable de l'état courant (records + denylist + seuils). */
|
|
176
|
+
snapshot() {
|
|
177
|
+
return {
|
|
178
|
+
records: [...this.#byId.values()],
|
|
179
|
+
deniedJti: [...this.#deniedJti.entries()],
|
|
180
|
+
invalidBefore: [...this.#invalidBefore.entries()]
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Remplace l'état par celui d'un instantané (reconstruit les index dérivés). */
|
|
184
|
+
restore(snapshot) {
|
|
185
|
+
this.#byId.clear();
|
|
186
|
+
this.#idByHash.clear();
|
|
187
|
+
this.#idsByFamily.clear();
|
|
188
|
+
this.#idsBySubject.clear();
|
|
189
|
+
this.#deniedJti.clear();
|
|
190
|
+
this.#invalidBefore.clear();
|
|
191
|
+
for (const record of snapshot.records) {
|
|
192
|
+
this.#byId.set(record.id, record);
|
|
193
|
+
this.#idByHash.set(record.secretHash, record.id);
|
|
194
|
+
this.#addToIndex(this.#idsBySubject, record.subjectId, record.id);
|
|
195
|
+
if (record.family) this.#addToIndex(this.#idsByFamily, record.family, record.id);
|
|
196
|
+
}
|
|
197
|
+
for (const [jti, expiresAt] of snapshot.deniedJti) this.#deniedJti.set(jti, expiresAt);
|
|
198
|
+
for (const [subjectId, ts] of snapshot.invalidBefore) this.#invalidBefore.set(subjectId, ts);
|
|
199
|
+
}
|
|
200
|
+
#addToIndex(index, key, id) {
|
|
201
|
+
let set = index.get(key);
|
|
202
|
+
if (!set) {
|
|
203
|
+
set = /* @__PURE__ */ new Set();
|
|
204
|
+
index.set(key, set);
|
|
205
|
+
}
|
|
206
|
+
set.add(id);
|
|
207
|
+
}
|
|
208
|
+
#revokeRecord(record, reason) {
|
|
209
|
+
if (record && record.revokedAt === null) {
|
|
210
|
+
record.revokedAt = this.#now();
|
|
211
|
+
record.revokedReason = reason;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Retire un record et nettoie TOUS ses index (évite les références fantômes). */
|
|
215
|
+
#removeRecord(id, record) {
|
|
216
|
+
this.#byId.delete(id);
|
|
217
|
+
this.#idByHash.delete(record.secretHash);
|
|
218
|
+
this.#dropFromIndex(this.#idsBySubject, record.subjectId, id);
|
|
219
|
+
if (record.family) this.#dropFromIndex(this.#idsByFamily, record.family, id);
|
|
220
|
+
}
|
|
221
|
+
#dropFromIndex(index, key, id) {
|
|
222
|
+
const set = index.get(key);
|
|
223
|
+
if (set) {
|
|
224
|
+
set.delete(id);
|
|
225
|
+
if (set.size === 0) index.delete(key);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Purge amortie des `jti` expirés — borne la denylist sans coût par appel. */
|
|
229
|
+
#maybeSweep() {
|
|
230
|
+
if ((++this.#sweepCounter & 255) !== 0) return;
|
|
231
|
+
const now = this.#now();
|
|
232
|
+
for (const [jti, expiresAt] of this.#deniedJti) if (expiresAt <= now) this.#deniedJti.delete(jti);
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
//#endregion
|
|
236
|
+
export { MemoryTokenStore, matchesTokenQuery };
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { canonicalIssuer, extractScopes, issuerMetadataUrls, validateIssuerMetadata } from "nodefony";
|
|
2
|
+
//#region nodefony/src/token/RemoteJwtVerifier.ts
|
|
3
|
+
/**
|
|
4
|
+
* Codes d'erreur `jose` qui désignent un **jeton fautif** — la liste est
|
|
5
|
+
* BLANCHE, et c'est le point important.
|
|
6
|
+
*
|
|
7
|
+
* La distinction refus / panne n'est pas cosmétique : répondre « jeton refusé »
|
|
8
|
+
* quand l'émetteur est injoignable envoie le client en chercher un autre — qui
|
|
9
|
+
* échouera pareil — et noie la panne dans une statistique d'authentification.
|
|
10
|
+
*
|
|
11
|
+
* Le sens de la liste a été choisi en lisant la source de `jose`, pas de
|
|
12
|
+
* mémoire : un JWKS qui répond `500`, ou dont le corps n'est pas du JSON, y
|
|
13
|
+
* lève une erreur GÉNÉRIQUE (`ERR_JOSE_GENERIC`), et une panne réseau brute
|
|
14
|
+
* remonte sans aucun code. Une liste noire des pannes aurait donc classé ces
|
|
15
|
+
* trois cas — tous des pannes — en « jeton invalide », silencieusement. Ici,
|
|
16
|
+
* ce qui n'est pas explicitement imputable au jeton devient une panne visible.
|
|
17
|
+
*/
|
|
18
|
+
const TOKEN_FAULT_CODES = /* @__PURE__ */ new Set([
|
|
19
|
+
"ERR_JWS_SIGNATURE_VERIFICATION_FAILED",
|
|
20
|
+
"ERR_JWT_EXPIRED",
|
|
21
|
+
"ERR_JWT_CLAIM_VALIDATION_FAILED",
|
|
22
|
+
"ERR_JWT_INVALID",
|
|
23
|
+
"ERR_JWS_INVALID",
|
|
24
|
+
"ERR_JOSE_ALG_NOT_ALLOWED",
|
|
25
|
+
"ERR_JWKS_NO_MATCHING_KEY"
|
|
26
|
+
]);
|
|
27
|
+
/**
|
|
28
|
+
* Vérificateur de jetons d'accès émis par un **serveur d'autorisation tiers**.
|
|
29
|
+
*
|
|
30
|
+
* C'est la pièce qui manquait pour que le rôle *serveur de ressource* du cœur
|
|
31
|
+
* (`nodefony/src/oauth/`) soit autre chose qu'un refus poli : il sait publier ce
|
|
32
|
+
* qu'il protège et dire où prendre un jeton, mais rien, jusqu'ici, ne savait
|
|
33
|
+
* LIRE ce jeton. `JwtAuthenticator` ne vérifie que les jetons émis par
|
|
34
|
+
* Nodefony lui-même (jeu de clés local) ; ici, les clés appartiennent à
|
|
35
|
+
* quelqu'un d'autre, arrivent par le réseau et tournent sans prévenir.
|
|
36
|
+
*
|
|
37
|
+
* ## Ce qui vaut garantie
|
|
38
|
+
*
|
|
39
|
+
* - **L'audience est obligatoire et vient de l'APPELANT** — jamais du jeton. Un
|
|
40
|
+
* jeton parfaitement valide, émis par un émetteur de confiance, pour un AUTRE
|
|
41
|
+
* service, est refusé (RFC 8707 §2). C'est la seule chose qui empêche le
|
|
42
|
+
* rejeu d'un jeton légitime d'une ressource vers une autre.
|
|
43
|
+
* - **L'algorithme est imposé par la configuration** (RFC 8725 §3.1), jamais lu
|
|
44
|
+
* dans l'en-tête ; `alg: none` n'existe pas pour cette API.
|
|
45
|
+
* - **Les clés viennent du `jwks_uri` de l'émetteur**, jamais d'un `jku` ou
|
|
46
|
+
* d'un `jwk` porté par le jeton (§3.5) — sans quoi un attaquant fournirait
|
|
47
|
+
* la clé qui valide sa propre signature.
|
|
48
|
+
* - **La liste des émetteurs est fermée** : un `iss` inconnu est refusé avant
|
|
49
|
+
* toute requête sortante.
|
|
50
|
+
*
|
|
51
|
+
* ## Ce que cette classe ne fait pas
|
|
52
|
+
*
|
|
53
|
+
* Elle n'établit pas d'utilisateur applicatif : elle rend un sujet et des
|
|
54
|
+
* scopes. Rattacher ce sujet à un compte local (approvisionnement à la volée,
|
|
55
|
+
* comptes de service) est une décision d'application, pas de protocole — et
|
|
56
|
+
* l'entremêler ici rendrait impossible d'accepter un appelant purement machine,
|
|
57
|
+
* qui est précisément le cas d'usage.
|
|
58
|
+
*
|
|
59
|
+
* @see references/rfc/ietf/rfc8707.txt — l'audience, qui LIE un jeton à CE service
|
|
60
|
+
*/
|
|
61
|
+
var RemoteJwtVerifier = class {
|
|
62
|
+
#issuers;
|
|
63
|
+
#resolved = /* @__PURE__ */ new Map();
|
|
64
|
+
#options;
|
|
65
|
+
#jose = null;
|
|
66
|
+
/**
|
|
67
|
+
* @param options - émetteurs de confiance et réglages réseau
|
|
68
|
+
* @throws Error si un émetteur est invalide, dupliqué, ou déclare un
|
|
69
|
+
* algorithme à secret partagé
|
|
70
|
+
*/
|
|
71
|
+
constructor(options) {
|
|
72
|
+
this.#options = options;
|
|
73
|
+
this.#issuers = /* @__PURE__ */ new Map();
|
|
74
|
+
for (const trusted of options.issuers) {
|
|
75
|
+
const issuer = canonicalIssuer(trusted.issuer);
|
|
76
|
+
if (this.#issuers.has(issuer)) throw new Error(`émetteur « ${issuer} » déclaré deux fois — deux politiques pour un même émetteur ne peuvent pas coexister : la seconde serait ignorée en silence.`);
|
|
77
|
+
assertAsymmetric(issuer, trusted.algorithms);
|
|
78
|
+
this.#issuers.set(issuer, {
|
|
79
|
+
...trusted,
|
|
80
|
+
issuer
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/** Nombre d'émetteurs de confiance — pour l'introspection et les journaux. */
|
|
85
|
+
get size() {
|
|
86
|
+
return this.#issuers.size;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Vérifie un jeton porté, pour UNE ressource donnée.
|
|
90
|
+
*
|
|
91
|
+
* Conforme au contrat `IAccessTokenVerifier` du cœur : un refus est un `null`,
|
|
92
|
+
* jamais une exception. Les exceptions sont réservées aux pannes — un
|
|
93
|
+
* émetteur injoignable n'est pas un jeton invalide.
|
|
94
|
+
*
|
|
95
|
+
* @param token - le jeton brut, tel que présenté
|
|
96
|
+
* @param audience - URI canonique de la ressource visée ; le jeton DOIT la
|
|
97
|
+
* porter dans `aud`
|
|
98
|
+
* @returns le principal établi, ou `null` si le jeton est refusé
|
|
99
|
+
* @throws Error si l'émetteur ne peut pas être joint ou publie un jeu de clés
|
|
100
|
+
* inutilisable — la porte doit alors refuser de servir, pas répondre
|
|
101
|
+
* « jeton invalide »
|
|
102
|
+
*/
|
|
103
|
+
async verify(token, audience) {
|
|
104
|
+
if (typeof token !== "string" || token.length === 0) return null;
|
|
105
|
+
const jose = this.#jose ??= await import("jose");
|
|
106
|
+
let claimedIssuer;
|
|
107
|
+
try {
|
|
108
|
+
const claims = jose.decodeJwt(token);
|
|
109
|
+
if (typeof claims.iss !== "string") return null;
|
|
110
|
+
claimedIssuer = canonicalIssuer(claims.iss);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const trusted = this.#issuers.get(claimedIssuer);
|
|
115
|
+
if (!trusted) {
|
|
116
|
+
this.#audit(`jeton refusé : émetteur « ${claimedIssuer} » non déclaré.`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const { getKey } = await this.#resolve(trusted, jose);
|
|
120
|
+
try {
|
|
121
|
+
const { payload } = await jose.jwtVerify(token, getKey, {
|
|
122
|
+
algorithms: [...trusted.algorithms],
|
|
123
|
+
issuer: trusted.issuer,
|
|
124
|
+
audience,
|
|
125
|
+
clockTolerance: this.#options.clockToleranceS ?? 5,
|
|
126
|
+
...trusted.typ ? { typ: trusted.typ } : {},
|
|
127
|
+
...trusted.requiredClaims?.length ? { requiredClaims: [...trusted.requiredClaims] } : {}
|
|
128
|
+
});
|
|
129
|
+
const subject = typeof payload.sub === "string" ? payload.sub : void 0;
|
|
130
|
+
return {
|
|
131
|
+
issuer: claimedIssuer,
|
|
132
|
+
subject,
|
|
133
|
+
scopes: extractScopes(payload),
|
|
134
|
+
expiresAt: typeof payload.exp === "number" ? payload.exp : void 0,
|
|
135
|
+
issuedAt: typeof payload.iat === "number" ? payload.iat : void 0,
|
|
136
|
+
tokenId: typeof payload.jti === "string" ? payload.jti : void 0
|
|
137
|
+
};
|
|
138
|
+
} catch (error) {
|
|
139
|
+
const code = error.code;
|
|
140
|
+
if (!code || !TOKEN_FAULT_CODES.has(code)) throw new Error(`vérification impossible : l'émetteur « ${trusted.issuer} » n'a pas fourni de jeu de clés utilisable (${code ?? error.message}).`, { cause: error });
|
|
141
|
+
this.#audit(`jeton refusé (${trusted.issuer}) : ${code}`);
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Résout — une seule fois par émetteur — la fonction qui fournit les clés.
|
|
147
|
+
*
|
|
148
|
+
* La promesse elle-même est mémorisée, pas son résultat : deux jetons qui
|
|
149
|
+
* arrivent ensemble à froid partagent la même découverte au lieu d'en lancer
|
|
150
|
+
* deux. En cas d'échec, l'entrée est retirée pour qu'un appel ultérieur
|
|
151
|
+
* réessaie — une panne passagère ne doit pas condamner l'émetteur pour la
|
|
152
|
+
* durée de vie du processus.
|
|
153
|
+
*/
|
|
154
|
+
#resolve(trusted, jose) {
|
|
155
|
+
if (trusted.localJwks) return this.#build(trusted, jose);
|
|
156
|
+
const pending = this.#resolved.get(trusted.issuer);
|
|
157
|
+
if (pending) return pending;
|
|
158
|
+
const promise = this.#build(trusted, jose).catch((error) => {
|
|
159
|
+
this.#resolved.delete(trusted.issuer);
|
|
160
|
+
throw error;
|
|
161
|
+
});
|
|
162
|
+
this.#resolved.set(trusted.issuer, promise);
|
|
163
|
+
return promise;
|
|
164
|
+
}
|
|
165
|
+
async #build(trusted, jose) {
|
|
166
|
+
if (trusted.localJwks) return {
|
|
167
|
+
trusted,
|
|
168
|
+
getKey: jose.createLocalJWKSet(await trusted.localJwks())
|
|
169
|
+
};
|
|
170
|
+
const jwksUri = trusted.jwksUri ?? await this.#discover(trusted.issuer);
|
|
171
|
+
const options = {
|
|
172
|
+
timeoutDuration: this.#options.timeoutMs ?? 5e3,
|
|
173
|
+
cooldownDuration: this.#options.cooldownMs ?? 3e4,
|
|
174
|
+
cacheMaxAge: this.#options.cacheMaxAgeMs ?? 6e5
|
|
175
|
+
};
|
|
176
|
+
const fetchImpl = this.#options.fetch;
|
|
177
|
+
if (fetchImpl) options[jose.customFetch] = fetchImpl;
|
|
178
|
+
return {
|
|
179
|
+
trusted,
|
|
180
|
+
getKey: jose.createRemoteJWKSet(new URL(jwksUri), options)
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Trouve le `jwks_uri` d'un émetteur en essayant les points bien connus,
|
|
185
|
+
* dans l'ordre normatif, et en s'arrêtant au premier document VALIDE.
|
|
186
|
+
*
|
|
187
|
+
* Un document qui répond mais ne se réclame pas du bon émetteur n'est pas une
|
|
188
|
+
* « meilleure réponse que rien » : il est écarté comme s'il n'avait pas
|
|
189
|
+
* répondu, et la recherche continue.
|
|
190
|
+
*/
|
|
191
|
+
async #discover(issuer) {
|
|
192
|
+
const doFetch = this.#options.fetch ?? globalThis.fetch;
|
|
193
|
+
const timeout = this.#options.timeoutMs ?? 5e3;
|
|
194
|
+
const failures = [];
|
|
195
|
+
for (const url of issuerMetadataUrls(issuer)) try {
|
|
196
|
+
const response = await doFetch(url, {
|
|
197
|
+
signal: AbortSignal.timeout(timeout),
|
|
198
|
+
redirect: "manual",
|
|
199
|
+
headers: { accept: "application/json" }
|
|
200
|
+
});
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
failures.push(`${url} → HTTP ${response.status}`);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const metadata = validateIssuerMetadata(await response.json(), issuer);
|
|
206
|
+
this.#audit(`émetteur « ${issuer} » découvert : clés sur ${metadata.jwksUri}.`);
|
|
207
|
+
return metadata.jwksUri;
|
|
208
|
+
} catch (error) {
|
|
209
|
+
failures.push(`${url} → ${error.message}`);
|
|
210
|
+
}
|
|
211
|
+
throw new Error(`découverte impossible pour l'émetteur « ${issuer} » : aucun document de métadonnées valide. Essayé — ${failures.join(" ; ")}. Déclarer \`jwksUri\` évite toute découverte.`);
|
|
212
|
+
}
|
|
213
|
+
#audit(message) {
|
|
214
|
+
this.#options.log?.(message);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
/**
|
|
218
|
+
* Refuse un algorithme à secret partagé sur un jeu de clés PUBLIC.
|
|
219
|
+
*
|
|
220
|
+
* C'est la confusion d'algorithme la plus classique (RFC 8725 §2.1) : la clé
|
|
221
|
+
* publique de l'émetteur est lisible par tout le monde ; acceptée comme secret
|
|
222
|
+
* HMAC, elle laisse n'importe qui signer un jeton valide. La règle vaut au
|
|
223
|
+
* démarrage, où elle empêche la configuration d'exister, plutôt qu'à la
|
|
224
|
+
* vérification, où elle dépendrait d'un jeton pour se manifester.
|
|
225
|
+
*/
|
|
226
|
+
function assertAsymmetric(issuer, algorithms) {
|
|
227
|
+
if (algorithms.length === 0) throw new Error(`émetteur « ${issuer} » : aucun algorithme accepté — la liste est la garde principale (RFC 8725 §3.1), elle ne peut pas être vide.`);
|
|
228
|
+
for (const alg of algorithms) if (alg.startsWith("HS") || alg.toLowerCase() === "none") throw new Error(`émetteur « ${issuer} » : algorithme « ${alg} » refusé. Les clés proviennent d'un jeu PUBLIC : un algorithme à secret partagé y transformerait la clé publique en secret de signature.`);
|
|
229
|
+
}
|
|
230
|
+
//#endregion
|
|
231
|
+
export { RemoteJwtVerifier, RemoteJwtVerifier as default };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { anonymousUser } from "@nodefony/user";
|
|
2
|
+
//#region nodefony/src/token/UserToken.ts
|
|
3
|
+
/**
|
|
4
|
+
* Jeton porteur d'un utilisateur réel — produit par les authenticators à
|
|
5
|
+
* credential (`userpassword`, puis `session`/`jwt`...).
|
|
6
|
+
*
|
|
7
|
+
* Cycle en deux états, UN SEUL objet alloué par tentative (cold path login) :
|
|
8
|
+
* 1. `createToken()` → non authentifié : porte le credential brut extrait de la
|
|
9
|
+
* requête, `getUser()` rend l'anonyme (jamais `null`, Zero Trust).
|
|
10
|
+
* 2. `authenticate()` réussit → {@link promote} : l'utilisateur vérifié est posé
|
|
11
|
+
* et le credential est **effacé** (anti-fuite : un mot de passe ne doit
|
|
12
|
+
* survivre ni en mémoire ni dans un heap dump/log).
|
|
13
|
+
*
|
|
14
|
+
* Les attributs (claims, providerId...) sont lazy — `null` tant que rien n'est posé.
|
|
15
|
+
*/
|
|
16
|
+
var UserToken = class {
|
|
17
|
+
type;
|
|
18
|
+
#user = null;
|
|
19
|
+
#credentials;
|
|
20
|
+
#attributes = null;
|
|
21
|
+
/**
|
|
22
|
+
* @param type - type du token (`"userpassword"`, `"session"`, `"jwt"`...).
|
|
23
|
+
* @param credentials - credential brut extrait de la requête (vidé au succès).
|
|
24
|
+
*/
|
|
25
|
+
constructor(type, credentials = null) {
|
|
26
|
+
this.type = type;
|
|
27
|
+
this.#credentials = credentials;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Marque le jeton authentifié : pose l'utilisateur vérifié et EFFACE le
|
|
31
|
+
* credential. Appelé uniquement par l'authenticator au succès.
|
|
32
|
+
*
|
|
33
|
+
* @param user - utilisateur vérifié par la source d'identité.
|
|
34
|
+
* @returns le jeton lui-même (chaînage).
|
|
35
|
+
*/
|
|
36
|
+
promote(user) {
|
|
37
|
+
this.#user = user;
|
|
38
|
+
this.#credentials = null;
|
|
39
|
+
return this;
|
|
40
|
+
}
|
|
41
|
+
getUser() {
|
|
42
|
+
return this.#user ?? anonymousUser;
|
|
43
|
+
}
|
|
44
|
+
getUserIdentifier() {
|
|
45
|
+
return (this.#user ?? anonymousUser).identifier;
|
|
46
|
+
}
|
|
47
|
+
isAuthenticated() {
|
|
48
|
+
return this.#user !== null;
|
|
49
|
+
}
|
|
50
|
+
getRoles() {
|
|
51
|
+
return this.#user ? [...this.#user.roles] : [...anonymousUser.roles];
|
|
52
|
+
}
|
|
53
|
+
getCredentials() {
|
|
54
|
+
return this.#credentials;
|
|
55
|
+
}
|
|
56
|
+
getScopes() {
|
|
57
|
+
return this.#attributes?.get("scopes") ?? [];
|
|
58
|
+
}
|
|
59
|
+
getAttribute(key) {
|
|
60
|
+
return this.#attributes?.get(key);
|
|
61
|
+
}
|
|
62
|
+
setAttribute(key, value) {
|
|
63
|
+
(this.#attributes ??= /* @__PURE__ */ new Map()).set(key, value);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { UserToken, UserToken as default };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region nodefony/src/token/jwtRuntime.ts
|
|
2
|
+
/**
|
|
3
|
+
* Dérive les paramètres effectifs depuis la config sécurité. `issuer` omis →
|
|
4
|
+
* `"nodefony"` (DEVRAIT être surchargé en prod) ; `audiences` vide → l'app est sa
|
|
5
|
+
* propre audience (`[issuer]`).
|
|
6
|
+
*/
|
|
7
|
+
function resolveJwtRuntime(jwt) {
|
|
8
|
+
const issuer = jwt.issuer && jwt.issuer.length > 0 ? jwt.issuer : "nodefony";
|
|
9
|
+
return {
|
|
10
|
+
issuer,
|
|
11
|
+
audiences: jwt.audiences.length > 0 ? [...jwt.audiences] : [issuer],
|
|
12
|
+
accessTtlS: jwt.accessTtlS,
|
|
13
|
+
refreshTtlS: jwt.refreshTtlS,
|
|
14
|
+
rotateRefresh: jwt.rotateRefresh,
|
|
15
|
+
alg: "EdDSA"
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { resolveJwtRuntime };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
//#region nodefony/src/token/secretFile.ts
|
|
6
|
+
/**
|
|
7
|
+
* Écrire un SECRET sur disque — la seule implémentation du dépôt.
|
|
8
|
+
*
|
|
9
|
+
* ## Les trois règles, et ce que chacune évite
|
|
10
|
+
*
|
|
11
|
+
* 1. **Ne jamais tester la présence avant de lire.** `existsSync(f) ? read(f)
|
|
12
|
+
* : ""` ouvre une fenêtre entre le test et l'usage : le fichier peut
|
|
13
|
+
* disparaître, ou devenir un lien vers ailleurs. La forme juste est de lire
|
|
14
|
+
* et de traiter `ENOENT` — le système de fichiers répond en une opération ce
|
|
15
|
+
* que deux appels ne peuvent pas garantir.
|
|
16
|
+
* 2. **Écrire en 0600, atomiquement.** Un secret créé au masque par défaut est
|
|
17
|
+
* lisible par tous les comptes de la machine, et rien ne le signale. Le
|
|
18
|
+
* couple fichier temporaire + `rename` évite en plus qu'un lecteur tombe sur
|
|
19
|
+
* un fichier à demi écrit.
|
|
20
|
+
* 3. **CONSTATER le mode obtenu.** Le mode demandé est une intention, pas une
|
|
21
|
+
* garantie : NTFS l'ignore, comme un montage FAT/exFAT ou NFS sans mapping
|
|
22
|
+
* d'identité. Une capacité se constate, elle ne se déduit pas de
|
|
23
|
+
* `process.platform` — et si la restriction n'a pas pris, il faut le DIRE
|
|
24
|
+
* plutôt que laisser croire à une protection.
|
|
25
|
+
*
|
|
26
|
+
* ## Pourquoi les deux formes, synchrone et asynchrone
|
|
27
|
+
*
|
|
28
|
+
* Le runtime persiste ses clés dans du code asynchrone ; une commande de CLI
|
|
29
|
+
* écrit un jeton dans un flot synchrone, où introduire une promesse
|
|
30
|
+
* changerait l'ordre des messages affichés. Les deux formes appliquent le même
|
|
31
|
+
* raisonnement, écrit ici une seule fois — deux copies divergeraient, et l'on
|
|
32
|
+
* sait exactement comment : l'une porterait le mode 0600, l'autre non.
|
|
33
|
+
*
|
|
34
|
+
* @module
|
|
35
|
+
*/
|
|
36
|
+
/** Mode attendu d'un fichier qui porte un secret : lisible par son seul propriétaire. */
|
|
37
|
+
const MODE_SECRET = 384;
|
|
38
|
+
/**
|
|
39
|
+
* Le contenu du fichier, ou `null` s'il n'existe pas.
|
|
40
|
+
*
|
|
41
|
+
* @throws Toute erreur autre qu'`ENOENT` — un fichier illisible pour cause de
|
|
42
|
+
* droits n'est PAS un fichier absent, et le confondre ferait écraser un
|
|
43
|
+
* secret existant par un fichier neuf.
|
|
44
|
+
*/
|
|
45
|
+
async function readIfPresent(file) {
|
|
46
|
+
try {
|
|
47
|
+
return await readFile(file, "utf8");
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e.code === "ENOENT") return null;
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Forme synchrone de {@link readIfPresent}. */
|
|
54
|
+
function readIfPresentSync(file) {
|
|
55
|
+
try {
|
|
56
|
+
return readFileSync(file, "utf8");
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code === "ENOENT") return null;
|
|
59
|
+
throw e;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Le mode effectif du fichier n'est-il PAS restreint au propriétaire ?
|
|
64
|
+
*
|
|
65
|
+
* @returns `null` si le fichier a disparu ou n'est pas interrogeable (le chemin
|
|
66
|
+
* d'erreur normal parlera), sinon le mode effectif quand il diffère de
|
|
67
|
+
* 0600 — et `undefined` quand tout va bien.
|
|
68
|
+
*/
|
|
69
|
+
function modeNonRestreint(file) {
|
|
70
|
+
let mode;
|
|
71
|
+
try {
|
|
72
|
+
mode = statSync(file).mode & 511;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return mode === 384 ? void 0 : mode;
|
|
77
|
+
}
|
|
78
|
+
/** Forme asynchrone de {@link modeNonRestreint}. */
|
|
79
|
+
async function modeNonRestreintAsync(file) {
|
|
80
|
+
let mode;
|
|
81
|
+
try {
|
|
82
|
+
mode = (await stat(file)).mode & 511;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return mode === 384 ? void 0 : mode;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* La phrase à journaliser quand la restriction n'a PAS pris.
|
|
90
|
+
*
|
|
91
|
+
* Elle nomme la cause probable et ce qui reste à faire : un avertissement qui
|
|
92
|
+
* dit seulement « mode inattendu » se lit comme du bruit et finit ignoré.
|
|
93
|
+
*/
|
|
94
|
+
function messageNonRestreint(file, mode) {
|
|
95
|
+
return `${file} porte un SECRET mais n'est PAS restreint au seul propriétaire (mode ${mode.toString(8).padStart(4, "0")}, attendu 0600). Le système de fichiers n'applique pas les permissions POSIX (NTFS, FAT/exFAT, NFS sans mapping d'identité), ou le fichier a été déposé par un tiers. La confidentialité dépend alors des seuls droits du dossier — restreignez-les : ` + (process.platform === "win32" ? `icacls "${file}" /inheritance:r /grant:r "%USERNAME%:R"` : `chmod 600 "${file}"`) + `.`;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Écrit un secret : dossier créé, mode 0600, remplacement ATOMIQUE.
|
|
99
|
+
*
|
|
100
|
+
* Le mode est posé à la création du temporaire — et non après `rename` — pour
|
|
101
|
+
* qu'il n'existe à aucun instant un fichier au contenu secret et au masque par
|
|
102
|
+
* défaut. `chmod` est ensuite réappliqué sur la cible : un `rename` par-dessus
|
|
103
|
+
* un fichier EXISTANT conserve, sur certains systèmes, le mode de la cible.
|
|
104
|
+
*/
|
|
105
|
+
async function writeSecret(file, content) {
|
|
106
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
107
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
108
|
+
await writeFile(tmp, content, { mode: 384 });
|
|
109
|
+
try {
|
|
110
|
+
await rename(tmp, file);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
await rm(tmp, { force: true }).catch(() => {});
|
|
113
|
+
throw e;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/** Forme synchrone de {@link writeSecret}. */
|
|
117
|
+
function writeSecretSync(file, content) {
|
|
118
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
119
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
120
|
+
writeFileSync(tmp, content, { mode: 384 });
|
|
121
|
+
try {
|
|
122
|
+
renameSync(tmp, file);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
try {
|
|
125
|
+
rmSync(tmp, { force: true });
|
|
126
|
+
} catch {}
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
chmodSync(file, 384);
|
|
131
|
+
} catch {}
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
export { MODE_SECRET, messageNonRestreint, modeNonRestreint, modeNonRestreintAsync, readIfPresent, readIfPresentSync, writeSecret, writeSecretSync };
|