import { createApiClient, type ApiClient, type components } from '@pipu/api-client'; export type AuthUser = components['schemas']['Me']; export type AuthTokens = components['schemas']['AuthTokens']; export type ClientType = components['schemas']['ClientType']; /** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:`. */ export class ApiError extends Error { readonly status: number; readonly code: string; readonly params: Record; readonly fields: { field: string; code: string }[]; constructor(status: number, code: string, params: Record = {}, fields: ApiError['fields'] = []) { super(code); this.name = 'ApiError'; this.status = status; this.code = code; this.params = params; this.fields = fields; } /** Код ошибки конкретного поля формы, если сервер его указал. */ fieldCode(field: string): string | undefined { return this.fields.find((f) => f.field === field)?.code; } } /** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */ export function toApiError(error: unknown, response?: Response): ApiError { if (error instanceof ApiError) return error; const p = (error ?? {}) as Partial; if (typeof p.code === 'string') { return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []); } if (response) return new ApiError(response.status, response.status === 413 ? 'common.request_too_large' : response.status >= 500 ? 'common.internal' : 'common.bad_request'); return new ApiError(0, 'common.network'); } /** Достаёт данные из результата openapi-fetch или бросает ApiError. */ export function unwrap(res: { data?: T; error?: unknown; response: Response }): T { if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response); return res.data as T; } /** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */ export interface RefreshStore { get(): Promise; set(token: string): Promise; clear(): Promise; /** Последний вошедший пользователь — чтобы открыть приложение без сети (Android, офлайн-режим). */ getUser?(): Promise; setUser?(user: AuthUser): Promise; } export type AuthState = | { status: 'unknown' } | { status: 'signed_out'; reason?: string } /** offline — сервер недоступен при запуске, пользователь взят из RefreshStore; токен обновится при первом запросе. */ | { status: 'signed_in'; user: AuthUser; offline?: boolean }; export interface AuthSessionOptions { /** Адрес сервера; пусто — тот же origin (веб). */ baseUrl?: string; fetch?: typeof globalThis.fetch; clientType: ClientType; deviceName?: string; /** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */ refreshStore?: RefreshStore; } /** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */ const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']); const RACE_RETRY_MS = 300; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android). * `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все * параллельные запросы (single-flight), после чего повторяет их. */ export class AuthSession { readonly client: ApiClient; private readonly raw: ApiClient; private readonly opts: AuthSessionOptions; private readonly baseFetch: typeof globalThis.fetch; private access: string | null = null; private refreshing: Promise | null = null; private networkDown = false; private current: AuthState = { status: 'unknown' }; private readonly listeners = new Set<(s: AuthState) => void>(); constructor(opts: AuthSessionOptions) { if (opts.clientType !== 'web' && !opts.refreshStore) { throw new Error(`AuthSession: ${opts.clientType} client needs a refreshStore`); } this.opts = opts; this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args)); this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch }); this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) }); } get state(): AuthState { return this.current; } /** Текущий access-токен — для запросов мимо `client` (загрузка файлов на Android). */ get accessToken(): string | null { return this.access; } /** Подписка на смену состояния; возвращает функцию отписки. */ subscribe(fn: (s: AuthState) => void): () => void { this.listeners.add(fn); return () => this.listeners.delete(fn); } private setState(s: AuthState) { this.current = s; for (const fn of this.listeners) fn(s); } /** Восстанавливает сессию после перезагрузки по refresh-токену. */ async restore(): Promise { if (!(await this.refresh())) { const user = this.networkDown ? await this.opts.refreshStore?.getUser?.() : null; if (user) this.setState({ status: 'signed_in', user, offline: true }); else if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' }); } return this.current; } async login(username: string, password: string): Promise { const res = await this.raw.POST('/api/v1/auth/login', { body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName }, }); return this.apply(unwrap(res)); } /** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */ async apply(tokens: AuthTokens): Promise { this.access = tokens.access_token; if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token); await this.opts.refreshStore?.setUser?.(tokens.user); this.setState({ status: 'signed_in', user: tokens.user }); return tokens.user; } /** Обновляет профиль в состоянии (после PATCH /me). */ setUser(user: AuthUser) { if (this.current.status === 'signed_in') { this.setState({ ...this.current, user }); void this.opts.refreshStore?.setUser?.(user); } } async logout(all = false): Promise { try { if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout'); } finally { await this.signOut(); } } private async signOut(reason?: string) { this.access = null; await this.opts.refreshStore?.clear(); this.setState({ status: 'signed_out', reason }); } /** Single-flight: параллельные вызовы ждут один и тот же запрос. */ refresh(): Promise { this.refreshing ??= this.doRefresh().finally(() => { this.refreshing = null; }); return this.refreshing; } private async doRefresh(attempt = 0): Promise { let body: { refresh_token?: string } | undefined; if (this.opts.refreshStore) { const token = await this.opts.refreshStore.get(); if (!token) return false; body = { refresh_token: token }; } let res; try { res = await this.raw.POST('/api/v1/auth/refresh', { body }); this.networkDown = false; } catch { this.networkDown = true; return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова } if (res.data) { await this.apply(res.data); return true; } const err = toApiError(res.error, res.response); if (err.code === 'auth.refresh_race' && attempt === 0) { // Другая вкладка только что обновила cookie — повторяем уже с новой. await sleep(RACE_RETRY_MS); return this.doRefresh(attempt + 1); } if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code); return false; } private async authFetch(request: Request): Promise { // Запущены без сети (offline): access-токена ещё нет — получаем его перед первым запросом. if (!this.access && this.opts.refreshStore && this.current.status === 'signed_in') await this.refresh(); const retry = request.clone(); const sent = this.access; const res = await this.baseFetch(this.withAuth(request)); if (res.status !== 401) return res; const code = await res .clone() .json() .then((p: { code?: string }) => p.code) .catch(() => undefined); if (!code || !RETRYABLE.has(code)) { if (code === 'auth.user_blocked') await this.signOut(code); return res; } // Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации. const fresh = this.access !== null && this.access !== sent; if (!fresh && !(await this.refresh())) return res; return this.baseFetch(this.withAuth(retry)); } private withAuth(request: Request): Request { if (!this.access) return request; const headers = new Headers(request.headers); headers.set('Authorization', `Bearer ${this.access}`); return new Request(request, { headers }); } }