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

Большинство современных API используют механизм авторизации через токены. Обычно клиент получает токен после успешного входа пользователя в систему, а затем передаёт его в HTTP-заголовках при каждом запросе.

В RTK Query обработка токенов чаще всего реализуется через:

  • prepareHeaders
  • кастомный baseQuery
  • перехват ошибок 401 Unauthorized
  • механизм обновления refresh token
  • хранение токенов в Redux Store, localStorage или cookies

Наиболее распространённый сценарий:

  1. Пользователь выполняет вход.

  2. Сервер возвращает:

    • accessToken
    • refreshToken
  3. accessToken добавляется в заголовки запросов.

  4. При истечении срока действия accessToken выполняется запрос обновления токена.

  5. После обновления повторяется исходный запрос.


Базовая схема авторизации

Типичная структура:

Client
   ↓
POST /login
   ↓
accessToken + refreshToken
   ↓
Все последующие запросы:
Authorization: Bearer <token>

В RTK Query центральной точкой настройки авторизации является fetchBaseQuery.


Добавление токена через prepareHeaders

Самый распространённый способ передачи токена — использование prepareHeaders.

Пример:

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

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

    baseQuery: fetchBaseQuery({
        baseUrl: 'https://api.site.com',

        prepareHeaders: (headers, { getState }) => {
            const token = getState().auth.token

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

            return headers
        }
    }),

    endpoints: (builder) => ({
        getProfile: builder.query({
            query: () => '/profile'
        })
    })
})

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

Функция вызывается перед каждым HTTP-запросом.

Она получает:

(headers, api)

Где:

Аргумент Назначение
headers объект HTTP-заголовков
api служебная информация RTK Query

Доступ к Redux Store

Через второй аргумент можно получить состояние Redux:

prepareHeaders: (headers, { getState }) => {
    const state = getState()

    console.log(state)

    return headers
}

Это позволяет динамически извлекать токен.


Пример authSlice

Обычно токен хранится в отдельном слайсе:

import { createSlice } from '@reduxjs/toolkit'

const initialState = {
    token: null,
    user: null
}

