Обновление токенов при истечении

В большинстве современных API используется схема авторизации на основе двух токенов:

  • accessToken — короткоживущий токен доступа;
  • refreshToken — долгоживущий токен обновления.

accessToken передаётся практически в каждом HTTP-запросе и используется сервером для проверки прав пользователя. Из соображений безопасности срок его жизни обычно ограничен несколькими минутами или часами.

После истечения времени действия сервер начинает возвращать ошибку:

{
  "status": 401,
  "message": "Unauthorized"
}

или:

{
  "error": "Token expired"
}

Без механизма обновления токена пользователь был бы вынужден повторно проходить авторизацию после каждого истечения accessToken.

RTK Query позволяет централизованно реализовать автоматическое обновление токенов через кастомный baseQuery.


Общая схема обновления токенов

Последовательность работы обычно выглядит следующим образом:

  1. Клиент отправляет запрос с accessToken.
  2. Сервер отвечает ошибкой 401 Unauthorized.
  3. Клиент автоматически отправляет refreshToken на endpoint обновления.
  4. Сервер возвращает новый accessToken.
  5. Клиент сохраняет новый токен.
  6. Исходный запрос автоматически повторяется.
  7. Пользователь продолжает работу без повторного логина.

Базовая структура API

Типичная конфигурация RTK Query начинается с fetchBaseQuery.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('Authorization', `Bearer ${token}`)
        }

        return headers
    }
})

В этом примере:

  • токен берётся из Redux Store;
  • заголовок Authorization добавляется автоматически;
  • все endpoints используют единый механизм авторизации.

Проблема стандартного fetchBaseQuery

Стандартный fetchBaseQuery умеет:

  • отправлять запросы;
  • обрабатывать ошибки;
  • сериализовать JSON;
  • работать с заголовками.

Однако он не умеет:

  • автоматически обновлять токен;
  • повторять запросы;
  • синхронизировать refresh-процесс;
  • предотвращать множественные refresh-запросы.

Для решения этих задач создаётся обёртка над baseQuery.


Создание кастомного baseQuery

Основная идея — перехватывать ошибки 401.

import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('Authorization', `Bearer ${token}`)
        }

        return headers
    }
})

const baseQueryWithReauth = async (args, api, extraOptions) => {
    let result = await rawBaseQuery(args, api, extraOptions)

    if (result.error && result.error.status === 401) {
        const refreshResult = await rawBaseQuery(
            {
                url: '/auth/refresh',
                method: 'POST',
                body: {
                    refreshToken: api.getState().auth.refreshToken
                }
            },
            api,
            extraOptions
        )

        if (refreshResult.data) {
            api.dispatch(setCredentials(refreshResult.data))

            result = await rawBaseQuery(args, api, extraOptions)
        } else {
            api.dispatch(logout())
        }
    }

    return result
}

Подключение кастомного baseQuery

После создания обёртки необходимо подключить её в createApi.

export const api = createApi({
    reducerPath: 'api',
    baseQuery: baseQueryWithReauth,
    endpoints: () => ({})
})

Теперь:

  • все запросы автоматически используют обновление токена;
  • логика централизована;
  • endpoints остаются чистыми и простыми.

Сохранение новых токенов

После успешного refresh необходимо обновить Redux Store.

Пример slice:

import { createSlice } from '@reduxjs/toolkit'

const initialState = {
    accessToken: null,
    refreshToken: null,
    user: null
}

const authSlice = createSlice({
    name: 'auth',
    initialState,
    reducers: {
        setCredentials: (state, action) => {
            state.accessToken = action.payload.accessToken
            state.refreshToken = action.payload.refreshToken
        },

        logout: (state) => {
            state.accessToken = null
            state.refreshToken = null
            state.user = null
        }
    }
})

export const {
    setCredentials,
    logout
} = authSlice.actions

export default authSlice.reducer

Повтор исходного запроса

Ключевой особенностью RTK Query является возможность повторно выполнить тот же запрос.

result = await rawBaseQuery(args, api, extraOptions)

args содержит:

  • URL;
  • метод;
  • body;
  • query parameters;
  • заголовки.

Повторный вызов полностью воспроизводит исходный запрос.


Разделение accessToken и refreshToken

Практически всегда рекомендуется хранить токены отдельно.

Access Token

Используется:

  • в заголовке Authorization;
  • для доступа к API;
  • имеет короткий срок жизни.

