Попробовать API
</>
Third-party на OAuth: multi-tenant и хранение токенов
integraciyaИнструкция
18 мин

Third-party на OAuth: multi-tenant и хранение токенов

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

Коротко

  • Механизм авторизации выбирается по вопросу «кто действует от чьего имени». Собственный бэкенд — API-ключ. Сторонний сервис, которому пользователь даёт разрешение, — OAuth 2.0 с PKCE. Подмена одного другим — утечка доступа, а не «упрощение».
  • Секреты не живут в коде. client_secret и refresh-токены хранятся зашифрованными (AES-256-GCM), access — в Redis с TTL, ключ шифрования — в secret manager. Семь антипаттернов хранения закрываются одной схемой.
  • Multi-tenant держится на изоляции project_id. Один инстанс SDK на проект, никакого глобального «текущего токена», scope сужен под задачу, ключи ротируются без даунтайма, каждый вызов пишется в audit-лог.

Что мы строим и какой механизм авторизации выбрать

Third-party сервис, подключающийся к проектам пользователей BOTIX. Сценарий: внешний CRM, который читает контакты и шлёт сообщения от имени десятка-сотни клиентов, каждый — отдельный проект на платформе. Пользователь в кабинете BOTIX нажимает «подключить CRM» — даёт согласие — наш сервис получает access_token, привязанный к нашему приложению и к проекту пользователя.

Первое решение принимается ещё до архитектуры и звучит как один вопрос: кто действует от чьего имени. От ответа зависит выбор между API-ключом и OAuth, и подмена одного другим почти всегда ведёт к утечке доступа.

Есть два сценария, которые постоянно путают. Первый — у компании есть собственный бэкенд, и она хочет, чтобы он общался с API. Бэкенд принадлежит ей, ключ принадлежит ей, действия выполняются от её имени. Здесь правильный инструмент — обычный API-ключ: Authorization: Bearer btx_live_…, один заголовок, никакого лишнего механизма.

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

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

OAuth закрывает обе проблемы. Долгоживущий ключ стороннему сервису никогда не попадает — только access_token, ограниченный по времени и scope. Пользователь в любой момент отзывает доступ через app.botix.pro/settings/connected-apps.

Важно: OAuth — это не «безопаснее, чем API-ключ», а другая модель доверия. Короткоживущие токены, отзыв, scope нужны, когда доступом владеет третья сторона. Когда им владеет сама компания — ничего из этого не даёт выгоды, только усложняет: вместо одного заголовка появляется танец из получения access token, обновления через refresh, обработки 401 на истечении. Поэтому индустриальный паттерн: API-ключи — для собственных интеграций, OAuth — для third-party, и в зрелом API они сосуществуют. В 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). Подробнее про развилку «свой бэкенд vs партнёр» — в разборе Партнёр через API: OAuth и хранение ключей.

Регистрация OAuth-приложения. app.botix.pro/settings/oauth-apps. Создаём:

  • Имя — увидят пользователи на странице согласия.
  • Redirect URI — https://my-crm.example.com/oauth/botix/callback.
  • Запрошенные scope — конкретные. Например, contacts:read, messages:write.

BOTIX выдаёт client_id и client_secret. Secret показывается один раз — в secret manager:

bash
export BOTIX_CLIENT_ID="cli_..."
export BOTIX_CLIENT_SECRET="cls_..."
export BOTIX_REDIRECT_URI="https://my-crm.example.com/oauth/botix/callback"

Регистрация и кабинет согласия пользователя намеренно разнесены: oauth-apps — для разработчика, connected-apps — для пользователя.

Минимальный набор OAuth-flow. Когда команда впервые делает OAuth, велик соблазн упростить: не делать PKCE, не делать state, не делать короткий expiry. Каждая такая срезка превращает OAuth в дырявое решето, которое формально называется OAuth, а фактически даёт меньше защиты, чем добротно реализованный API-ключ. Опускать нельзя ничего:

  • PKCE с code_verifier и code_challenge — закрывает атаку authorization code interception на промежуточных прокси/CDN.
  • Параметр state, случайный и проверяемый — без него возможна CSRF-атака на callback.
  • Авторизационный код живёт 5-10 минут и используется одноразово.
  • Access token — около часа. Refresh token — 30-90 дней, ротируется при каждом обмене (старый инвалидируется).
  • Владельцу кабинета доступен список активных installations с отзывом в один клик.