const authSlice = createSlice({
    name: 'auth',

    initialState,

    reducers: {
        setCredentials: (state, action) => {
            state.token = action.payload.token
            state.user = action.payload.user
        },

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

export const {
    setCredentials,
    logout
} = authSlice.actions

export default authSlice.reducer

Авторизация после логина

Mutation для входа:

login: builder.mutation({
    query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials
    })
})

Использование:

const [login] = useLoginMutation()

const handleLogin = async () => {
    const result = await login({
        email: 'admin@mail.com',
        password: '123456'
    }).unwrap()

    dispatch(setCredentials(result))
}

После записи токена в Store все последующие запросы автоматически начнут отправлять Authorization.


Формат Bearer Token

Стандартный формат:

Authorization: Bearer eyJhbGciOiJIUzI1Ni...

Слово Bearer является частью стандарта OAuth2.


Условное добавление токена

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

Пример:

prepareHeaders: (headers, { endpoint, getState }) => {
    const token = getState().auth.token

    const publicEndpoints = [
        'login',
        'register'
    ]

    if (token && !publicEndpoints.includes(endpoint)) {
        headers.set('Authorization', `Bearer ${token}`)
    }

    return headers
}

Использование endpoint

RTK Query передаёт название endpoint:

prepareHeaders: (
    headers,
    { endpoint }
) => {
    console.log(endpoint)

    return headers
}

Это позволяет строить гибкую систему авторизации.


Использование extra

В prepareHeaders также доступен extra.

Пример:

prepareHeaders: (
    headers,
    { extra }
) => {
    console.log(extra)

    return headers
}

Чаще используется в сложных middleware-сценариях.


Использование type

RTK Query сообщает тип запроса:

prepareHeaders: (
    headers,
    { type }
) => {
    console.log(type)

    return headers
}

Возможные значения:

query
mutation

Хранение токена в localStorage

Очень распространённый вариант:

prepareHeaders: (headers) => {
    const token = localStorage.getItem('token')

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

    return headers
}

Недостатки localStorage

localStorage удобен, но имеет недостатки:

Проблема Описание
XSS JavaScript может получить доступ к токену
Нет автоматической защиты Токен хранится в открытом виде
Нет HttpOnly Защита браузером отсутствует

Более безопасный вариант — HttpOnly Cookies

Во многих production-проектах используют cookies:

Set-Cookie: accessToken=...
HttpOnly
Secure
SameSite=Strict

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

  • JavaScript не может прочитать токен
  • защита от XSS
  • браузер автоматически отправляет cookies

Использование credentials

Если сервер использует cookies:

baseQuery: fetchBaseQuery({
    baseUrl: 'https://api.site.com',
    credentials: 'include'
})

Что делает credentials: ‘include’

Браузер начинает автоматически отправлять:

  • cookies
  • session id
  • refresh token в cookie

Access Token и Refresh Token

Обычно применяют двухтокенную схему.

Access Token

Короткоживущий токен:

5 минут
15 минут
30 минут

Используется для API-запросов.


Refresh Token

Долгоживущий токен:

7 дней
30 дней
90 дней

Используется для обновления accessToken.


Почему access token делают коротким

Если токен украден:

короткое время жизни = меньший ущерб

Автоматическое обновление токена

Одна из самых важных задач RTK Query.

Схема:

Запрос → 401
       ↓
refresh token request
       ↓
новый access token
       ↓
повтор исходного запроса

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

Для refresh-механизма почти всегда создают обёртку над fetchBaseQuery.

Пример:

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

const baseQuery = fetchBaseQuery({
    baseUrl: 'https://api.site.com',

    prepareHeaders: (headers, { getState }) => {
        const token = getState().auth.token

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

        return headers
    }
})

Обёртка над baseQuery

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

    if (result.error?.status === 401) {
        const refreshResult = await baseQuery(
            {
                url: '/refresh',
                method: 'POST'
            },
            api,
            extraOptions
        )

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

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

    return result
}

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

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

    endpoints: (builder) => ({
        getProfile: builder.query({
            query: () => '/profile'
        })
    })
})

Что происходит при 401

RTK Query получает:

401 Unauthorized

После этого:

  1. Выполняется /refresh
  2. Получается новый токен
  3. Токен сохраняется
  4. Исходный запрос повторяется

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

Ключевая строка:

result = await baseQuery(
    args,
    api,
    extraOptions
)

Используются те же аргументы запроса.


Возможная проблема — множественные refresh-запросы

Представим:

10 запросов одновременно получили 401

Без защиты произойдёт:

10 refresh запросов

Это создаёт гонки данных.


Mutex-подход

Часто используют async-mutex.

Установка:

npm install async-mutex

Пример mutex

import { Mutex } from 'async-mutex'

const mutex = new Mutex()

Защита от параллельного refresh

await mutex.waitForUnlock()

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