Refresh Token

Используется:

  • только для обновления;
  • реже отправляется;
  • имеет больший срок жизни.

Где хранить refreshToken

Существует несколько подходов.

Redux Store

Простой вариант:

state.auth.refreshToken

Недостатки:

  • токен доступен JavaScript;
  • повышается риск XSS-атак.

localStorage

localStorage.setItem('refreshToken', token)

Преимущества:

  • токен сохраняется после перезагрузки страницы.

Недостатки:

  • доступен JavaScript;
  • уязвим к XSS.

Наиболее безопасный вариант.

Особенности:

  • cookie недоступна JavaScript;
  • браузер отправляет её автоматически;
  • защита от XSS значительно выше.

В этом случае refresh-запрос выглядит проще:

const refreshResult = await rawBaseQuery(
    {
        url: '/auth/refresh',
        method: 'POST',
        credentials: 'include'
    },
    api,
    extraOptions
)

Использование credentials: ‘include’

Если refreshToken хранится в cookie, необходимо разрешить передачу cookies.

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    credentials: 'include'
})

Без этого браузер не отправит cookie вместе с запросом.


Предотвращение бесконечного цикла refresh

Одна из самых опасных ошибок — бесконечное обновление токена.

Проблемный сценарий:

  1. accessToken истёк;
  2. refresh-запрос тоже получает 401;
  3. система снова пытается обновить токен;
  4. цикл повторяется бесконечно.

Правильная реализация:

if (refreshResult.data) {
    api.dispatch(setCredentials(refreshResult.data))

    result = await rawBaseQuery(args, api, extraOptions)
} else {
    api.dispatch(logout())
}

Если refresh не удался:

  • пользователь разлогинивается;
  • токены очищаются;
  • повтор refresh не выполняется.

Проверка endpoint refresh

Иногда требуется исключить сам refresh-endpoint из повторного refresh.

Пример:

if (
    result.error &&
    result.error.status === 401 &&
    args.url !== '/auth/refresh'
) {

}

Это дополнительная защита от циклических запросов.


Работа с несколькими одновременными запросами

Сложная проблема возникает при параллельных запросах.

Сценарий:

  1. accessToken истёк;
  2. одновременно отправляется 10 запросов;
  3. все получают 401;
  4. все пытаются выполнить refresh.

Результат:

  • перегрузка сервера;
  • гонки состояний;
  • перезапись токенов;
  • хаотичное поведение приложения.

Mutex для синхронизации refresh

Наиболее популярное решение — mutex.

Для этого часто используется библиотека:

npm install async-mutex

Реализация mutex

import { Mutex } from 'async-mutex'

const mutex = new Mutex()

Полная реализация с mutex

import {
    createApi,
    fetchBaseQuery
} from '@reduxjs/toolkit/query/react'

import { Mutex } from 'async-mutex'

const mutex = new Mutex()

const rawBaseQuery = fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.accessToken

        if (token) {
            headers.set('Authorization', `Bearer ${token}`)
        }

        return headers
    }
})

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {

    await mutex.waitForUnlock()

    let result = await rawBaseQuery(
        args,
        api,
        extraOptions
    )

    if (result.error && result.error.status === 401) {

        if (!mutex.isLocked()) {

            const release = await mutex.acquire()

            try {

                const refreshResult = await rawBaseQuery(
                    {
                        url: '/auth/refresh',
                        method: 'POST',
                        body: {
                            refreshToken:
                                api.getState().auth.refreshToken
                        }
                    },
                    api,
                    extraOptions
                )

                if (refreshResult.data) {

                    api.dispatch(
                        setCredentials(refreshResult.data)
                    )

                    result = await rawBaseQuery(
                        args,
                        api,
                        extraOptions
                    )

                } else {

                    api.dispatch(logout())
                }

            } finally {

                release()
            }

        } else {

            await mutex.waitForUnlock()

            result = await rawBaseQuery(
                args,
                api,
                extraOptions
            )
        }
    }

    return result
}

Как работает mutex

Первый запрос

Первый запрос:

  • получает 401;
  • захватывает mutex;
  • выполняет refresh.

Остальные запросы

Остальные запросы:

  • ждут завершения refresh;
  • не создают новые refresh-запросы;
  • повторяются после получения нового токена.

Автоматический logout

Если refreshToken просрочен:

  • пользователь должен быть разлогинен;
  • состояние должно быть очищено;
  • защищённые данные должны удаляться.