Authorization Code Flow с PKCE, старт:

javascript
const crypto = require('crypto');
const express = require('express');
const app = express();
const sessions = new Map(); // в проде — Redis

app.get('/oauth/botix/start', (req, res) => {
    const codeVerifier = crypto.randomBytes(64).toString('base64url');
    const codeChallenge = crypto
        .createHash('sha256').update(codeVerifier).digest('base64url');
    const state = crypto.randomBytes(32).toString('hex');
    
    sessions.set(state, { codeVerifier, userId: req.session.userId, createdAt: Date.now() });
    
    const params = new URLSearchParams({
        response_type: 'code',
        client_id: process.env.BOTIX_CLIENT_ID,
        redirect_uri: process.env.BOTIX_REDIRECT_URI,
        scope: 'contacts:read messages:write',
        state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
    });
    res.redirect(`https://app.botix.pro/oauth/authorize?${params}`);
});

Пользователь видит app.botix.pro/oauth/authorize, список scope, нажимает «разрешить». BOTIX делает редирект на наш redirect_uri с code и state. Обмен кода на токены:

javascript
app.get('/oauth/botix/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 response = await fetch('https://api.botix.pro/oauth/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            grant_type: 'authorization_code',
            code,
            client_id: process.env.BOTIX_CLIENT_ID,
            client_secret: process.env.BOTIX_CLIENT_SECRET,
            redirect_uri: process.env.BOTIX_REDIRECT_URI,
            code_verifier: session.codeVerifier,
        }),
    });
    const tokens = await response.json();
    // { access_token, refresh_token, expires_in, project_id, scope }
    
    await saveTokens(session.userId, tokens);
    res.redirect('/dashboard');
});

code — 5-10 минут, одноразовый. access_token — около часа. refresh_token — 30-90 дней, BOTIX ротирует его при каждом обмене (старый инвалидируется). Это закрывает атаку «утёкший refresh использовали повторно».

Не делать PKCE «потому что у нас бэкенд» — ошибка. PKCE защищает от перехвата authorization code, и его пропуск экономит 5 строк ценой открытой атаки. Граница между «нужен OAuth» и «хватит API-ключа» — момент появления первого стороннего пользователя. До него OAuth — преждевременная оптимизация; в этот момент — необходимость: пускать сторонние приложения по API-ключам значит в первый же серьёзный инцидент уничтожить репутацию.

Хранение секретов: семь антипаттернов и одна рабочая схема

