Nodefony 10.0.0-beta.5
Framework Node.js fullstack — HTTP & WebSocket, même contexte

Firewall — le pare-feu applicatif

stable@nodefony/securitymis à jour 2026-07-19

Pour chaque requête (HTTP comme WebSocket), le firewall répond à trois questions dans l’ordre : est-ce une zone protégée ? qui es-tu ? as-tu le droit ? La politique par défaut est Zero Trust : sur une zone protégée, pas de preuve d’identité valide = 401. Ancré sur src/packages/@nodefony/security/nodefony/service/firewall.ts et les authenticators de nodefony/src/authenticator/.

📍 Documentation › Sécurité › Firewall

🧠 Le modèle mental — chemin chaud, chemin froid#

Le firewall sépare détecter (chaud, sur chaque requête) et décider (froid, seulement zone protégée) — pour ne pas payer l’authentification sur les routes publiques.

Firewall.isSecure() (firewall.ts#Firewall.isSecure) rattache la requête à une zone via Firewall.matchPath() (firewall.ts#Firewall.matchPath) ; Firewall.handleSecurity() (firewall.ts#Firewall.handleSecurity) décide. Les zones sont triées par spécificité dans #build() — list.sort par longueur de motif : le plus long gagne, pas le premier déclaré (firewall.ts#Firewall.#build).

📖 Lexique#

Terme Sens
Zone Un motif d’URL (+ host) avec sa politique (config.areas, un objet par nom).
Authenticator Une stratégie d’identification (session, userpassword, jwt, apikey, anonymous).
Zero Trust Sans preuve valide sur une zone protégée → 401.
Challenge En-tête WWW-Authenticate (RFC 9110 §11) qui dit comment s’authentifier.
BFF Backend-For-Frontend : le serveur gère session/jetons pour le front web.
PAT Personal Access Token : une clé d’API opaque, révocable côté serveur.
Bearer Schéma Authorization: Bearer <token> (RFC 6750).

🚀 Démarrage rapide#

Dans une app nodefony create app, le firewall est DÉJÀ actif#

Le scaffold déclare trois zones (main, secure, machine) dans nodefony/config/security.ts, importé par nodefony.config.ts ; en voici deux — c’est la forme canonique (un objet par nom, validé Zod au boot : areas: z.record(...), config.ts#securityConfigSchema) :

// nodefony/config/security.ts
import type { ISecurityConfigInput } from "@nodefony/security";

export const securityConfig = () =>
  ({
    areas: {
      // Zone de TES routes : `session` PUIS `anonymous` → identifié si cookie,
      // sinon visiteur accepté. Hors zone, l'identité n'est JAMAIS résolue.
      main: {
        pattern: "^/api",
        authenticators: ["session", "anonymous"],
      },
      // Zone PROTÉGÉE — pattern PLUS SPÉCIFIQUE que ^/api : le firewall trie
      // par longueur → /api/secure/* tombe ICI. Pas d'`anonymous` : sans
      // session → 401 AVANT ton controller (Zero Trust).
      secure: {
        pattern: "^/api/secure",
        authenticators: ["session"],
      },
    },
    roleHierarchy: {
      ROLE_NODEFONY_ADMIN: ["ROLE_ADMIN", "ROLE_SUPERVISOR", "ROLE_DEV"],
      ROLE_ADMIN: ["ROLE_USER"],
    },
  }) satisfies ISecurityConfigInput;

❗ Important

Hors zone, l’identité n’est JAMAIS résolue — même connecté, une route non couverte par une zone ne sait pas qui tu es. Une route « publique » qui veut connaître l’utilisateur se couvre par ["session", "anonymous"].

Le login est FOURNI : le module security expose le BFF POST /nodefony/security/api/auth/login (body { username, password } → Set-Cookie de session ; AuthFlow.login régénère l’ID de session — anti-fixation OWASP). Pas de LoginController à écrire.

Ce que TU écris : le controller protégé#

// nodefony/controllers/AccountController.ts — complet, compile tel quel
import {
  controller,
  Controller,
  Get,
  IsGranted,
  CurrentUser,
} from "@nodefony/framework";
import type { ContextType } from "@nodefony/http";
import type { IUser } from "@nodefony/user";

@controller("/api/secure/account")
class AccountController extends Controller {
  // Zone `secure` : context.user est GARANTI ici (le firewall a authentifié).
  // @IsGranted ajoute l'AUTORISATION : il faut aussi le rôle.
  @IsGranted(["ROLE_USER"])
  @Get("/me")
  async me(@CurrentUser() user: IUser) {
    // identité ré-résolue à chaque requête → rôles frais, révocation immédiate
    return this.renderJson({ identifier: user.identifier, roles: user.roles });
  }
}

export default AccountController;

(Wiring : @controllers([AccountController]) dans le module de l’app — nodefony create controller le fait pour toi.)

Ce qu’on observe#

# 1) Sans session : Zero Trust → 401 (aucun code à toi n'a tourné)
curl -si http://localhost:5151/api/secure/account/me | head -1
# HTTP/1.1 401 Unauthorized

# 2) Login BFF (compte dev seedé admin/nodefony-dev-42) → cookie de session
curl -si -c /tmp/jar -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"nodefony-dev-42"}' \
  http://localhost:5151/nodefony/security/api/auth/login | head -1
# HTTP/1.1 200 OK

# 3) Rejouer avec le cookie → 200, identité résolue
curl -s -b /tmp/jar http://localhost:5151/api/secure/account/me
# {"identifier":"admin","roles":["ROLE_NODEFONY_ADMIN", …]}

Protéger une API machine (jwt et/ou apikey)#

use("@nodefony/security", {
  areas: {
    // jwt et apikey cohabitent : discriminés par la FORME du bearer (voir plus bas)
    api: {
      pattern: "^/api/v1",
      authenticators: ["jwt", "apikey"],
      mode: "first",
    },
  },
});

Le client envoie l’un ou l’autre :

Authorization: Bearer eyJhbGciOiJFZERTQS␣…␣.␣…␣.␣…        # un JWT (structure a.b.c)
Authorization: Bearer nf_9a2c…                           # une clé API (préfixe nf_)

Protéger des pages : conduire le visiteur à la connexion (entryPoint)#

Sans réglage, un visiteur anonyme qui ouvre une page protégée reçoit un 401 brut. entryPoint lui donne la page de connexion à la place :

use("@nodefony/security", {
  areas: {
    pages: {
      pattern: "^/compte(/|$)",
      authenticators: ["session"],
      entryPoint: "/login", // la page de connexion du framework (loginPage.path)
    },
  },
});
La requête refusée Réponse
GET/HEAD, Accept: text/html, Sec-Fetch-Mode: navigate ou absent 302 → /login?from=%2Fcompte
appel d’API (Accept: application/json), fetch() (Sec-Fetch-Mode: cors) 401 + WWW-Authenticate, inchangé
POST, PUT, DELETE…, WebSocket refus inchangé

🔐 Les authenticators intégrés#

Tous respectent le même contrat (IAuthenticator) : supports(context) (test bon marché : la requête porte-t-elle ce type de credential ?), createToken() (extrait le credential brut), authenticate(token) (valide + promeut, ou lève un 401), challenge() (l’en-tête WWW-Authenticate). Point commun de sécurité : message d’échec uniforme ("Invalid token" / "Invalid credentials") — la cause fine (expiré, révoqué, sujet banni…) part dans l’audit, jamais au client (anti-énumération).

Nom Credential Vérité Révocable Pour…
session cookie de session (identifiant en blob) serveur immédiate le web après login (BFF)
userpassword Authorization: Basic base64(id:mdp) verifier n/a outils/scripts, brique login
jwt Authorization: Bearer <a.b.c> auto-porté via état API service↔service, agents
apikey Authorization: Bearer <prefix>_… serveur immédiate API/CI/scripts d’un user
anonymous (aucun) — — accepter l’anonymat explicitement
sessionla preuve du web après login#

Credential = l’identifiant posé dans le blob de session (jamais un secret).

  • N’ouvre jamais la session lui-même : il exige une session reprise portant un user (supports(), SessionAuthenticator.ts#SessionAuthenticator.supports). C’est AuthFlow.login() (BFF) qui ouvre et régénère l’ID (anti-fixation).
  • L’identité est re-résolue à CHAQUE requête (SessionAuthenticator.ts#SessionAuthenticator.authenticate) → rôles frais, révocation et verrouillage effectifs immédiatement.
  • Pas de challenge() : session absente = 401 nu → le front redirige vers son écran de login (pas de popup Basic).
userpasswordHTTP Basic, avec throttle NIST#

Credential = Authorization: Basic base64(identifiant:motdepasse) — RFC 7617, split au premier : (UserPasswordAuthenticator.ts#UserPasswordAuthenticator.createToken).

  • La vérification est déléguée au IPasswordVerifier (le UserService) : hash, comparaison, leurre anti-timing, re-hash. L’authenticator ne voit que le verdict.
  • Le throttle NIST SP 800-63B passe AVANT le verifier (UserPasswordAuthenticator.ts#UserPasswordAuthenticator.authenticate) : un identifiant bloqué ne coûte aucun hash argon2 — protège d’un DoS par hachage. Échec → backoff ; ThrottledError → 429 + Retry-After.
  • Challenge : Basic realm="nodefony".
  • Piège : le login par formulaire (JSON) n’est pas ici — c’est le BFF (/nodefony/security/api/auth/login). Basic sert l’outillage (scripts, CLI).
jwtBearer JWT signé, durci RFC 8725#

Credential = Authorization: Bearer <jws> de structure compacte a.b.c (JwtAuthenticator.ts#COMPACT_JWS). Réservé API service↔service / agents (le web reste sur la session). Access token EdDSA signé par le keystore du serveur. Défenses dures, prouvées en test (RFC 8725 JWT BCP), toutes dans JwtAuthenticator.authenticate() :

  • allowlist d’algorithmes ["EdDSA"] — l’algo n’est jamais choisi d’après l’en-tête du token ; alg=none rejeté (JwtAuthenticator.ts#JwtAuthenticator.#verify).
  • clé par kid depuis le JWKS LOCAL (createLocalJWKSet) — jamais jku/jwk de l’en-tête (anti-injection de clé / SSRF, JwtAuthenticator.ts#JwtAuthenticator.#ensureGetKey).
  • aud + iss obligatoires + typ:"at+jwt" (un refresh présenté comme access est rejeté) + exp/nbf (JwtAuthenticator.ts#JwtAuthenticator.verifyForResource).
  • révocation malgré l’auto-portage : denylist jti + invalidBefore par sujet (JwtAuthenticator.ts#JwtAuthenticator.#verify).
  • sujet revérifié à réception (loadUserByIdentifier(sub)) : compte disparu/inactif/verrouillé = rejet (JwtAuthenticator.ts#JwtAuthenticator.#verify).

Le token promu porte scopes, jti, claims (JwtAuthenticator.ts#JwtAuthenticator.#verify).

⚠️ Attention

Un JWT est auto-porté : sans état serveur il n’est pas révocable. C’est la denylist jti + invalidBefore (état serveur) qui le rend révocable — vérifie que ton tokenStore les porte.

apikeyclé d’API opaque (PAT), révocable#

Credential = Authorization: Bearer <prefix>_… (ApiKeyAuthenticator.ts#ApiKeyAuthenticator.supports). Contrairement au JWT, c’est un bearer opaque : sa vérité vit côté serveur (ITokenStore) → révocable immédiatement.

Défenses de ApiKeyAuthenticator.authenticate() :

  • forme + CRC validés AVANT tout accès au store — anti-DoS (parseApiKey(), ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate) ;
  • lookup par hash sha256 : le secret n’existe nulle part au repos (ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate) ;
  • révocation (revokedAt), expiration (expiresAt), ban en masse du porteur (invalidBefore vs createdAt, ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate) ;
  • sujet revérifié à chaque requête — rôles frais (ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate) ;
  • lastUsedAt écrit en throttlé — pas une écriture par requête (ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate).

Le token porte scopes, apiKeyId, tenantId (ApiKeyAuthenticator.ts#ApiKeyAuthenticator.authenticate). jwt et apikey cohabitent dans une zone : ils se discriminent par la forme (JWT = a.b.c, PAT = prefix_…).

anonymousaccepter l’anonymat, explicitement#

Le seul authenticator qui produit un token non authentifié sans déclencher le Zero Trust (AnonymousAuthenticator.ts#AnonymousAuthenticator). À lister volontairement : ["jwt", "anonymous"] en mode first = « identifié si preuve présente, sinon visiteur anonyme accepté ». En mode all, utile en dernier : « le canal doit être prouvé (ex. mTLS), l’identité utilisateur est optionnelle ». Coût nul : supports() accepte tout, le token porte le singleton gelé anonymousUser (0 allocation). Sans lui, zone protégée + aucune preuve = 401.

firewall-realtimel’identité du firewall, côté WebSocket (câblé auto)#

Il promeut en jeton realtime toute identité que le firewall a résolue — session BFF comme jeton porteur (JWT, clé d’API). Enregistré automatiquement par Firewall.#wireRealtime() au handshake des zones protégées realtime (firewall.ts#Firewall.#wireRealtime).

  • Perf : il ne relit pas la base. Handshake et frames tournent dans la même bulle ALS — l’identité déjà posée est réutilisée, 2 lectures base économisées par connexion (FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator).
  • Asymétrie HTTP↔WS assumée : le jeton est figé au handshake (les frames lisent un cache O(1)) → une révocation prend effet en une fenêtre (tick du hub, et devant chaque api.request), pas à la frame suivante (FirewallRealtimeAuthenticator.ts#FirewallRealtimeAuthenticator). C’est l’état de l’art (Socket.IO et Phoenix figent aussi).
  • Filet : un revalidator re-lit la session avant chaque action data plane ; fail-closed → fermeture 4001.

🗝️ stateless — la zone tient-elle un registre ?#

Une zone dit, par ce drapeau, où vit l’identité — et ce n’est pas la même chose que la liste de ses authentificateurs.

Valeur Ce que la zone fait Pour qui
false (défaut) la zone peut tenir un registre serveur : session créée au login, cookie opaque révocable un navigateur (modèle BFF)
true la zone n’ouvre ni ne reprend de session — le cookie entrant est ignoré, aucun Set-Cookie n’est renvoyé un porteur de preuve (clé, jeton)

Ce que true évite concrètement : sans lui, un appelant qui envoie un cookie inconnu — un client qui recycle un en-tête, un navigateur qui traîne une vieille session, un attaquant qui en fabrique un — fait reprendre puis réécrire une session serveur et repartir un Set-Cookie, y compris quand la réponse est un 401. Un registre pour quelqu’un qui ne le relira jamais.

🔴 stateless: true et "session" dans la même zone est une contradiction, et l’application REFUSE de démarrer en la nommant (SessionAuthenticator.validateArea). Une zone sert un navigateur ou un porteur de preuve. Si les deux publics doivent atteindre la même fonctionnalité, ce sont deux zones — le firewall trie par longueur de motif, donc la plus spécifique gagne.

Une route qui demande une session (@UseSession) sous une zone stateless ne l’emporte pas : la zone est la déclaration de sécurité, elle gagne, et le journal le dit une fois par zone au lieu de laisser chercher pourquoi context.session est nul.

⚙️ Ordre et modes (mode: "first" vs "all")#

La liste area.authenticators se lit dans l’ordre, déroulée par Firewall.#authenticate() (firewall.ts#Firewall.#authenticate). Le mode dit comment la parcourir. Trois situations concrètes :

Situation 1 — humains ET machines sur la même API (first, le mode courant)#

Ton back-office est appelé par le navigateur des utilisateurs connectés ET par un script CI. Deux preuves différentes, mêmes routes :

back: {
  pattern: "^/api/back",
  authenticators: ["session", "apikey"],
  mode: "first",   // (défaut) le PREMIER qui reconnaît la requête authentifie
},

Ce qui se passe, requête par requête :

Le client envoie… supports() vrai pour… Résultat
le cookie de session session identifié, apikey jamais consulté
Authorization: Bearer nf_… apikey identifié (session ne matche pas, on passe)
une clé révoquée nf_… apikey 401 direct — l’échec d’authenticate() remonte, pas de fallback (firewall.ts#Firewall.#authenticate)
rien aucun 401 (Zero Trust)

Situation 2 — le piège de l’ordre (anonymous toujours EN DERNIER)#

Tu veux « identifié si connecté, sinon visiteur » :

authenticators: ["session", "anonymous"],   // ✅ session d'abord
authenticators: ["anonymous", "session"],   // ❌ anonymous accepte TOUT le monde

AnonymousAuthenticator.supports() accepte toutes les requêtes — placé en premier en mode first, il court-circuite la liste : personne n’est jamais identifié, même avec un cookie valide. L’ordre est ta politique.

Situation 3 — le « sudo mode » (all : empiler les preuves)#

Une action destructrice (suppression de compte, rotation des clés) doit exiger la session ET une re-saisie du mot de passe — même logique que GitHub avant une action sensible :

danger: {
  pattern: "^/api/back/danger",
  authenticators: ["session", "userpassword"],
  mode: "all",   // CHAQUE maillon est obligatoire
},

Le client doit présenter les deux preuves dans la même requête (cookie + Authorization: Basic …). Une seule manque → 401. Le dernier token de la chaîne porte l’identité (firewall.ts#Firewall.#authenticate) — ici la preuve mot de passe, la plus fraîche.

💡 Astuce

Un nom d’authenticator inconnu en config fait échouer le boot — Firewall.#instantiateAuthenticators() est fail-closed (firewall.ts#Firewall.#instantiateAuthenticators) : jamais de zone « protégée » silencieusement ouverte à cause d’une faute de frappe.

🧑‍⚖️ Autorisation — rôles, scopes, voters (« as-tu le droit ? »)#

L’authentification dit qui tu es ; l’autorisation dit ce que tu peux faire. On déclare l’exigence sur l’action, un jury de voters tranche. La garde s’applique avant l’instanciation du contrôleur (seam Resolver) — une action protégée ne s’exécute jamais pour un non-autorisé.

@IsGranted(["ROLE_ADMIN"])                 // rôle — OR interne : un seul attribut suffit
@Post("/users") async create() {}

@RequireScope("users:write")               // scope — pour une clé/JWT délégué
@Delete("/users/{id}") async remove(@Param("id") id: string) {}

@IsGranted("doc.edit", { subject: "id" })  // règle métier — le param de route `id` est passé au voter
@Put("/docs/{id}") async edit() {}

Le jury et sa stratégie#

Authorization.decide(token, attribut, subject?) (service/authorization.ts#Authorization.decide) applique une stratégie affirmative + DENY veto, fermée par défaut (Zero Trust) :

Tout refus est audité (WARNING + recordAudit, authorization.ts#Authorization) ; les octrois restent muets (volume, pas un signal). Les voters sont instanciés une fois au boot via le registre (aucun nom en dur, authorization.ts#Authorization).

Les voters intégrés — deux axes#

rolesle rôle exigé PAR DÉFAUT dans une zone#

Une zone qui ne liste que des authenticators exige une identité, rien de plus : toute route qu’elle couvre est alors ouverte à n’importe quel compte connecté. C’est acceptable pour une application ; c’est un défaut ouvert pour une surface d’administration, où l’oubli d’une garde ne devrait jamais se solder par une fuite.

roles renverse ce défaut. Il se déclare une fois sur la zone, au lieu d’être recopié sur chaque route :

use("@nodefony/security", {
  areas: {
    admin: {
      pattern: "^/back-office(/|$)",
      authenticators: ["session"],
      roles: ["ROLE_ADMIN"], // ← exigé par défaut dans toute la zone
    },
  },
});

Les rôles listés sont en OU — un seul suffit — et la hiérarchie s’applique : un ROLE_NODEFONY_ADMIN satisfait ROLE_ADMIN si la hiérarchie le dit. Un refus est un 403 (authentifié, mais pas autorisé), jamais un 401. Omettre la clé laisse le comportement historique inchangé : la zone n’exige qu’une identité.

Ce que le rôle de zone n’écrase PAS#

C’est un défaut, pas une exclusivité. Quatre cas gardent la main, et chacun est une déclaration explicite du code — jamais un contournement :

Ce qui décide à la place Pourquoi
Une garde de rôle (@IsGranted) L’action a déjà dit quel rôle elle exige (un @RequireScope seul ne dispense pas du rôle de zone). Sans cela, une page réservée à l’exploitant deviendrait inatteignable pour lui.
Une route areaRoleExempt Elle tranche son autorisation dans son code — typiquement parce qu’elle ne sert QUE le porteur courant.
Un bypassFirewall La route EST le mécanisme d’authentification : lui imposer un rôle serait un verrou dont la clé est à l’intérieur.
L’ouverture d’une connexion WebSocket Une connexion n’accède à rien ; ce sont les frames qui accèdent, et chacune est gardée pour son compte.

Le dernier cas mérite d’être compris, parce qu’il pourrait passer pour un trou. Il n’en est pas un : une frame api.request {path} repasse par la même décision que la requête HTTP équivalente. L’invariant tenu est donc api.request {path} ≤ GET {path} — on n’obtient jamais par la socket ce que la porte HTTP refuse. Ce que la dispense évite, c’est qu’un compte sans rôle d’administration ne puisse plus ouvrir aucune socket, et perde du même coup le self-service dont il est le seul destinataire.

⚠️ areaRoleExempt n’est pas un interrupteur pour faire taire un refus. Le poser sur une route qui ne décide de rien la rend accessible à tout compte connecté, en silence. Il se justifie par ce que fait le CODE de l’action : scoper au porteur courant, ou résoudre un rôle par point d’entrée. Dans le doute, une garde d’action explicite est toujours préférable.

La hiérarchie de rôles#

RoleHierarchyWalker (src/RoleHierarchyWalker.ts) : ROLE_ADMIN hérite ROLE_USER, etc. Aplatissement précalculé au boot → hasRole() est O(1) sur le hot path (RoleHierarchyWalker.ts#RoleHierarchyWalker.hasRole), et les cycles sont détectés au boot (throw avec le chemin complet, pas de fail-silent, RoleHierarchyWalker.ts#RoleHierarchyWalker.#detectCycles). La hiérarchie est posée au container par le firewall au boot ; le RoleVoter la lit en lazy.

Voters métier (le vrai pouvoir applicatif)#

Pour une règle qui dépend des données (ownership, tenant, état), on enregistre une fabrique :

import { registerVoterFactory } from "@nodefony/security";

registerVoterFactory(
  "projectVoter",
  ({ container }) => new ProjectVoter(container),
);
// ProjectVoter.supports("doc.edit") → true ; vote(token, "doc.edit", subject) → lookup DB async :
//   l'utilisateur est-il propriétaire/membre du `subject` ? GRANT / DENY / ABSTAIN.

Le voter est découvert automatiquement par le service authorization — aucun changement dans le cœur (registerVoterFactory(), voterRegistry.ts#registerVoterFactory). Pourquoi un registre et pas un scan DI des @injectable : les interfaces TS sont effacées à la compilation — rien à scanner ; le registre est le marqueur explicite (TSDoc du registre, voterRegistry.ts#IVoterFactoryContext). Trois axes (rôles, scopes, métier), un même jury, combinables.

🔌 HTTP et WebSocket — le même firewall#

Firewall.#wireRealtime() (firewall.ts#Firewall.#wireRealtime) câble, pour toute zone protégée realtime !== false (opt-out, firewall.ts#Firewall.#build), le FirewallRealtimeAuthenticator au handshake (firewall.ts#Firewall.#wireRealtime) et un frameAuthorizer (RBAC par canal, firewall.ts#Firewall.#wireRealtime). Même résolution de zone que HTTP. Sur une socket, un refus n’a pas d’en-tête WWW-Authenticate (Firewall.#setChallenge(), firewall.ts#Firewall.#setChallenge) : le code de fermeture suffit.

🛡️ En-têtes de sécurité, CSRF, CORS#

📜 Normes appliquées#

Domaine Norme Ancrage
Challenge d’auth (401) RFC 9110 §11 Firewall.#setChallenge() (firewall.ts#Firewall.#setChallenge)
Bearer RFC 6750 JwtAuthenticator.ts · ApiKeyAuthenticator.ts#INVALID_TOKEN
JWT (BCP) RFC 7519, 8725 JwtAuthenticator.ts#JwtAuthenticator,104-108
HTTP Basic RFC 7617 UserPasswordAuthenticator.ts
Rate limit (429) RFC 6585 429 + Retry-After (firewall.ts#Firewall.handleSecurity)
Backoff de login NIST SP 800-63B UserPasswordAuthenticator.ts#UserPasswordAuthenticator,101-104
CSRF Fetch Metadata Firewall.enforceCsrf() (firewall.ts#Firewall.enforceCsrf)
Modèle Zero Trust firewall.ts#Firewall (aucune preuve → 401)

⚡ Performance & mémoire#

Le découpage chaud/froid EST l’optimisation : isSecure() (chaque requête) ne fait qu’un matchPath ; handleSecurity() (throttler, authenticators, nonce CSP, securityTrace) n’est payé que sur zone protégée. Les dépendances des authenticators (keystore, tokenStore, userProvider, verifier, jose) sont résolues paresseusement au premier usage (cold path) ; jose est importé lazy (dep lourde). Le nonce CSP et le securityTrace sont alloués à la demande. Une route publique ne paie quasiment rien.

⚠️ Pièges (symptôme → cause → correction)#

Symptôme Cause (dans le code) Correction
Boot rejette la config (areas) areas déclaré en tableau — c’est un objet par nom areas: { monNom: { pattern, authenticators } }
Boot « authenticator inconnu » Nom absent du registre (fail-closed) Corriger le nom / enregistrer l’authenticator
401 alors qu’un credential est envoyé Mode first : credential invalide échoue sans fallback Vérifier le format/authenticator attendu
Route « publique » ne voit jamais l’user Hors zone, l’identité n’est jamais résolue Couvrir la route par une zone ["session", "anonymous"]
API : JWT et clé API se marchent dessus — Rien à faire : discriminés par la forme (a.b.c vs prefix_…)
JWT révoqué encore accepté Auto-portage : révocation = état serveur S’assurer que tokenStore porte la denylist/invalidBefore
WS : révocation pas immédiate Jeton figé au handshake (asymétrie assumée) Effet en une fenêtre (tick du hub + avant chaque api.request, fermeture 4001)
429 au login Throttle NIST (backoff par identifiant) Respecter Retry-After ; attendu sous attaque

📡 Observabilité — Studio#

Écran Firewall (/nodefony/firewall) : zones, authenticators, décisions (securityTrace). Écran Roles (/nodefony/roles) : hiérarchie de rôles consommée par les voters. Écran ApiKeys (/nodefony/api-keys) : gestion et révocation des PAT.

🧪 Tests & couverture#

Quatre familles couvrent la brique — les chiffres exacts vivent dans la carte de l’aperçu (régénérée par gen-counters.mjs depuis vitest, jamais figée ici) :

Couverture : npm run coverage dans @nodefony/security.

🔗 Pour aller plus loin#