Тема
Авторизация и refresh token
Для одного private API достаточно createAxiosClient. Он сам добавляет Bearer token, дедуплицирует одновременный refresh и повторяет исходный request один раз, если HTTP-метод идемпотентен. POST/PATCH повторяются только с явным Idempotency-Key.
Token store
ts
import { makeAutoObservable } from "mobx"
import type { ITokenLike, ITokenProvider } from "@dubium/query-layer"
class TokenStore implements ITokenProvider {
encodedToken: string | null = null
constructor() {
makeAutoObservable(this, {}, { autoBind: true })
}
getToken(): ITokenLike | null {
return this.encodedToken ? { encoded: this.encodedToken } : null
}
setToken(token: ITokenLike | null): void {
this.encodedToken = token?.encoded ?? null
}
clear(): void {
this.encodedToken = null
}
}
export const tokenStore = new TokenStore()Не добавляйте access token в query key, persisted payload или telemetry.
Refresh API
ts
import type { AxiosInstance, AxiosResponse } from "axios"
export interface RefreshResponse {
accessToken: string
}
export const refreshTokenApi = (
client: AxiosInstance,
authorization: string,
): Promise<AxiosResponse<RefreshResponse>> =>
client.post("/auth/refresh", undefined, { headers: { Authorization: authorization } })Переданный client имеет ту же base configuration, но не имеет auth response interceptor. Refresh endpoint не запускает рекурсивный refresh.
Public и private clients
ts
import { createAxiosClient } from "@dubium/query-layer"
const baseConfig = {
baseURL: "/api",
timeout: 15_000,
withCredentials: true,
}
export const publicApi = createAxiosClient(baseConfig)
export const authApi = createAxiosClient({
...baseConfig,
isPrivate: true,
tokenProvider: tokenStore,
refreshToken: async ({ client, failedRequest }) => {
const authorization = failedRequest.headers.get("Authorization")
if (typeof authorization !== "string" || authorization.length === 0) {
return null
}
const response = await refreshTokenApi(client, authorization)
return { encoded: response.data.accessToken }
},
retryAfterRefresh: true,
refreshFailureCooldownMs: 1_000,
onUnauthorizedBeforeRefresh: (error) => {
authMetrics.recordExpiredToken(error)
},
onRefreshFailed: (error) => {
errorReporter.capture(error)
},
onUnauthorizedFinal: () => {
tokenStore.clear()
router.openLogin()
},
})Одновременные 401
Пока refresh promise активен, следующие 401 того же client присоединяются к нему. После успеха каждый исходный запрос получает новый Authorization header и безопасно повторяется. Flag внутри Axios config не разрешает второй retry. После ошибки или пустого refresh действует короткий cooldown: новые 401 не создают storm запросов к refresh endpoint. Поздний 401, отправленный со старым Authorization, использует уже сохранённый новый token и не запускает второй refresh.
Для изменяющих запросов MutationStore автоматически добавляет один стабильный Idempotency-Key, когда включён mutation retry. Backend обязан использовать этот header для дедупликации. Для прямого authApi.post() передайте ключ явно.
Feature API
ts
import type { AxiosRequestConfig, AxiosResponse } from "axios"
export const getProfileApi = (config?: AxiosRequestConfig): Promise<AxiosResponse<Profile>> =>
authApi.get("/private/profile", config)FetchStore
Получение текущего профиля — ручной кэшируемый GET. Domain store скрывает технический FetchStore:
ts
import { makeAutoObservable } from "mobx"
import { FetchStore } from "@dubium/query-layer"
export class ProfileStore {
private readonly requestHandler = new FetchStore<Profile, []>({
queryKey: () => ["profile", "current"],
request: getProfileApi,
options: { retry: false },
})
constructor() {
makeAutoObservable<this, "requestHandler">(this, { requestHandler: false }, { autoBind: true })
}
get profile(): Profile | null {
return this.requestHandler.data ?? null
}
async fetch(): Promise<Profile | null> {
const response = await this.requestHandler.fetch()
return response?.data ?? null
}
}Refresh является внутренней частью request, запущенного FetchStore. React-компонент не вызывает authApi или getProfileApi; он вызывает profileStore.fetch() и читает предметные getters domain store.
SessionCoordinator
SessionCoordinator нужен, если один refresh lifecycle разделяют несколько private clients, несколько runtime или cross-tab lock.
ts
import { createSessionCoordinator } from "@dubium/query-layer/auth"
import { createLockManager } from "@dubium/query-layer/coordination"
export const session = createSessionCoordinator({
tokenProvider: tokenStore,
lockManager: createLockManager(),
refreshLockName: "portal:session-refresh",
refreshFailureCooldownMs: 1_000,
refreshToken: async ({ client, failedRequest }) => {
const authorization = failedRequest.headers.get("Authorization")
if (typeof authorization !== "string") {
return null
}
const response = await refreshTokenApi(client, authorization)
return { encoded: response.data.accessToken }
},
})
await session.initialize()Передайте coordinator каждому private client:
ts
export const coreApi = createAxiosClient({
...coreConfig,
isPrivate: true,
sessionCoordinator: session,
})
export const billingApi = createAxiosClient({
...billingConfig,
isPrivate: true,
sessionCoordinator: session,
})Coordinator гарантирует один refresh execution между этими clients. При cross-tab lock ожидающие вкладки используют результат владельца lock и не запускают последовательные refresh-попытки после его ошибки.
QueryRuntime
Runtime может использовать тот же coordinator для user-scoped lifecycle:
ts
import { createQueryRuntime } from "@dubium/query-layer/runtime"
const runtime = createQueryRuntime({
identity: { applicationId: "portal" },
sessionCoordinator: session,
})Logout
ts
import { disposeAxiosClient } from "@dubium/query-layer"
async function logout(): Promise<void> {
currentScope?.dispose()
await runtime?.dispose()
disposeAxiosClient(coreApi)
disposeAxiosClient(billingApi)
await session.logout()
await session.dispose()
window.location.assign("/login")
}После logout очистите user-scoped cache и создайте новый runtime после следующей аутентификации.