#Авторизация в приложении на BlackHole

Gateway сам аутентифицирует пользователя и пробрасывает к приложению уже подписанный запрос. Браузер не видит и не хранит vibe_session_* — токен живёт только между Gateway и app-сервером. Приложение получает заголовок X-Vibe-Authorization: Bearer vibe_session_<…> и читает identity через GET /v1/me. Это BFF-паттерн (Backend-for-Frontend) — тот же, что у Cloudflare Access, Google IAP и AWS API Gateway.

Скоуп: vibe:apps (на стороне V1 API), внутри туннеля — без явного скоупа, Gateway сам управляет доступом.

#Жизненный цикл запроса

Битрикс24 placement                       Backend (vibecode.bitrix24.tech)
   │                                         │
   ├── POST /v1/bitrix-handler ──────────────►│  exchange OAuth code,
   │     (B24 placement triple)               │  upsert AppUserToken,
   │                                          │  createAppSession (mint vibe_session_*),
   │                                          │  mint signed __init JWT (TTL 5 min)
   │                                          │
   │◄── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ──┤
   │
Browser
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…]&__init=<jwt> ───►│ Gateway
   │                                                                     │  Priority 0 redeem:
   │                                                                     │   • verify JWT (HMAC-SHA256, "gateway-init")
   │                                                                     │   • subdomain binding
   │                                                                     │   • mark jti used (one-time)
   │                                                                     │   • store (sub, subdomain) → raw in cache
   │                                                                     │   • mint _vibe_gw cookie
   │                                                                     │
   │◄────────── 302 https://app-XXX/?placement=…&member_id=…[&placement_options=…] ───────┤
   │                                          Set-Cookie: _vibe_gw=…
   │
   ├── GET https://app-XXX/?placement=…&member_id=…[&placement_options=…] ──────────────►│ Gateway
   │   Cookie: _vibe_gw=…                                                │  Priority 1 (cookie):
   │                                                                     │   • resolve raw from cache
   │                                                                     │   • strip incoming X-Vibe-Authorization (anti-spoof)
   │                                                                     │   • inject X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │
   │                                                                     ├── GET / ─────► App server
   │                                                                     │   X-Vibe-Authorization: Bearer vibe_session_…
   │                                                                     │   Cookie: _vibe_gw=…  (Gateway-only; не для app)
   │                                                                     │
   │◄────────────────────── HTTP response ───────────────────────────────┤

Ключевая разница от стандартного OAuth:

  • Browser никогда не видит vibe_session_* — даже на момент первого open. Раньше токен ехал в sessionStorage через auto-submit форму; теперь — только в подписанном одноразовом коде в URL, который Gateway сразу redeems и стирает.
  • __init — одноразовый. Повторный redeem того же jti отклоняется. TTL 5 минут. На медленных сетях iframe-загрузки этого хватает с запасом — если упрётесь, проверьте Gateway-логи init code replay rejected / init code verify failed.
  • Cookie _vibe_gw живёт 10 минут, sliding-refresh при активности. Не пытайтесь её читать в JS — она HttpOnly.
  • Cold-start sleeping: если сервер в SLEEPING, Gateway редкеемит __init до wake-page, кладёт raw в кэш и cookie на ответ. После reconnect-а agent'а browser делает location.replace(clean URL) — кэш всё ещё горячий, Authorization инжектируется на первый же GET.

#Что приходит в каждый запрос

GET /api/tasks
Host: app-9edac24091ba.vibecode.bitrix24.tech
Cookie: _vibe_gw=<…>                       ← платформенная cookie, к приложению пробрасывается прозрачно

X-Vibe-Request-Id:   req_<14 hex chars>
X-Vibe-User-Id:      42
X-Vibe-Portal-Id:    f2342f7a-b1c5-4d50-8a3e-30219c5ae0c1
X-Vibe-User-Name:         Иван Иванов
X-Vibe-User-Name-Encoded: %D0%98%D0%B2%D0%B0%D0%BD%20%D0%98%D0%B2%D0%B0%D0%BD%D0%BE%D0%B2
X-Vibe-User-Role:    MEMBER
X-Vibe-Authorization: Bearer vibe_session_<43 chars>

