Pourquoi les comptes 401s concurrents déconnectent les utilisateurs, et la solution de mise à jour en mode unique
Comment les demandes parallèles et le renouvellement des tokens de mise à jour s’affrontent, entraînant des déconnexions inattendues, et comment une promesse de renouvellement partagée dans un intercepteur Axios permet d’y remédier.
Imaginez une plateforme de gestion des dossiers utilisée par le personnel sur le terrain. Les agents saisissent les données d’inspection ou d’examen sur des tablettes, souvent via des connexions mobiles peu fiables. Un rapport arrive, puis un autre d’un utilisateur différent : l’application les a déconnectés alors qu’ils remplissaient un formulaire. Dans un système de ce type, ce n’est pas une petite gêne ; cela peut signifier de devoir réentrer soigneusement les mêmes données pendant vingt minutes.
Le suspect évident est l’expiration des jetons, et à première vue cela semble innocent. Les tokens d’accès durent 15 minutes. Lorsqu’une requête API renvoie 401, le client appelle l’endpoint de renouvellement, enregistre le nouveau token et continue. Rien dans cette description ne devrait interrompre une session en cours d’utilisation. Lorsque l’explication évidente semble valable, la démarche la plus efficace est de cesser de se fier à sa propre interprétation du code et de reproduire l’échec.
Reproduire le problème lié à l’expiration des jetons
Ce bug ne se manifeste qu’à une seule condition : plusieurs appels API effectués très rapidement, juste au moment où le jeton d’accès expire. Un formulaire à plusieurs sections constitue un déclencheur idéal. Il peut envoyer une demande de validation, une demande d’enregistrement automatique et une vérification de l’état du téléchargement de fichier à quelques millisecondes d’intervalle. Si le jeton expire pendant cette période, tous ces appels renvoient presque simultanément un code 401.
L’intercepteur a été conçu pour le cas d’un seul appel : en cas de réponse 401, on appelle l’endpoint de renouvellement, on reçoit un nouveau jeton, puis on réexécute la demande initiale. Testé avec un appel à la fois, il fonctionne parfaitement. Cependant, lorsque quatre appels échouent en même temps, il lance quatre demandes de renouvellement indépendantes simultanément.
Cela entre en conflit avec une décision logique du backend : la rotation des tokens de renouvellement. Lorsqu’un token de renouvellement est utilisé, le serveur le rend invalide et en émet un nouveau, de sorte qu’un token volé ne peut pas être réutilisé indéfiniment. Examinons maintenant les quatre tentatives de renouvellement simultanées :
- La première demande de renouvellement réussit et reçoit un nouveau token de renouvellement.
- La deuxième, la troisième et la quatrième ont déjà été envoyées avec l’ancien token de renouvellement, avant même l’arrivée de la première réponse.
- Le serveur les rejette, car ce token vient d’être rendu invalide.
- L’intercepteur interprète un échec de renouvellement comme « la session est réellement terminée » et déconnecte l’utilisateur.
La durée de vie du jeton d’accès n’a jamais été le problème. L’hypothèse erronée était que les renouvellements ne se produisaient jamais en même temps. De nombreuses implementations de rotation vont plus loin et considèrent la réutilisation d’un ancien jeton de renouvellement comme un signe de vol, annulant ainsi toute la famille de jetons, ce qui transforme ce même problème en un processus de déconnexion encore plus agressif.
Pourquoi la lecture du code ne l’a pas révélé
C’est sans doute une leçon plus précieuse que la correction elle-même. En lisant du haut vers le bas, l’intercepteur est correct : capturer le 401, renouveler, réessayer. Telle est la séquence pour laquelle il a été conçu, et en le relisant, on ne fait que confirmer cette séquence.
Ce que la lecture cache, c’est le fait que l’intercepteur ne s’exécute pas une seule fois. Il s’exécute une fois par demande échouée, et ces demandes sont simultanées, non séquentielles. Le débogage en le traitant comme un script linéaire signifie raisonner sur ce que fait chaque étape tout en ignorant le moment où elle a lieu.
Dès que vous enregistrez une date et heure pour chaque appel de mise à jour, le schéma devient presque immédiatement visible. Dans un tel scénario, on observe quatre tentatives de mise à jour en l’espace d’environ 40 millisecondes, toutes dirigées vers le même point de terminaison. Une heure passée à examiner la logique peut être remplacée par deux minutes consacrées à l’analyse des temps d’exécution. Lorsqu’un bug « ne peut pas se produire » selon le code, l’analyse de l’ordre et du timing des événements est souvent plus rapide que de relire le code.
La solution : une seule mise à jour en cours, tout le reste attend
Lorsque la véritable nature du problème devient claire, la solution est simple. Au lieu de permettre à chaque erreur 401 de déclencher un renouvellement indépendant, l’intercepteur vérifie s’il y a déjà un renouvellement en cours. S’il y en a un, la nouvelle erreur ne déclenche pas un autre ; elle attend sur la même promesse en attente et tente à nouveau lorsque cette promesse est résolue. Cela est souvent appelé le pattern single-flight.
La première composante est une variable au niveau du module qui conserve l’information sur le renouvellement en cours, ou null lorsque aucun renouvellement n’est actif :
let refreshPromise = null;
401. Si refreshPromise est vide, il appelle refreshAccessToken() et stocke la promesse résultante, en y attachant un bloc finally qui réinitialise la variable à null, que le renouvellement réussisse ou échoue, afin que la prochaine expiration puisse déclencher une nouvelle tentative. Chaque appelant, le premier comme tous les suivants, attend alors cette même promesse, définit le nouveau token d’authentification sur la configuration de la requête originale, et réexécute la requête via axios.
async function handleUnauthorized(originalRequest) {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
} const newToken = await refreshPromise;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
return axios(originalRequest);
}
La syntaxe importe moins que l’idée : une seule promesse partagée à laquelle attendent toutes les requêtes échouées, au lieu que chacune tente de se mettre à jour en même temps. Le premier code 401 déclenche la mise à jour ; tout autre code 401 reçu pendant ce processus s’appuie sur ce résultat plutôt que d’utiliser à nouveau le jeton de mise à jour. Comme JavaScript exécute ce code sur un seul thread, la vérification et l’affectation de refreshPromise ne peuvent pas se superposer entre les différentes appels, ce qui rend un tel mécanisme de protection suffisant.
Quelques détails doivent être pris en compte lorsqu’on l’intègre dans un véritable intercepteur :
- Si la mise à jour elle-même échoue, toutes les requêtes en attente sont rejetées simultanément, permettant ainsi à l’application d’exécuter un seul processus de déconnexion propre plutôt que plusieurs processus concurrents.
_retry dans la configuration) afin qu’une requête qui échoue avec 401 après un renouvellement ne crée pas une boucle infinie.401 provenant de l’endpoint de renouvellement tentera de se renouveler lui-même.Pour la partie serveur de ce même schéma, y compris la rotation et la révocation des tokens, consultez notre guide sur la stratégie de tokens de renouvellement pour les systèmes d’authentification Node.js.
Points clés
Avec la mise en place du renouvellement unique, les déconnexions inattendues cessent. Le changement plus important est une habitude : considérer les appels simultanés comme le cas par défaut, et non comme un cas particulier à gérer ultérieurement. Chaque fois que vous écrivez un intercepteur, une file d’attente ou tout autre gestionnaire qui réagit à une erreur asynchrone, demandez-vous ce qui se passerait s’il s’exécutait quatre fois dans une fenêtre de cinquante millisecondes. Le trafic en production provenant de formulaires chargés et de connexions mobiles instables provoquera tôt ou tard cette situation.
- Le bug ne concernait pas vraiment les tokens de renouvellement ; il s’agissait plutôt de code qui supposait que les événements se produisaient un par un.
- La rotation des tokens de renouvellement est une bonne pratique de sécurité, mais c’est précisément elle qui expose les demandes de renouvellement dupliquées.
- Un code qui semble correct lorsqu’on le lit ligne par ligne peut encore échouer en situation de concurrence ; enregistrez les timestamps pour savoir quand les événements se produisent, et non seulement ce qui se produit.
finally, et protégez-vous contre les boucles de tentative.Lectures complémentaires
- Mettre en œuvre des tokens d’accès et de réapprovisionnement ensemble dans Node.js — Apprenez comment combiner des tokens d’accès à courte durée de vie avec des tokens de réapprovisionnement rotatifs dans Node.js pour équilibrer sécurité et sessions utilisateur fluides.
- Pourquoi les appels de backeffect dans React ne devraient jamais être des fonctions asynchrones — Découvrez pourquoi renvoyer une fonction asynchrone depuis useEffect viole le contrat de nettoyage de React, et découvrez quatre modèles corrects pour gérer la logique asynchrone en toute sécurité.
- Express et Axios : mise à jour du token, suivi demande par demande — Créez un flux d’accès et de mise à jour JWT avec un backend Express et un client Axios, puis suivez comment une erreur 401 se transforme en une mise à jour partagée et en une tentative de réessai transparente.