Как только токены получены, встаёт второй вопрос — где они лежат. API-ключ и client_secret — это аналог пароля, написанного в открытом виде: ни срока действия по умолчанию, ни второго фактора, ни попыточного лимита. Кто получил секрет — делает всё, что доступно тарифу проекта. Большинство утечек происходит не из-за изощрённого взлома, а из-за стандартных антипаттернов в коде интегратора. Их семь, и каждый встречается регулярно даже в зрелых командах:

  1. Секрет в репозитории. $key = 'btx_live_XXXX…'; в конфиге → коммит → через неделю репозиторий публичный (open-source, переезд, утечка через бэкап). Gitleaks-сканеры (GitHub Secret Scanning) уведомят владельца через часы — но за эти часы базу контактов уже выгрузят.
  2. Секрет в .env без шифрования. Plaintext на диске. Любой, кто получил доступ к ФС (RCE, бэкап, увольнённый админ), читает всё разом. Классика — Docker-образ с забытым .env внутри, попавший в публичный реестр. .env приемлем как место для *переменной*, но не для *хранения*: ключ должен попадать туда из защищённого источника при деплое.
  3. Один секрет на всю команду. Master-ключ в Slack, из команды ушли пятеро, при увольнении ничего не ротировалось. Контрмера — один ключ на *исполнителя* (бот, воркер, сервис), не на человека.
  4. Секрет в логах. error_log(print_r($headers, true)) → в логах Authorization: Bearer XXXXXXXX… → ELK/Loki/Datadog, где его видят все с доступом к мониторингу. Контрмера — маскирование чувствительных полей: SDK BOTIX (PHP, Python, Node) заменяют значение Authorization на Bearer *** при сериализации автоматически.
  5. Секрет в URL. ?api_key=… пишется в access.log nginx, в логах прокси, в Referer при переходах на третьи ресурсы. Платформа BOTIX категорически не принимает ключ через query-string — только Authorization: Bearer btx_live_…, это закреплено в app/Middleware/PublicApiAuth.php явной проверкой источника заголовка.
  6. Секрет без ротации. Ключ выпущен два года назад, сменилась половина команды, был инцидент «но не точно, оставим». Гигиена — ротация раз в 6 месяцев плановая плюс внеплановая после любого подозрения.
  7. Секрет с избыточными правами. Сервис только шлёт сообщения, а ключ умеет всё. При компрометации ущерб максимальный. Контрмера — scopes (о них — в разделе про multi-tenant).

Нормальная схема закрывает все семь сразу. Источник правды — secret manager (HashiCorp Vault, AWS Secrets Manager, Yandex Lockbox, в простом случае — pass поверх gpg): секрет хранится зашифрованным, доступ выдаётся ролям, у каждого доступа — audit-лог. При деплое скрипт читает секрет и подставляет в ENV процесса; .env в репозитории отсутствует или содержит только placeholder'ы. Контейнер пересобирается *без* секрета, секрет инжектится при запуске через docker run -e BOTIX_CLIENT_SECRET=... или Kubernetes Secret. SDK читает из ENV; в коде нет строковой константы, конфига или файла с секретом — если ENV не установлен, SDK падает на старте с понятной ошибкой.

В OAuth-сценарии к этому добавляется хранение *refresh-токенов пользователей*. Refresh живёт долго, его утечка = доступ к проекту пользователя на месяцы. Plaintext в БД недопустим:

  • Refresh шифруется приложением перед записью (AES-256-GCM). Ключ — в secret manager.
  • Access — в Redis с TTL = expires_in минус минута.
  • Привязка our_user_id -> botix_project_id — отдельной таблицей.
  • При отзыве пользователем следующий refresh вернёт 401 — удаляем токены.
javascript
const ENC_KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY, 'hex');

function encrypt(plaintext) {
    const iv = crypto.randomBytes(12);
    const cipher = crypto.createCipheriv('aes-256-gcm', ENC_KEY, iv);
    const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
    return Buffer.concat([iv, cipher.getAuthTag(), ct]).toString('base64');
}

async function saveTokens(userId, tokens) {
    await db.query(`
        INSERT INTO botix_connections (user_id, project_id, refresh_token_enc, scope)
        VALUES ($1, $2, $3, $4)
        ON CONFLICT (user_id) DO UPDATE SET
            project_id = EXCLUDED.project_id,
            refresh_token_enc = EXCLUDED.refresh_token_enc
    `, [userId, tokens.project_id, encrypt(tokens.refresh_token), tokens.scope]);
    
    await redis.set(`botix:access:${userId}`, tokens.access_token,
                    'EX', tokens.expires_in - 60);
}

Не путать client_secret с пользовательским токеном. client_secret — секрет *приложения*, общий на всех пользователей: его утечка = компрометация всех подключённых. Refresh-токен — секрет *конкретного* пользователя. Оба шифруются, но живут в разных местах: первый в ENV из secret manager, второй в БД в зашифрованном виде. Полный чек-лист хранения ключей для код-ревью разбираем отдельно — Хранение API-ключей: антипаттерны и решения.

Multi-tenant: изоляция project_id, scopes, ротация, audit

