Устранение состояний гонки при обновлении токена с помощью Web Locks API
Исправьте гонки обновления токена между вкладками с Web Locks API, navigator.locks.request и повторной проверкой токена.
Когда несколько вкладок используют один ротируемый refresh token, вызов обновления из первой вкладки может инвалидировать токен, который остальные вкладки как раз собираются отправить. Их запросы на обновление завершатся ошибкой, и пользователь может оказаться разлогинен сразу во всех вкладках. Решение — обернуть обновление в navigator.locks.request, чтобы его выполняла ровно одна вкладка, пока остальные ждут, а затем переиспользовать сохранённый ею токен.
Если вы разбираетесь с багрепортом «меня разлогинило, я ничего не делал», который никогда не воспроизводится на вашей машине, спросите, сколько вкладок было открыто у пользователя. Однопоточная очередь обновления в вашем интерсепторе корректна в пределах своей области, но этой гонки она попросту не видит.
В этой статье разбирается последовательность событий, приводящая к разлогину, почему флаг в localStorage не является надёжным решением, и три составляющие правильного подхода: блокировка, повторная проверка внутри неё и подключение к интерсептору.
Ключевые выводы
- Когда несколько вкладок одновременно обновляют ротируемый токен, успешным оказывается только первый вызов; сервер инвалидирует токен, который вот-вот отправит каждая из остальных вкладок, что приводит к разлогину пользователя везде.
navigator.locks.requestпредоставляет всем вкладкам, iframe и воркерам одного origin общий мьютекс, и он имеет статус Baseline Widely available, поэтому код-фоллбэк не нужен.- Флаг в
localStorage— не блокировка: атомарного compare-and-set нет, а флаг, записанный аварийно завершившейся вкладкой, останется навсегда, тогда как Web Lock освобождается автоматически. - Ожидающие вкладки должны повторно проверять сохранённый токен внутри callback-а блокировки и опираться на собственный срок истечения токена, а не на метку времени «недавно обновлялось».
- При корректной реализации любое количество вкладок, столкнувшихся с истёкшим токеном, приведёт ровно к одному сетевому запросу на обновление.
Почему обновление refresh token ломается при нескольких вкладках?
Для возникновения гонки нужны четыре ингредиента: короткоживущие access-токены, endpoint обновления, ротация refresh-токенов и более одной вкладки. Все четыре встречаются повсеместно. RFC 9700, OAuth 2.0 Security Best Current Practice, оставляет публичным клиентам два варианта работы с refresh-токенами: привязывать каждый токен к клиенту, которому он был выдан, либо выдавать новый при каждом использовании. Это делает ротацию поведением по умолчанию для SPA.
Последовательность событий: у пользователя открыто три вкладки, и access-токен истекает. Следующий запрос каждой вкладки получает 401, и интерсептор каждой вкладки независимо вызывает endpoint обновления. Вызов из вкладки A приходит первым и завершается успешно; если сервер выполняет ротацию и инвалидирует предыдущий refresh token при использовании, вкладки B и C теперь отправляют «мёртвые» учётные данные. Их обновление завершается ошибкой, их обработчики ошибок трактуют неудачное обновление как терминальный сбой аутентификации, и пользователя редиректит на страницу входа в середине работы.
В session replay этот баг имеет характерную подпись: редирект на экран входа без каких-либо предшествующих действий пользователя, происходящий во всех открытых вкладках в течение одной секунды. Именно эта подпись, а не что-либо в консоли, позволяет опознать проблему.
Почему состояние на уровне вкладки или флаг в localStorage не решают проблему?
Флаг isRefreshing и очередь промисов вашего интерсептора живут в JavaScript-памяти одной вкладки. Вкладки не разделяют память, поэтому вкладка B никогда не увидит флаг вкладки A. Для координации между вкладками нужен примитив уровня браузера.
Традиционный обходной путь — флаг «обновление в процессе» в localStorage — блокировкой не является. Web Storage API даёт вам getItem и setItem, но не атомарный compare-and-set, поэтому две вкладки могут одновременно прочитать, что обновление не выполняется, и обе начать его до того, как хоть одна запись будет зафиксирована. Флаг подводит и в обратную сторону: если вкладка, установившая его, аварийно завершилась или была закрыта посреди обновления, флаг останется установленным навсегда, и все выжившие вкладки будут ждать обновления, которое никогда не завершится. Web Lock освобождается браузером в тот момент, когда документ его владельца исчезает, — а это ровно тот сценарий, который «заклинивает» самописный флаг.
Как обернуть обновление в navigator.locks.request?
Web Locks API предоставляет всем вкладкам, iframe и воркерам одного origin общий мьютекс. При использовании navigator.locks.request(name, callback) в каждый момент времени callback выполняет только один владелец блокировки с данным именем, и браузер снимает блокировку сразу после того, как возвращённый callback-ом промис завершается — независимо от того, разрешился он или был отклонён. Здесь нет вызова unlock, который можно забыть, и нет утечки блокировки при неудачном fetch. Все основные движки поддерживают этот API с тех пор, как Safari 15.4 добавил его в марте 2022 года, поэтому MDN присваивает ему статус Baseline Widely available — а значит, ни feature detection, ни ветка фоллбэка не требуются.
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 и writeToken намеренно оставлены абстрактными: место хранения токенов — отдельное решение в области безопасности, которое эта статья не принимает, и блокировка работает одинаково в любом случае.
Повторная проверка: проверяйте токен, а не часы
Шаг, который чаще всего пропускают в реализациях, — повторная проверка внутри callback-а блокировки: вкладка, дождавшаяся блокировки, должна сначала проверить, стал ли сохранённый токен валидным, и если да — вернуть его без второго сетевого запроса. Три вкладки становятся в очередь за блокировкой; первая выполняет round trip, вторая и третья получают блокировку после неё, находят пригодный токен и сразу его возвращают. Один сетевой запрос на обновление, независимо от числа вкладок.
Основывайте эту проверку на том, действительно ли сохранённый токен пригоден к использованию, а не на том, насколько недавно была записана метка времени. Условие по «настенным часам» вида «обновлялось в последние 5 секунд» ломается двумя способами: обновление, занявшее больше времени, чем окно, заставит ожидающие вкладки ошибочно заключить, что ничего не произошло, и отправить дубликаты; а расхождение часов клиента делает сравнение недействительным в любую из сторон. Собственный срок истечения токена о себе не соврёт.
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;
}
Подключение блокировки к интерсептору 401
Задача интерсептора остаётся прежней: перехватить 401, получить свежий токен, один раз повторить исходный запрос. Единственное отличие — вызов обновления теперь refreshTokenAcrossTabs(), поэтому сериализация распространяется на все вкладки.
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);
}
}
);
Защита через _retry предотвращает циклы, а 401 от самого endpoint-а обновления означает logout, а не повторную попытку. С точки зрения ожидающей вкладки полный путь таков: 401, постановка в очередь за блокировкой, получение блокировки, обнаружение валидного токена, возврат без единого сетевого запроса, повтор исходного запроса.
Нюансы, о которых стоит знать
- Web Locks требует secure context;
http://localhostсчитается potentially trustworthy origin. - Правила завершения из спецификации освобождают блокировки документа при unload, поэтому блокировка никогда не переживает перезагрузку или навигацию и никогда не является durable state.
- Держите критическую секцию строго в рамках обновления; всё остальное, что вы там await-ите, блокирует все вкладки.
- Никогда не запрашивайте ту же блокировку внутри её собственного callback-а: внутренний запрос встанет в очередь за внешним удержанием и молча зависнет навсегда.
- Shared-режим, leader election через
ifAvailableиstealсуществуют для других задач; для обновления токена ни один из них не нужен. - BroadcastChannel сообщает другим вкладкам, что что-то произошло; блокировка не даёт им всем делать это одновременно.
Заключение
Баг со случайным разлогином — это гонка из мира распределённых систем, выполняющаяся на машине пользователя, и браузер предоставляет мьютекс, который её устраняет. Оберните обновление в navigator.locks.request, сделайте первой строкой callback-а проверку валидности токена и направьте обработчик 401 через него. Сначала воспроизведите баг на нескольких вкладках с коротким временем жизни токена, затем примените блокировку и посмотрите, как N вызовов обновления сожмутся до одного.
Часто задаваемые вопросы
Может ли BroadcastChannel заменить Web Locks API для обновления токена между вкладками?
Нет. BroadcastChannel — это транспорт для сообщений, а не мьютекс: он может объявить, что обновление произошло, но ничто не мешает двум вкладкам начать обновление до того, как любое из сообщений дойдёт, — та же гонка «прочитал, затем действуй», что и у флага в localStorage. Используйте navigator.locks.request для сериализации обновления и добавляйте BroadcastChannel уже потом, только если хотите доставлять новый токен слушающим вкладкам.
Как добавить таймаут к вызову navigator.locks.request?
Передайте AbortSignal через опцию signal. Прервите его, пока запрос ещё стоит в очереди, и промис будет отклонён с AbortError, так что AbortSignal.timeout задаёт дедлайн для ожидания. После того как блокировка выдана, signal перестаёт оказывать какое-либо влияние, поэтому таймаут не может прервать уже выполняющийся callback. Комбинация signal с steal или ifAvailable приводит к отклонению с NotSupportedError, так что выбирайте одну стратегию на запрос.
Работает ли Web Locks API в web workers и service workers?
Да. Спецификация предоставляет LockManager как в контексте Window, так и в контексте Worker, поэтому dedicated workers, shared workers и service workers могут вызывать navigator.locks.request. Все контексты одного origin разделяют один менеджер блокировок, то есть воркер, запрашивающий блокировку 'token-refresh', встаёт в очередь вместе с вкладками, запрашивающими то же имя. Поэтому паттерн остаётся корректным, даже если часть вашей логики аутентификации выполняется вне основного потока.