@nodefony/security 10.0.0-beta.3 → 10.0.0-beta.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/_virtual/{_@oxc-project_runtime@0.153.0 → _@oxc-project_runtime@0.155.0}/helpers/esm/decorate.js +1 -2
- package/dist/index.js +6 -8
- package/dist/nodefony/command/{security-token.js → security-mcp-token.js} +18 -11
- package/dist/nodefony/command/security-oauth-doctor.js +5 -3
- package/dist/nodefony/command/security-secrets.js +10 -4
- package/dist/nodefony/command/security-user-add.js +9 -3
- package/dist/nodefony/command/security-user-delete.js +5 -3
- package/dist/nodefony/command/security-user-list.js +5 -3
- package/dist/nodefony/command/security-user-password.js +5 -3
- package/dist/nodefony/config/config.js +20 -4
- package/dist/nodefony/config/defineModuleConfig.js +1 -2
- package/dist/nodefony/contracts/IAccessVoter.js +1 -2
- package/dist/nodefony/errors/AccessDeniedError.js +1 -2
- package/dist/nodefony/errors/ApiKeyError.js +3 -2
- package/dist/nodefony/errors/AuthenticationError.js +1 -2
- package/dist/nodefony/errors/CsrfError.js +1 -2
- package/dist/nodefony/errors/InsecureTransportError.js +1 -2
- package/dist/nodefony/errors/InvalidTargetError.js +1 -2
- package/dist/nodefony/errors/SsrfError.js +1 -2
- package/dist/nodefony/errors/ThrottledError.js +1 -2
- package/dist/nodefony/errors/UnverifiableTokenError.js +1 -2
- package/dist/nodefony/errors/WebAuthnError.js +1 -2
- package/dist/nodefony/keycloak/keycloakAdmin.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealm.js +1 -2
- package/dist/nodefony/keycloak/keycloakRealmInput.js +1 -2
- package/dist/nodefony/keycloak/scaffold.js +3 -3
- package/dist/nodefony/keycloak/security-keycloak-realm.js +5 -3
- package/dist/nodefony/scaffold/contribute.js +1 -2
- package/dist/nodefony/service/accessTokenVerifier.js +101 -34
- package/dist/nodefony/service/apiKeys.js +45 -5
- package/dist/nodefony/service/auditService.js +4 -5
- package/dist/nodefony/service/authFlow.js +10 -8
- package/dist/nodefony/service/authorization.js +1 -2
- package/dist/nodefony/service/cors.js +1 -2
- package/dist/nodefony/service/csrf.js +1 -2
- package/dist/nodefony/service/firewall.js +5 -4
- package/dist/nodefony/service/keycloak.js +1 -2
- package/dist/nodefony/service/oauth2.js +29 -7
- package/dist/nodefony/service/securityHeaders.js +1 -2
- package/dist/nodefony/service/tokenService.js +38 -22
- package/dist/nodefony/service/totp.js +4 -5
- package/dist/nodefony/service/webAuthn.js +4 -5
- package/dist/nodefony/service/webhooks.js +4 -5
- package/dist/nodefony/src/RoleHierarchyWalker.js +1 -2
- package/dist/nodefony/src/SecuredArea.js +1 -2
- package/dist/nodefony/src/accountBinding.js +96 -0
- package/dist/nodefony/src/admin/SecurityAdminApi.js +1 -2
- package/dist/nodefony/src/admin/WebhookAdminApi.js +23 -7
- package/dist/nodefony/src/admin/adminAudit.js +1 -2
- package/dist/nodefony/src/admin/userRevocationCascade.js +79 -15
- package/dist/nodefony/src/apikey/apiKeyFormat.js +1 -2
- package/dist/nodefony/src/audit/MemoryAuditStore.js +1 -2
- package/dist/nodefony/src/audit/auditBridge.js +1 -2
- package/dist/nodefony/src/audit/auditFilters.js +1 -2
- package/dist/nodefony/src/audit/auditStoreRegistry.js +1 -2
- package/dist/nodefony/src/audit/readAuditContext.js +1 -2
- package/dist/nodefony/src/audit/recordAudit.js +1 -2
- package/dist/nodefony/src/authenticator/AnonymousAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/ApiKeyAuthenticator.js +3 -2
- package/dist/nodefony/src/authenticator/ExternalJwtAuthenticator.js +8 -113
- package/dist/nodefony/src/authenticator/ExternalSubjectResolver.js +180 -0
- package/dist/nodefony/src/authenticator/FirewallRealtimeAuthenticator.js +42 -4
- package/dist/nodefony/src/authenticator/JwtAuthenticator.js +91 -16
- package/dist/nodefony/src/authenticator/SessionAuthenticator.js +9 -5
- package/dist/nodefony/src/authenticator/UserPasswordAuthenticator.js +1 -2
- package/dist/nodefony/src/authenticator/authenticatorRegistry.js +3 -17
- package/dist/nodefony/src/authenticator/externalSubject.js +1 -2
- package/dist/nodefony/src/authenticator/peekIssuer.js +1 -2
- package/dist/nodefony/src/boot/publicRoutesNotice.js +1 -2
- package/dist/nodefony/src/crypto/secretCipher.js +1 -2
- package/dist/nodefony/src/csp.js +1 -2
- package/dist/nodefony/src/csrfToken.js +1 -2
- package/dist/nodefony/src/entryPoint.js +1 -2
- package/dist/nodefony/src/net/ssrfGuard.js +1 -2
- package/dist/nodefony/src/net/transportFailure.js +57 -0
- package/dist/nodefony/src/oauth/clientCredentials.js +1 -2
- package/dist/nodefony/src/oauth/httpJson.js +3 -3
- package/dist/nodefony/src/oauth/metadata.js +5 -16
- package/dist/nodefony/src/oauth/oauth2Client.js +1 -2
- package/dist/nodefony/src/oauth/oauthProviderRegistry.js +1 -2
- package/dist/nodefony/src/oauth/providerDiagnosis.js +5 -6
- package/dist/nodefony/src/oauth/providerRoles.js +1 -2
- package/dist/nodefony/src/oauth/providers/github.js +16 -6
- package/dist/nodefony/src/oauth/providers/oidc.js +9 -6
- package/dist/nodefony/src/openToSignup.js +1 -2
- package/dist/nodefony/src/realtime/UserRealtimeToken.js +17 -3
- package/dist/nodefony/src/realtime/frameAuthorizer.js +2 -3
- package/dist/nodefony/src/rolesWithin.js +57 -0
- package/dist/nodefony/src/sessionAuthentication.js +1 -2
- package/dist/nodefony/src/sessionIdentity.js +29 -3
- package/dist/nodefony/src/throttle/LoginThrottler.js +1 -2
- package/dist/nodefony/src/token/AnonymousToken.js +1 -2
- package/dist/nodefony/src/token/JwtKeystore.js +14 -5
- package/dist/nodefony/src/token/MemoryTokenStore.js +1 -2
- package/dist/nodefony/src/token/RemoteJwtVerifier.js +6 -3
- package/dist/nodefony/src/token/UserToken.js +1 -2
- package/dist/nodefony/src/token/jwtRuntime.js +1 -2
- package/dist/nodefony/src/token/scopeGrants.js +109 -0
- package/dist/nodefony/src/token/secretFile.js +1 -2
- package/dist/nodefony/src/token/tokenCriteria.js +1 -2
- package/dist/nodefony/src/token/tokenFilters.js +1 -2
- package/dist/nodefony/src/token/tokenSort.js +1 -2
- package/dist/nodefony/src/token/tokenStatus.js +1 -2
- package/dist/nodefony/src/token/tokenStoreRegistry.js +1 -2
- package/dist/nodefony/src/totp/MemoryTotpSecretStore.js +1 -2
- package/dist/nodefony/src/totp/totpCipher.js +1 -2
- package/dist/nodefony/src/totp/totpCrypto.js +1 -2
- package/dist/nodefony/src/totp/totpOperations.js +1 -2
- package/dist/nodefony/src/totp/totpSecretStoreRegistry.js +1 -2
- package/dist/nodefony/src/transport/CredentialTransportPolicy.js +1 -2
- package/dist/nodefony/src/voter/RoleVoter.js +1 -2
- package/dist/nodefony/src/voter/ScopeVoter.js +1 -2
- package/dist/nodefony/src/voter/voterRegistry.js +1 -2
- package/dist/nodefony/src/webauthn/MemoryWebAuthnCredentialStore.js +1 -2
- package/dist/nodefony/src/webauthn/webAuthnCredentialStoreRegistry.js +1 -2
- package/dist/nodefony/src/webhook/MemoryWebhookStore.js +1 -2
- package/dist/nodefony/src/webhook/WebhookDispatcher.js +1 -2
- package/dist/nodefony/src/webhook/webhookCipher.js +1 -2
- package/dist/nodefony/src/webhook/webhookDelivery.js +1 -2
- package/dist/nodefony/src/webhook/webhookFilters.js +1 -2
- package/dist/nodefony/src/webhook/webhookSignature.js +1 -2
- package/dist/nodefony/src/webhook/webhookSort.js +1 -2
- package/dist/nodefony/src/webhook/webhookStoreRegistry.js +1 -2
- package/dist/nodefony/testing/index.js +108 -0
- package/dist/types/nodefony/command/{security-token.d.ts → security-mcp-token.d.ts} +7 -6
- package/dist/types/nodefony/config/config.d.ts +1 -0
- package/dist/types/nodefony/errors/ApiKeyError.d.ts +3 -1
- package/dist/types/nodefony/service/accessTokenVerifier.d.ts +35 -14
- package/dist/types/nodefony/service/apiKeys.d.ts +13 -2
- package/dist/types/nodefony/service/oauth2.d.ts +20 -0
- package/dist/types/nodefony/src/accountBinding.d.ts +82 -0
- package/dist/types/nodefony/src/admin/userRevocationCascade.d.ts +23 -5
- package/dist/types/nodefony/src/authenticator/ExternalJwtAuthenticator.d.ts +2 -44
- package/dist/types/nodefony/src/authenticator/ExternalSubjectResolver.d.ts +99 -0
- package/dist/types/nodefony/src/authenticator/FirewallRealtimeAuthenticator.d.ts +4 -1
- package/dist/types/nodefony/src/authenticator/JwtAuthenticator.d.ts +37 -1
- package/dist/types/nodefony/src/net/transportFailure.d.ts +22 -0
- package/dist/types/nodefony/src/oauth/httpJson.d.ts +0 -8
- package/dist/types/nodefony/src/realtime/UserRealtimeToken.d.ts +7 -1
- package/dist/types/nodefony/src/realtime/frameAuthorizer.d.ts +2 -2
- package/dist/types/nodefony/src/realtime/realtimeContracts.d.ts +6 -0
- package/dist/types/nodefony/src/rolesWithin.d.ts +27 -0
- package/dist/types/nodefony/src/sessionIdentity.d.ts +22 -0
- package/dist/types/nodefony/src/token/scopeGrants.d.ts +77 -0
- package/dist/types/nodefony/testing/index.d.ts +51 -0
- package/docs/api-keys.md +181 -174
- package/docs/audit.md +140 -140
- package/docs/authenticators.md +101 -101
- package/docs/authorization.md +116 -69
- package/docs/cors.md +64 -64
- package/docs/csrf.md +74 -71
- package/docs/external-jwt.md +22 -12
- package/docs/firewall.md +85 -82
- package/docs/headers.md +81 -81
- package/docs/index.md +4 -4
- package/docs/keycloak.md +95 -78
- package/docs/login-page.md +23 -23
- package/docs/oauth2.md +157 -146
- package/docs/obtenir-un-jeton.md +116 -26
- package/docs/tokens.md +128 -122
- package/docs/totp.md +136 -136
- package/docs/webauthn.md +153 -153
- package/docs/webhooks.md +136 -124
- package/package.json +19 -13
- package/dist/_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js +0 -6
package/docs/csrf.md
CHANGED
|
@@ -91,13 +91,13 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
91
91
|
## La vision Nodefony
|
|
92
92
|
|
|
93
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
|
|
94
|
+
couche 1 est la défense **par défaut**, `csrf.enabled: true` (`config.ts#areaSchema`).
|
|
95
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
|
|
96
|
+
branchée dans le pipeline HTTP — l'appel `enforceCsrf` (`http-kernel.ts#HttpKernel.armAndGuard`) arrive **après** le
|
|
97
97
|
resolve (les marqueurs de route sont lisibles) et **avant** la session (rejet précoce : un
|
|
98
98
|
attaquant ne coûte ni lecture de session ni authentification).
|
|
99
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
|
|
100
|
+
testable sans serveur, instanciée une fois au boot (`csrf.ts#Csrf`).
|
|
101
101
|
- La couche 2 (`@CsrfProtect`) est la ceinture-et-bretelles des mutations à haute valeur ; la
|
|
102
102
|
couche 3 côté cookies (attribut `SameSite`) reste portée par les émetteurs de cookies.
|
|
103
103
|
|
|
@@ -107,11 +107,11 @@ contrôleur** : l'attaque meurt sans avoir touché ton code.
|
|
|
107
107
|
|
|
108
108
|
Rien à écrire ni à configurer : toute mutation dont la provenance est un site tiers reçoit **403**,
|
|
109
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
|
|
110
|
+
cookies d'une victime, ils sont hors vecteur (`csrf.ts#Csrf.enforce`).
|
|
111
111
|
|
|
112
112
|
### Opt-in couche 2 : `@CsrfProtect` sur une mutation à haute valeur
|
|
113
113
|
|
|
114
|
-
Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts
|
|
114
|
+
Les décorateurs `CsrfProtect`/`CsrfExempt` sont exportés par `@nodefony/framework` (`framework/index.ts`) :
|
|
115
115
|
|
|
116
116
|
```typescript
|
|
117
117
|
// nodefony/controllers/ProfileController.ts — complet, compile tel quel
|
|
@@ -148,13 +148,16 @@ export default ProfileController;
|
|
|
148
148
|
|
|
149
149
|
(Wiring : `@controllers([ProfileController])` dans le module de l'app — `nodefony create controller`
|
|
150
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
|
|
151
|
+
`csrfProtect`/`csrfExempt` acceptent méthode OU classe, `routerDecorators.ts#computeActionMeta`.)
|
|
152
152
|
|
|
153
153
|
### Comment le front obtient — puis rejoue — le token
|
|
154
154
|
|
|
155
|
-
1. **Obtenir** :
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
1. **Obtenir** : `GET /nodefony/security/api/auth/me` sème TOUJOURS le token — c'est la requête sûre
|
|
156
|
+
que tout client connaît, et elle évite de décorer une lecture pour le seul plaisir d'en obtenir
|
|
157
|
+
un (le token est sans état : celui-ci vaut pour toute route protégée). Plus généralement, une
|
|
158
|
+
requête **sûre** (GET) vers n'importe quelle route `@CsrfProtect` sème le token
|
|
159
|
+
(`firewall.ts#Firewall.#recordAuth`) ; la réponse pose le cookie **lisible** `csrf-token` — non `HttpOnly`
|
|
160
|
+
exprès, `SameSite=Strict`, `Secure` en HTTPS (`HttpContext.writeHead()`, `HttpContext.ts#HttpContext.writeHead`).
|
|
158
161
|
2. **Rejouer** : le SPA lit le cookie et renvoie sa valeur **à l'identique** dans l'en-tête
|
|
159
162
|
`x-csrf-token` sur chaque mutation.
|
|
160
163
|
|
|
@@ -199,8 +202,8 @@ curl -si -b /tmp/jar -H "x-csrf-token: $TOKEN" \
|
|
|
199
202
|
> [!IMPORTANT]
|
|
200
203
|
> En **prod/cluster**, le secret du synchronizer doit être **fixé et partagé entre process** —
|
|
201
204
|
> 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
|
|
203
|
-
> `npx nodefony security:secrets` (`security-secrets.ts
|
|
205
|
+
> chaque pod rejette les tokens des autres (`firewall.ts#Firewall.#build`). Générer et câbler :
|
|
206
|
+
> `npx nodefony security:secrets` (`security-secrets.ts#ROLES`) → `NF_CSRF_SECRET` →
|
|
204
207
|
> `use("@nodefony/security", { csrf: { secret: ctx.env.NF_CSRF_SECRET } })`.
|
|
205
208
|
|
|
206
209
|
## ⚙️ Choisir sa défense — trois situations
|
|
@@ -241,131 +244,131 @@ autorisation :
|
|
|
241
244
|
```
|
|
242
245
|
|
|
243
246
|
> [!WARNING]
|
|
244
|
-
> `@CsrfExempt` (`routerDecorators.ts
|
|
247
|
+
> `@CsrfExempt` (`routerDecorators.ts#CsrfExempt`) est un opt-out **ciblé CSRF**. Ne jamais « débloquer un
|
|
245
248
|
> webhook » avec `@BypassFirewall`/`@Anonymous` : eux désactivent l'authentification de la zone.
|
|
246
249
|
|
|
247
250
|
Cas voisin — **façade multi-domaine** (`www.example.com` poste vers l'API d'un autre domaine à toi) :
|
|
248
251
|
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
|
|
252
|
+
ouvrirait **aussi** la lecture des réponses au JS tiers (`config.ts#corsSchema`).
|
|
250
253
|
|
|
251
254
|
## 🏗️ Architecture interne
|
|
252
255
|
|
|
253
256
|
### Couche 1 — la chaîne de décision (`Csrf.enforce()`)
|
|
254
257
|
|
|
255
|
-
`Csrf.enforce()` (`csrf.ts
|
|
256
|
-
sûres → coût nul sur le GET dominant (`csrf.ts
|
|
258
|
+
`Csrf.enforce()` (`csrf.ts#Csrf.enforce`) est **pure, synchrone, zéro I/O**, no-op immédiat sur les méthodes
|
|
259
|
+
sûres → coût nul sur le GET dominant (`csrf.ts#Csrf.enforce`). Pour une mutation, dans l'ordre :
|
|
257
260
|
|
|
258
261
|
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
|
|
260
|
-
firewall au boot, `firewall.ts
|
|
262
|
+
que CORS autorise déjà explicitement **n'est pas** du CSRF (`csrf.ts#Csrf`, union construite par le
|
|
263
|
+
firewall au boot, `firewall.ts#Firewall.#build`).
|
|
261
264
|
2. **Fetch Metadata** (`Sec-Fetch-Site`, infalsifiable par un script) : `same-origin`/`none` → OK ;
|
|
262
|
-
`same-site` → OK sauf `strictSameSite` (`csrf.ts
|
|
263
|
-
inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts
|
|
265
|
+
`same-site` → OK sauf `strictSameSite` (`csrf.ts#Csrf.enforce`) ; `cross-site` → **403** ; valeur
|
|
266
|
+
inconnue → on **délègue au repli** (forward-compat, le W3C dit « SHOULD ignore », `csrf.ts#Csrf.enforce`).
|
|
264
267
|
3. **Repli `Origin`/`Referer`** (vieux navigateur, ou `Sec-Fetch-Site` absent) : **aucune** des
|
|
265
268
|
deux → client non-navigateur, hors vecteur → OK ; sinon **same-host** exigé, mismatch → **403**
|
|
266
|
-
(`csrf.ts
|
|
269
|
+
(`csrf.ts#Csrf.enforce`).
|
|
267
270
|
|
|
268
271
|
Lectures durcies côté firewall :
|
|
269
272
|
|
|
270
273
|
- 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
|
|
272
|
-
parse du contexte (`cookieValue()`, `firewall.ts
|
|
274
|
+
`headerValue()`, `firewall.ts#headerValue`) ; cookie extrait de l'en-tête **brut**, sans dépendre du
|
|
275
|
+
parse du contexte (`cookieValue()`, `firewall.ts#cookieValue`) ;
|
|
273
276
|
- hôte cible **brut avec port** — `:authority` en HTTP/2, `context.domain` en dernier recours
|
|
274
|
-
(`firewall.ts
|
|
277
|
+
(`firewall.ts#Firewall`) ;
|
|
275
278
|
- 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
|
|
279
|
+
whitelist) ne fuite jamais au client (`CsrfError.ts#CsrfError`).
|
|
277
280
|
|
|
278
281
|
### Couche 2 — le token signé (`CsrfTokenManager`)
|
|
279
282
|
|
|
280
|
-
`CsrfTokenManager` (`csrfToken.ts
|
|
283
|
+
`CsrfTokenManager` (`csrfToken.ts#CsrfTokenManager`) implémente le **signed double-submit** (OWASP Cheat Sheet) :
|
|
281
284
|
|
|
282
|
-
- token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts
|
|
283
|
-
émis par `issue()` (`csrfToken.ts
|
|
285
|
+
- token = `nonce.HMAC-SHA256(secret, nonce)` en base64url — nonce de 144 bits (`csrfToken.ts#CsrfTokenManager.#NONCE_BYTES`),
|
|
286
|
+
émis par `issue()` (`csrfToken.ts#CsrfTokenManager.issue`) ;
|
|
284
287
|
- `verify()` exige en-tête **et** cookie **présents**, **égaux** (double-submit) et la **signature
|
|
285
|
-
valide** (`csrfToken.ts
|
|
286
|
-
(`csrfToken.ts
|
|
288
|
+
valide** (`csrfToken.ts#CsrfTokenManager.verify`) — comparaisons à **temps constant**, jamais d'exception
|
|
289
|
+
(`csrfToken.ts#CsrfTokenManager.#timingSafeEqual`).
|
|
287
290
|
|
|
288
291
|
Pourquoi ça tient : le secret HMAC empêche un script tiers de **forger** un token (il ne peut pas
|
|
289
292
|
calculer la signature) ; le double-submit l'empêche d'en **injecter** un (il ne peut ni écrire
|
|
290
293
|
l'en-tête custom — préflight CORS — ni lire le cookie de la victime — SameSite + SOP). Et c'est
|
|
291
294
|
**stateless** : aucune session requise → couvre le BFF web **et** l'API JWT sans coupler au stockage
|
|
292
|
-
de session (TSDoc `CsrfTokenManager`, `csrfToken.ts
|
|
295
|
+
de session (TSDoc `CsrfTokenManager`, `csrfToken.ts#CsrfTokenManager`).
|
|
293
296
|
|
|
294
297
|
### Le câblage dans le pipeline (du décorateur au 403)
|
|
295
298
|
|
|
296
299
|
1. `@CsrfProtect`/`@CsrfExempt` posent un **marqueur** de metadata — zéro import de
|
|
297
|
-
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts
|
|
300
|
+
`@nodefony/security` côté framework, zéro cycle (`routerDecorators.ts#resolveSessionIntent`).
|
|
298
301
|
2. Au match de la route, `Resolver.match()` recopie les marqueurs sur le contexte
|
|
299
|
-
(`Resolver.ts
|
|
300
|
-
(`Context.ts
|
|
301
|
-
3. `Firewall.enforceCsrf()` (`firewall.ts
|
|
302
|
+
(`Resolver.ts#Resolver.match`) — champs portés par le `Context` de base, HTTP comme WS
|
|
303
|
+
(`Context.ts#Context`).
|
|
304
|
+
3. `Firewall.enforceCsrf()` (`firewall.ts#Firewall.enforceCsrf`) fait les trois rôles : **émission** du token sur
|
|
302
305
|
requête sûre `@CsrfProtect`, **couche 1** sur toute mutation, **couche 2** en plus si
|
|
303
306
|
`@CsrfProtect`. Seules les `@CsrfExempt` sortent, après la barrière méthode sûre
|
|
304
|
-
(`firewall.ts
|
|
307
|
+
(`firewall.ts#Firewall.enforceCsrf`). Une route `bypassFirewall` n'est **pas** exemptée : le bypass retire
|
|
305
308
|
l'authentification, pas la provenance — les routes de connexion sont en bypass, et c'est là
|
|
306
309
|
qu'un formulaire tiers ouvrirait une session sur le compte de l'attaquant (login CSRF,
|
|
307
|
-
CWE-352, `firewall.ts
|
|
310
|
+
CWE-352, `firewall.ts#Firewall.enforceCsrf`).
|
|
308
311
|
4. `HttpContext.writeHead()` matérialise `context.csrfToken` en cookie `csrf-token` — flush groupé
|
|
309
|
-
avec le cookie de session (`HttpContext.ts
|
|
312
|
+
avec le cookie de session (`HttpContext.ts#HttpContext`).
|
|
310
313
|
|
|
311
|
-
## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts
|
|
314
|
+
## ⚙️ Configuration (schéma Zod `csrfSchema`, `config.ts#csrfSchema`)
|
|
312
315
|
|
|
313
316
|
<!-- prettier-ignore -->
|
|
314
317
|
| Option | Type · défaut | Effet |
|
|
315
318
|
| --- | --- | --- |
|
|
316
|
-
| `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts
|
|
317
|
-
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts
|
|
318
|
-
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts
|
|
319
|
-
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts
|
|
320
|
-
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts
|
|
321
|
-
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts
|
|
322
|
-
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts
|
|
319
|
+
| `enabled` | boolean · `true` | Active toute la défense — couches 1 **et** 2 (`config.ts#areaSchema`). |
|
|
320
|
+
| `fetchMetadata` | boolean · `true` | Défense primaire `Sec-Fetch-Site` (`config.ts`). |
|
|
321
|
+
| `checkOrigin` | boolean · `true` | Repli `Origin`/`Referer` same-host pour les navigateurs sans `Sec-Fetch-*` (`config.ts#corsSchema`). |
|
|
322
|
+
| `strictSameSite` | boolean · `false` | `true` = refuser aussi `same-site` (sous-domaine non maîtrisé / multi-tenant) — distinct de l'attribut cookie (`config.ts#csrfSchema`). |
|
|
323
|
+
| `sameSite` | enum · `Lax` | **Déclaratif** : surfacé dans l'introspection (`firewall.ts#Firewall.#describeDefenses`) ; l'attribut effectif du cookie `csrf-token` est `Strict` en dur (`HttpContext.ts#HttpContext.writeHead`). |
|
|
324
|
+
| `trustedOrigins` | string[] · `[]` | Alias **exacts** (`scheme://host[:port]`) autorisés même cross-site — sans ouvrir la lecture CORS (`config.ts#csrfSchema`). |
|
|
325
|
+
| `secret` | string ≥ 16 car. · — | Secret HMAC du synchronizer — PROD : via env, **partagé cluster** ; absent = éphémère dev (`config.ts#csrfSchema`). |
|
|
323
326
|
|
|
324
327
|
## 📜 Normes appliquées
|
|
325
328
|
|
|
326
|
-
| Domaine | Norme | Ancrage
|
|
327
|
-
| ------------------------------ | --------------------------------- |
|
|
328
|
-
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts
|
|
329
|
-
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts
|
|
330
|
-
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts
|
|
331
|
-
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts
|
|
332
|
-
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts
|
|
333
|
-
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts
|
|
334
|
-
| Cookies (SameSite) | RFC 6265bis §8.8.1 | TSDoc `Csrf` (`csrf.ts
|
|
329
|
+
| Domaine | Norme | Ancrage |
|
|
330
|
+
| ------------------------------ | --------------------------------- | ---------------------------------------------------- |
|
|
331
|
+
| Méthodes sûres | RFC 9110 §9.2.1 | `SAFE_METHODS` (`csrf.ts#SAFE_METHODS`) |
|
|
332
|
+
| Provenance | W3C Fetch Metadata | `Csrf.enforce()` (`csrf.ts#Csrf.enforce`) |
|
|
333
|
+
| Valeur `site` inconnue → repli | Fetch Metadata « SHOULD ignore » | `csrf.ts#Csrf.enforce` |
|
|
334
|
+
| Token signé | OWASP Signed Double-Submit Cookie | `CsrfTokenManager` (`csrfToken.ts#CsrfTokenManager`) |
|
|
335
|
+
| Refus 403 | RFC 9110 §15.5.4 | `CsrfError` (`CsrfError.ts#CsrfError`) |
|
|
336
|
+
| Modèle de référence | Go 1.25 `CrossOriginProtection` | TSDoc `Csrf` (`csrf.ts#Csrf`) |
|
|
337
|
+
| Cookies (SameSite) | RFC 6265bis §8.8.1 | TSDoc `Csrf` (`csrf.ts#Csrf`) |
|
|
335
338
|
|
|
336
339
|
## ⚡ Performance & mémoire
|
|
337
340
|
|
|
338
|
-
- **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts
|
|
339
|
-
route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts
|
|
341
|
+
- **GET = 0** : retour immédiat avant toute lecture d'en-tête (`csrf.ts#Csrf.enforce`) ; seule exception, une
|
|
342
|
+
route `@CsrfProtect` mint le token **une fois** (skip si déjà posé, `firewall.ts#Firewall.enforceCsrf`).
|
|
340
343
|
- **Zéro microtask** : la chaîne est synchrone de bout en bout (pas d'`async` pour du pur calcul).
|
|
341
344
|
- **Lazy** : `#csrf`/`#csrfTokens` restent `null` si la défense est désactivée — aucune structure
|
|
342
|
-
allouée « au cas où » (`firewall.ts
|
|
345
|
+
allouée « au cas où » (`firewall.ts#Firewall`).
|
|
343
346
|
- Le coût HMAC (1 à l'émission, 1 à la vérif) n'est payé **que** sur les routes `@CsrfProtect` ; les
|
|
344
|
-
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts
|
|
347
|
+
marqueurs sont lus depuis le memo de route — 0 `Reflect` par requête (`Resolver.ts#Resolver.match`).
|
|
345
348
|
|
|
346
349
|
## 📡 Observabilité — Studio
|
|
347
350
|
|
|
348
351
|
L'écran **Firewall** de Studio expose la défense dans son onglet Défenses (`FirewallDefenses`,
|
|
349
|
-
`Firewall.tsx
|
|
350
|
-
`Firewall.#describeDefenses()` (`firewall.ts
|
|
351
|
-
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts
|
|
352
|
+
`Firewall.tsx#Firewall`). La projection est **sans secret par construction** :
|
|
353
|
+
`Firewall.#describeDefenses()` (`firewall.ts#Firewall.#describeDefenses`) publie la config résolue, et `synchronizerToken`
|
|
354
|
+
n'est que la **présence** du secret armé — jamais sa valeur (`firewall.ts#Firewall.#describeDefenses`).
|
|
352
355
|
|
|
353
356
|
## ⚠️ Pièges (symptôme → cause → correction)
|
|
354
357
|
|
|
355
|
-
| Symptôme | Cause (dans le code)
|
|
356
|
-
| ---------------------------------------------- |
|
|
357
|
-
| Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré
|
|
358
|
-
| Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts
|
|
359
|
-
| `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts
|
|
360
|
-
| Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts
|
|
361
|
-
| `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts
|
|
362
|
-
| `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts
|
|
363
|
-
| Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist
|
|
358
|
+
| Symptôme | Cause (dans le code) | Correction |
|
|
359
|
+
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- |
|
|
360
|
+
| Mutation légitime cross-domaine bloquée en 403 | Domaine alias non déclaré | Ajouter l'origine à `csrf.trustedOrigins` (ou CORS si lecture voulue) |
|
|
361
|
+
| Client non-navigateur (curl/CI) refusé | N'arrive pas sur une route non décorée : ni Fetch Metadata ni `Origin` → passe (`csrf.ts#ICsrfRequest.origin`) | Attendu ; sur `@CsrfProtect`, semer le token (GET) avant la mutation |
|
|
362
|
+
| `@CsrfProtect` échoue en 403 côté SPA | En-tête `x-csrf-token` non rejoué, ou ≠ cookie (`firewall.ts#Firewall.handleSecurity`) | Relire le cookie `csrf-token` et le rejouer à l'identique |
|
|
363
|
+
| Tokens invalidés au redémarrage / entre pods | `csrf.secret` absent → secret éphémère par process (`firewall.ts#Firewall.#build`) | Fixer `csrf.secret` (≥ 16 car., partagé cluster) — `security:secrets` |
|
|
364
|
+
| `same-site` refusé alors qu'attendu OK | `strictSameSite` activé (`csrf.ts#Csrf.enforce`) | Le désactiver si les sous-domaines sont de confiance |
|
|
365
|
+
| `http://` accepté par le repli (même hôte) | Le repli compare l'**hôte seul**, jamais le scheme (`Csrf.#sameHost()`, `csrf.ts#Csrf.#sameHost`) | Limite documentée (banc red-team) ; Fetch Metadata prime sur nav. moderne |
|
|
366
|
+
| Webhook provider bloqué en 403 | POST cross-site légitime, hors whitelist | `@CsrfExempt` sur la route — jamais `@BypassFirewall` |
|
|
364
367
|
|
|
365
368
|
> [!TIP]
|
|
366
369
|
> Un banc d'intégration **live** du repo exerce exactement ce flow (émission GET, double-submit,
|
|
367
370
|
> exemption) sur les routes `/csrf/token`, `/csrf/submit` et `/csrf/webhook` du module de test
|
|
368
|
-
> (`FrameworkController.ts
|
|
371
|
+
> (`FrameworkController.ts#FrameworkController.csrfWebhook`) — la référence exécutable si un comportement te surprend.
|
|
369
372
|
|
|
370
373
|
## 🧪 Tests & couverture
|
|
371
374
|
|
package/docs/external-jwt.md
CHANGED
|
@@ -33,9 +33,9 @@ Accepter un émetteur ne veut pas dire lui remettre les clés. Deux décisions r
|
|
|
33
33
|
sont elles qui font la différence entre « intégrer un annuaire » et « en faire l'unique autorité
|
|
34
34
|
d'accès » :
|
|
35
35
|
|
|
36
|
-
1. **La liste des émetteurs acceptés** est une allowlist (`trustedIssuerSchema`, `config.ts
|
|
36
|
+
1. **La liste des émetteurs acceptés** est une allowlist (`trustedIssuerSchema`, `config.ts#trustedIssuerSchema`). Un jeton dont l'`iss` n'y figure pas est
|
|
37
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
|
|
38
|
+
2. **Le sujet du jeton ne devient pas d'office un utilisateur** (`config.ts#remoteJwtAlgSchema`). Par défaut, il
|
|
39
39
|
doit correspondre à un compte local. Un annuaire d'entreprise vaut pour des milliers de personnes : les accepter
|
|
40
40
|
toutes parce que leur jeton est valide supprimerait la seconde décision, qui est la raison d'être
|
|
41
41
|
du pare-feu.
|
|
@@ -89,7 +89,7 @@ normalisés (RFC 8414 / OpenID), et le pare-feu accepte désormais un `Authoriza
|
|
|
89
89
|
Un `sub` n'est unique que **dans l'espace de son émetteur** (OIDC Core §2). L'identité est donc la
|
|
90
90
|
paire `(émetteur, sujet)`, jamais le sujet seul.
|
|
91
91
|
|
|
92
|
-
En mode `prefixed` (`config.ts
|
|
92
|
+
En mode `prefixed` (`config.ts#trustedIssuerSchema`), l'identifiant cherché localement est `<issuer>#<sub>` : deux émetteurs ne peuvent
|
|
93
93
|
pas se disputer un compte, et surtout **aucun sujet étranger ne peut tomber par hasard sur un
|
|
94
94
|
identifiant local existant**. En mode `subject`, le `sub` est cherché tel quel — dans un annuaire où
|
|
95
95
|
l'utilisateur choisit son identifiant, quelqu'un peut alors se présenter avec `sub: "admin"` et être
|
|
@@ -104,7 +104,7 @@ parce qu'il **est** cette application, ou parce que ses sujets sont déjà des i
|
|
|
104
104
|
Absent, désactivé ou verrouillé : l'accès est refusé. C'est le mode d'une application dont les
|
|
105
105
|
utilisateurs existent chez elle, l'émetteur ne servant qu'à les authentifier.
|
|
106
106
|
- **`ephemeral`** — aucun compte local n'est exigé ni créé ; l'appelant vit le temps de la requête
|
|
107
|
-
avec les rôles d'`ephemeralRoles` (`config.ts
|
|
107
|
+
avec les rôles d'`ephemeralRoles` (`config.ts#trustedIssuerSchema`). C'est le mode de l'appelant **purement machine** : un agent, un
|
|
108
108
|
service. Sans rôle déclaré, il ne passe aucun `@IsGranted` et n'est autorisé que par ses **scopes**
|
|
109
109
|
— qui viennent du jeton, donc bornés par le serveur d'autorisation.
|
|
110
110
|
|
|
@@ -145,12 +145,16 @@ Trois règles, toutes vérifiées au boot ou au rattachement :
|
|
|
145
145
|
|
|
146
146
|
## Comment ça s'articule
|
|
147
147
|
|
|
148
|
-
Le service `accessTokenVerifier`
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
148
|
+
Le service `accessTokenVerifier` est posé au conteneur dès qu'il a **quelque chose à vérifier** :
|
|
149
|
+
les jetons de l'application elle-même (capacité `jwt` active, le défaut), et ceux des émetteurs de
|
|
150
|
+
`issuers`. Il aiguille sur l'émetteur revendiqué : celui de l'application est jugé par les règles de
|
|
151
|
+
l'authentificateur `jwt` (révocation et compte compris), tout autre par cette liste fermée — règle
|
|
152
|
+
complète et contrôles, dans l'ordre : [Ce que la porte vérifie](obtenir-un-jeton.md#-ce-que-la-porte-vérifie--un-jeton-de-cette-application-face-à-une-ressource).
|
|
153
|
+
Capacité `jwt` coupée ET `issuers` vide : rien n'est posé, et une porte protégée **refuse de servir**
|
|
154
|
+
(`503`) plutôt que d'accepter des porteurs qu'elle ne sait pas lire. `issuers` mal configurée :
|
|
155
|
+
rien n'est posé non plus, jetons de l'application compris.
|
|
152
156
|
|
|
153
|
-
L'authentificateur `external-jwt` (`authenticatorRegistry.ts
|
|
157
|
+
L'authentificateur `external-jwt` (`authenticatorRegistry.ts`) reconnaît les jetons qui le
|
|
154
158
|
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.
|
|
155
159
|
La liste sert donc à deux choses : router, et dire dans quel espace de noms lire le sujet.
|
|
156
160
|
|
|
@@ -173,11 +177,13 @@ et son secret. Le framework le porte, pour qu'aucun appelant ne recopie la plomb
|
|
|
173
177
|
|
|
174
178
|
```ts
|
|
175
179
|
import { requestClientCredentialsToken } from "@nodefony/security";
|
|
180
|
+
// `env.ts` est le seul lecteur de l'environnement : le secret y est déclaré (NF_KEYCLOAK_MACHINE_SECRET).
|
|
181
|
+
import { env } from "../../env";
|
|
176
182
|
|
|
177
183
|
const tokens = await requestClientCredentialsToken({
|
|
178
184
|
issuer: "https://sso.example.com/realms/acme", // le point de jeton en est DÉCOUVERT (RFC 8414)
|
|
179
185
|
clientId: "acme-machine",
|
|
180
|
-
clientSecret:
|
|
186
|
+
clientSecret: env.NF_KEYCLOAK_MACHINE_SECRET ?? "",
|
|
181
187
|
resource: "https://app.example/api/partner", // audience visée (RFC 8707), facultative
|
|
182
188
|
});
|
|
183
189
|
await fetch("https://app.example/api/partner/whoami", {
|
|
@@ -215,8 +221,12 @@ l'audience à demander.
|
|
|
215
221
|
l'aiguillage se fait sur l'`iss` du jeton. Inutile de les ranger « dans le bon ordre ».
|
|
216
222
|
- **`HS256` est impossible en configuration**, et ce n'est pas un oubli : un secret partagé ferait de
|
|
217
223
|
l'application un émetteur autant qu'un vérificateur. Seules des clés publiques sont acceptées.
|
|
218
|
-
- **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut
|
|
219
|
-
|
|
224
|
+
- **`issuers` vide n'est pas « désactivé par erreur »** : c'est le défaut. La zone `external-jwt`
|
|
225
|
+
ne prend alors AUCUN jeton — elle n'aiguille que les émetteurs qu'elle déclare —, et les jetons de
|
|
226
|
+
l'application restent lisibles par le vérificateur pour les ressources qui le consultent.
|
|
227
|
+
- **Déclarer l'émetteur de l'application dans `issuers` ne le soustrait pas à la révocation** : ses
|
|
228
|
+
jetons sont aiguillés vers la vérification locale avant toute liste, donc un jeton révoqué ou
|
|
229
|
+
celui d'un compte désactivé est refusé, déclaré ou non.
|
|
220
230
|
|
|
221
231
|
## 📖 Lexique
|
|
222
232
|
|