Tokens — émission, clés (keystore), rotation et révocation
stable@nodefony/securitymis à jour 2026-10-05
Les authenticators vérifient des jetons ; cette page décrit leur face émission : comment Nodefony signe un access token JWT, gère la clé (keystore Ed25519 + JWKS), fait tourner les refresh tokens avec détection de rejeu (RFC 9700), et révoque — au-dessus d’un
ITokenStorepluggable (memory/drizzle/mongoose/redis) désormais paginé pour l’admin. Ancré sursrc/packages/@nodefony/security/nodefony/service/tokenService.tsetnodefony/src/token/.
📍 Documentation › Sécurité › Jetons
🧠 Le modèle mental — émission, rotation, révocation#
📖 Lexique#
| Terme | Sens |
|---|---|
| Access token | JWT court (15 min) signé EdDSA, porté en Authorization: Bearer — jamais en cookie/URL. |
| Refresh token | Secret opaque longue durée (nfr_…), stocké haché, échangé contre un nouvel access. |
| PAT | Personal Access Token (clé API) — même store, autre page (authenticators). |
| Grant | Échange d’un credential (identifiant/mot de passe) contre un couple access+refresh. |
| Keystore | Gestionnaire des clés de signature Ed25519 + du JWKS public. |
| JWKS | JSON Web Key Set : les clés publiques exposées pour vérifier les signatures. |
kid |
Identifiant de clé (empreinte) posé dans l’en-tête du JWT → sélection de la bonne clé. |
jti |
Identifiant unique d’un JWT — clé de la denylist de révocation ciblée. |
| Rotation | Émettre un nouveau refresh à chaque usage et révoquer l’ancien (RFC 9700). |
| Famille | Chaîne de refresh liés par rotation ; un rejeu coupe toute la famille. |
| Downscoping | Les scopes ne montent jamais le long d’une chaîne de refresh. |
invalidBefore |
Seuil par porteur : tout access émis avant cet instant est rejeté (révocation en masse). |
Qu’est-ce que ce système résout — la faille#
Un JWT est auto-porté : le serveur peut le vérifier sans état. Génial pour la scalabilité, dangereux pour la révocation — un jeton volé reste valide jusqu’à son expiration si rien ne le suit côté serveur. Deux attaques concrètes :
- le vol de refresh token — l’attaquant le rejoue pour obtenir des access frais indéfiniment ;
- l’absence de révocation — bannir un compte ne coupe pas ses jetons déjà émis.
Nodefony répond par un store de vérité côté serveur (denylist jti + invalidBefore + rotation
avec détection de rejeu) et une gestion de clé qui ne génère jamais de secret en clair « par
défaut » en prod.
La vision Nodefony — un service propriétaire, des endpoints minces#
TokenService est propriétaire du store et du keystore : à TokenService.#build()
(tokenService.ts#TokenService.#build), si jwt.enabled ou apiKeys.enabled, il résout le store pluggable, pose
tokenStore au container (tokenService.ts#TokenService.#build) puis crée le keystore et pose jwtKeystore
(tokenService.ts#TokenService.#build) — consommés par le JwtAuthenticator et les endpoints. Il arme un
gc via GcScheduler (timer unref + jitter de phase pour étaler les balayages entre pods,
tokenService.ts#TokenService.#build).
Les endpoints HTTP sont des adaptateurs minces portés par @nodefony/framework, couplés par
nom de service via le contrat structurel ITokenIssuer — framework n’importe jamais security
(TokenAuthController.ts#ITokenIssuer).
Constat clé de cohérence : iss/aud/ttl sont dérivés une seule fois par
resolveJwtRuntime() (jwtRuntime.ts#resolveJwtRuntime) et partagés entre l’émetteur et le vérificateur —
une divergence ferait tout rejeter. Fonction pure : les deux côtés obtiennent la même valeur sans la
partager par référence. issuer omis → "nodefony" (jwtRuntime.ts#resolveJwtRuntime), à surcharger en prod.
Être DÉCOUVRABLE — publier ses clés (RFC 8414)#
Tant que Nodefony émet et vérifie ses propres jetons, iss n’est qu’une chaîne comparée à
elle-même. Dès qu’un tiers doit valider une signature émise ici (une autre application
Nodefony, un agent, un service), il lui faut deux documents publics :
| Route | Contenu |
|---|---|
GET /.well-known/oauth-authorization-server |
issuer, jwks_uri — RFC 8414 §2 (chemin non négociable) |
GET /.well-known/jwks.json |
clés publiques de signature (jamais d) |
Les deux sont montées par @nodefony/framework (IssuerMetadataController.ts) uniquement si
TokenService.publishedIssuer() répond — soit jwt.enabled, jwt.jwks, et jwt.issuer écrit
sous forme d’URL https. Sinon : aucune route (404) et un avertissement au boot.
🔴 L’URL ne se devine pas. Derrière un relais (HAProxy, ingress, CDN), Host et
X-Forwarded-* viennent de la requête, donc du client : un document dérivé de l’en-tête ferait
servir, par le vrai serveur, l’identité d’un attaquant — et empoisonnerait tout cache mutualisé.
L’exploitant l’écrit, comme il écrit son domaine (NF_JWT_ISSUER dans env.ts).
use("@nodefony/security", { jwt: { issuer: ctx.env.NF_JWT_ISSUER } });
Le document publié n’annonce aucun flux d’autorisation (response_types_supported: [],
grant_types_supported: []) : Nodefony n’est pas un serveur d’autorisation OAuth 2.1, elle rend
seulement ses signatures vérifiables. Omettre grant_types_supported annoncerait
["authorization_code", "implicit"] par défaut (RFC 8414 §2) — deux flux inexistants.
🚀 Démarrage rapide#
Les endpoints d’émission sont FOURNIS#
Dans une app nodefony create app, dès que le module security est chargé avec jwt.enabled (défaut),
le framework monte deux routes (mountTokenAuthRoutes(), TokenAuthController.ts#mountTokenAuthRoutes) :
POST /nodefony/security/api/token— body{username, password, scope?}→ couple access/refresh ;POST /nodefony/security/api/token/refresh— body{refresh_token}→ rotation.
❗ Important
Ces routes n’existent que si
@nodefony/securityest chargé — sinon 404, zéro surface (framework/index.ts#Framework.onKernelReady). Module chargé mais JWT désactivé : elles existent et répondent503 Token issuance unavailable(TokenAuthController.ts#TokenAuthController.token). Elles sontbypassFirewall: true(TokenAuthController.ts#mountTokenAuthRoutes) : elles SONT le mécanisme d’émission — protégées, obtenir un token exigerait d’être déjà authentifié (deadlock). Le JWT part en réponse JSON (Bearer), jamais en cookie ni en URL.
La config : une zone protégée par jwt + une clé qui survit au redémarrage#
// nodefony/config/security.ts — la zone API machine + la source de clé
import type { ConfigContext } from "nodefony";
import type { ISecurityConfigInput } from "@nodefony/security";
import type { env } from "../../env";
export const securityConfig = (ctx: ConfigContext<typeof env>) =>
({
jwt: {
// dev : persiste la clé Ed25519 sous var/ (sinon clé ÉPHÉMÈRE + warning).
// prod : NF_JWT_KEYSET, la même clé sur tous les pods — voir la section keystore.
keystore: {
keySetJson: ctx.env.NF_JWT_KEYSET,
dir: "var/keys",
},
},
areas: {
// Le firewall vérifie le Bearer JWT sur CHAQUE requête de la zone.
api: { pattern: "^/api/v1", authenticators: ["jwt"] },
},
}) satisfies ISecurityConfigInput;
Ce que TU écris : le controller scopé#
// nodefony/controllers/OrdersController.ts — complet, compile tel quel
import {
controller,
Controller,
Get,
RequireScope,
CurrentUser,
} from "@nodefony/framework";
import type { IUser } from "@nodefony/user";
@controller("/api/v1/orders")
class OrdersController extends Controller {
// Zone `api` : le firewall a déjà validé le JWT (signature, exp, aud/iss,
// denylist, sujet actif). @RequireScope borne ce que la CLÉ a le droit de
// faire — un token émis sans `orders:read` reçoit 403, même sujet valide.
@RequireScope("orders:read")
@Get("/list")
async list(@CurrentUser() user: IUser) {
return this.renderJson({ subject: user.identifier, orders: [] });
}
}
export default OrdersController;
Ce qu’on observe#
# 0) Un compte (mot de passe demandé MASQUÉ — jamais en dur dans un script)
npx nodefony security:user:add ci-bot
# 1) Grant : credential → couple access/refresh (réponse RFC 6749 §5.1)
curl -s -H 'Content-Type: application/json' \
-d "{\"username\":\"ci-bot\",\"password\":\"$NF_PASS\",\"scope\":\"orders:read\"}" \
http://localhost:5151/nodefony/security/api/token
# {"access_token":"eyJ…","refresh_token":"nfr_…","token_type":"Bearer",
# "expires_in":900,"scope":"orders:read"}
# 2) L'access token en Bearer → 200 (zone api, scope vérifié)
curl -s -H "Authorization: Bearer $ACCESS" \
http://localhost:5151/api/v1/orders/list
# {"subject":"ci-bot","orders":[]}
# 3) Rotation : le refresh → NOUVEAU couple (l'ancien refresh est révoqué)
curl -s -H 'Content-Type: application/json' \
-d "{\"refresh_token\":\"$REFRESH\"}" \
http://localhost:5151/nodefony/security/api/token/refresh
# 4) Rejouer l'ANCIEN refresh → 401 {"error":"invalid_grant"} + famille coupée
Erreurs mappées par duck-typing dans #renderAuthError() (TokenAuthController.ts#TokenAuthController.#renderAuthError) :
401 message uniforme invalid_grant (anti-énumération), 429 avec Retry-After du throttler NIST.
Émission indisponible (JWT désactivé, store absent) → 503 isEnabled()
(TokenAuthController.ts#ITokenIssuer.isEnabled).
🏗️ Architecture interne — la vie d’un couple access/refresh#
Émission (grant M2M/CLI)#
issueForCredentials() (tokenService.ts#TokenService.issueForCredentials) vérifie l’identifiant/mot de passe via le
service users, avec le throttling NIST partagé — ThrottledError avant tout hachage
(tokenService.ts#TokenService.issueForCredentials). Chaque tentative échouée est auditée login.failure/login.throttled
par #auditGrant() (tokenService.ts#TokenService.#auditGrant). Puis issueTokens() (tokenService.ts#TokenService.issueTokens)
produit :
- un access token : JWT signé EdDSA, en-tête
typ:"at+jwt"+kid, claimsiss/sub/aud/exp(15 min) +jti—#signAccess()(tokenService.ts#TokenService.#signAccess) ; - un refresh token : secret opaque haute entropie
nfr_<32 octets base64url>, stocké hachésha256(le clair n’existe qu’en réponse, jamais au repos) —#buildRefresh()(tokenService.ts#TokenService.#buildRefresh).
La réponse suit RFC 6749 §5.1 — ITokenResponse (tokenService.ts#ITokenResponse). Tout succès est audité
token.issued via recordAudit avec le tokenId corrélable (tokenService.ts#TokenService.runGc).
Rotation & détection de rejeu (RFC 9700 §4.14)#
refresh() (tokenService.ts#TokenService.refresh) est le cœur défensif, dans l’ordre :
- Lookup par hash —
findByHash, refus uniforme si inconnu/mauvais type (tokenService.ts#TokenService.refresh). - Détection de rejeu : refresh déjà révoqué re-présenté →
revokeFamilycoupe toute la famille + audittoken.reuse_detected, signal d’attaque fort (tokenService.ts#TokenService.refresh). - Expiration
expiresAtvérifiée (tokenService.ts#TokenService.refresh). - Sujet revérifié — compte disparu/inactif/verrouillé rejeté sans attendre l’exp,
#resolveUserForRefresh()(tokenService.ts#TokenService.#resolveUserForRefresh). - Downscoping : les
scopesdu nouveau couple sont ceux de l’ancien, jamais plus — et ils sont REJUGÉS sur les rôles du compte relu (grantableScopes, règle d’administration) : un rôle retiré retire son scope au prochain renouvellement (tokenService.ts#TokenService.#grantableScopes). - Rotation : nouveau refresh (même famille), l’ancien chaîné
replacedBy+ révoqué"rotated"(tokenService.ts#TokenService.refresh). SirotateRefreshest désactivé, l’access est réémis et le refresh courant reste valide (tokenService.ts#TokenService.refresh).
Mise en situation — ton refresh token a été volé#
Besoin vécu : le refresh d’un poste compromis est exfiltré. Rotation active (défaut) — voici ce que chacun vit, requête par requête :
| # | Qui présente quoi | Ce que fait refresh() |
Résultat client |
|---|---|---|---|
| 1 | Client légitime → refresh R1 | rotation : R2 émis, R1 révoqué rotated |
200, nouveau couple |
| 2 | Voleur → R1 (volé, déjà tourné) | R1 révoqué re-présenté = rejeu → famille entière coupée | 401 invalid_grant |
| 3 | Client légitime → R2 | famille coupée : R2 est révoqué aussi | 401 → se reconnecte (nouveau grant) |
Si le voleur joue en premier, la rotation lui répond normalement (le serveur ne peut pas encore le distinguer) — mais dès que le légitime rejoue son vieux refresh, le rejeu est détecté et le voleur perd aussi son couple. Dans les deux ordres, l’attaque est bornée à une fenêtre courte et la victime est déconnectée (signal visible) au lieu d’un vol silencieux indéfini.
🔐 Le keystore Ed25519 — la clé ne fuit pas, pas de secret « par défaut » en prod#
JwtKeystore.#load() résout la source de clé par priorité (JwtKeystore.ts#JwtKeystore.#load), pensée
pour ne jamais auto-générer une clé en clair silencieusement en prod :
- env —
keySetJson(NF_JWT_KEYSETdans une application générée) : la MÊME clé pour tous les process qui servent l’application (JwtKeystore.ts#JwtKeystore.#load). - fichier —
dir/keyset.json, généré si absent, en mode 600, par une création exclusive (createSecretExclusive,secretFile.ts#createSecretExclusive) : quand plusieurs workers denodefony clusterdémarrent ensemble, un seul crée la clé et les autres relisent la sienne (JwtKeystore.ts#JwtKeystore.#load). Source de développement, ou d’un serveur unique à disque persistant. - mémoire — aucune source → clé éphémère, propre au process (
JwtKeystore.ts#JwtKeystore.#load). En développement : un WARNING au premier jeton. En production, un process qui sert refuse de démarrer (§ suivant).
Le JWKS servi par getPublicJWKS() est public — JwtKeystore.ts#JwtKeystore.getPublicJWKS.
La composante privée d en est retirée à l’import, par liste BLANCHE de paramètres
(#importKeyset(), JwtKeystore.ts#JwtKeystore.#importKeyset, RFC 8037/7517).
C’est ce JWKS qu’utilise le vérificateur local (createLocalJWKSet, JwtAuthenticator.ts#JwtAuthenticator.#ensureGetKey),
jamais une clé venue du jeton. Le chargement est mémoïsé — #ensureLoaded() (JwtKeystore.ts#JwtKeystore.#ensureLoaded).
Plusieurs pods ou workers — UNE clé pour tous#
Chaque jeton porte le kid de la clé qui l’a signé. Deux process qui ont chacun la leur refusent
les jetons l’un de l’autre : derrière un répartiteur, l’utilisateur est déconnecté au hasard (401),
et tout redémarrage invalide les jetons en vol.
En production, un process qui SERT sans source partagée refuse de démarrer
(#requireSharedSigningKey, tokenService.ts#TokenService.#requireSharedSigningKey), avec le motif et les trois issues dans le
journal. Une commande de console (orm:migrate, security:user:add) n’est pas concernée : elle
ne signe aucun jeton. Sous nodefony cluster, un worker refusé ne se relance pas — le master
arrête le cluster et sort en 78 (ClusterManager.ts#ClusterManager.#onExit), le code d’une faute de configuration.
Le déploiement, de bout en bout :
# 1. Générer la valeur UNE fois par environnement (staging ≠ production)
npx nodefony security:secrets --jwt-keyset > keyset.json
# 2. La ranger dans le gestionnaire de secrets — ici un Secret Kubernetes
kubectl create secret generic app-jwt --from-file=NF_JWT_KEYSET=keyset.json
rm keyset.json # elle porte la clé PRIVÉE : aucun exemplaire ne traîne
# 3. L'injecter dans CHAQUE pod (extrait du Deployment)
# env:
# - name: NF_JWT_KEYSET
# valueFrom: { secretKeyRef: { name: app-jwt, key: NF_JWT_KEYSET } }
Le câblage, que le gabarit d’application écrit déjà :
jwt: {
keystore: {
keySetJson: ctx.env.NF_JWT_KEYSET, // présente : l'emporte partout
dir: ctx.isProd ? undefined : "var/keys", // développement
},
},
- Jamais dans un fichier du poste, jamais dans git : en développement la clé vit dans
var/keys/; une clé privée de production sur un poste n’apporte que le risque de fuir. - Dans un fichier
.env(Docker Composeenv_file), l’entourer de quotes simples :NF_JWT_KEYSET='{"active":"…","keys":[…]}'. - Valeur illisible : la configuration security est refusée au démarrage, le chemin
jwt.keystore.keySetJsonnommé, la valeur jamais recopiée dans le message. - Rotation : la valeur est STABLE — la remplacer refuse les jetons en vol. Ajouter la nouvelle
clé au tableau
keys, la désigner dansactive, garder l’ancienne le temps que ses jetons expirent (accessTtlS,refreshTtlS), puis la retirer.
🧩 Le store pluggable — durable par défaut, jamais de faux durable silencieux#
Le contrat et l’enregistrement#
Le tokenStore héberge trois structures : les records (refresh + PAT), la denylist jti et le
seuil invalidBefore par porteur (ITokenStore.ts). Les backends s’enregistrent par
fabrique — registerTokenStore() (tokenStoreRegistry.ts#registerTokenStore) : les adapters lourds importent
import type { ITokenStore } (effacé à la compilation), zéro couplage runtime.
Sa résolution au boot (tokenService.ts#TokenService.#build) suit la doctrine store:"auto" du framework :
auto(défaut) → suit l’infra database déclarée viaresolveAutoStore— borné aux backends réellement enregistrés, repli memory annoncé (tokenService.ts#TokenService.#build) ;- store explicite inconnu → en prod, boot avorté (fail-loud) ; en dev, brique désactivée et
annoncée avec la liste
listTokenStores()(tokenService.ts#TokenService.#build) — jamais de fallback memory silencieux pour du durable ; - store
memoryen prod →WARNINGnommant l’impact : denylist/refresh/clés API per-pod et volatils, révocation non partagée (tokenService.ts#TokenService.#build).
La décision (configuré → résolu, raison) est publiée au kernel par registerStoreResolution()
(tokenService.ts#TokenService.#build) — visible dans Studio.
Mise en situation — quel store pour quelle app ?#
| Ta situation | Store | Pourquoi |
|---|---|---|
| Dev / tests mono-process | memory (auto) |
0 dépendance ; volatil — un redémarrage déconnecte tout le monde |
Prod, base SQL déclarée (NF_DATABASE_URL) |
auto → drizzle |
durable + partagé entre pods : révocation et rejeu vus PARTOUT |
| Prod, MongoDB | mongoose |
même contrat, même banc, sur Mongo |
| Flotte de pods, Redis déjà présent, denylist chaude | redis |
TTL natif, lecture O(1) ; listing par curseur SCAN (capacité réduite) |
| Prod sans infra durable | ❌ memory |
ça boote, mais WARNING mérité : la révocation ne traverse pas un pod |
Les backends (catalogue)#
memoryla référence 0 dépendance#- Builtin, enregistré à l’import du module via
registerTokenStore(tokenStoreRegistry.ts#registerTokenStore). listPage: tricreatedAtDESC + tiebreakerid, déterministe pour l’offset — parité SQL (MemoryTokenStore.ts#MemoryTokenStore.listPage).- Denylist bornée : purge amortie tous les 256 ajouts —
#maybeSweep()(MemoryTokenStore.ts#MemoryTokenStore.#maybeSweep) + expiration paresseuse à la lecture. Pas de minuterie, pas de fuite. snapshot()/restore()sérialisables — base d’une persistance fichier, index reconstruits (MemoryTokenStore.ts#MemoryTokenStore.restore).- Volatil, par-process : dev/tests. Pilote le banc de contrat commun.
drizzleSQL, le défaut durable#- Enregistré par le module drizzle (
drizzle/nodefony/registerStores.ts#registerDrizzleFrameworkStores). - Élu par
store:"auto"dès qu’une infra database SQL est déclarée ; sinon sqlite local si drizzle est chargé (config.ts#jwtSchema). - Pagination offset + total (helper
paginate()d’orm-core) ; e2e sur PostgreSQL et MySQL réels.
mongooseMongoDB#- Enregistré par le module mongoose (
mongoose/nodefony/registerStores.ts#registerMongooseFrameworkStores). - Pagination offset + total via
listPage(MongooseTokenStore.ts#MongooseTokenStore.listPage). - Purge par
gc()explicite surexpiresAt(MongooseTokenStore.ts#MongooseTokenStore.#toRecord).
rediscluster, TTL natif#- Enregistré par le module redis (
redis/nodefony/registerStores.ts#registerRedisFrameworkStores). - TTL natif :
expire()posé à l’écriture du record (RedisTokenStore.ts#RedisClientLike.expire) — l’expiration ne dépend pas du gc. - Listing par
SCAN: curseur opaqueskip:scanCursor,decodeCursor()(RedisTokenStore.ts#RedisTokenStore.listAll) — sans ordre global ni total, capacité réduite assumée. countTokens()renvoie-1: un comptage exact exigerait un SCAN complet O(N), refusé (RedisTokenStore.ts#RedisTokenStore.countTokens).
Le record — une seule table pour PAT et refresh#
IAccessTokenRecord (ITokenStore.ts#IAccessTokenRecord) est single-table : un même schéma porte PAT et
refresh, champs non pertinents à null, horodatages epoch ms (ITokenStore.ts#IAccessTokenRecord). Deux
constats de design :
subjectIdest une référence logique souple, PAS une FK SQL : porteur polymorphe (user/service), store multi-backend, découplage des modules — intégrité assurée à l’usage (ITokenStore.ts#IAccessTokenRecord.subjectId) ;- slots prêts sans migration :
resources(permissions fine-grained façon GitHub) etcnf(sender-constrained DPoP/mTLS) (ITokenStore.ts#IAccessTokenRecord.cnf).
Les colonnes par dialecte vivent dans la doc de chaque adapter (règle anti-triple-vérité).
Lister pour l’admin — pagination native#
listPage()ne matérialise jamais plus d’une page (ITokenStore.ts#ITokenStore.listPage) — capacité par backend : offset + total (SQL/Mongo/mémoire) ou curseur (nextCursor, Redis).countTokens()donne letotal; Redis répond-1(ITokenStore.ts#ITokenStore.countTokens).- Filtres portables
ITokenListQuery:subjectId,kind,revoked(ITokenStore.ts#ITokenListQuery) — prédicat partagématchesTokenQuery()(MemoryTokenStore.ts#matchesTokenQuery). listAll()reste réservé au dump d’incident cross-porteur, cold-path admin (ITokenStore.ts#ITokenStore.listAll).- Consommateur type : le data plane des clés API —
ApiKeyService.listPagePat()(apiKeys.ts#ApiKeyService.listPagePat), jamais un listAll matérialisé.
Révoquer — trois portées#
- Un access avant son
exp: denylistdenyJti()/isJtiDenied()(ITokenStore.ts#ITokenStore.isJtiDenied). - Un refresh/PAT :
revoke()idempotent,revokeFamily()pour la chaîne de rotation (ITokenStore.ts#ITokenStore.revokeFamily). - Tout un porteur (logout global, ban) : seuil
revokeAllForSubject()— tout access dontiat < invalidBeforeest rejeté (ITokenStore.ts#ITokenStore) ; le seuil est monotone, deux logouts successifs ne le reculent pas (MemoryTokenStore.ts#MemoryTokenStore.revokeAllForSubject).
La maintenance (gc)#
Le gc() du store purge la denylist expirée, les records à terme, les PAT révoqués au-delà de
la rétention (ITokenStore.ts#ITokenStore.gc). Orchestré par le GcScheduler du service ; runGc() reste public pour
un futur worker cron — poser alors gcIntervalS: 0 (tokenService.ts#TokenService.getPublicJWKS).
💡 Astuce
Un refresh révoqué par rotation n’est PAS purgé tout de suite : il est conservé jusqu’à son
expiresAt— c’est la fenêtre de détection de rejeu — puis tombe au gc,#isPurgeable()(MemoryTokenStore.ts#MemoryTokenStore.#isPurgeable). Un store local (memory) est par-process : seul SON process peut le purger → ne déléguez le gc au cron QUE pour un store partagé.
⚙️ Configuration#
Tables dérivées du schéma Zod — jwtSchema (config.ts#jwtSchema) et tokenStoreSchema
(config.ts#tokenStoreSchema), défauts inclus.
jwt.*#| Option | Type | Défaut | Effet |
|---|---|---|---|
enabled |
boolean | true |
Active signature + refresh (config.ts#rateLimitSchema) |
alg |
EdDSA | RS256 |
EdDSA |
RS256 = slot non câblé (jwtRuntime.ts#IJwtRuntime.alg) |
accessTtlS |
number (s) | 900 |
TTL de l’access token — 15 min (config.ts#jwtSchema) |
refreshTtlS |
number (s) | 604800 |
TTL du refresh — 7 jours (config.ts#jwtSchema) |
rotateRefresh |
boolean | true |
Rotation du refresh à chaque usage, OWASP (config.ts#jwtSchema) |
jwks |
boolean | true |
Publie /.well-known/jwks.json + les métadonnées RFC 8414 — sans issuer en URL https, rien n’est publié |
audiences |
string[] | [] |
aud acceptées (RFC 8707) ; vide = [issuer] (config.ts#jwtSchema) |
issuer |
string? | — | Claim iss, STABLE après émission ; omis → repli "nodefony", qui n’est PAS publiable (RFC 8414 §2 exige une URL https) |
keystore.keySetJson |
string? | — | JWK Set privé partagé par tous les process (NF_JWT_KEYSET) — SECRET, forme vérifiée au démarrage (security/nodefony/config/config.ts#jwtSchema) |
keystore.dir |
string? | — | Dossier keyset.json chmod 600, création exclusive — dev, ou serveur unique à disque persistant (config.ts#jwtSchema) |
tokenStore.*#| Option | Type | Défaut | Effet |
|---|---|---|---|
store |
string | "auto" |
auto|memory|drizzle|mongoose|redis — pluggable (config.ts#jwtSchema) |
gcIntervalS |
number (s) | 600 |
Purge périodique ; 0 = désactivé — chaque process purge SON store (config.ts#tokenStoreSchema) |
gcJitter |
boolean | true |
Étale le gc d’un délai aléatoire par process — cluster (config.ts#tokenStoreSchema) |
retentionRevokedDays |
number (j) | 30 |
Rétention d’un PAT révoqué SANS expiration avant purge (config.ts#tokenStoreSchema) |
📜 Normes appliquées#
| Domaine | Norme | Ancrage |
|---|---|---|
| Réponse d’émission | RFC 6749 §5.1 | ITokenResponse (tokenService.ts#ITokenResponse) |
| Rotation + détection de rejeu | RFC 9700 §4.14 | refresh() (tokenService.ts#TokenService.refresh) |
Profil access token typ:at+jwt |
RFC 9068 | #signAccess() (tokenService.ts#TokenService.#signAccess) |
Claims JWT (iss/sub/aud/exp) |
RFC 7519 | #signAccess() (tokenService.ts#TokenService.#signAccess) |
| Ed25519 / JWK / JWKS public | RFC 8037 · 7517 | #importKeyset() (JwtKeystore.ts#JwtKeystore.#importKeyset) |
| Audiences liées à la ressource | RFC 8707 | audience du record (ITokenStore.ts#IAccessTokenRecord.audience) |
429 + Retry-After |
RFC 6585 | #renderAuthError() (TokenAuthController.ts#TokenAuthController.#renderAuthError) |
| Backoff de login | NIST SP 800-63B | ThrottledError avant hachage (tokenService.ts#TokenService.issueForCredentials) |
⚡ Performance & mémoire#
joseimporté lazy (dep lourde) :#ensureJose()au premier usage (tokenService.ts#TokenService.#ensureJose) — le boot ne paie rien si le JWT n’est jamais sollicité ; keystore mémoïsé pareil.- Rien sur le hot path requête : émission et rotation sont des endpoints cold-path ; la
vérification (hot path) vit chez le
JwtAuthenticator. - Timers civilisés :
GcSchedulerunref(n’empêche pas l’arrêt) + jitter anti-balayages simultanés (tokenService.ts#TokenService.#build) ; denylist mémoire bornée par purge amortie (MemoryTokenStore.ts#MemoryTokenStore.restore). - Jamais N en RAM :
listPage()borne toute lecture admin à une page (ITokenStore.ts#ITokenStore.listPage).
📡 Observabilité — Studio#
- Écran Stores (
/nodefony/stores) : la résolution du store de jetons (configuré → résolu + raison) publiée parregisterStoreResolution()(tokenService.ts#TokenService.#build). - Écran Audit (
/nodefony/audit) : événementstoken.issued,token.reuse_detected(signal d’attaque),login.failure/login.throttleddu grant — corrélables partokenId. - Écran ApiKeys : le même store côté PAT, listing paginé serveur.
⚠️ Pièges (symptôme → cause → correction)#
| Symptôme | Cause (dans le code) | Correction |
|---|---|---|
404 sur /nodefony/security/api/token |
Module @nodefony/security non chargé |
Charger @nodefony/security + jwt.enabled: true |
| 503 « Token issuance unavailable » | Service non initialisé (JWT désactivé, store indisponible) | Vérifier config jwt/tokenStore + logs de boot |
| Refresh tokens invalidés à chaque redémarrage | Keystore en mémoire (aucune source configurée) | NF_JWT_KEYSET (prod) ou jwt.keystore.dir (dev) |
| Boot refusé « DÉMARRAGE REFUSÉ — aucune clé de signature » | Production, process qui sert, ni keySetJson ni dir |
security:secrets --jwt-keyset → secret → NF_JWT_KEYSET |
| Cluster arrêté « cluster master down (exit 78) » | Un worker a refusé de démarrer (faute de configuration) | Lire le motif du worker juste au-dessus, le corriger |
| JWT rejeté après un déploiement multi-pod | Clés différentes par pod (pas de clé partagée) | La même NF_JWT_KEYSET injectée dans chaque pod |
| Tout rejeté après changement de config | issuer/audiences divergents entre émission et vérification |
issuer STABLE — ne pas le changer après émission |
| Révocation sans effet entre pods | tokenStore:"memory" en prod (per-pod) |
Store durable (NF_DATABASE_URL → drizzle, ou redis) |
| Reconnexion forcée inattendue | Détection de rejeu : un vieux refresh révoqué a été rejoué | Attendu (anti-vol) — la famille est coupée |
| Boot avorté « token store inconnu » | tokenStore.store explicite introuvable (fail-loud prod) |
Corriger le nom / enregistrer le store |
| Scopes qui n’augmentent pas au refresh | Downscoping volontaire | Réémettre via un nouveau grant pour élargir |
Listing admin sans total sur Redis |
countTokens() = -1 (comptage O(N) refusé), curseur SCAN |
Attendu — capacité réduite annoncée, paginer par curseur |
🧪 Tests & couverture#
Cinq 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) :
- unit :
tokenService(émission, rotation, rejeu, downscoping),tokenStore(révocation, denylist, gc, rétention),jwtKeystore(les 3 priorités de source),jwtPipeline(bout en bout signature → vérification),tokenPagination(le banc de contrat piloté par le store mémoire) ; - banc de contrat :
tokenPaginationContract— invariantslistPage/countTokenstenus par tous les backends (tri, offset, filtres, curseur) ; - intégration adapters : token-store + token-pagination chez drizzle (sqlite), mongoose et redis — le MÊME banc rejoué sur chaque backend ;
- e2e base réelle : drizzle sur PostgreSQL et MySQL ;
- manque assumé : pas de banc d’attaque dédié à l’émission (le rejeu est couvert en unit) ni de test de charge sur le grant.
Les bancs sur serveur réel se skippent sans leurs variables d’infra — un skip compte comme
vert : lire le bloc gates (scripts/test/vitest/gates.ts, affiché en fin de run) avant de conclure.
Couverture : npm run coverage dans @nodefony/security.
🔗 Pour aller plus loin#
-
⬆️ Retour au hub : Sécurité — vue d’ensemble · Toute la documentation
-
🧭 Pages sœurs : api-keys · Authenticators
-
La vérification de ces jetons (JWT/PAT) → authenticators
-
Le firewall qui applique la zone → firewall · L’autorisation par scopes → authorization
-
La doctrine
store:"auto"→ configuration -
Vue d’ensemble sécurité → index