Третий слой — сама multi-tenant архитектура. Большинство ошибок здесь лежит не в коде, а в неявных предположениях: «допустим, project_id один на запрос», «допустим, токен можно положить в общий конфиг», «допустим, лимит у всех одинаковый». Каждое однажды оказывается неверным — и тогда клиент A видит данные клиента B, либо вся интеграция упирается в лимит самого слабого тарифа из ста.

Границы доверия. В multi-tenant интеграции три зоны: интегратор, платформа, клиент платформы. Ключи как механизм аутентификации эти границы должны явно отражать. Три паттерна:

  • API-ключ на проект. По одному ключу на клиента, выпущены через клиентский кабинет. Просто и предсказуемо, но требует ручного шага при подключении каждого клиента.
  • OAuth installation. Третья сторона регистрирует приложение, любой клиент одним кликом подключает его к своему проекту. На выходе — access_token, привязанный к приложению и к проекту. Стандарт для публичных third-party (GitHub Apps, Stripe Connect).
  • Partner API. Платформа выделяет интегратору права создавать проекты от имени партнёра. Для крупных интеграторов и реселлеров.

В BOTIX доступны первый и второй: ключ привязан к project_id явно, OAuth installations реализованы через btx_oauth_installations и страницу согласия app.botix.pro/oauth/authorize. Partner API — в roadmap'е без публичного релиза.

Изоляция project_id — главное требование. На уровне платформы: каждый запрос приходит с токеном, токен резолвится в project_id, любой SQL содержит WHERE project_id = ? от текущего токена. В BOTIX это закреплено правилом №6 в CLAUDE.md и проверяется через Auth::assertProjectAccess() в каждом контроллере — защита от утечки данных между проектами. Запросы по OAuth логируются с oauth_app_id — пользователь видит в connected-apps, какое приложение что делало.

На стороне интегратора нужна та же дисциплина. Главная ловушка — инициализировать SDK глобально с одним токеном и переключать токен между запросами. Любая ошибка = запрос на чужой проект. Правильный паттерн — один инстанс SDK на проект, кэшируется. Для API-ключевого сценария это выглядит так (Python):

python
from botix import BotixClient

clients = {
    project_id: BotixClient(api_key=XXXXXXXX(project_id))
    for project_id in active_projects
}

def send_to_project(project_id: int, contact_id: int, text: str):
    client = clients[project_id]
    client.messages.create(contact_id=contact_id, text=text)

Ключи берутся из защищённого хранилища, инстансы кэшируются, и в момент использования невозможно случайно перепутать project_id с api_key. Если у одного project_id ключ ротируется — пересоздаётся только один инстанс, остальные работают.

В OAuth-сценарии добавляется автоматический refresh access-токена и тот же кэш инстансов (Node):

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

async function getClient(userId) {
    let accessToken = await redis.get(`botix:access:${userId}`);
    if (!accessToken) accessToken = await refreshAccess(userId);
    
    const cacheKey = `${userId}:${accessToken.slice(-8)}`;
    if (!clientsCache.has(cacheKey)) {
        clientsCache.set(cacheKey, new Client({
            accessToken,
            appId: process.env.BOTIX_CLIENT_ID,
        }));
    }
    return clientsCache.get(cacheKey);
}

async function refreshAccess(userId) {
    const row = await db.query(
        'SELECT refresh_token_enc FROM botix_connections WHERE user_id = $1', [userId]);
    const refreshToken = decrypt(row.refresh_token_enc);
    
    const response = await fetch('https://api.botix.pro/oauth/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: refreshToken,
            client_id: process.env.BOTIX_CLIENT_ID,
            client_secret: process.env.BOTIX_CLIENT_SECRET,
        }),
    });
    
    if (response.status === 401) {
        await db.query('DELETE FROM botix_connections WHERE user_id = $1', [userId]);
        throw new Error('User revoked access');
    }
    
    const data = await response.json();
    await db.query(
        'UPDATE botix_connections SET refresh_token_enc = $1 WHERE user_id = $2',
        [encrypt(data.refresh_token), userId]);
    await redis.set(`botix:access:${userId}`, data.access_token,
                    'EX', data.expires_in - 60);
    return data.access_token;
}