Gateway инжектирует семь заголовков на каждый проксируемый запрос. X-Vibe-Request-Id приходит на любой запрос; остальные — только когда пользователь аутентифицирован (пройден placement-bootstrap или Bearer-flow):

Заголовок Когда Значение
X-Vibe-Request-Id всегда Идентификатор запроса для корреляции в журналах. Префикс req_ + 14 hex-символов.
X-Vibe-User-Id аутентифицированный запрос Числовой ID пользователя в Битрикс24 (например, 42). Совпадает с currentUser.bitrixUserId в ответе /v1/me.
X-Vibe-Portal-Id аутентифицированный запрос UUID портала во внутренней БД Вайбкод. Не путать с доменом — он отдельно.
X-Vibe-User-Name аутентифицированный запрос Отображаемое имя пользователя в виде сырых UTF-8 байтов. ⚠️ Большинство HTTP-стеков (Express и др.) читают значения заголовков как latin1 (RFC 7230 §3.2.4), поэтому локализованные имена приходят «крокозябрами» (Ðван вместо Иван). Для корректного имени используйте X-Vibe-User-Name-Encoded (ниже). Заголовок сохранён для обратной совместимости.
X-Vibe-User-Name-Encoded аутентифицированный запрос То же отображаемое имя, percent-encoded по RFC 3986 (UTF-8). Декодируется любым стандартным декодером: в JS — decodeURIComponent(req.headers['x-vibe-user-name-encoded']). Рекомендуемый источник имени вместо сырого X-Vibe-User-Name.
X-Vibe-User-Role аутентифицированный запрос ADMIN или MEMBER — роль пользователя на портале.
X-Vibe-Authorization аутентифицированный запрос Bearer vibe_session_<…> — credential для server-side вызовов V1 API.
  • X-Vibe-Authorization — единственный источник Bearer-токена. Платформенный префикс X-Vibe- выбран, чтобы не конфликтовать с собственной Authorization-цепочкой приложения (например, если оно проксирует к своему upstream API). Любой входящий X-Vibe-Authorization от клиента Gateway стрипает до проброса — приложение никогда не получит spoofed-токен.
  • X-Vibe-User-Id / -Portal-Id / -User-Name / -User-Role — быстрый способ опознать пользователя без дополнительного вызова /v1/me. Подходят для логов, аналитики и простого ACL. Для полного контекста (scopes, capabilities, tariff, trial) всё равно делайте один вызов /v1/me и кэшируйте его на токен.
  • Имя пользователя — читайте X-Vibe-User-Name-Encoded, не X-Vibe-User-Name. Сырой X-Vibe-User-Name несёт UTF-8 байты, которые HTTP-стеки трактуют как latin1 → кириллица/локализованные имена приходят «крокозябрами». Корректное имя: const name = decodeURIComponent(req.headers['x-vibe-user-name-encoded'] ?? ''). Старый заголовок оставлен как есть, чтобы не ломать приложения, уже использующие workaround Buffer.from(name, 'latin1').toString('utf8').
  • X-Vibe-Request-Id — кладите в свой структурированный лог, чтобы трассировать запрос между Gateway и приложением. При обращении в поддержку Вайбкод присылайте этот ID — он есть и в журналах платформы.
  • Cookie: _vibe_gw — оставляется как есть, но это не credential для V1 API. Это сессионный токен Gateway для быстрой авторизации следующего запроса. Приложению она не нужна и помечена HttpOnly — JS её даже не увидит.
  • access_token / auth[*] в body — больше не приходят. Не пытайтесь их читать.

#URL-параметры iframe после placement-редиректа

После POST /v1/bitrix-handler мы 302-редиректим браузер на <appUrl> и докидываем в query string:

Параметр Когда Значение
placement всегда Код placement-а (LEFT_MENU, CRM_DEAL_DETAIL_TAB, IM_TEXTAREA, …). Совпадает с PLACEMENT в POST от B24.
member_id всегда Идентификатор портала Bitrix24 (тот же member_id, что в B24 OAuth).
placement_options когда B24 присылает PLACEMENT_OPTIONS — типично для DETAIL_TAB / DETAIL_TOOLBAR / LIST_MENU и других placement'ов, где есть контекст сущности JSON-строка с контекстом сущности — например {"ID":"42"} для CRM_DEAL_DETAIL_TAB. Сюда B24 кладёт ID текущей карточки (сделки/лида/контакта/компании).
__init первый запрос Одноразовый JWT для Gateway, обмениваемый на _vibe_gw cookie. После redeem-а — исчезает (replace на чистый URL).

Заметка по нэймингу: B24 шлёт это поле как PLACEMENT_OPTIONS (uppercase) в POST к /v1/bitrix-handler; мы пробрасываем под lowercase именем placement_options в iframe URL. Это одно и то же поле — ищите в спецификации B24 по uppercase.

Как читать placement_options — забирайте из URLSearchParams и парсите JSON:

JavaScript
const params = new URLSearchParams(window.location.search)
const placement = params.get('placement')                  // "CRM_DEAL_DETAIL_TAB"
const options = JSON.parse(params.get('placement_options') || '{}')
const entityId = options.ID                                // "42" — текущая карточка

Не используйте BX24.placement.info() — SDK не видит контекст через наш handler-redirect и вернёт {placement:null, options:null}. Всё, что вам нужно про placement и его параметры, уже лежит в URL.

#Получение identity (`GET /v1/me`)

X-Vibe-Authorization несёт только credential, не identity. Чтобы узнать пользователя, портал, скоупы и capabilities — один раз дёргаем /v1/me на app-сервере и кэшируем в собственной сессии:

GET https://vibecode.bitrix24.tech/v1/me
X-Api-Key: <ключ-вашего-приложения>
Authorization: Bearer vibe_session_<…>     ← забрали из X-Vibe-Authorization

200 OK
{
  "success": true,
  "data": {
    "type": "oauth_app",
    "portal": "company.bitrix24.ru",
    "scopes": ["crm", "tasks", "imbot", ...],
    "currentUser": {
      "bitrixUserId": "42",
      "_note": "B24 numeric user id of the OAuth-authorized end user (from Bearer session)."
    },
    "capabilities": { "servers": { "create": { "available": true, "reason": "TRIAL_ACTIVE" }, ... }, ... },
    "tariff": { ... },
    "trial": { ... },
    "app": { "title": "My App", "id": "<uuid>" }
  }
}

Показаны характерные поля. В ответе также есть api (список доступных эндпоинтов и сущностей), oauth, oauthTutorial, deployment, infra, feedback и другие — модель использует их, чтобы построить запросы без чтения остальной документации.

Идентификатор пользователя — data.currentUser.bitrixUserId (строка с числовым ID Битрикс24). Не userId, не user.id, не верхнеуровневое поле — именно currentUser.bitrixUserId. Совпадает с заголовком X-Vibe-User-Id (см. секцию «Что приходит в каждый запрос»), который Gateway уже инжектировал в запрос; для простой идентификации заголовка достаточно. /v1/me нужен, когда требуются scopes, capabilities, tariff, trial или информация о приложении.

Без Authorization: Bearer тот же /v1/me отвечает с тем же набором полей, только currentUser будет null. Это рабочий режим: используется для server-to-server bootstrap приложения до OAuth — портал, скоупы и capabilities уже видны. Не пытайтесь распознавать «нет сессии» по 401 — его не будет, проверяйте currentUser !== null.

Кэш — 24 часа TTL. После 401 на V1 — отправляйте пользователя обратно на placement (см. ниже).

#Скелеты обработчика

#Node.js / Express

JavaScript
import express from 'express'
const app = express()

const VIBE_API = 'https://vibecode.bitrix24.tech'
const VIBE_APP_KEY = process.env.VIBE_APP_KEY  // ваш vibe_app_<…> ключ