if (result.error?.status === 401) {
    if (!mutex.isLocked()) {
        const release = await mutex.acquire()

        try {
            const refreshResult = await baseQuery(
                {
                    url: '/refresh',
                    method: 'POST'
                },
                api,
                extraOptions
            )

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

                result = await baseQuery(
                    args,
                    api,
                    extraOptions
                )
            } else {
                api.dispatch(logout())
            }
        } finally {
            release()
        }
    } else {
        await mutex.waitForUnlock()

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

Почему mutex важен

Без него:

  • refresh может выполниться несколько раз
  • токены могут перезаписаться
  • часть запросов завершится ошибкой
  • пользователь может быть разлогинен

Обработка logout

При logout важно:

  • удалить токены
  • очистить пользователя
  • очистить кэш RTK Query

Сброс API-кэша

dispatch(api.util.resetApiState())

Полный logout

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

И:

dispatch(api.util.resetApiState())

Почему нужно очищать кэш

Иначе после выхода могут остаться:

  • профиль пользователя
  • приватные данные
  • закэшированные запросы

Повторная авторизация

После логина RTK Query автоматически начнёт использовать новый токен, потому что prepareHeaders вызывается перед каждым запросом.


Использование selectFromResult

Иногда нужно извлекать токен отдельно:

const token = useSelector(
    (state) => state.auth.token
)

Но RTK Query обычно работает напрямую через getState.


Обработка 403 Forbidden

Нужно отличать:

Код Значение
401 токен истёк
403 доступа нет

Типичная логика

if (status === 401) {
    // refresh token
}

if (status === 403) {
    // недостаточно прав
}

Проверка срока действия JWT

JWT содержит payload:

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

Поле exp

JWT обычно содержит:

payload.exp

Это Unix timestamp времени истечения.


Проверка истечения токена

const isExpired =
    Date.now() >= payload.exp * 1000

Зачем проверять exp на клиенте

Это позволяет:

  • заранее обновлять токен
  • избегать лишних 401
  • улучшать UX

Пример proactive refresh

if (isExpired) {
    await refreshToken()
}

Недостатки client-side проверки

Клиенту нельзя полностью доверять.

Сервер всё равно обязан:

  • проверять подпись JWT
  • проверять expiration
  • валидировать refresh token

Использование transformResponse

Иногда токен приходит в нестандартном формате.

Пример:

{
    "data": {
        "access_token": "123"
    }
}

Нормализация ответа

login: builder.mutation({
    query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials
    }),

    transformResponse: (response) => {
        return {
            token: response.data.access_token
        }
    }
})

Использование onQueryStarted

Иногда токен нужно сохранять автоматически.

login: builder.mutation({
    query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials
    }),

    async onQueryStarted(
        arg,
        { dispatch, queryFulfilled }
    ) {
        try {
            const { data } =
                await queryFulfilled

            dispatch(setCredentials(data))
        } catch (error) {
            console.error(error)
        }
    }
})

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

Позволяет:

  • централизовать авторизацию
  • уменьшить код компонентов
  • автоматизировать side effects

Обработка ошибок refresh token

Если refresh тоже вернул 401:

refresh token недействителен

Требуется:

  • logout
  • переход на login page
  • очистка состояния

Redirect после logout

Часто используют:

window.location.href = '/login'

Или:

navigate('/login')

SSR и токены

При Server-Side Rendering появляются особенности:

  • localStorage недоступен
  • cookies доступны на сервере
  • токены нужно извлекать из request headers

Безопасность refresh token

Refresh token нельзя:

  • хранить в открытом виде
  • передавать в URL
  • логировать в консоль
  • сохранять в Redux DevTools

Что обычно хранится в Redux

Обычно:

access token
user info
auth state

Refresh token чаще помещают в HttpOnly cookie.


Обновление заголовков после refresh

После:

dispatch(setCredentials(newToken))

следующий вызов baseQuery автоматически получит новый токен через getState().


Retry после refresh

RTK Query позволяет полностью прозрачно повторять запросы.

Пользовательский интерфейс при этом даже не узнает о refresh-механизме.


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

Наиболее распространённая архитектура:

access token:
- хранится в памяти
- короткое время жизни

refresh token:
- HttpOnly cookie
- long-term

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

Добавление токена вручную в каждом endpoint

Плохо:

headers: {
    Authorization: `Bearer ${token}`
}

в каждом endpoint.

Правильно — централизованный prepareHeaders.


Хранение refresh token в localStorage

Это создаёт серьёзные риски безопасности.


Отсутствие resetApiState при logout

Может привести к утечке приватных данных.


Несколько refresh-запросов одновременно

Решается через mutex.


Полная зависимость от client-side exp проверки

Серверная проверка обязательна всегда.


Архитектура production-уровня

Часто используют:

authSlice
    ↓
baseQueryWithReauth
    ↓
prepareHeaders
    ↓
mutex refresh
    ↓
resetApiState

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

  • централизованную авторизацию
  • автоматическое обновление токенов
  • защиту от гонок
  • безопасную очистку состояния
  • прозрачную работу RTK Query с защищёнными API-запросами