Использование:

javascript
app.post('/api/send-message', async (req, res) => {
    const client = await getClient(req.user.id);
    await client.messages.create({
        contact_id: req.body.contactId,
        text: req.body.text,
    });
    res.json({ ok: true });
});

client гарантированно работает в контексте проекта пользователя req.user.id. Никакого глобального состояния, никакой возможности случайно отправить в чужой проект. Один инстанс SDK на процесс — нет: это глобальное состояние, утечёт между запросами. Минимум — один на пользователя/проект.

Scopes — третий слой защиты. По умолчанию выпущенный ключ может всё, что доступно тарифу. На практике это редко нужно: если интеграция отвечает за отправку сообщений, ей не нужны права на удаление контактов, изменение настроек, чтение журнала транзакций. BOTIX поддерживает scopes через поле scopes в btx_api_keys и btx_oauth_access_tokens. При создании ключа на app.botix.pro/developers указывается список scope, middleware Public API при каждом запросе проверяет вхождение эндпоинта в scope токена. Несоответствие — 403 с кодом INSUFFICIENT_SCOPE. Практика: на каждый изолированный кусок логики — отдельный ключ с минимальным scope. Сервис отправки — messages:write. Аналитический воркер — отдельный ключ с analytics:read. Упадут в общий лог — ни один не даст компрометации полного контура. У OAuth-токена scope дополнительно виден пользователю на странице согласия: принцип наименьших привилегий плюс прозрачность.

Ротация без даунтайма. Любой ключ когда-нибудь ротируется: плановый ротейт раз в полгода — гигиена, внеплановый после утечки — обязателен. Архитектура должна допускать ротацию тремя независимыми шагами: создать новый ключ → прокинуть в код → проверить → отозвать старый. У BOTIX это два состояния через поле revoked_at в btx_api_keys: active (можно использовать) и revoked (отказ). Никаких grace-period'ов с двумя ключами одновременно — новый работает до отзыва старого.

Audit-логи — финальный слой. На каждый вызов пишется строка: какой ключ, какой эндпоинт, какой HTTP-статус, какой project_id, время ответа. У BOTIX это btx_api_log с retention 90 дней, фильтр по ключу и статусу — на app.botix.pro/developers. Журнал решает три задачи: отладку (где начались ошибки), расследование инцидентов (что шло с подозрительного ключа) и оптимизацию (что вызывается чаще всего — может, пора на webhook). На стороне интегратора логируем свои запросы с собственным request_id, чтобы при разборе была видна вся цепочка. Как этот слой сочетается с выбором polling/webhook — в разборе Multi-tenant, polling vs webhook и OAuth.

Чек-лист архитектора multi-tenant. Ключи хранятся отдельно на каждый project_id и кэшируются на старте процесса. Scope каждого ключа сужен до минимума. Любая операция в коде явно указывает project_id — нет глобального «текущий проект». Ротация возможна без остановки обработчика. Журнал вызовов читается из платформы и пишется внутри интегратора параллельно. Пропущен хоть один пункт — архитектура работает только до первого инцидента.

Когда это всё не нужно

Интегрируете BOTIX в собственный бэкенд одной компании — OAuth не нужен. Берёте API-ключ из app.botix.pro/developers, кладёте в ENV, инициализируете SDK. Multi-tenant, шифрование refresh-токенов, страницы согласия — всё это включается ровно в момент появления первого стороннего пользователя. До — преждевременная оптимизация. В этот момент — необходимость.

Ссылки на код

  • Пример third-party приложения — github.com/BOTIX-pro/example-express (ветка oauth).
  • Документация OAuth — developers.botix.pro/oauth.
  • Список scope — developers.botix.pro/best-practices.
  • Кабинет разработчика — app.botix.pro/settings/oauth-apps.