Chapitre 1524 minIntermédiaire
Refresh token, idempotence et notifications : l'API Node.js d'une application mobile
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- stocker un jeton de rafraîchissement et le faire tourner à chaque usage ;
- révoquer une session quand un jeton volé est rejoué ;
- rendre un POST rejouable avec une clé d'idempotence ;
- enregistrer les appareils d'un utilisateur pour les notifications push.
4 exercices corrigés
Une application mobile demande à son API plus qu'un site web : garder l'utilisateur connecté des semaines sans stocker son mot de passe, accepter qu'une même requête arrive deux fois après une coupure de réseau, et savoir à quels téléphones envoyer les notifications. Ce chapitre écrit, en Node.js avec Express 5, les routes serveur qui rendent cela possible : le jeton de rafraîchissement (refresh token) avec rotation et révocation, la clé d'idempotence et l'enregistrement des appareils.
Ce que l'application mobile attend de l'API
Le cours Node.js construit l'API d'AppliFrais : les routes des fiches, la base MySQL avec mysql2, la validation avec Zod, la connexion par JWT et le téléversement des justificatifs. Le client mobile du chapitre 6 lui demande quatre choses de plus :
- un jeton de rafraîchissement à la connexion, et sa rotation (
POST /v1/auth/rafraichissement) ; - une déconnexion côté serveur, qui révoque ce jeton (
POST /v1/auth/deconnexion) ; - la clé d'idempotence sur la création d'un frais, pour la file d'attente hors ligne ;
- l'enregistrement des appareils pour les notifications push (
PUT /v1/appareils).
Voici le code à ajouter, dans la même organisation que le cours Node.js (Express 5, modules ES, dossiers routes, controllers, services, middlewares, classe ErreurApi). C'est contre cette API qu'a tourné le script du chapitre 6 qui teste le client sans téléphone.
Les tables à ajouter
Trois tables, liées à la table utilisateur du cours Node.js :
-- sql/mobile.sql : les tables qu'ajoute l'application mobile (MySQL 8.4)
CREATE TABLE jeton_rafraichissement (
id INT AUTO_INCREMENT PRIMARY KEY,
id_utilisateur INT NOT NULL,
famille CHAR(36) NOT NULL, -- la même pour tous les jetons nés d'une connexion
empreinte CHAR(64) NOT NULL UNIQUE, -- SHA-256 du jeton, jamais le jeton lui-même
expire_le DATETIME NOT NULL, -- en UTC
utilise_le DATETIME NULL, -- rempli à la rotation : un jeton ne sert qu'une fois
revoque BOOLEAN NOT NULL DEFAULT FALSE,
INDEX idx_famille (famille),
CONSTRAINT fk_jeton_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;
CREATE TABLE cle_idempotence (
id_utilisateur INT NOT NULL,
cle VARCHAR(64) NOT NULL,
empreinte CHAR(64) NOT NULL, -- SHA-256 de la requête : même clé, même contenu
statut SMALLINT NULL, -- NULL tant que la première requête est en cours
reponse TEXT NULL, -- le corps JSON renvoyé la première fois
cree_le DATETIME NOT NULL, -- en UTC ; une clé vaut 24 heures
PRIMARY KEY (id_utilisateur, cle),
CONSTRAINT fk_cle_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;
CREATE TABLE appareil (
jeton_push VARCHAR(255) PRIMARY KEY, -- ExponentPushToken[…]
id_utilisateur INT NOT NULL,
plateforme ENUM('android', 'ios') NOT NULL,
maj_le DATETIME NOT NULL,
CONSTRAINT fk_appareil_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id) ON DELETE CASCADE
) ENGINE = InnoDB;
mysql -u root -p applifrais < sql/mobile.sql
Les clés étrangères en ON DELETE CASCADE permettent au script sql/donnees.sql du cours Node.js de continuer à vider la table utilisateur : les jetons, clés et appareils partent avec elle.
Ces tables grossissent avec le temps : les jetons expirés et les clés de plus de 24 heures ne servent plus. Une tâche planifiée les purge, par exemple chaque nuit, avec DELETE FROM jeton_rafraichissement WHERE expire_le < ? et DELETE FROM cle_idempotence WHERE cree_le < ?, la date limite étant calculée par le code.
Le refresh token : rotation et révocation
Le jeton de rafraîchissement n'est pas un JWT : c'est une suite de 32 octets aléatoires, que l'API retrouve en base. C'est ce qui permet de le révoquer, ce qu'un JWT autoporteur ne permet pas. La base n'en garde que l'empreinte SHA-256, comme pour un mot de passe : si la table fuit, les empreintes ne permettent pas de se connecter. Un simple SHA-256 suffit ici, sans argon2, parce que le jeton est aléatoire sur 256 bits : il n'y a rien à deviner dans un dictionnaire.
// src/services/jetons.service.js
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import jwt from 'jsonwebtoken';
import { config } from '../config.js';
import { pool } from '../db.js';
import { ErreurApi } from '../erreurs.js';
export const DUREE_ACCES = 15 * 60; // secondes
const DUREE_RAFRAICHISSEMENT_MS = 30 * 24 * 60 * 60 * 1000; // 30 jours
const empreinte = (jeton) => createHash('sha256').update(jeton).digest('hex');
// Date au format DATETIME de MySQL, en UTC : '2026-11-02 08:15:00'
export const dateSql = (ms = Date.now()) => new Date(ms).toISOString().slice(0, 19).replace('T', ' ');
const sessionExpiree = () => new ErreurApi(401, 'session-expiree', 'Session expirée', 'Reconnecte-toi.');
export function signerAcces(utilisateur) {
return jwt.sign({ role: utilisateur.role }, config.jwtSecret, {
subject: String(utilisateur.id),
expiresIn: DUREE_ACCES,
algorithm: 'HS256',
});
}
// Un jeton aléatoire de 256 bits ; la base n'en garde que l'empreinte SHA-256.
export async function creerRafraichissement(idUtilisateur, famille = randomUUID()) {
const jeton = randomBytes(32).toString('base64url');
await pool.execute(
'INSERT INTO jeton_rafraichissement (id_utilisateur, famille, empreinte, expire_le) VALUES (?, ?, ?, ?)',
[idUtilisateur, famille, empreinte(jeton), dateSql(Date.now() + DUREE_RAFRAICHISSEMENT_MS)],
);
return jeton;
}
// Rotation : le jeton présenté est consommé, une nouvelle paire est émise dans la même famille.
export async function tourner(jeton) {
const [[ligne]] = await pool.execute(
'SELECT id, id_utilisateur, famille, expire_le, revoque FROM jeton_rafraichissement WHERE empreinte = ?',
[empreinte(jeton)],
);
if (!ligne || ligne.revoque || ligne.expire_le < dateSql()) throw sessionExpiree();
// « AND utilise_le IS NULL » : un jeton ne sert qu'une fois, même si deux requêtes arrivent ensemble.
const [resultat] = await pool.execute(
'UPDATE jeton_rafraichissement SET utilise_le = ? WHERE id = ? AND utilise_le IS NULL',
[dateSql(), ligne.id],
);
if (resultat.affectedRows === 0) {
// Jeton déjà utilisé : il a peut-être été volé. Toute la session (la famille) est révoquée.
await pool.execute('UPDATE jeton_rafraichissement SET revoque = TRUE WHERE famille = ?', [ligne.famille]);
throw new ErreurApi(401, 'session-revoquee', 'Session révoquée', 'Jeton déjà utilisé : reconnecte-toi.');
}
const [[utilisateur]] = await pool.execute('SELECT id, role FROM utilisateur WHERE id = ?', [ligne.id_utilisateur]);
return {
accessToken: signerAcces(utilisateur),
refreshToken: await creerRafraichissement(utilisateur.id, ligne.famille),
};
}
// Déconnexion : toute la famille du jeton est révoquée ; un jeton inconnu ne change rien.
export async function revoquer(jeton) {
const [[ligne]] = await pool.execute(
'SELECT famille FROM jeton_rafraichissement WHERE empreinte = ?',
[empreinte(jeton)],
);
if (ligne) await pool.execute('UPDATE jeton_rafraichissement SET revoque = TRUE WHERE famille = ?', [ligne.famille]);
}
Les idées à retenir :
- La famille. Chaque connexion crée une famille (un UUID) ; chaque rotation crée un nouveau jeton dans la même famille et marque l'ancien comme utilisé. Si un jeton déjà utilisé revient, deux personnes ont le même jeton : Lucas et peut-être un voleur. L'API ne sait pas lequel est légitime, alors elle révoque toute la famille, et seul celui qui connaît le mot de passe pourra se reconnecter. C'est la détection de réutilisation décrite dans le cours API REST.
- La condition dans le
UPDATE. Lireutilise_lepuis l'écrire en deux temps laisserait passer deux requêtes simultanées.WHERE … AND utilise_le IS NULLfait le test et l'écriture en une seule instruction : la seconde requête modifie 0 ligne, etaffectedRowsle dit. - Les dates en UTC, calculées par JavaScript au format
AAAA-MM-JJ hh:mm:ss. Dans ce format, l'ordre alphabétique des chaînes est l'ordre chronologique :expire_le < dateSql()compare donc bien deux dates. - Le prix de la rotation. Si la réponse d'un rafraîchissement se perd (le train entre dans un tunnel), le téléphone garde l'ancien jeton, déjà utilisé : au rafraîchissement suivant, la famille est révoquée et Lucas doit se reconnecter. C'est rare et sans perte de données (les frais attendent dans la file) ; certaines API tolèrent l'ancien jeton quelques secondes pour l'éviter.
Les routes de connexion, de rafraîchissement et de déconnexion
La connexion du cours Node.js change sur deux lignes : elle signe le jeton d'accès avec signerAcces et crée un jeton de rafraîchissement. Les deux nouvelles routes sont courtes :
// src/controllers/auth.controller.js (extraits)
import { creerRafraichissement, DUREE_ACCES, revoquer, signerAcces, tourner } from '../services/jetons.service.js';
export async function connexion(req, res) {
// … vérification du login et du mot de passe, inchangée …
const accessToken = signerAcces(utilisateur);
const refreshToken = await creerRafraichissement(utilisateur.id); // une nouvelle famille
req.log.info({ utilisateurId: utilisateur.id }, 'Connexion réussie');
res.set('Cache-Control', 'no-store').json({ accessToken, tokenType: 'Bearer', expiresIn: DUREE_ACCES, refreshToken });
}
export async function rafraichissement(req, res) {
const { accessToken, refreshToken } = await tourner(req.valide.body.refreshToken);
res.set('Cache-Control', 'no-store').json({ accessToken, tokenType: 'Bearer', expiresIn: DUREE_ACCES, refreshToken });
}
export async function deconnexion(req, res) {
await revoquer(req.valide.body.refreshToken);
res.sendStatus(204); // même réponse pour un jeton inconnu : il n'y a rien à apprendre ici
}
// src/schemas/fiches.schemas.js (ajout)
export const schemaJetonRafraichissement = z.strictObject({
refreshToken: z.string().min(20).max(100),
});
// src/routes/auth.routes.js (ajout)
routeurAuth.post('/rafraichissement', valider({ body: schemaJetonRafraichissement }), rafraichissement);
routeurAuth.post('/deconnexion', valider({ body: schemaJetonRafraichissement }), deconnexion);
Ces deux routes ne passent pas par authentifier : le jeton d'accès est justement expiré quand on rafraîchit, et c'est le jeton de rafraîchissement qui prouve la session. Elles n'ont pas besoin de la limitation de débit de la connexion : un jeton aléatoire de 256 bits ne se devine pas.
La clé d'idempotence côté serveur
Une requête est idempotente quand la rejouer ne change rien de plus que la première fois. PUT et DELETE le sont par nature ; POST, qui crée, ne l'est pas : deux envois créent deux frais. La clé d'idempotence corrige cela. Le client envoie un en-tête Idempotency-Key avec chaque création de frais. Le middleware idempotence retient, pour chaque visiteur et chaque clé, l'empreinte de la requête et la réponse envoyée :
// src/middlewares/idempotence.js
import { createHash } from 'node:crypto';
import { pool } from '../db.js';
import { ErreurApi } from '../erreurs.js';
import { dateSql } from '../services/jetons.service.js';
const DUREE_CLE_MS = 24 * 60 * 60 * 1000; // une clé vaut 24 heures
// Même clé et même requête : la réponse d'origine est rejouée, rien n'est créé une deuxième fois.
// À placer après authentifier et valider.
export async function idempotence(req, res, next) {
const cle = req.get('Idempotency-Key');
if (cle === undefined) return next(); // l'en-tête est facultatif
if (!/^[\w-]{8,64}$/.test(cle)) {
throw new ErreurApi(400, 'cle-idempotence-invalide', "Clé d'idempotence invalide",
'De 8 à 64 caractères : lettres, chiffres, tirets.');
}
const idUtilisateur = req.utilisateur.id;
const empreinte = createHash('sha256')
.update(`${req.method} ${req.originalUrl} ${JSON.stringify(req.valide.body)}`)
.digest('hex');
await pool.execute(
'DELETE FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ? AND cree_le < ?',
[idUtilisateur, cle, dateSql(Date.now() - DUREE_CLE_MS)],
);
try {
await pool.execute(
'INSERT INTO cle_idempotence (id_utilisateur, cle, empreinte, cree_le) VALUES (?, ?, ?, ?)',
[idUtilisateur, cle, empreinte, dateSql()],
);
} catch (erreur) {
if (erreur.code !== 'ER_DUP_ENTRY') throw erreur;
// La clé est connue : c'est un renvoi.
const [[connue]] = await pool.execute(
'SELECT empreinte, statut, reponse FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ?',
[idUtilisateur, cle],
);
if (connue.empreinte !== empreinte) {
throw new ErreurApi(422, 'cle-deja-utilisee', 'Clé déjà utilisée', 'Cette clé a servi pour une autre requête.');
}
if (connue.statut === null) {
throw new ErreurApi(409, 'requete-en-cours', 'Requête en cours', 'La même requête est encore en traitement.');
}
return res.status(connue.statut).json(JSON.parse(connue.reponse));
}
// Première fois : la route travaille ; sa réponse est enregistrée avant de partir.
const envoyerJson = res.json.bind(res);
res.json = async (corps) => {
try {
if (res.statusCode < 300) {
await pool.execute(
'UPDATE cle_idempotence SET statut = ?, reponse = ? WHERE id_utilisateur = ? AND cle = ?',
[res.statusCode, JSON.stringify(corps), idUtilisateur, cle],
);
} else {
// Refus (404, 409, 422…) : la clé est libérée, le client pourra renvoyer une requête corrigée.
await pool.execute('DELETE FROM cle_idempotence WHERE id_utilisateur = ? AND cle = ?', [idUtilisateur, cle]);
}
} catch (erreur) {
req.log.error(erreur, "Clé d'idempotence non enregistrée");
}
return envoyerJson(corps);
};
next();
}
// src/routes/fiches.routes.js : le middleware se place après la validation
routeurFiches.post('/:id/frais-hors-forfait', exigerRole('visiteur'),
valider({ params: schemaId, body: schemaFraisHorsForfait }), idempotence, fiches.ajouterFraisHorsForfait);
Quatre cas se présentent :
| Situation | Réponse de l'API |
|---|---|
| Clé inconnue (ou de plus de 24 heures) | La route crée le frais ; la réponse 201 est enregistrée avec la clé |
| Même clé, même requête, déjà traitée | La réponse d'origine est rejouée (201 et le même frais), sans nouvelle insertion |
| Même clé, requête différente | 422 : une clé identifie une requête ; la réutiliser pour un autre frais est une erreur du client |
| Même clé, première requête pas encore finie | 409 de type requete-en-cours : la file d'attente du chapitre 7 garde le frais et réessaiera |
- La clé appartient à un visiteur (clé primaire
id_utilisateur, cle) : un autre visiteur qui enverrait la même clé ne recevrait jamais le frais de Lucas. - L'empreinte est calculée sur les données validées (
req.valide.body), dans l'ordre des champs du schéma Zod : le même frais donne toujours la même empreinte, que l'envoi vienne du formulaire ou de la file d'attente. - Une requête refusée libère sa clé. Après un
422, Lucas corrige le formulaire et renvoie avec la même clé : c'est une nouvelle requête légitime. - L'insertion de la clé avant le travail règle le cas de deux envois simultanés : la clé primaire n'accepte qu'une ligne, le second envoi reçoit
ER_DUP_ENTRYet voit la requête en cours.
Les appareils pour les notifications
L'application envoie le jeton push de l'appareil avec PUT /v1/appareils (chapitre 9). L'API le range dans la table appareil, lié au visiteur connecté.
// src/schemas/fiches.schemas.js (ajout)
export const schemaAppareil = z.strictObject({
jetonPush: z.string().max(255).regex(/^Expo(nent)?PushToken\[.+\]$/, 'Jeton Expo attendu'),
plateforme: z.enum(['android', 'ios']),
});
// src/routes/appareils.routes.js
import { Router } from 'express';
import { pool } from '../db.js';
import { authentifier } from '../middlewares/authentifier.js';
import { exigerRole } from '../middlewares/autoriser.js';
import { valider } from '../middlewares/valider.js';
import { schemaAppareil } from '../schemas/fiches.schemas.js';
import { dateSql } from '../services/jetons.service.js';
export const routeurAppareils = Router();
routeurAppareils.use(authentifier, exigerRole('visiteur'));
// PUT : enregistrer deux fois le même jeton ne crée pas deux lignes.
routeurAppareils.put('/', valider({ body: schemaAppareil }), async (req, res) => {
const { jetonPush, plateforme } = req.valide.body;
// Le visiteur vient du jeton d'accès, jamais du corps. REPLACE : un téléphone prêté change de propriétaire.
await pool.execute(
'REPLACE INTO appareil (jeton_push, id_utilisateur, plateforme, maj_le) VALUES (?, ?, ?, ?)',
[jetonPush, req.utilisateur.id, plateforme, dateSql()],
);
res.sendStatus(204);
});
// src/app.js (ajout, avec les autres routeurs)
app.use('/v1/appareils', routeurAppareils);
REPLACE INTO remplace la ligne qui a la même clé primaire, ou l'insère si elle n'existe pas : si Sarah se connecte sur le téléphone que Lucas utilisait, les notifications iront à Sarah. Pour rester court, la requête SQL est écrite dans la route ; dans ton projet, range-la dans un dépôt appareils.depot.js, comme les autres. L'envoi des notifications par l'API est présenté au chapitre 9.
Exercice 1 sur 4 Le jeton rejoué Réussi À revoir
Lucas se connecte : l'API lui remet le jeton de rafraîchissement R1, dans la famille F. Plus tard, l'application échange R1 contre R2. Un attaquant qui avait copié R1 le présente alors à POST /v1/auth/rafraichissement.
- Que répond l'API à l'attaquant, et que devient R2 ?
- Que voit Lucas à son prochain rafraîchissement ?
- La veille, Lucas s'était aussi connecté sur sa tablette (famille G). Est-elle touchée ?
Voir la réponseMasquer la réponse de l'exercice 1
- R1 existe, n'est ni expiré ni révoqué, mais son
utilise_leest déjà rempli : leUPDATE … AND utilise_le IS NULLmodifie 0 ligne. L'API révoque toute la famille F, donc R2 aussi, et répond401de typesession-revoquee. - Son rafraîchissement avec R2 reçoit
401de typesession-expiree(R2 est révoqué). Le client appellesurDeconnexionet affiche l'écran de connexion : Lucas tape son mot de passe, ce que l'attaquant ne peut pas faire. - Non : la révocation porte sur la famille F seulement. La session de la tablette, dans la famille G, continue de fonctionner.
Tu avais trouvé ?
Exercice 2 sur 4 Quelle réponse pour quelle clé ? Réussi À revoir
Lucas est connecté. Donne le code de statut de chaque requête, envoyée dans cet ordre, et dis si un frais est créé. Le corps B vaut { "date": "2026-10-20", "libelle": "Parking gare", "montant": 12 }.
POST /v1/fiches/12/frais-hors-forfait, clécle-aaaa-1, corps B.- La même requête, rejouée par la file d'attente.
- Clé
cle-aaaa-1, corps B avec"montant": 13. POST /v1/fiches/11/frais-hors-forfait(fiche validée), clécle-bbbb-2, corps B.POST /v1/fiches/12/frais-hors-forfait, clécle-bbbb-2, corps B.- Clé
abc, corps B.
Voir la réponseMasquer la réponse de l'exercice 2
201: le frais est créé (le n° 60 avec le jeu de données du cours Node.js), et la réponse est enregistrée avec la clé.201avec le même frais n° 60 : la réponse d'origine est rejouée, rien n'est inséré.422de typecle-deja-utilisee: même clé, mais l'empreinte du corps a changé.409de typefiche-cloturee(la fiche n'est pas en cours). Comme la réponse est un refus, la clécle-bbbb-2est supprimée.201: la clécle-bbbb-2est de nouveau libre, un nouveau frais (n° 61) est créé.400de typecle-idempotence-invalide: la clé fait moins de 8 caractères. Rien n'est créé.
La fiche 12 compte alors trois frais hors forfait : le n° 58 du jeu de données, et les n° 60 et 61.
Tu avais trouvé ?
Exercice 3 sur 4 Une empreinte plutôt que le jeton Réussi À revoir
- Un attaquant obtient une copie de la table
jeton_rafraichissement. Que pourrait-il faire si la colonne contenait les jetons eux-mêmes ? Et avec les empreintes SHA-256 ? - Pour les mots de passe, le cours Node.js utilise argon2, lent et salé. Pourquoi ne pas l'utiliser ici aussi ?
Voir la réponseMasquer la réponse de l'exercice 3
- Avec les jetons en clair, il pourrait appeler
POST /v1/auth/rafraichissementavec chacun d'eux et obtenir un jeton d'accès au nom de chaque utilisateur, pendant 30 jours. Avec les empreintes, il ne peut rien faire : retrouver un jeton aléatoire de 256 bits à partir de son SHA-256 est hors de portée. - argon2 sert à ralentir l'attaque par dictionnaire sur un mot de passe choisi par un humain, donc devinable. Un jeton de 256 bits aléatoires ne se devine pas : la lenteur n'apporte rien. Et le sel d'argon2 empêcherait de retrouver le jeton avec
WHERE empreinte = ?: il faudrait tester chaque ligne de la table. SHA-256, sans sel, donne toujours la même empreinte pour le même jeton, ce qui permet la recherche par l'indexUNIQUE.
Tu avais trouvé ?
Exercice 4 sur 4 Notifier le visiteur Réussi À revoir
Quand le comptable valide la fiche 11, l'API doit envoyer une notification à tous les appareils du visiteur de cette fiche. Écris la requête SQL qui récupère leurs jetons push, puis celle qui supprime un jeton quand le service d'Expo répond DeviceNotRegistered.
Voir la réponseMasquer la réponse de l'exercice 4
-- Les jetons des appareils du visiteur de la fiche
SELECT a.jeton_push
FROM appareil a
JOIN fiche_frais f ON f.id_visiteur = a.id_utilisateur
WHERE f.id = ?;
-- Un appareil désinstallé ou qui a coupé les notifications
DELETE FROM appareil WHERE jeton_push = ?;
Les deux requêtes sont préparées : l'identifiant de la fiche et le jeton passent en paramètres, par exemple pool.execute(sql, [11]). La première renvoie la liste à passer à notifierValidation (chapitre 9).
Tu avais trouvé ?