Пример:

api.dispatch(logout())

После этого обычно выполняется:

window.location.href = '/login'

или:

navigate('/login')

Очистка RTK Query cache при logout

Важно очищать API cache.

api.dispatch(api.util.resetApiState())

Без этого:

  • старые данные могут остаться в памяти;
  • другой пользователь увидит предыдущие данные;
  • возможны проблемы безопасности.

Полный logout flow

Часто logout включает:

logout: (state) => {
    state.accessToken = null
    state.refreshToken = null
    state.user = null
}

и:

dispatch(api.util.resetApiState())

Обновление токена заранее

Некоторые приложения не ждут 401.

Подход:

  1. декодировать JWT;
  2. проверять exp;
  3. обновлять токен заранее.

Пример:

const payload = JSON.parse(
    atob(token.split('.')[1])
)

Проверка срока:

const currentTime = Date.now() / 1000

if (payload.exp < currentTime) {

}

Плюсы предварительного refresh

Преимущества:

  • меньше 401;
  • более плавная работа интерфейса;
  • меньше повторных запросов;
  • меньше сетевых ошибок.

Минусы предварительного refresh

Недостатки:

  • усложнение логики;
  • необходимость таймеров;
  • проблемы с синхронизацией вкладок;
  • риск лишних refresh-запросов.

Retry после refresh

Иногда после обновления токена запрос всё равно может завершиться ошибкой.

Например:

  • сервер недоступен;
  • network error;
  • timeout;
  • повреждённый ответ.

RTK Query позволяет комбинировать refresh и retry.

import { retry } from '@reduxjs/toolkit/query'

Пример retry

const staggeredBaseQuery = retry(
    baseQueryWithReauth,
    {
        maxRetries: 3
    }
)

Refresh и WebSocket

Если приложение использует WebSocket:

  • accessToken может использоваться и там;
  • после refresh требуется переподключение.

Пример:

socket.auth.token = newAccessToken

socket.connect()

SSR и refresh токенов

При серверном рендеринге возникают особенности:

  • токены доступны на сервере;
  • cookies читаются иначе;
  • нельзя использовать window.

В SSR-проектах refresh обычно строится через:

  • cookies;
  • server-side middleware;
  • отдельные auth utilities.

Наиболее распространённые ошибки

Обновление токена в каждом endpoint

Неправильно:

queryFn: async () => {

}

Refresh должен быть централизован в baseQuery.


Хранение токенов в нескольких местах

Проблемный вариант:

  • Redux;
  • localStorage;
  • sessionStorage;
  • React state.

Это приводит к рассинхронизации.


Отсутствие mutex

Без mutex:

  • появляются гонки;
  • refresh вызывается многократно;
  • запросы начинают конфликтовать.

Отсутствие logout после refresh failure

Если refresh не удался, но приложение продолжает работу:

  • пользователь зацикливается;
  • интерфейс ломается;
  • запросы постоянно получают 401.

Архитектурная схема

Типичная production-схема:

RTK Query Endpoint
        ↓
baseQueryWithReauth
        ↓
fetchBaseQuery
        ↓
API Request
        ↓
401 Unauthorized
        ↓
Refresh Token Request
        ↓
New Access Token
        ↓
Retry Original Request

Практические рекомендации

Рекомендуемая архитектура

Для production-приложений оптимально:

  • accessToken хранить в памяти;
  • refreshToken хранить в HttpOnly cookie;
  • использовать mutex;
  • централизовать refresh в baseQuery;
  • очищать cache при logout.

Что не рекомендуется

Нежелательные практики:

  • refresh внутри компонентов;
  • refresh внутри каждого endpoint;
  • хранение refreshToken в открытом виде;
  • множественные refresh-запросы;
  • отсутствие retry и logout logic.

Пример production-конфигурации

export const api = createApi({
    reducerPath: 'api',

    baseQuery: retry(
        baseQueryWithReauth,
        {
            maxRetries: 2
        }
    ),

    tagTypes: [
        'User',
        'Post',
        'Comment'
    ],

    endpoints: () => ({})
})

Такая архитектура обеспечивает:

  • автоматическое обновление токенов;
  • повтор запросов;
  • защиту от гонок;
  • централизованную авторизацию;
  • устойчивость к сетевым ошибкам;
  • безопасную работу с истекающими JWT.