HMAC visitor token — полный референс
Подписанный токен для cross-device идентификации посетителей: формат, генерация, валидация, ротация, edge-cases.
Зачем нужен токен
Анонимный visitor session ID живёт в 5-tier storage на устройстве (cookie / localStorage / sessionStorage / IndexedDB). Cross-device он НЕ переносится — посетитель с iPhone и MacBook видится бэком как два разных контакта. Решение: ваш бекенд подписывает токен с стабильным user-id (например, PK из вашей CRM), виджет передаёт токен в SupportHub, бэк верифицирует HMAC и резолвит к одному и тому же Contact.verified_user_id независимо от устройства.
Формат токена
<base64url(payload)>.<hex(hmac_sha256(secret, payload_bytes))>
Пример (укорочен):
eyJ1c2VyX2lkIjoiNDIiLCJleHAiOjE3MzMyNTcyMDB9.a1b2c3d4...Две части, разделённые точкой. Первая — base64url-кодированный JSON-payload (без padding-знака =). Вторая — hex-encoded HMAC-SHA256 поверх raw JSON-байтов, не поверх base64-строки.
Payload поля
"crm-12345" — что угодно. SupportHub использует его как handle для cross-device дедупликации (Contact.verified_user_id). Должен быть строкой; число конвертируйте через str(user.id).time.time() и вернёт 401 visitor_token expired если просрочено. Рекомендуется 1-24 часа — баланс между удобством (реже просить виджет переподключиться) и безопасностью (короче окно компрометации). Если exp отсутствует — токен живёт вечно (не рекомендуется).| Поле | Тип | По умолчанию | Описание |
|---|---|---|---|
| user_id | string | — | Обязательно. Ваш стабильный идентификатор пользователя — primary key в вашей БД, VK ID, slug,"crm-12345" — что угодно. SupportHub использует его как handle для cross-device дедупликации (Contact.verified_user_id). Должен быть строкой; число конвертируйте через str(user.id). |
| exp | int (unix timestamp) | — | Опционально. Время истечения в секундах с epoch. Бэк сравнит с time.time() и вернёт 401 visitor_token expired если просрочено. Рекомендуется 1-24 часа — баланс между удобством (реже просить виджет переподключиться) и безопасностью (короче окно компрометации). Если exp отсутствует — токен живёт вечно (не рекомендуется). |
Откуда взять секрет
Workspace-уровневый widget_signing_secret создаётся автоматически при первом запросе и забирается через API:
GET /api/v1/widget/signing-secret
Authorization: Bearer sk_xxx
200 OK
{
"secret": "wsig_..."
}Endpoint workspace-scoped (определяется по API key). Секрет стабилен — повторный GET вернёт то же значение. Сохраните в env вашего бекенда один раз; не запрашивайте на каждый /token вызов (лишний round-trip).
Никогда не показывайте wsig_* на клиенте. Генерация токена должна жить ТОЛЬКО на вашем бекенде. Если секрет попал в JS-bundle / commit / log — обратитесь в саппорт для ротации; старые токены инвалидируются мгновенно.
Генерация токена
Готовые сниппеты эндпоинта GET /api/supporthub/token для FastAPI, Django, Flask, Express, Next.js (App Router), Laravel, Rails, Go, .NET и Spring Boot собраны на отдельной странице: примеры на популярных стэках. Все используют HMAC-SHA256 поверх raw JSON-байт (не base64-строки), base64url-кодирование без padding-знака =, и компактный JSON без пробелов (separators=(",", ":") или эквивалент). TTL рекомендуется 1 час; ваше дело, но не вечно.
Подключение к виджету
Виджет принимает токен через один из трёхмеханизмов:
1. Через one-script install (рекомендуется)
Самый чистый паттерн — один <script> в <body>, который сначала fetch'ит токен с вашего endpoint'а, потом подгружает виджет:
<script>
(function(w, d){
function loadWidget(token){
var s = d.createElement('script');
s.async = 1;
s.src = 'https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID';
if (token) s.setAttribute('data-visitor-token', token);
d.head.appendChild(s);
}
fetch('/api/supporthub/token', { credentials: 'include' })
.then(function(r){ return r.ok ? r.json() : null; })
.then(function(data){ loadWidget(data && data.token); })
.catch(function(){ loadWidget(); });
})(window, document);
</script>credentials: 'include' обязательно — иначе ваш backend не увидит session cookie. Failed fetch / 401 / no endpoint = silent fall-back на анонимный режим без задержки загрузки виджета.
2. SSR-инжект через data-атрибут
Если ваш backend рендерит страницу — впишите токен прямо в script-тег:
<script
async
src="https://support.forestsnet.com/widget-bundle?ws=YOUR_WORKSPACE_UUID"
data-visitor-token="{{ generate_supporthub_token(user.id) }}"
></script>3. Программно через JS API
Для SPA где визитор логинится после загрузки страницы — вызов SupportHub.identify({visitor_token: "..."}) уже после загрузки бандла. Метод доступен сразу, не нужно ждать init() — вызовы буферизуются.
// onLoginSuccess в вашем SPA:
const resp = await fetch('/api/supporthub/token', { credentials: 'include' });
const { token } = await resp.json();
window.SupportHub.identify({ visitor_token: token });Что делает бэк после получения токена
- Бьёт
visitor_tokenпо точке: получаетpayload_b64+sig_hex. - base64url-decode'ит payload в JSON bytes, парсит как JSON.
- Сам считает
hmac_sha256(workspace.signing_secret, payload_bytes)и сравнивает с переданнымsig_hexчерезcompare_digest(timing-safe). - Если signature не сходится → 401
invalid visitor_token signature. - Если есть
expи оно меньшеtime.time()→ 401visitor_token expired. - Если signature ОК → берёт
payload.user_idи возвращает виртуальный handleverified:<user_id>в `_resolve_visitor_id`. - Caller использует helper
_lookup_visitor_contactкоторый смотрит вContact.verified_user_id(не вinternal_id) — там и живёт cross-device идентичность.
Edge cases
Токен истёк (exp меньше now)
Backend вернёт 401 на любой widget endpoint с этим токеном (conversations, identify, ws handshake closes 4401). Виджет:
- Long-poll / REST endpoints — fetch упадёт, но виджет не сломается. На следующей загрузке страницы one-script install заново вызовет
/token— получит свежий токен. - WebSocket — handshake fails с code 4401, виджет реконнектится с new token из storage (если он там есть) или anonymous (если нет).
Решение для долгоживущих сессий: сделайте /api/supporthub/token идемпотентным — чтобы повторный вызов из виджета (например, черезsetInterval(() => fetch('/api/supporthub/token').then(r => r.json()) .then(d => SupportHub.identify({visitor_token: d.token})), 30 * 60 * 1000)) обновлял токен раз в полчаса.
Anonymous visitor становится логиненым mid-session
Сценарий: визитор пришёл анонимно, написал «привет» — создан Contact с internal_id="vs_abc". Потом залогинился — ваш onLoginSuccess вызывает SupportHub.identify({visitor_token: "..."}).
Сейчас бэк создаёт отдельный Contact с verified_user_id="42" — анонимная история не переносится. Это известная проблема, merge-flow в roadmap. Воркэраунд: оператор может вручную смерджить контакты в дашборде.
Несколько вкладок одного браузера
Все вкладки используют один и тот же visitor_token (cookie shared) и один и тот же verified Contact. Cross-tab WebSocket sync через BroadcastChannel — изменения видны во всех вкладках моментально.
Юзер залогинился под другим аккаунтом
Если в вашем приложении user.id поменялся (logout + login as different user), /api/supporthub/token вернёт токен с новым user_id. Виджет передаст его → backend резолвит к другому Contact'у. Чужие тикеты не видны — backend всегда фильтрует по resolved Contact.id.
Чтобы старая сессия не «торчала» в виджете — после logout вызовите SupportHub.destroy() и затем заново init() (либо просто перезагрузите страницу).
Безопасность — что важно
- Никогда не показывайте
wsig_*секрет на клиенте. Генерация только на бекенде, токен передаётся готовым. - Никогда не передавайте чужой
user_idиз клиентского кода. Используйте серверную сессию хоста как источник истины — иначе любой посетитель сможет читать тикеты любого другого. - Authorization на /api/supporthub/token — обязательна. Endpoint должен возвращать токен только если визитор реально залогинен в вашей системе.
- HTTPS обязательно — токен в URL / data-атрибуте виден сети. На HTTPS они зашифрованы.
- Короткое
exp— рекомендуется 1-24 часа. Длинное окно увеличивает blast-radius компрометации. - Стабильный
user_id— должен однозначно идентифицировать одного человека. Не используйте email если он может меняться (CRM merge — два контакта становятся одним).
Ротация секрета
Если секрет скомпрометирован (попал в commit / log / клиентский bundle): обратитесь в саппорт. UI ротации в roadmap; пока ротация делается через support-команду:
- Старый секрет инвалидируется в DB
- Новый секрет генерируется и отдаётся вам
- Все активные токены (подписанные старым) сразу 401
- Вы обновляете env на своём бекенде. Виджет получит новый токен через
/api/supporthub/tokenна следующем запросе.
Downtime — минуты (пока обновится env + перекатится pod). Все залогиненные пользователи продолжат видеть свои тикеты после обновления, ничего не теряется.
Тестирование локально
Сгенерировать токен из CLI
# Python one-liner
python3 -c "
import base64, hashlib, hmac, json, time
SECRET = 'wsig_paste_here'
payload = json.dumps({'user_id': 'test-42', 'exp': int(time.time()) + 3600}, separators=(',', ':'))
sig = hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest()
b64 = base64.urlsafe_b64encode(payload.encode()).decode().rstrip('=')
print(f'{b64}.{sig}')
"Проверить что бэк его принимает
# /widget/identify должен вернуть 200
curl -X POST "https://api.support.forestsnet.com/api/webhooks/widget/YOUR_WS/identify?visitor_token=TOKEN" \
-H "Content-Type: application/json" \
-d '{"email": "test@example.com"}'
# Проверьте созданный Contact
psql ... -c "SELECT id, verified_user_id, email FROM contacts WHERE verified_user_id = 'test-42';"FAQ
Q: Можно ли использовать JWT вместо HMAC?
Нет — backend проверяет именно HMAC-SHA256 в формате описанном выше. JWT-парсер не задействован. Если у вас уже есть JWT infrastructure — отдельно сгенерируйте HMAC-токен из той же серверной сессии, не пытайтесь переиспользовать JWT signing key.
Q: Что если у пользователя нет integer-id?
Любой стабильный string подойдёт — UUID, slug, email-hash, vk_id в виде строки. Главное чтобы для одного человека всегда возвращалось одно и то же значение.
Q: Можно ли подписать токен с дополнительными полями (email, name)?
Технически — да, JSON принимает любые поля. Backend просто игнорирует их. Email/name всё равно нужно передавать через SupportHub.identify({email, name}) или data-email/data-name атрибуты — это идёт в /widget/identify body, не в токен.
Q: Сколько токенов можно выдать одному пользователю?
Сколько угодно — backend хранит только Contact, не токены. Каждый /api/supporthub/token вызов выдаёт свежий токен; все они валидны до своего exp. Удобно для rotation: старый токен ещё работает пока новый не получен.
См. также
- Identify-flow целиком — data-* атрибуты, JS API, three-way decision matrix
- API-справочник виджета — все endpoint'ы, controller API, multi-tier session storage
- Troubleshooting — типичные проблемы и их решения