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-ключ:
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, обмениваем код:
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:
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:
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 к партнёрам оценивается за полчаса по короткому списку:
- Есть ли отдельный тип ключа с расширенными правами (partner key)?
- Есть ли концепция sub-проектов?
- Есть ли
WHERE project_id = ?в каждом запросе? - Вызывается ли
Auth::assertProjectAccess()в начале каждого эндпоинта? - Есть ли отдельные partner-scope в OAuth?
- Можно ли получить сырые usage-данные через API?
- Доступен ли партнёру аудит-журнал по всем sub-проектам?
- Есть ли отдельная документация и коллекция (Postman/Bruno) для партнёрских операций?
Если «нет» начинается с третьего пункта — API технически не готов к партнёрской работе. Решение о партнёрском слое — продуктовое, не техническое: не каждый API должен быть платформой, но если решение принято, эти свойства закладываются с самого начала, а не на третьей итерации после первых жалоб.
Подводные камни
PKCE и state — нельзя экономить ни на одном. Refresh-token при обмене сразу инвалидируется — новый прокидывается в БД и кеш атомарно, при параллельных refresh нужен advisory lock. Scopes на странице согласия не запрашивать сверх необходимого — клиент видит список и закрывает вкладку, конверсия теряется. Каждый sub-проект — свой ключ с минимальным scope, никакого master-ключа, гуляющего по мессенджерам: первый же клиент, передавший master-ключ через мессенджер, становится точкой отказа для всей инфраструктуры агентства. С правильной схемой подключение нового клиента занимает три минуты, а отзыв доступа — один клик из его кабинета.
Попробуйте API BOTIX
Получите ключ, отправьте первый запрос и поймайте webhook — прямо в браузере, без боевого канала.
Попробовать API бесплатноЧитайте также
Third-party на OAuth: multi-tenant и хранение токенов
Multi-tenant API: OAuth, scopes и изоляция project_id
Multi-tenant, polling vs webhook и OAuth для агентства
Чек-лист интеграции: идемпотентность, backoff, webhook