app.use(async (req, _res, next) => {
  // Gateway уже всё проверил — но защищаемся on the off-chance
  const bearer = req.headers['x-vibe-authorization']?.toString().replace(/^Bearer /, '')
  if (!bearer) return next() // anonymous (PUBLIC policy) — кэшируем без identity

  // Кэшируем identity in-process per token (одно обращение к /v1/me на сессию).
  // /v1/me возвращает { success, data: { currentUser, portal, scopes, ... } } —
  // разворачиваем envelope, чтобы дальше работать с самими полями.
  let me = identityCache.get(bearer)
  if (!me) {
    const res = await fetch(`${VIBE_API}/v1/me`, {
      headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${bearer}` },
    })
    if (!res.ok) return next() // 401 → render "needs re-auth" UI
    const json = await res.json()
    me = json.data
    identityCache.set(bearer, me)
  }
  req.user = me           // me.currentUser?.bitrixUserId, me.portal, me.scopes, me.capabilities
  req.bearer = bearer
  next()
})

app.get('/api/tasks', async (req, res) => {
  // currentUser может быть null, если запрос без Bearer пришёл (PUBLIC-политика).
  // Альтернатива — прочитать заголовок X-Vibe-User-Id (Gateway уже его инжектировал).
  const userId = req.user?.currentUser?.bitrixUserId ?? req.headers['x-vibe-user-id']
  const r = await fetch(`${VIBE_API}/v1/tasks?filter[RESPONSIBLE_ID]=${userId}`, {
    headers: { 'X-Api-Key': VIBE_APP_KEY, Authorization: `Bearer ${req.bearer}` },
  })
  res.json(await r.json())
})

#Python / FastAPI

Python
from fastapi import FastAPI, Request, HTTPException
import httpx

VIBE_API = "https://vibecode.bitrix24.tech"
VIBE_APP_KEY = os.environ["VIBE_APP_KEY"]
app = FastAPI()
identity_cache: dict[str, dict] = {}

async def resolve_identity(request: Request):
    raw = request.headers.get("x-vibe-authorization", "")
    bearer = raw.removeprefix("Bearer ")
    if not bearer:
        return None
    if bearer in identity_cache:
        return identity_cache[bearer]
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/me",
            headers={"X-Api-Key": VIBE_APP_KEY, "Authorization": f"Bearer {bearer}"},
        )
    if r.status_code != 200:
        return None
    # /v1/me возвращает {"success": True, "data": {...}} — кладём в кэш именно data.
    me = r.json()["data"]
    identity_cache[bearer] = me
    return me

@app.get("/api/tasks")
async def tasks(request: Request):
    me = await resolve_identity(request)
    if not me:
        raise HTTPException(401, "Re-authentication required")
    # currentUser может быть None при запросе без Bearer (PUBLIC-политика); откат на заголовок Gateway.
    user_id = (me.get("currentUser") or {}).get("bitrixUserId") or request.headers.get("x-vibe-user-id")
    async with httpx.AsyncClient() as c:
        r = await c.get(
            f"{VIBE_API}/v1/tasks",
            params={"filter[RESPONSIBLE_ID]": user_id},
            headers={"X-Api-Key": VIBE_APP_KEY,
                     "Authorization": request.headers["x-vibe-authorization"]},
        )
    return r.json()

#Go / net-http

go
package main

import (
    "encoding/json"
    "net/http"
    "os"
    "strings"
    "sync"
)

const vibeAPI = "https://vibecode.bitrix24.tech"

var vibeAppKey = os.Getenv("VIBE_APP_KEY")
var identityCache sync.Map // bearer string -> *Identity

// CurrentUser совпадает по полям с объектом data.currentUser в ответе /v1/me.
// Когда запрос пришёл без Bearer (PUBLIC-политика) — data.currentUser приходит как null,
// и поле CurrentUser в раскодированной структуре остаётся пустым (BitrixUserID == "").
type CurrentUser struct {
    BitrixUserID string `json:"bitrixUserId"`
}

type Identity struct {
    Portal      string       `json:"portal"`       // домен портала, например "company.bitrix24.ru"
    Scopes      []string     `json:"scopes"`
    CurrentUser *CurrentUser `json:"currentUser"`  // nil без Bearer
}

type meEnvelope struct {
    Success bool     `json:"success"`
    Data    Identity `json:"data"`
}

func resolveIdentity(r *http.Request) (*Identity, string, error) {
    raw := strings.TrimPrefix(r.Header.Get("X-Vibe-Authorization"), "Bearer ")
    if raw == "" {
        return nil, "", nil
    }
    if v, ok := identityCache.Load(raw); ok {
        return v.(*Identity), raw, nil
    }
    req, _ := http.NewRequest("GET", vibeAPI+"/v1/me", nil)
    req.Header.Set("X-Api-Key", vibeAppKey)
    req.Header.Set("Authorization", "Bearer "+raw)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, "", err
    }
    defer resp.Body.Close()
    if resp.StatusCode != 200 {
        return nil, "", nil
    }
    var env meEnvelope
    if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
        return nil, "", err
    }
    identityCache.Store(raw, &env.Data)
    return &env.Data, raw, nil
}

// Пример использования: r.User.CurrentUser.BitrixUserID — числовой ID пользователя Битрикс24.
// Альтернатива — заголовок r.Header.Get("X-Vibe-User-Id"), Gateway инжектировал его на входе.

#Жизненный цикл сессии

  • 24 часа на vibe_session_* (AppSession.expiresAt). Sliding-refresh не делается — после истечения пользователь должен заново открыть placement.
  • 10 минут на _vibe_gw cookie. Sliding-refresh при активности: каждый запрос с remaining < 5 мин получает свежую cookie.
  • На 401 от V1 — отправьте пользователя обратно на placement. Не пытайтесь сами refresh-нуть токен.
JavaScript
// Express snippet — на 401 от V1 редиректим обратно на B24 placement
if (vibeResponse.status === 401) {
  return res.redirect(302, '/oauth/complete?reauth=1')
}

#Что не делать

  • sessionStorage.setItem('vibe_token', …) — токена в браузере физически нет, и не должно быть. OAuth 2.0 Security BCP § 4.1 явно запрещает хранение access-токенов в sessionStorage / localStorage. Не пытайтесь воссоздать его на client-side.
  • Возвращать токен в JSON-ответе SPA. X-Vibe-Authorization живёт исключительно между Gateway и app-сервером. Раскрытие в browser-side ответе уничтожает всё преимущество BFF-паттерна (XSS → утечка credential).
  • Читать _vibe_gw cookie руками. Cookie помечена HttpOnly — JS даже не увидит её. Если хочется session ID для логирования — генерируйте свой.
  • Использовать Authorization вместо X-Vibe-Authorization. Платформенный префикс выбран специально, чтобы приложение могло пользоваться своей собственной Authorization-цепочкой (например, для проксирования в свой upstream).
  • Хранить vibe_session_* где-либо за пределами одной HTTP-сессии. Это credential с blast-radius на 24 часа и на весь V1 API в скоупе приложения. Любая утечка — компрометация.
  • Пытаться декодировать __init или _vibe_gw. Это HMAC-подписанные JWT с одним получателем — Gateway. Содержимое внутреннее, в будущем поменяется.

#Аналогия для понимания

Mental model: Cloudflare Access / Google IAP / AWS API Gateway. Прокси (Gateway) делает всю аутентификацию и кладёт identity в заголовки на входе. Приложение читает заголовки и доверяет им, потому что:

  1. Заголовок может прийти только от Gateway (всё остальное стрипается).
  2. Туннель защищён mTLS / WebSocket на уровне инфраструктуры.
  3. Если приложению нужен capability — оно делает server-side вызов на платформенное API, неся credential как Bearer.

Идиоматическое чтение — req.headers['x-vibe-authorization'] (или эквивалент вашего фреймворка). Идиоматический ответ на 401 от V1 — редирект пользователя на placement, не самостоятельный refresh.

#Связанные документы