Skip to content

Авторизация и 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 после следующей аутентификации.