ci / module boundaries / boundaries (push) Successful in 9s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m37s
ci / api / api (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
ci / db / db (push) Successful in 6m19s
ci / ts / ts (packages + web) (push) Failing after 27m2s
240 lines
10 KiB
TypeScript
240 lines
10 KiB
TypeScript
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:<code>`. */
|
||
export class ApiError extends Error {
|
||
readonly status: number;
|
||
readonly code: string;
|
||
readonly params: Record<string, unknown>;
|
||
readonly fields: { field: string; code: string }[];
|
||
|
||
constructor(status: number, code: string, params: Record<string, unknown> = {}, 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<components['schemas']['Problem']>;
|
||
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<T>(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<string | null>;
|
||
set(token: string): Promise<void>;
|
||
clear(): Promise<void>;
|
||
/** Последний вошедший пользователь — чтобы открыть приложение без сети (Android, офлайн-режим). */
|
||
getUser?(): Promise<AuthUser | null>;
|
||
setUser?(user: AuthUser): Promise<void>;
|
||
}
|
||
|
||
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<boolean> | 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<AuthState> {
|
||
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<AuthUser> {
|
||
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<AuthUser> {
|
||
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<void> {
|
||
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<boolean> {
|
||
this.refreshing ??= this.doRefresh().finally(() => {
|
||
this.refreshing = null;
|
||
});
|
||
return this.refreshing;
|
||
}
|
||
|
||
private async doRefresh(attempt = 0): Promise<boolean> {
|
||
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<Response> {
|
||
// Запущены без сети (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 });
|
||
}
|
||
}
|