Добавление токенов к запросам

Токены используются для аутентификации и авторизации HTTP-запросов. Наиболее распространённый сценарий — передача JWT-токена через заголовок Authorization.

RTK Query предоставляет несколько механизмов для автоматического добавления токенов:

  • через prepareHeaders
  • через кастомный baseQuery
  • через middleware-логику
  • через динамическое обновление access token
  • через интеграцию с Redux Store

На практике почти все приложения используют именно prepareHeaders, поскольку этот механизм встроен в fetchBaseQuery и позволяет централизованно модифицировать запросы.


Базовое добавление токена

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

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

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

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

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

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

            return headers
        }
    }),

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

В данном примере:

  • токен хранится в Redux Store
  • перед каждым запросом вызывается prepareHeaders
  • заголовок добавляется автоматически
  • все endpoints получают авторизацию без дублирования кода

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

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

Сигнатура:

prepareHeaders: (headers, api) => {}

Где:

headers

— экземпляр Headers.

А объект api содержит:

{
    getState,
    endpoint,
    type,
    forced,
    extra
}

Пример:

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

    return headers
}

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

getState позволяет получить текущее состояние Redux Store.

Пример структуры состояния:

{
    auth: {
        token: 'jwt-token-value',
        user: {
            id: 1,
            name: 'Alex'
        }
    }
}

Получение токена:

const token = getState().auth.token

Полный пример:

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

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

    return headers
}

Почему используется Bearer

Формат:

Authorization: Bearer token_value

является стандартом OAuth 2.0 и JWT-аутентификации.

Сервер обычно ожидает именно такой формат:

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

Без префикса Bearer многие backend-системы отклоняют запрос.


Добавление нескольких заголовков

Часто вместе с токеном добавляются:

  • язык
  • версия API
  • timezone
  • client-id
  • tenant-id

Пример:

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

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

    headers.set('Accept', 'application/json')
    headers.set('X-App-Version', '1.0.0')
    headers.set('X-Timezone', Intl.DateTimeFormat().resolvedOptions().timeZone)

    return headers
}

Разница между set и append

set

Перезаписывает значение:

headers.set('Authorization', 'Bearer token')

append

Добавляет ещё одно значение:

headers.append('X-Test', 'value')

Для токенов почти всегда используется именно set.


Добавление токена из localStorage

Иногда токен хранится не в Redux Store, а в localStorage.

Пример:

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

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

    return headers
}

Недостатки localStorage

Такой подход имеет несколько проблем:

Отсутствие реактивности

Если токен изменился:

localStorage.setItem('token', 'new-token')

компоненты Redux не узнают об изменении автоматически.

Проблемы SSR

В server-side rendering отсутствует объект window.

Следующий код приведёт к ошибке:

localStorage.getItem('token')

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

localStorage уязвим для XSS-атак.


Хранение токена в Redux Store

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

{
    auth: {
        accessToken,
        refreshToken,
        user
    }
}

Slice:

import { createSlice } from '@reduxjs/toolkit'

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

    initialState: {
        accessToken: null
    },

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

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

export const {
    setCredentials,
    logout
} = authSlice.actions

export default authSlice.reducer

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

Пример:

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

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

    return headers
}

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

Иногда токен нужен только для определённых endpoints.

Пример:

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

    const protectedEndpoints = [
        'getProfile',
        'updateProfile',
        'createPost'
    ]

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

    return headers
}

Добавление токена только для mutation

RTK Query передаёт тип запроса:

type

Значения:

  • query
  • mutation

Пример:

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

    if (type === 'mutation' && token) {
        headers.set('Authorization', `Bearer ${token}`)
    }

    return headers
}

Передача токена через cookies

Некоторые backend-приложения используют HttpOnly cookies вместо Bearer Token.

В таком случае:

baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    credentials: 'include'
})

Теперь браузер автоматически отправляет cookies.


credentials: include

Варианты:

omit

Cookies не отправляются.

same-origin

Cookies отправляются только внутри текущего домена.

include

Cookies всегда отправляются, включая cross-origin запросы.


Комбинация Bearer Token и Cookies

Иногда backend требует оба варианта:

baseQuery: fetchBaseQuery({
    baseUrl: '/api',
    credentials: 'include',

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

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

        return headers
    }
})

Централизация авторизации

Одно из главных преимуществ RTK Query — отсутствие необходимости вручную писать:

headers: {
    Authorization: ...
}

в каждом endpoint.

Плохой вариант:

getProfile: builder.query({
    query: () => ({
        url: '/profile',
        headers: {
            Authorization: 'Bearer token'
        }
    })
})

Правильная архитектура:

baseQuery: fetchBaseQuery({
    prepareHeaders
})

Добавление токена через кастомный baseQuery

Иногда prepareHeaders недостаточно.

Например:

  • требуется сложная логика
  • нужен refresh token
  • необходимо логирование
  • требуется повтор запроса

Пример:

const baseQuery = fetchBaseQuery({
    baseUrl: '/api'
})

const baseQueryWithAuth = async (args, api, extraOptions) => {
    const token = api.getState().auth.token

    let headers = {}

    if (token) {
        headers.Authorization = `Bearer ${token}`
    }

    const result = await baseQuery(
        {
            ...args,
            headers: {
                ...args.headers,
                ...headers
            }
        },
        api,
        extraOptions
    )

    return result
}

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

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

Проблема просроченного токена

JWT access token обычно живёт:

  • 5 минут
  • 15 минут
  • 1 час

После этого сервер начинает возвращать:

401 Unauthorized

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

Распространённая архитектура:

  1. access token истёк
  2. сервер вернул 401
  3. frontend отправляет refresh token
  4. получает новый access token
  5. повторяет исходный запрос

Пример refresh token логики

import { fetchBaseQuery } from '@reduxjs/toolkit/query'
import { setCredentials, logout } from './authSlice'

const baseQuery = fetchBaseQuery({
    baseUrl: '/api',
    credentials: 'include',

    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 baseQuery(args, api, extraOptions)

    if (result.error && result.error.status === 401) {
        const refreshResult = await baseQuery(
            {
                url: '/auth/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
}

Защита от множественных refresh-запросов

Проблема:

  • несколько запросов одновременно получили 401
  • каждый начал refresh token
  • сервер получил множество refresh-запросов

Это создаёт race condition.


Mutex для refresh token

Популярное решение — использование mutex.

Пример с async-mutex:

import { Mutex } from 'async-mutex'

const mutex = new Mutex()

Далее:

await mutex.waitForUnlock()

И только один запрос выполняет refresh.


Полная схема работы авторизации

Типичная production-архитектура:

Frontend

  • access token хранится в памяти
  • refresh token хранится в HttpOnly cookie
  • RTK Query автоматически добавляет Authorization header
  • при 401 выполняется refresh
  • запрос повторяется автоматически

Backend

  • проверяет JWT
  • обновляет access token
  • возвращает новый refresh token
  • контролирует срок жизни сессии

Добавление токена в GraphQL-запросы

RTK Query может работать не только с REST.

Пример:

const graphqlBaseQuery =
    ({ baseUrl }) =>
    async ({ body }, api) => {
        const token = api.getState().auth.token

        const result = await fetch(baseUrl, {
            method: 'POST',

            headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${token}`
            },

            body: JSON.stringify(body)
        })

        return {
            data: await result.json()
        }
    }

Добавление API-ключей

Некоторые сервисы используют API Key вместо JWT.

Пример:

prepareHeaders: (headers) => {
    headers.set('X-API-Key', 'secret-key')

    return headers
}

Либо:

headers.set(
    'Authorization',
    'ApiKey secret-key'
)

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

Токены и API-ключи часто передаются через переменные окружения.

Пример:

headers.set(
    'X-API-Key',
    process.env.REACT_APP_API_KEY
)

В Vite:

import.meta.env.VITE_API_KEY

Ошибки при добавлении токенов

Отсутствует return headers

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

prepareHeaders: (headers) => {
    headers.set('Authorization', 'token')
}

Правильно:

prepareHeaders: (headers) => {
    headers.set('Authorization', 'token')

    return headers
}

Неверный путь к token

Ошибка:

getState().token

Правильно:

getState().auth.token

Добавление Bearer null

Ошибка:

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

при:

token === null

В результате сервер получает:

Authorization: Bearer null

Правильная проверка:

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

Потеря заголовков

Ошибка:

headers: headers

при кастомном baseQuery.

Можно случайно затереть существующие заголовки.

Правильный вариант:

headers: {
    ...args.headers,
    Authorization: `Bearer ${token}`
}

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

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

    baseQuery: baseQueryWithReauth,

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

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

Где:

  • baseQueryWithReauth автоматически добавляет токены
  • refresh выполняется централизованно
  • повтор запросов инкапсулирован
  • endpoints остаются чистыми
  • авторизация не дублируется по всему приложению