@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
package/docs/csrf.md
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "CSRF — anti-forgery (Fetch Metadata + double-submit signé)"
|
|
3
|
+
navTitle: CSRF
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: csrf
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "csrf.ts,csrfToken.ts"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer]
|
|
11
|
+
tags:
|
|
12
|
+
[
|
|
13
|
+
security,
|
|
14
|
+
csrf,
|
|
15
|
+
fetch-metadata,
|
|
16
|
+
double-submit,
|
|
17
|
+
sec-fetch-site,
|
|
18
|
+
owasp,
|
|
19
|
+
rfc9110,
|
|
20
|
+
]
|
|
21
|
+
version: "doc"
|
|
22
|
+
status: stable
|
|
23
|
+
updated: 2026-07-19
|
|
24
|
+
source: "src/packages/@nodefony/security/docs/csrf.md"
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
# CSRF — empêcher les requêtes forgées cross-site
|
|
28
|
+
|
|
29
|
+
> Le CSRF fait exécuter au navigateur d'une victime **déjà authentifiée** une mutation qu'elle n'a
|
|
30
|
+
> pas voulue (son cookie de session part automatiquement). Nodefony défend en **deux couches** : une
|
|
31
|
+
> défense **globale** par _Fetch Metadata_ (le navigateur tamponne lui-même la provenance), et une
|
|
32
|
+
> défense **en profondeur opt-in** par _synchronizer token_ signé (`@CsrfProtect`, double-submit).
|
|
33
|
+
> Ancré sur `src/packages/@nodefony/security/nodefony/service/csrf.ts` et `src/csrfToken.ts`.
|
|
34
|
+
|
|
35
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **CSRF**
|
|
36
|
+
|
|
37
|
+
## 🧠 Le modèle mental — deux couches, zéro friction la plupart du temps
|
|
38
|
+
|
|
39
|
+
```mermaid
|
|
40
|
+
flowchart TD
|
|
41
|
+
REQ["requête entrante"] --> M{"méthode sûre ?<br/>GET/HEAD/OPTIONS/TRACE"}
|
|
42
|
+
M -->|"oui — 0 coût<br/>(+ @CsrfProtect : émettre le token)"| PASS["laisser passer"]
|
|
43
|
+
M -->|non = mutation| TO{"origine de confiance ?<br/>trustedOrigins ∪ CORS"}
|
|
44
|
+
TO -->|oui| PASS
|
|
45
|
+
TO -->|non| FM{"Sec-Fetch-Site ?"}
|
|
46
|
+
FM -->|same-origin / none| CP
|
|
47
|
+
FM -->|same-site| SS{"strictSameSite ?"}
|
|
48
|
+
SS -->|non| CP
|
|
49
|
+
SS -->|oui| B403["403"]
|
|
50
|
+
FM -->|cross-site| B403
|
|
51
|
+
FM -->|absent / inconnu| FB{"Origin/Referer<br/>same-host ?"}
|
|
52
|
+
FB -->|oui, ou non-navigateur| CP
|
|
53
|
+
FB -->|non| B403
|
|
54
|
+
CP{"route @CsrfProtect ?"} -->|oui| DS["exiger le token double-submit<br/>(en-tête ≡ cookie + HMAC)"]
|
|
55
|
+
CP -->|non| OK["→ contrôleur"]
|
|
56
|
+
DS -->|valide| OK
|
|
57
|
+
DS -->|absent / invalide| B403
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
La couche 1 (provenance) est **globale et active par défaut** ; la couche 2 (token) n'est payée que
|
|
61
|
+
sur les routes décorées `@CsrfProtect`.
|
|
62
|
+
|
|
63
|
+
## 📖 Lexique
|
|
64
|
+
|
|
65
|
+
| Terme | Sens |
|
|
66
|
+
| ------------------ | ------------------------------------------------------------------------------------------------------ |
|
|
67
|
+
| CSRF | _Cross-Site Request Forgery_ : un site tiers déclenche une action authentifiée à l'insu de la victime. |
|
|
68
|
+
| Méthode sûre | GET/HEAD/OPTIONS/TRACE — sans effet de bord (RFC 9110 §9.2.1), hors vecteur CSRF. |
|
|
69
|
+
| Fetch Metadata | En-têtes `Sec-Fetch-*` posés **par le navigateur** (infalsifiables par un script). |
|
|
70
|
+
| `Sec-Fetch-Site` | `same-origin` / `same-site` / `cross-site` / `none` : d'où vient la requête. |
|
|
71
|
+
| Synchronizer token | Jeton anti-CSRF rejoué par le client pour prouver l'intention. |
|
|
72
|
+
| Double-submit | Le token est à la fois dans un **cookie lisible** et dans un **en-tête** ; les deux doivent coïncider. |
|
|
73
|
+
| HMAC | _Hash-based MAC_ : signature symétrique — sans le secret, impossible de forger un token valide. |
|
|
74
|
+
| SOP | _Same-Origin Policy_ : un script tiers ne peut pas lire les cookies d'un autre site. |
|
|
75
|
+
| BFF | _Backend-For-Frontend_ : le serveur gère session/jetons pour le front web. |
|
|
76
|
+
|
|
77
|
+
## Qu'est-ce que le CSRF ? — l'attaque, vue de la victime
|
|
78
|
+
|
|
79
|
+
1. Tu es connecté·e à `app.example.org` — ton **cookie de session** est en poche.
|
|
80
|
+
2. Un autre onglet affiche `evil.site` : la page embarque un `<form>` invisible pointant sur
|
|
81
|
+
`https://app.example.org/api/profile/email`, soumis automatiquement en JS.
|
|
82
|
+
3. Ton navigateur envoie la requête **avec ton cookie** — c'est le comportement normal des cookies,
|
|
83
|
+
`evil.site` n'a rien volé.
|
|
84
|
+
4. Sans défense, le serveur voit une mutation authentifiée : l'email du compte est remplacé, et le
|
|
85
|
+
« mot de passe oublié » part chez l'attaquant.
|
|
86
|
+
|
|
87
|
+
Ce que la défense bloque : à l'étape 3, le navigateur tamponne lui-même `Sec-Fetch-Site: cross-site`
|
|
88
|
+
— un script attaquant **ne peut pas** falsifier cet en-tête. Le serveur répond **403 avant tout
|
|
89
|
+
contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
90
|
+
|
|
91
|
+
## La vision Nodefony
|
|
92
|
+
|
|
93
|
+
- **Vérifier la provenance d'abord** (OWASP 2025, modèle Go 1.25 `CrossOriginProtection`) : la
|
|
94
|
+
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts:151-156`).
|
|
95
|
+
- **Globale, pas liée aux zones** : toute mutation cross-site est refusée, route publique ou non —
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts:1427`) arrive **après** le
|
|
97
|
+
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
|
+
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
|
+
- **Logique pure** : la classe `Csrf` est synchrone, sans I/O ni allocation sur le hot-path —
|
|
100
|
+
testable sans serveur, instanciée une fois au boot (`csrf.ts:56`).
|
|
101
|
+
- La couche 2 (`@CsrfProtect`) est la ceinture-et-bretelles des mutations à haute valeur ; la
|
|
102
|
+
couche 3 côté cookies (attribut `SameSite`) reste portée par les émetteurs de cookies.
|
|
103
|
+
|
|
104
|
+
## 🚀 Démarrage rapide
|
|
105
|
+
|
|
106
|
+
### Dans une app `nodefony create app`, la couche 1 est DÉJÀ active
|
|
107
|
+
|
|
108
|
+
Rien à écrire ni à configurer : toute mutation dont la provenance est un site tiers reçoit **403**,
|
|
109
|
+
sur toutes tes routes. Les clients non-navigateurs (curl, CI) passent — ils n'embarquent pas les
|
|
110
|
+
cookies d'une victime, ils sont hors vecteur (`csrf.ts:113`).
|
|
111
|
+
|
|
112
|
+
### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
|
|
113
|
+
|
|
114
|
+
Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts:86-87`) :
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// nodefony/controllers/ProfileController.ts — complet, compile tel quel
|
|
118
|
+
import {
|
|
119
|
+
controller,
|
|
120
|
+
Controller,
|
|
121
|
+
Get,
|
|
122
|
+
Post,
|
|
123
|
+
Body,
|
|
124
|
+
CsrfProtect,
|
|
125
|
+
} from "@nodefony/framework";
|
|
126
|
+
|
|
127
|
+
@controller("/api/profile")
|
|
128
|
+
class ProfileController extends Controller {
|
|
129
|
+
// Requête SÛRE vers une route @CsrfProtect : le firewall MINT le token →
|
|
130
|
+
// la réponse pose le cookie lisible `csrf-token` (et on le rend au SPA).
|
|
131
|
+
@CsrfProtect()
|
|
132
|
+
@Get("/csrf")
|
|
133
|
+
csrf() {
|
|
134
|
+
return this.renderJson({ token: this.context?.csrfToken ?? null });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Mutation @CsrfProtect : couche 1 (provenance) PUIS couche 2 —
|
|
138
|
+
// en-tête `x-csrf-token` ≡ cookie `csrf-token` + HMAC valide, sinon 403.
|
|
139
|
+
@CsrfProtect()
|
|
140
|
+
@Post("/email")
|
|
141
|
+
async changeEmail(@Body() body: { email: string }) {
|
|
142
|
+
return this.renderJson({ ok: true, email: body.email });
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export default ProfileController;
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
(Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
|
|
150
|
+
le fait pour toi. Posé sur la **classe**, `@CsrfProtect()` couvre toutes les actions : les marqueurs
|
|
151
|
+
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts:1605-1611`.)
|
|
152
|
+
|
|
153
|
+
### Comment le front obtient — puis rejoue — le token
|
|
154
|
+
|
|
155
|
+
1. **Obtenir** : une requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
|
|
156
|
+
(`firewall.ts:753-757`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
|
|
157
|
+
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts:419-432`).
|
|
158
|
+
2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
|
|
159
|
+
`x-csrf-token` sur chaque mutation.
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Côté SPA — lire le cookie lisible, le rejouer dans l'en-tête
|
|
163
|
+
export async function updateEmail(email: string): Promise<Response> {
|
|
164
|
+
const token =
|
|
165
|
+
document.cookie.match(/(?:^|;\s*)csrf-token=([^;]+)/)?.[1] ?? "";
|
|
166
|
+
return fetch("/api/profile/email", {
|
|
167
|
+
method: "POST",
|
|
168
|
+
headers: {
|
|
169
|
+
"content-type": "application/json",
|
|
170
|
+
"x-csrf-token": decodeURIComponent(token),
|
|
171
|
+
},
|
|
172
|
+
body: JSON.stringify({ email }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Ce qu'on observe
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
# 1) Mutation forgée cross-site (ce que déclenche evil.site) → 403, couche 1
|
|
181
|
+
curl -si -X POST -H 'Sec-Fetch-Site: cross-site' \
|
|
182
|
+
http://localhost:5151/api/profile/email | head -1
|
|
183
|
+
# HTTP/1.1 403 Forbidden
|
|
184
|
+
|
|
185
|
+
# 2) Provenance saine MAIS pas de token (route @CsrfProtect) → 403, couche 2
|
|
186
|
+
curl -si -X POST -H 'Sec-Fetch-Site: same-origin' \
|
|
187
|
+
http://localhost:5151/api/profile/email | head -1
|
|
188
|
+
# HTTP/1.1 403 Forbidden
|
|
189
|
+
|
|
190
|
+
# 3) Semer le token (requête sûre), puis rejouer cookie + en-tête → 200
|
|
191
|
+
TOKEN=$(curl -s -c /tmp/jar http://localhost:5151/api/profile/csrf \
|
|
192
|
+
| sed -E 's/.*"token":"([^"]+)".*/\1/')
|
|
193
|
+
curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
|
|
194
|
+
-H 'Content-Type: application/json' -d '{"email":"ada@example.org"}' \
|
|
195
|
+
-X POST http://localhost:5151/api/profile/email | head -1
|
|
196
|
+
# HTTP/1.1 200 OK
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
> [!IMPORTANT]
|
|
200
|
+
> En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
|
|
201
|
+
> absent, un secret **éphémère** est généré (dev) : un redémarrage invalide les tokens en cours, et
|
|
202
|
+
> chaque pod rejette les tokens des autres (`firewall.ts:199-209`). Générer et câbler :
|
|
203
|
+
> `npx nodefony security:secrets` (`security-secrets.ts:39`) → `NF_CSRF_SECRET` →
|
|
204
|
+
> `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
|
|
205
|
+
|
|
206
|
+
## ⚙️ Choisir sa défense — trois situations
|
|
207
|
+
|
|
208
|
+
### Situation 1 — l'app web classique : la couche 1 suffit (défaut)
|
|
209
|
+
|
|
210
|
+
Ton SPA + BFF session sert des utilisateurs connectés ; tu ne veux **aucune friction**. Rien à
|
|
211
|
+
configurer : le navigateur tamponne la provenance, le serveur tranche.
|
|
212
|
+
|
|
213
|
+
| Le client envoie… | Couche 1 (provenance) | Résultat |
|
|
214
|
+
| -------------------------------------------------------- | ---------------------------- | :------: |
|
|
215
|
+
| SPA same-origin, cookie de session | `same-origin` → passe | **200** |
|
|
216
|
+
| `evil.site` (form auto-soumis, cookie embarqué de force) | `cross-site` | **403** |
|
|
217
|
+
| curl / script CI (aucun en-tête navigateur) | non-navigateur, hors vecteur | **200** |
|
|
218
|
+
|
|
219
|
+
### Situation 2 — mutation à haute valeur : ajouter `@CsrfProtect`
|
|
220
|
+
|
|
221
|
+
Changement d'email/mot de passe, virement : tu veux que la mutation tienne **même si** un signal de
|
|
222
|
+
provenance manque (proxy qui strippe, navigateur ancien, valeur `Sec-Fetch-Site` future). Le token
|
|
223
|
+
prouve l'**intention** en plus de la provenance :
|
|
224
|
+
|
|
225
|
+
| Le client envoie… | Couche 1 | Couche 2 (token) | Résultat |
|
|
226
|
+
| ---------------------------------------------- | -------- | --------------------- | :------: |
|
|
227
|
+
| SPA : cookie + en-tête `x-csrf-token` ≡ cookie | passe | signature HMAC valide | **200** |
|
|
228
|
+
| SPA qui oublie l'en-tête | passe | token absent | **403** |
|
|
229
|
+
| curl sans rien (passait en situation 1) | passe | token **exigé** | **403** |
|
|
230
|
+
| curl après GET du token (cookie + en-tête) | passe | signature HMAC valide | **200** |
|
|
231
|
+
|
|
232
|
+
### Situation 3 — webhook entrant : `@CsrfExempt`, jamais `@BypassFirewall`
|
|
233
|
+
|
|
234
|
+
Un provider (paiement, git) POST cross-origin **légitimement**, authentifié autrement (signature
|
|
235
|
+
HMAC du provider, clé API). La route sort de la défense CSRF **en conservant** authentification et
|
|
236
|
+
autorisation :
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
@CsrfExempt() // ✅ hors défense CSRF, l'auth de la zone RESTE appliquée
|
|
240
|
+
@BypassFirewall() // ❌ coupe AUSSI l'authentification — porte grande ouverte
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
> [!WARNING]
|
|
244
|
+
> `@CsrfExempt` (`routerDecorators.ts:1099`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
|
|
245
|
+
> webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
|
|
246
|
+
|
|
247
|
+
Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
|
|
248
|
+
déclarer l'alias dans `csrf.trustedOrigins` (match exact d'origine), pas dans `cors.origins` — CORS
|
|
249
|
+
ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts:176-181`).
|
|
250
|
+
|
|
251
|
+
## 🏗️ Architecture interne
|
|
252
|
+
|
|
253
|
+
### Couche 1 — la chaîne de décision (`Csrf.enforce()`)
|
|
254
|
+
|
|
255
|
+
`Csrf.enforce()` (`csrf.ts:85`) est **pure, synchrone, zéro I/O**, no-op immédiat sur les méthodes
|
|
256
|
+
sûres → coût nul sur le GET dominant (`csrf.ts:88`). Pour une mutation, dans l'ordre :
|
|
257
|
+
|
|
258
|
+
1. **Origine de confiance** — `csrf.trustedOrigins` ∪ whitelist CORS → passe même en cross-site : ce
|
|
259
|
+
que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts:45`, union construite par le
|
|
260
|
+
firewall au boot, `firewall.ts:190-194`).
|
|
261
|
+
2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
|
|
262
|
+
`same-site` → OK sauf `strictSameSite` (`csrf.ts:102-104`) ; `cross-site` → **403** ; valeur
|
|
263
|
+
inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts:98-109`).
|
|
264
|
+
3. **Repli `Origin`/`Referer`** (vieux navigateur, ou `Sec-Fetch-Site` absent) : **aucune** des
|
|
265
|
+
deux → client non-navigateur, hors vecteur → OK ; sinon **same-host** exigé, mismatch → **403**
|
|
266
|
+
(`csrf.ts:112-116`).
|
|
267
|
+
|
|
268
|
+
Lectures durcies côté firewall :
|
|
269
|
+
|
|
270
|
+
- en-têtes lus en **première occurrence** — jamais un tableau d'en-têtes répétés (garde d'injection,
|
|
271
|
+
`headerValue()`, `firewall.ts:103`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
|
|
272
|
+
parse du contexte (`cookieValue()`, `firewall.ts:90-103`) ;
|
|
273
|
+
- hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
|
|
274
|
+
(`firewall.ts:772-775`) ;
|
|
275
|
+
- le refus est un `CsrfError` **403 au message générique** : la politique (en-têtes inspectés,
|
|
276
|
+
whitelist) ne fuite jamais au client (`CsrfError.ts:17-21`).
|
|
277
|
+
|
|
278
|
+
### Couche 2 — le token signé (`CsrfTokenManager`)
|
|
279
|
+
|
|
280
|
+
`CsrfTokenManager` (`csrfToken.ts:23`) implémente le **signed double-submit** (OWASP Cheat Sheet) :
|
|
281
|
+
|
|
282
|
+
- token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts:27`),
|
|
283
|
+
émis par `issue()` (`csrfToken.ts:34`) ;
|
|
284
|
+
- `verify()` exige en-tête **et** cookie **présents**, **égaux** (double-submit) et la **signature
|
|
285
|
+
valide** (`csrfToken.ts:49-63`) — comparaisons à **temps constant**, jamais d'exception
|
|
286
|
+
(`csrfToken.ts:71-76`).
|
|
287
|
+
|
|
288
|
+
Pourquoi ça tient : le secret HMAC empêche un script tiers de **forger** un token (il ne peut pas
|
|
289
|
+
calculer la signature) ; le double-submit l'empêche d'en **injecter** un (il ne peut ni écrire
|
|
290
|
+
l'en-tête custom — préflight CORS — ni lire le cookie de la victime — SameSite + SOP). Et c'est
|
|
291
|
+
**stateless** : aucune session requise → couvre le BFF web **et** l'API JWT sans coupler au stockage
|
|
292
|
+
de session (TSDoc `CsrfTokenManager`, `csrfToken.ts:12-15`).
|
|
293
|
+
|
|
294
|
+
### Le câblage dans le pipeline (du décorateur au 403)
|
|
295
|
+
|
|
296
|
+
1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
|
|
297
|
+
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts:886`).
|
|
298
|
+
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
|
+
(`Resolver.ts:152-153`) — champs portés par le `Context` de base, HTTP comme WS
|
|
300
|
+
(`Context.ts:181-183`).
|
|
301
|
+
3. `Firewall.enforceCsrf()` (`firewall.ts:932`) fait les trois rôles : **émission** du token sur
|
|
302
|
+
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
303
|
+
`@CsrfProtect`. Les routes `bypassFirewall` (callbacks OAuth) sont exemptées
|
|
304
|
+
(`firewall.ts:743-745`), les `@CsrfExempt` sortent après la barrière méthode sûre
|
|
305
|
+
(`firewall.ts:951`).
|
|
306
|
+
4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
|
|
307
|
+
avec le cookie de session (`HttpContext.ts:419-432`).
|
|
308
|
+
|
|
309
|
+
## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts:149-192`)
|
|
310
|
+
|
|
311
|
+
<!-- prettier-ignore -->
|
|
312
|
+
| Option | Type · défaut | Effet |
|
|
313
|
+
| --- | --- | --- |
|
|
314
|
+
| `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts:151-156`). |
|
|
315
|
+
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts:157-162`). |
|
|
316
|
+
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts:164-169`). |
|
|
317
|
+
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts:170-175`). |
|
|
318
|
+
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts:582`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts:469`). |
|
|
319
|
+
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts:176-181`). |
|
|
320
|
+
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts:182-188`). |
|
|
321
|
+
|
|
322
|
+
## 📜 Normes appliquées
|
|
323
|
+
|
|
324
|
+
| Domaine | Norme | Ancrage |
|
|
325
|
+
| ------------------------------ | --------------------------------- | -------------------------------------- |
|
|
326
|
+
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts:8-13`) |
|
|
327
|
+
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts:85`) |
|
|
328
|
+
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts:107` |
|
|
329
|
+
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts:23`) |
|
|
330
|
+
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts:17-21`) |
|
|
331
|
+
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts:38-41`) |
|
|
332
|
+
| Cookies (SameSite) | RFC 6265bis §8.8.1 | TSDoc `Csrf` (`csrf.ts:54`) |
|
|
333
|
+
|
|
334
|
+
## ⚡ Performance & mémoire
|
|
335
|
+
|
|
336
|
+
- **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts:88`) ; seule exception, une
|
|
337
|
+
route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts:946`).
|
|
338
|
+
- **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
|
|
339
|
+
- **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
|
|
340
|
+
allouée « au cas où » (`firewall.ts:164`).
|
|
341
|
+
- Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
|
|
342
|
+
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts:142`).
|
|
343
|
+
|
|
344
|
+
## 📡 Observabilité — Studio
|
|
345
|
+
|
|
346
|
+
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
347
|
+
`Firewall.tsx:313-314`). La projection est **sans secret par construction** :
|
|
348
|
+
`Firewall.#describeDefenses()` (`firewall.ts:575`) publie la config résolue, et `synchronizerToken`
|
|
349
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts:557`).
|
|
350
|
+
|
|
351
|
+
## ⚠️ Pièges (symptôme → cause → correction)
|
|
352
|
+
|
|
353
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
354
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
355
|
+
| Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
|
|
356
|
+
| Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts:113`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
|
|
357
|
+
| `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts:778-783`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
|
|
358
|
+
| Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts:199-209`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
|
|
359
|
+
| `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts:102-104`) | Le désactiver si les sous-domaines sont de confiance |
|
|
360
|
+
| `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts:130-136`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
|
|
361
|
+
| Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
|
|
362
|
+
|
|
363
|
+
> [!TIP]
|
|
364
|
+
> Un banc d'intégration **live** du repo exerce exactement ce flow (émission GET, double-submit,
|
|
365
|
+
> exemption) sur les routes `/csrf/token`, `/csrf/submit` et `/csrf/webhook` du module de test
|
|
366
|
+
> (`FrameworkController.ts:139-160`) — la référence exécutable si un comportement te surprend.
|
|
367
|
+
|
|
368
|
+
## 🧪 Tests & couverture
|
|
369
|
+
|
|
370
|
+
Trois familles couvrent la brique — les **chiffres exacts vivent dans la carte de l'aperçu**
|
|
371
|
+
(régénérée par `gen-counters.mjs` depuis vitest, jamais figée ici) :
|
|
372
|
+
|
|
373
|
+
- **unit** : `csrf.test.ts` (la chaîne de décision couche 1 : Fetch Metadata, `strictSameSite`,
|
|
374
|
+
repli, origines de confiance), `csrfToken.test.ts` (le double-submit signé : émission, formats,
|
|
375
|
+
vérification) ;
|
|
376
|
+
- **attaque** : `csrf.attack.test.ts` (red-team — spoofing d'`Origin` host-exact, provenance
|
|
377
|
+
illisible, token malformé, **splicing** nonce/signature de deux vrais tokens, et la limite
|
|
378
|
+
host-only du repli **documentée par test**) ;
|
|
379
|
+
- **intégration live** : `tests/http/csrf.test.ts` chez `@nodefony/http` (serveur réel — défense
|
|
380
|
+
globale, flow double-submit `@CsrfProtect`, opt-out `@CsrfExempt`).
|
|
381
|
+
|
|
382
|
+
Couverture : `npm run coverage` dans `@nodefony/security`.
|
|
383
|
+
|
|
384
|
+
## 🔗 Pour aller plus loin
|
|
385
|
+
|
|
386
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
387
|
+
- 🧭 **Pages sœurs** : [CORS](cors.md) · [En-têtes de sécurité](headers.md)
|
|
388
|
+
|
|
389
|
+
- Le firewall qui câble les deux couches → [firewall](./firewall.md)
|
|
390
|
+
- CORS (ce qui est autorisé cross-origin, ∪ des origines de confiance CSRF) → [cors](./cors.md)
|
|
391
|
+
- En-têtes de sécurité (CSP, isolation) → [headers](./headers.md)
|
|
392
|
+
- Vue d'ensemble sécurité → [index](./index.md)
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Jetons d'un émetteur TIERS — accepter Keycloak, Auth0 ou Entra sans leur céder l'application"
|
|
3
|
+
navTitle: Émetteur tiers
|
|
4
|
+
lang: fr
|
|
5
|
+
module: "@nodefony/security"
|
|
6
|
+
topic: external-jwt
|
|
7
|
+
coverageModule: security
|
|
8
|
+
coverageFiles: "ExternalJwtAuthenticator,accessTokenVerifier,authenticatorRegistry"
|
|
9
|
+
section: "Sécurité"
|
|
10
|
+
audience: [developer, devops]
|
|
11
|
+
tags: [security, jwt, oauth2, resource-server, rfc9068, rfc8707, keycloak]
|
|
12
|
+
status: stable
|
|
13
|
+
version: "10.0.0"
|
|
14
|
+
updated: 2026-08-24
|
|
15
|
+
source: "src/packages/@nodefony/security/nodefony/src/authenticator/ExternalJwtAuthenticator.ts"
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Accepter les jetons d'un émetteur tiers
|
|
19
|
+
|
|
20
|
+
📍 [Documentation](../../../../../docs/index.md) › [Sécurité](index.md) › **Jetons d'un émetteur tiers**
|
|
21
|
+
|
|
22
|
+
Une application Nodefony sait émettre ses propres jetons ([Jetons](tokens.md)). Cette page traite du
|
|
23
|
+
cas inverse : **un serveur d'autorisation extérieur** — l'annuaire de l'entreprise, Keycloak, Auth0,
|
|
24
|
+
Entra ID — émet les jetons, et l'application doit décider qui entre. Elle devient alors ce que les
|
|
25
|
+
normes appellent un **serveur de ressources** (RFC 6750, RFC 9068).
|
|
26
|
+
|
|
27
|
+
C'est le mode d'une API appelée par d'autres services, par des agents, ou par une application dont
|
|
28
|
+
l'authentification est centralisée ailleurs.
|
|
29
|
+
|
|
30
|
+
## Ce que l'application NE délègue pas
|
|
31
|
+
|
|
32
|
+
Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions restent locales, et ce
|
|
33
|
+
sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
|
|
34
|
+
d'accès » :
|
|
35
|
+
|
|
36
|
+
1. **La liste des émetteurs acceptés** est une allowlist (`config.ts:660`). Un jeton dont l'`iss` n'y figure pas est
|
|
37
|
+
refusé **avant toute requête sortante** — l'application ne va pas interroger un émetteur inconnu.
|
|
38
|
+
2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts:666`). Par défaut, il
|
|
39
|
+
doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
|
|
40
|
+
toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
|
|
41
|
+
du pare-feu.
|
|
42
|
+
|
|
43
|
+
## Démarrage rapide
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// nodefony.config.ts
|
|
47
|
+
security: {
|
|
48
|
+
resourceServer: {
|
|
49
|
+
issuers: [
|
|
50
|
+
{
|
|
51
|
+
issuer: "https://auth.example.com/realms/mon-royaume",
|
|
52
|
+
algorithms: ["RS256"],
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Cela suffit : les clés publiques de l'émetteur sont découvertes par ses points de métadonnées
|
|
60
|
+
normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authorization: Bearer <jeton>`
|
|
61
|
+
émis par ce royaume — **à condition que le sujet du jeton corresponde à un compte local**.
|
|
62
|
+
|
|
63
|
+
> **L'audience n'est pas facultative.** Un jeton émis pour une autre application du même annuaire ne
|
|
64
|
+
> doit pas ouvrir celle-ci : c'est le rôle du claim `aud` (RFC 8707). La vérification l'exige.
|
|
65
|
+
|
|
66
|
+
## Les réglages, et ce qu'ils engagent
|
|
67
|
+
|
|
68
|
+
| Réglage | Défaut | Ce qu'il décide |
|
|
69
|
+
| -------------------------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
70
|
+
| `issuers[].issuer` | — | L'allowlist. En `https`, sans requête ni fragment (RFC 8414 §2). |
|
|
71
|
+
| `issuers[].jwksUri` | découvert | Déclaré, aucune découverte n'a lieu — utile pour un émetteur sans métadonnées, ou un démarrage à froid sans requête sortante. |
|
|
72
|
+
| `issuers[].algorithms` | `RS256`, `ES256`, `EdDSA` | Allowlist **serveur** : l'algorithme n'est jamais déduit de l'en-tête du jeton (RFC 8725 §3.1). À restreindre à ce que l'émetteur utilise réellement. |
|
|
73
|
+
| `issuers[].typ` | non exigé | `at+jwt` pour un émetteur conforme RFC 9068. |
|
|
74
|
+
| `issuers[].requiredClaims` | `[]` | Claims dont la présence est exigée, en plus d'`iss` et `aud`. |
|
|
75
|
+
| `issuers[].subjectMapping` | `prefixed` | Comment le `sub` devient un identifiant local — **voir l'encadré ci-dessous**. |
|
|
76
|
+
| `subjectPolicy` | `require` | `require` : un compte local est exigé. `ephemeral` : l'appelant vit le temps de la requête. |
|
|
77
|
+
| `ephemeralRoles` | `[]` | Rôles accordés en mode `ephemeral`. Vide à dessein. |
|
|
78
|
+
|
|
79
|
+
### 🔴 `subjectMapping` — pourquoi le défaut est `prefixed`
|
|
80
|
+
|
|
81
|
+
Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
|
|
82
|
+
paire `(émetteur, sujet)`, jamais le sujet seul.
|
|
83
|
+
|
|
84
|
+
En mode `prefixed` (`config.ts:650`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
|
|
85
|
+
pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
|
|
86
|
+
identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
|
|
87
|
+
l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
|
|
88
|
+
rattaché au compte local du même nom.
|
|
89
|
+
|
|
90
|
+
`subject` ne se déclare donc que si l'on maîtrise l'espace de noms de cet émetteur : typiquement
|
|
91
|
+
parce qu'il **est** cette application, ou parce que ses sujets sont déjà des identifiants locaux.
|
|
92
|
+
|
|
93
|
+
### `subjectPolicy` — qui a le droit d'exister
|
|
94
|
+
|
|
95
|
+
- **`require`** (défaut) — le sujet doit correspondre à un compte local (`loadUserByIdentifier`).
|
|
96
|
+
Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
|
|
97
|
+
utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
|
|
98
|
+
- **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
|
|
99
|
+
avec les rôles d'`ephemeralRoles` (`config.ts:672`). C'est le mode de l'appelant **purement machine** : un agent, un
|
|
100
|
+
service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
|
|
101
|
+
— qui viennent du jeton, donc bornés par le serveur d'autorisation.
|
|
102
|
+
|
|
103
|
+
> Écrire un rôle dans `ephemeralRoles` accorde un pouvoir local à quiconque détient un jeton valide
|
|
104
|
+
> pour cette ressource. À faire sciemment, jamais par confort.
|
|
105
|
+
|
|
106
|
+
## Comment ça s'articule
|
|
107
|
+
|
|
108
|
+
Le service `accessTokenVerifier` n'est posé au conteneur **que si `issuers` n'est pas vide** — il n'y
|
|
109
|
+
a pas de drapeau `enabled` qui permettrait « activé sans émetteur ». Une porte protégée par des
|
|
110
|
+
jetons tiers, dans une application qui n'en déclare aucun, **refuse de servir** plutôt que d'accepter
|
|
111
|
+
des porteurs qu'elle ne sait pas lire.
|
|
112
|
+
|
|
113
|
+
L'authentificateur `external-jwt` (`authenticatorRegistry.ts:118`) reconnaît les jetons qui le
|
|
114
|
+
concernent d'après la liste d'émetteurs, puis délègue la vérification au service — qui refait le contrôle sur sa propre liste.
|
|
115
|
+
La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
firewall: {
|
|
119
|
+
api: {
|
|
120
|
+
pattern: "^/api",
|
|
121
|
+
stateless: true,
|
|
122
|
+
authenticators: ["external-jwt"],
|
|
123
|
+
},
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Ce que l'application publie d'elle-même
|
|
128
|
+
|
|
129
|
+
Une ressource protégée doit dire **où obtenir un jeton valable pour elle**. C'est l'objet des
|
|
130
|
+
métadonnées de ressource protégée (RFC 9728), servies par l'application, et de l'en-tête
|
|
131
|
+
`WWW-Authenticate` renvoyé sur un refus. Un client conforme y trouve seul l'émetteur à interroger et
|
|
132
|
+
l'audience à demander.
|
|
133
|
+
|
|
134
|
+
## ⚠️ Pièges
|
|
135
|
+
|
|
136
|
+
- **Une panne de l'émetteur est un `503`, jamais un `401`.** Si les clés publiques sont
|
|
137
|
+
injoignables, l'application ne sait pas si le jeton est valide — répondre « refusé » ferait passer
|
|
138
|
+
une panne d'infrastructure pour un problème d'identifiants, et enverrait le porteur légitime
|
|
139
|
+
chercher au mauvais endroit. Le message de refus est **constant** ; la cause vit dans le journal.
|
|
140
|
+
- **L'audience vient de la ZONE, pas de l'authentificateur.** Elle est exigée au boot
|
|
141
|
+
(`validateArea`) : le pare-feu ne connaît aucun nom en dur, et une zone sans ressource déclarée ne
|
|
142
|
+
démarre pas.
|
|
143
|
+
- **L'ordre des authentificateurs dans la zone ne décide de rien** entre `jwt` et `external-jwt` :
|
|
144
|
+
l'aiguillage se fait sur l'`iss` du jeton. Inutile de les ranger « dans le bon ordre ».
|
|
145
|
+
- **`HS256` est impossible en configuration**, et ce n'est pas un oubli : un secret partagé ferait de
|
|
146
|
+
l'application un émetteur autant qu'un vérificateur. Seules des clés publiques sont acceptées.
|
|
147
|
+
- **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut, et il fait qu'une zone
|
|
148
|
+
protégée par jetons tiers refuse de servir plutôt que d'accepter des porteurs illisibles.
|
|
149
|
+
|
|
150
|
+
## 📖 Lexique
|
|
151
|
+
|
|
152
|
+
| Terme | Ce que ça désigne ici |
|
|
153
|
+
| ------------------------- | --------------------------------------------------------------------------------------------------- |
|
|
154
|
+
| **Émetteur** (`iss`) | Le serveur d'autorisation qui a signé le jeton. Identifiant en `https`, sans requête ni fragment. |
|
|
155
|
+
| **Audience** (`aud`) | Pour QUI le jeton a été émis. Un jeton destiné à une autre application ne doit pas ouvrir celle-ci. |
|
|
156
|
+
| **Sujet** (`sub`) | Qui est le porteur, **dans l'espace de noms de son émetteur** — jamais unique en soi. |
|
|
157
|
+
| **JWKS** | Le jeu de clés publiques de l'émetteur, qui permet de vérifier la signature sans secret partagé. |
|
|
158
|
+
| **Serveur de ressources** | Le rôle que joue l'application : elle vérifie des jetons qu'elle n'a pas émis (RFC 6750). |
|
|
159
|
+
| **Scope** | Ce que le serveur d'autorisation a autorisé. Distinct d'un **rôle**, qui est une décision locale. |
|
|
160
|
+
|
|
161
|
+
## 🧪 Tests & couverture
|
|
162
|
+
|
|
163
|
+
- **unit** : `externalJwtAuthenticator` (espace de noms du sujet, refus d'un `iss` hors allowlist),
|
|
164
|
+
`remoteJwtVerifier` (signature, audience, panne de l'émetteur), `protectedResourcePublication` et
|
|
165
|
+
`protectedResourceChain` (ce que la ressource publie d'elle-même, RFC 9728), et
|
|
166
|
+
`protectedResourceRoutes` côté framework ;
|
|
167
|
+
- **intégration, sur un serveur réel** : `external-jwt.test.ts` éprouve la PANNE (l'émetteur ne
|
|
168
|
+
répond pas), `external-jwt-e2e.test.ts` joue la boucle entière — l'application se déclarant
|
|
169
|
+
elle-même émetteur de confiance, ce qu'elle peut faire depuis qu'elle publie ses métadonnées.
|
|
170
|
+
|
|
171
|
+
Les chiffres exacts vivent dans la carte de l'aperçu, régénérée depuis vitest — jamais figés ici.
|
|
172
|
+
|
|
173
|
+
## Pour aller plus loin
|
|
174
|
+
|
|
175
|
+
- ⬆️ **Retour au hub** : [Sécurité — vue d'ensemble](index.md) · [Toute la documentation](../../../../../docs/index.md)
|
|
176
|
+
- [Jetons](tokens.md) — l'émission par l'application elle-même, le keystore, la révocation.
|
|
177
|
+
- [OAuth2](oauth2.md) — « se connecter avec GitHub » : un flux d'authentification, pas un serveur de
|
|
178
|
+
ressources.
|
|
179
|
+
- [Clés d'API](api-keys.md) — le porteur opaque, révocable, quand il n'y a pas de serveur
|
|
180
|
+
d'autorisation.
|
|
181
|
+
- [Pare-feu](firewall.md) — zones, `stateless`, ordre des authentificateurs.
|