Попробовать API
</>
OAuth, хранение ключей и white-label в партнёрском API
integraciyaИнструкция
15 мин

OAuth, хранение ключей и white-label в партнёрском API

TL;DR: агентство подключает BOTIX десяти клиентам и сразу упирается в три вопроса — какой механизм авторизации выбрать (API-ключ или OAuth), где держать долгоживущие секреты и как собрать white-label-кабинет под свой бренд. Разберём каждый по тезису: сначала выбор «кто действует от чьего имени», потом защищённое хранение токенов с кешем инстансов SDK, потом партнёрский слой. Код на Node, факты — по реальному API BOTIX.

Коротко

  • API-ключ и OAuth решают разные задачи. Ключ — когда ваш собственный бэкенд действует от своего имени (Authorization: Bearer btx_live_…). OAuth — когда стороннему приложению клиент дал разрешение действовать от его имени: короткий access, scope, отзыв в один клик. Подмена одного другим — типичная утечка доступа.
  • Долгоживущий секрет нельзя хранить в открытом виде. Refresh-токены шифруются на уровне приложения (AES-256-GCM) или уходят в secret manager; инстансы SDK кешируются по client_id с превентивным refresh, чтобы токен одного проекта не ушёл в запрос к чужому.
  • White-label — не фича поверх API, а набор архитектурных свойств: отдельный тип ключа, sub-проекты, Auth::assertProjectAccess() в каждом эндпоинте, отдельный префикс /partner/v1/*. В BOTIX OAuth installations работают, полноценный Partner API — в roadmap'е без публичного релиза.

Кто действует от чьего имени — API-ключ или OAuth

Первый вопрос при подключении нескольких клиентов — не «где хранить ключ», а «кто от чьего имени действует». От ответа зависит весь остальной дизайн.

Есть два сценария, которые постоянно путают.

Сценарий 1 — собственный бэкенд. У агентства (или у клиента) есть свой сервер, он общается с API BOTIX от своего имени. Бэкенд принадлежит ему, ключ принадлежит ему. Здесь правильный инструмент — API-ключ: один заголовок Authorization: Bearer btx_live_…, никакого лишнего танца с access/refresh. OAuth здесь — преждевременная оптимизация: короткоживущие токены, scope и отзыв нужны, когда доступом владеет третья сторона, а тут владелец — вы сами.

Сценарий 2 — стороннее приложение. Появляется третий сервис-партнёр, который хочет работать с данными клиента. Клиент должен *дать разрешение*, но не отдавать свой ключ. Здесь правильный инструмент — OAuth 2.0. Сторонний сервис никогда не получает на руки долгоживущий ключ; клиент отзывает установку из своего кабинета — и доступ исчезает немедленно.

Самая частая ошибка — клиент копирует production-ключ и вставляет его в форму «введите API-ключ» стороннего сервиса. С этого момента сервис имеет полный доступ ко всему, что даёт ключ, и клиент не может это ограничить: у API-ключа нет понятия «область действия», он всегда даёт полный набор прав. Если БД сервиса сольётся — сольётся и ключ клиента. Вторая ошибка: партнёр просит ключ как часть онбординга, клиент потом удаляет сервис, а ключ остаётся в чужой БД и продолжает работать. Отозвать его вручную из кабинета никто не идёт.

Индустриальный паттерн — оба механизма сосуществуют: API-ключи для своих интеграций, OAuth для third-party. В BOTIX оба работают на одних и тех же эндпоинтах api.botix.pro/public/v1/*. API-ключ передаётся как Authorization: Bearer btx_live_…. OAuth access-token — тот же Bearer плюс заголовок X-OAuth-App-Id. С точки зрения эндпоинта разница минимальна; различие — в аудите: запросы по OAuth логируются с привязкой к oauth_app_id (отдельная колонка в btx_api_log), и клиент видит, какое именно приложение делало запрос. Механику изоляции project_id под одним SDK на сотню проектов мы разбираем отдельно — multi-tenant: один SDK на сотню ботов агентства.

Граница между двумя случаями — момент появления первого стороннего приложения. Пока приложение одно и своё — API-ключ навсегда. Как только к данным клиента подключается кто-то третий — нужен OAuth, и здесь компромиссов нет.

Регистрируем OAuth-приложение и стартуем flow (PKCE + state)

Заходим в app.botix.pro/settings/oauth-apps под учётной записью агентства, «Создать приложение». Заполняем:

  • name — название, которое клиент увидит на странице согласия («Boost Agency»).
  • redirect_uri — URL, куда BOTIX вернёт клиента после согласия (https://agency.example.com/oauth/callback).
  • scopes — список разрешений. Список scope в BOTIX небольшой и сознательно не дробится мелко: contacts:read, contacts:write, messages:send, webhooks:read, webhooks:write и т.п. — каждый scope соответствует понятной пользователю группе действий. Не запрашивайте больше, чем нужно: пользователь видит список на странице согласия и может закрыть вкладку.

На выходе — client_id (публичный) и client_secret (хранить как пароль). Это учётные данные приложения, не клиента.

Backend агентства генерирует ссылку на страницу согласия с двумя обязательными параметрами безопасности — их нельзя срезать, иначе получается «OAuth», который защищает хуже, чем аккуратный API-ключ:

javascript
const express = require('express');
const { randomBytes, createHash } = require('crypto');
const axios = require('axios');

const app = express();
const sessions = new Map();
const b64 = b => b.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');

app.get('/oauth/start', (req, res) => {
  const state = b64(randomBytes(32));
  const verifier = b64(randomBytes(32));
  const challenge = b64(createHash('sha256').update(verifier).digest());
  sessions.set(state, { verifier, clientId: req.query.client_id });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.BOTIX_CLIENT_ID,
    redirect_uri: 'https://agency.example.com/oauth/callback',
    scope: 'messages:send contacts:read webhooks:write',
    state, code_challenge: challenge, code_challenge_method: 'S256',
  });
  res.redirect(`https://app.botix.pro/oauth/authorize?${params}`);
});

state — случайная строка, проверяется на callback; без неё возможна CSRF-атака на привязку аккаунта. code_verifier + code_challenge — это PKCE, закрывает «перехват авторизационного кода» на редиректе. В BOTIX реализованы все пять обязательных пунктов Authorization Code flow: PKCE, state, одноразовый код с жизнью 5–10 минут, access-token около часа, refresh-token 30–90 дней с ротацией при каждом обмене, и список активных installations с отзывом в один клик в кабинете клиента.

Обмениваем код на токены

После согласия BOTIX редиректит на redirect_uri с параметрами code и state. Проверяем state, обмениваем код:

javascript
app.get('/oauth/callback', async (req, res) => {
  const { code, state } = req.query;
  const session = sessions.get(state);
  if (!session) return res.status(400).send('Invalid state');
  sessions.delete(state);

  const { data } = await axios.post('https://api.botix.pro/oauth/token', {
    grant_type: 'authorization_code',
    code, redirect_uri: 'https://agency.example.com/oauth/callback',
    client_id: process.env.BOTIX_CLIENT_ID,
    client_secret: process.env.BOTIX_CLIENT_SECRET,
    code_verifier: session.verifier,
  });
  // data = { access_token, refresh_token, expires_in, project_id, scope }
  await saveTokens(session.clientId, data);
  res.send('Подключено');
});

В ответе четыре значения. access_token — bearer для запросов, живёт около часа. refresh_token — для получения нового access-token без участия клиента, живёт 30–90 дней и ротируется при каждом обмене (старый сразу инвалидируется). expires_in — срок жизни в секундах. project_id — идентификатор проекта клиента, тот самый контекст, к которому привязан доступ.

Проверить, что именно вы получили, можно служебным эндпоинтом GET /public/v1/me — он возвращает контекст текущего ключа (проект и набор scope), удобно для health-check после подключения.

Где хранить токены — антипаттерны и защищённая схема

Refresh-token — долгоживущий секрет с правом действовать от имени клиента. Та же дисциплина, что для API-ключей, применяется и к нему: API-ключ — это аналог пароля в открытом виде, без срока годности, второго фактора и попыточного лимита. Утечки почти всегда происходят не из-за изощрённого взлома, а из-за стандартных антипаттернов в коде интегратора:

  • Секрет в репозиторииconst key = 'btx_live_…' в конфиге, потом репозиторий уезжает на GitHub. Secret-сканеры уведомят владельца через часы — за эти часы базу успевают выгрузить.
  • Секрет в .env без шифрования.env лежит на диске в plaintext; RCE, забытый в Docker-образе файл или уволенный админ читают всё разом. .env — приемлемое место для *переменной*, но не для *хранения*.
  • Один ключ на всю команду в Slack — при увольнениях не ротируется. Контрмера: один ключ на исполнителя (сервис/воркер), не на человека.
  • Секрет в логахprint_r($headers) пишет полный Authorization: Bearer … в ELK/Loki/Datadog. Стандартные SDK BOTIX (PHP, Python, Node) маскируют Authorization в логах автоматически.
  • Секрет в URL?api_key=… оседает в access.log nginx и Referer. Платформа BOTIX категорически не принимает ключ через query-string — только Authorization: Bearer, это закреплено явной проверкой источника заголовка в app/Middleware/PublicApiAuth.php.
  • Секрет без ротации и с избыточными правами — сервису, который делает только POST /messages, выдают ключ с полным набором прав. Контрмера — scopes (JSON-поле в btx_api_keys): выдаём messages:send и ничего больше, requireScope() проверяет соответствие при каждом запросе.

На своей стороне BOTIX хранит ключи как bcrypt-хеш (cost 12), полный ключ показывает один раз при создании, а для отображения держит отдельный key_prefix из 12 символов. У интегратора refresh-токены клиентов должны храниться так же дисциплинированно. Минимальный приемлемый уровень — шифрование на уровне приложения через AES-256-GCM с ключом из переменной окружения; в Node это встроенный модуль crypto:

javascript
const { createCipheriv, randomBytes } = require('crypto');
const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'hex'); // 32 байта

function encrypt(text) {
  const iv = randomBytes(12);
  const c = createCipheriv('aes-256-gcm', KEY, iv);
  const enc = Buffer.concat([c.update(text, 'utf8'), c.final()]);
  return Buffer.concat([iv, c.getAuthTag(), enc]).toString('base64');
}

Зашифрованный токен сохраняем в БД стандартным INSERT ... ON DUPLICATE KEY UPDATE с привязкой к client_id и project_id. Зрелый уровень — внешний secret manager (HashiCorp Vault, AWS Secrets Manager, Yandex Lockbox): приложение читает токен в память по запросу и не хранит локально, при утечке БД токены клиентов не уходят вместе с остальными данными.

Отдельно про отзыв: в BOTIX при отзыве подписки или переходе клиента на тариф с api_enabled=0 все ключи проекта автоматически становятся revoked (хук в confirmInternal). Для OAuth аналог — клиент снимает установку в app.botix.pro/settings/connected-apps, и токены перестают работать без вашего участия.

Кеш инстансов SDK и превентивный refresh

Через час access-token истекает. Главное правило multi-tenant — невозможность утечки данных между проектами. Глобальный SDK с подменой токена по запросу рано или поздно приведёт к запросу с чужим токеном на чужой проект. Правильный паттерн — кеш инстансов SDK по client_id с превентивным refresh:

javascript
const { Client } = require('@botix/sdk');
const clientCache = new Map();

async function getClient(clientId) {
  const cached = clientCache.get(clientId);
  if (cached && cached.expiresAt > Date.now() + 60_000) return cached.client;

  const row = await db.fetchOne('SELECT * FROM client_tokens WHERE client_id = ?', [clientId]);
  let accessToken = decrypt(row.access_token);

  if (row.expires_at - Date.now() < 60_000) {
    const { data } = await axios.post('https://api.botix.pro/oauth/token', {
      grant_type: 'refresh_token',
      refresh_token: decrypt(row.refresh_token),
      client_id: process.env.BOTIX_CLIENT_ID,
      client_secret: process.env.BOTIX_CLIENT_SECRET,
    });
    accessToken = data.access_token;
    await saveTokens(clientId, data);
  }
  const client = new Client({ apiKey: XXXXXXXX });
  clientCache.set(clientId, { client, expiresAt: Date.now() + 50 * 60 * 1000 });
  return client;
}

Refresh-token ротируется при обмене — старый сразу инвалидируется, поэтому новый нужно атомарно прокинуть и в БД, и в кеш. При параллельных refresh одного клиента нужен advisory lock в БД, иначе два воркера обменяют один refresh-token и один из них получит инвалидированный. Запрос по OAuth-токену уходит с заголовком X-OAuth-App-Id, чтобы обращение корректно попало в аудит клиента (oauth_app_id в btx_api_log). Мутирующие вызовы (POST /messages, POST /scenarios/{id}/run) стоит слать с заголовком Idempotency-Key — BOTIX кеширует успешный ответ в Redis на 24 часа и на повтор возвращает тот же результат с Idempotent-Replayed: 1; о backoff по X-RateLimit-* — в разборе rate-limit, идемпотентность и повторы.

White-label и партнёрский слой — что нужно от API

Базовый OAuth закрывает большую часть потребностей агентства, но полноценная платформа для интеграторов начинается там, где появляется второй уровень: партнёр, встраивающий API внутрь своего продукта и продающий дальше. У прямого клиента контур простой — один аккаунт, один проект, несколько ключей, один webhook. У партнёра — многоуровневый: сам партнёр сверху, под ним десятки или сотни конечных клиентов, у каждого свой логический проект со своими данными, биллингом и webhook'ом. Это не решается тем же API, что и обычные клиенты. Минимальный набор свойств, которые делают API платформой:

  • Партнёрский тип ключа — partner key с более высокими привилегиями, чем project key: создавать проекты, перечислять их, получать ключи для каждого. Обращение к партнёрским операциям обычным ключом должно возвращать 403.
  • Sub-проекты под одним аккаунтом — иерархия «партнёр → набор конечных проектов», каждый со своими данными, webhook'ом, биллингом и жёсткой изоляцией.
  • OAuth-installations с реселлерскими scope — если партнёр строит маркетплейс, ему нужны partner-scope, позволяющие приложению видеть все sub-проекты, а не только тот, куда оно установлено напрямую.
  • Биллинг-данные через API — партнёр биллит по своей модели: нужны сырые usage-данные (запросы, сообщения, активные контакты) по каждому sub-проекту за период — отдельным эндпоинтом типа GET /partner/usage либо webhook-событиями в конце расчётного периода.
  • Audit-логи на партнёрском уровне — доступ к журналу по всем sub-проектам по тому же partner-ключу, чтобы партнёр диагностировал сам, не привлекая поставщика.
  • Кастомные домены и брендирование — сам API домена не требует, но кабинет под брендом партнёра требует поддержки white-label-доменов на стороне поставщика.

Критическая граница — изоляция данных. Внутри одного партнёрского аккаунта могут оказаться sub-проекты конкурирующих компаний; возможность одного увидеть данные другого — катастрофа. На уровне БД — WHERE project_id = ? в каждом запросе. На уровне API — явный project_id и проверка, что он принадлежит партнёрскому аккаунту. В BOTIX Auth::assertProjectAccess($projectId) вызывается в начале каждого эндпоинта, работающего с проектом (при доступе к чужому ресурсу возвращается 404, а не 403, чтобы не утекало само существование объектов). Партнёрский уровень добавляет проверку, что sub-проект принадлежит аккаунту, выпустившему ключ.

Честно про статус: в BOTIX OAuth installations работают, а полноценный Partner API — в roadmap'е без публичного релиза. Кастомные white-label-домены — тоже пока в плане. До выпуска Partner API агентство собирает партнёрскую модель из доступных кирпичей: OAuth для авторизации от имени клиента, отдельный API-ключ на каждый sub-проект, локальная агрегация usage на своей стороне. Партнёрские возможности не стоит навешивать на обычные эндпоинты флагами «если ты партнёр, передай ещё этот параметр» — это путь к запутанной семантике; правильнее отдельный префикс /partner/v1/*. Про то, как этот слой ложится на общую архитектуру платформы, — API как платформа: OAuth, scope и изоляция project_id.

Чек-лист готовности к партнёрской работе

Готовность API к партнёрам оценивается за полчаса по короткому списку:

  1. Есть ли отдельный тип ключа с расширенными правами (partner key)?
  2. Есть ли концепция sub-проектов?
  3. Есть ли WHERE project_id = ? в каждом запросе?
  4. Вызывается ли Auth::assertProjectAccess() в начале каждого эндпоинта?
  5. Есть ли отдельные partner-scope в OAuth?
  6. Можно ли получить сырые usage-данные через API?
  7. Доступен ли партнёру аудит-журнал по всем sub-проектам?
  8. Есть ли отдельная документация и коллекция (Postman/Bruno) для партнёрских операций?

Если «нет» начинается с третьего пункта — API технически не готов к партнёрской работе. Решение о партнёрском слое — продуктовое, не техническое: не каждый API должен быть платформой, но если решение принято, эти свойства закладываются с самого начала, а не на третьей итерации после первых жалоб.

Подводные камни

PKCE и state — нельзя экономить ни на одном. Refresh-token при обмене сразу инвалидируется — новый прокидывается в БД и кеш атомарно, при параллельных refresh нужен advisory lock. Scopes на странице согласия не запрашивать сверх необходимого — клиент видит список и закрывает вкладку, конверсия теряется. Каждый sub-проект — свой ключ с минимальным scope, никакого master-ключа, гуляющего по мессенджерам: первый же клиент, передавший master-ключ через мессенджер, становится точкой отказа для всей инфраструктуры агентства. С правильной схемой подключение нового клиента занимает три минуты, а отзыв доступа — один клик из его кабинета.