Corriger les conditions de concurrence lors du rafraîchissement de tokens avec l'API Web Locks
Corrigez les courses de rafraîchissement de jeton entre onglets avec Web Locks API, navigator.locks.request et une revalidation du jeton.
Lorsque plusieurs onglets partagent un même refresh token rotatif, l’appel de rafraîchissement du premier onglet peut invalider le token que les autres onglets sont sur le point d’envoyer. Leurs rafraîchissements échouent alors, et l’utilisateur peut se retrouver déconnecté de tous les onglets en même temps. La solution consiste à encapsuler le rafraîchissement dans navigator.locks.request afin qu’un seul onglet l’exécute pendant que les autres attendent, puis réutilisent le token qu’il a stocké.
Si vous enquêtez sur un rapport de bug qui indique « j’ai été déconnecté sans rien faire » et que le problème ne se reproduit jamais sur votre machine, demandez combien d’onglets la personne avait ouverts. Une file d’attente de rafraîchissement mono-onglet dans votre intercepteur est correcte dans son périmètre, mais elle ne peut pas détecter cette situation de concurrence.
Cet article détaille la séquence qui produit la déconnexion, pourquoi un indicateur dans localStorage n’est pas une solution fiable, et les trois éléments du correctif : le verrou, la re-vérification à l’intérieur de celui-ci, et le câblage de l’intercepteur.
Points clés
- Lorsque plusieurs onglets rafraîchissent simultanément un token rotatif, seul le premier appel réussit ; le serveur invalide le token que tous les autres onglets sont sur le point d’envoyer, déconnectant l’utilisateur partout.
navigator.locks.requestfournit à chaque onglet, iframe et worker de même origine un mutex partagé, et son statut est Baseline « largement disponible », donc aucun code de repli n’est nécessaire.- Un indicateur dans
localStoragen’est pas un verrou : il n’existe pas d’opération atomique compare-and-set, et un indicateur écrit par un onglet qui a planté reste bloqué, alors qu’un Web Lock est libéré automatiquement. - Les onglets en attente doivent re-vérifier le token stocké à l’intérieur du callback du verrou et se baser sur l’expiration propre du token, et non sur un horodatage du type « rafraîchi récemment ».
- Correctement mise en œuvre, cette approche fait qu’un nombre quelconque d’onglets confrontés à un token expiré ne produit qu’un seul rafraîchissement réseau.
Pourquoi un refresh token échoue-t-il avec plusieurs onglets ?
Cette condition de concurrence requiert quatre ingrédients : des access tokens à durée de vie courte, un endpoint de rafraîchissement, la rotation des refresh tokens, et plus d’un onglet. Ces quatre éléments sont courants. La RFC 9700, qui définit les meilleures pratiques de sécurité actuelles pour OAuth 2.0, laisse aux clients publics deux options concernant les refresh tokens : lier chacun d’eux au client auquel il a été délivré, ou en émettre un nouveau à chaque utilisation. Cela fait de la rotation la posture par défaut pour les SPA.
La séquence : l’utilisateur a trois onglets ouverts et l’access token expire. La requête suivante de chaque onglet reçoit un 401, et l’intercepteur de chaque onglet appelle indépendamment l’endpoint de rafraîchissement. L’appel de l’onglet A arrive en premier et réussit ; si le serveur applique une rotation et invalide le refresh token précédent lors de son utilisation, les onglets B et C envoient désormais une identification morte. Leurs rafraîchissements échouent, leurs gestionnaires d’erreur traitent un rafraîchissement échoué comme un échec d’authentification terminal, et l’utilisateur est redirigé vers la page de connexion en pleine tâche.
Dans un session replay, ce bug a une signature caractéristique : une redirection vers l’écran de connexion sans aucune interaction utilisateur préalable, se produisant dans chacun des onglets ouverts de l’utilisateur dans la même seconde. C’est cette signature, et non quoi que ce soit dans la console, qui permet de l’identifier.
Pourquoi un état par onglet ou un indicateur localStorage ne peut-il pas résoudre le problème ?
L’indicateur isRefreshing de votre intercepteur et sa file d’attente de promesses vivent dans la mémoire JavaScript d’un seul onglet. Les onglets ne partagent pas leur mémoire, donc l’onglet B ne voit jamais l’indicateur de l’onglet A. La coordination inter-onglets nécessite une primitive au niveau du navigateur.
La solution de contournement traditionnelle, un indicateur « rafraîchissement en cours » dans localStorage, n’est pas un verrou. L’API Web Storage fournit getItem et setItem mais aucune opération atomique compare-and-set ; deux onglets peuvent donc tous deux lire qu’aucun rafraîchissement n’est en cours et tous deux en démarrer un avant que l’une des écritures n’aboutisse. L’indicateur échoue également dans l’autre sens : si l’onglet qui l’a défini plante ou se ferme en cours de rafraîchissement, l’indicateur reste positionné indéfiniment et chaque onglet survivant attend un rafraîchissement qui n’aboutira jamais. Un Web Lock est libéré par le navigateur dès que le document de son détenteur disparaît, ce qui correspond précisément au mode de défaillance qui bloque un indicateur artisanal.
Comment encapsuler le rafraîchissement dans navigator.locks.request ?
L’API Web Locks fournit à chaque onglet, iframe et worker d’une origine un mutex partagé. Avec navigator.locks.request(name, callback), un seul détenteur d’un nom donné exécute son callback à la fois, et le navigateur libère le verrou dès que la promesse retournée par ce callback est réglée, qu’elle soit résolue ou rejetée. Il n’y a aucun appel de déverrouillage à oublier ni de verrou fuité en cas d’échec d’un fetch. Tous les principaux moteurs implémentent cette API depuis que Safari 15.4 l’a ajoutée en mars 2022, raison pour laquelle MDN lui attribue le statut Baseline « largement disponible » ; aucune détection de fonctionnalité ni branche de repli n’est donc justifiée.
async function refreshTokenAcrossTabs() {
return navigator.locks.request("token-refresh", async () => {
const existing = getUsableToken();
if (existing) return existing; // another tab already refreshed
const res = await fetch("/auth/refresh", {
method: "POST",
credentials: "include",
});
if (!res.ok) throw new Error("refresh_failed");
const { accessToken, expiresAt } = await res.json();
writeToken(accessToken, expiresAt);
return accessToken;
});
}
readToken et writeToken sont volontairement abstraits : l’emplacement de stockage des tokens est une décision de sécurité distincte que cet article ne tranche pas, et le verrou fonctionne de la même manière quelle que soit cette décision.
La re-vérification : tester le token, pas l’horloge
L’étape que la plupart des implémentations omettent est la re-vérification à l’intérieur du callback du verrou : un onglet qui a attendu le verrou doit d’abord tester si le token stocké est désormais valide, et si c’est le cas, le retourner sans second appel réseau. Trois onglets se mettent en file d’attente sur le verrou ; le premier effectue l’aller-retour réseau, les deuxième et troisième l’acquièrent ensuite, trouvent un token utilisable et retournent immédiatement. Un seul rafraîchissement réseau, quel que soit le nombre d’onglets.
Conditionnez cette re-vérification au fait que le token stocké est réellement utilisable, et non à l’ancienneté d’un horodatage. Une condition basée sur l’horloge murale du type « rafraîchi au cours des 5 dernières secondes » échoue de deux façons : un rafraîchissement plus lent que la fenêtre amène les onglets en attente à conclure à tort que rien ne s’est passé et à déclencher des doublons, et une horloge cliente décalée invalide la comparaison dans un sens comme dans l’autre. L’expiration propre du token, en revanche, ne peut pas mentir sur elle-même.
const SKEW_MS = 30_000; // tolerate modest clock drift
function getUsableToken() {
const stored = readToken(); // { token, expiresAt } or null
if (!stored) return null;
return stored.expiresAt - SKEW_MS > Date.now() ? stored.token : null;
}
Intégration du verrou dans un intercepteur de 401
Le rôle de l’intercepteur reste inchangé : capturer le 401, obtenir un token frais, rejouer la requête d’origine une seule fois. La seule différence est que l’appel de rafraîchissement est désormais refreshTokenAcrossTabs(), de sorte que la sérialisation s’étend à tous les onglets.
api.interceptors.response.use(
(response) => response,
async (error) => {
const original = error.config;
if (error.response?.status !== 401 || original._retry) {
return Promise.reject(error);
}
if (original.url?.includes("/auth/refresh")) {
await logout(); // the refresh itself failed: session is gone
return Promise.reject(error);
}
original._retry = true;
try {
const token = await refreshTokenAcrossTabs();
original.headers.Authorization = `Bearer ${token}`;
return api(original);
} catch (refreshError) {
await logout();
return Promise.reject(refreshError);
}
}
);
Le garde-fou _retry empêche les boucles, et un 401 provenant de l’endpoint de rafraîchissement lui-même signifie déconnexion, pas nouvelle tentative. Du point de vue d’un onglet en attente, le parcours complet est : 401, mise en file d’attente sur le verrou, acquisition, découverte d’un token valide, retour sans aucun appel réseau, rejeu de la requête d’origine.
Cas limites à connaître
- Web Locks exige un contexte sécurisé ;
http://localhostest considéré comme une origine potentiellement digne de confiance. - Les règles de terminaison de la spécification libèrent les verrous d’un document lors du unload : un verrou ne survit donc jamais à un rechargement ou à une navigation et ne constitue jamais un état durable.
- Limitez la section critique au seul rafraîchissement ; tout autre
awaità l’intérieur bloque tous les onglets. - Ne demandez jamais le même verrou à l’intérieur de son propre callback : la requête interne se met en file d’attente derrière la détention externe et se bloque silencieusement, indéfiniment.
- Le mode partagé, l’élection de leader via
ifAvailableetstealexistent pour d’autres usages ; le rafraîchissement de token n’en a besoin d’aucun. - BroadcastChannel informe les autres onglets qu’un événement s’est produit ; un verrou les empêche de tous l’exécuter en même temps.
Conclusion
Le bug de déconnexion aléatoire est une condition de concurrence de systèmes distribués qui s’exécute sur la machine de l’utilisateur, et le navigateur fournit le mutex qui y met fin. Encapsulez votre rafraîchissement dans navigator.locks.request, faites de la première ligne du callback une vérification de validité du token, et faites passer votre gestionnaire de 401 par ce mécanisme. Reproduisez d’abord le bug avec plusieurs onglets et une durée de vie de token courte, puis appliquez le verrou et observez N appels de rafraîchissement se réduire à un seul.
FAQ
BroadcastChannel peut-il remplacer l'API Web Locks pour le rafraîchissement de token inter-onglets ?
Non. BroadcastChannel est un transport de messages, pas un mutex : il peut annoncer qu'un rafraîchissement a eu lieu, mais rien n'empêche deux onglets d'en démarrer un chacun avant que l'un des messages n'arrive, exactement la même condition de concurrence lecture-puis-action qu'avec un indicateur localStorage. Utilisez navigator.locks.request pour sérialiser le rafraîchissement, et n'ajoutez BroadcastChannel ensuite que si vous souhaitez pousser le nouveau token vers les onglets à l'écoute.
Comment ajouter un délai d'expiration à un appel navigator.locks.request ?
Passez un AbortSignal via l'option signal. Interrompez-le pendant que la requête est encore dans la file d'attente et la promesse est rejetée avec une AbortError ; AbortSignal.timeout vous donne ainsi une échéance pour l'attente. Une fois le verrou accordé, le signal n'a plus aucun effet : un délai d'expiration ne peut donc pas interrompre un callback déjà en cours d'exécution. Associer signal à steal ou ifAvailable provoque un rejet avec une NotSupportedError ; choisissez donc une seule stratégie par requête.
L'API Web Locks fonctionne-t-elle dans les web workers et les service workers ?
Oui. La spécification expose LockManager aux contextes Window et Worker : les dedicated workers, shared workers et service workers peuvent donc appeler navigator.locks.request. Tous les contextes de même origine partagent un unique gestionnaire de verrous, ce qui signifie qu'un worker demandant le verrou « token-refresh » se met en file d'attente avec les onglets demandant le même nom. Le pattern reste donc correct même si une partie de votre logique d'authentification s'exécute hors du thread principal.