#Авторизация в приложении на 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'] ?? ''). Старый заголовок оставлен как есть, чтобы не ломать приложения, уже использующие workaroundBuffer.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:
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
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
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
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_gwcookie. Sliding-refresh при активности: каждый запрос с remaining < 5 мин получает свежую cookie. - На 401 от V1 — отправьте пользователя обратно на placement. Не пытайтесь сами refresh-нуть токен.
// 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_gwcookie руками. 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 в заголовки на входе. Приложение читает заголовки и доверяет им, потому что:
- Заголовок может прийти только от Gateway (всё остальное стрипается).
- Туннель защищён mTLS / WebSocket на уровне инфраструктуры.
- Если приложению нужен capability — оно делает server-side вызов на платформенное API, неся credential как Bearer.
Идиоматическое чтение — req.headers['x-vibe-authorization'] (или эквивалент вашего фреймворка). Идиоматический ответ на 401 от V1 — редирект пользователя на placement, не самостоятельный refresh.
#Связанные документы
- Black Hole — режимы и доступ —
accessPolicy, BlackHoleAccess, BlackHoleDepartment. - Токены доступа —
api-bearerиshare-urlдля делегирования. - Deploy API — как заливать код на BlackHole-сервер.