HMAC visitor token
Виджет / HMAC visitor token

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 независимо от устройства.

Формат токена

text
<base64url(payload)>.<hex(hmac_sha256(secret, payload_bytes))>

Пример (укорочен):
eyJ1c2VyX2lkIjoiNDIiLCJleHAiOjE3MzMyNTcyMDB9.a1b2c3d4...

Две части, разделённые точкой. Первая — base64url-кодированный JSON-payload (без padding-знака =). Вторая — hex-encoded HMAC-SHA256 поверх raw JSON-байтов, не поверх base64-строки.

Payload поля

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:

http
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'а, потом подгружает виджет:

html
<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-тег:

index.html.j2 / Blade / EJS / Twightml
<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() — вызовы буферизуются.

js
// onLoginSuccess в вашем SPA:
const resp = await fetch('/api/supporthub/token', { credentials: 'include' });
const { token } = await resp.json();
window.SupportHub.identify({ visitor_token: token });

Что делает бэк после получения токена

  1. Бьёт visitor_token по точке: получает payload_b64 + sig_hex.
  2. base64url-decode'ит payload в JSON bytes, парсит как JSON.
  3. Сам считает hmac_sha256(workspace.signing_secret, payload_bytes) и сравнивает с переданным sig_hex через compare_digest (timing-safe).
  4. Если signature не сходится → 401 invalid visitor_token signature.
  5. Если есть exp и оно меньше time.time() → 401 visitor_token expired.
  6. Если signature ОК → берёт payload.user_id и возвращает виртуальный handle verified:<user_id> в `_resolve_visitor_id`.
  7. 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-команду:

  1. Старый секрет инвалидируется в DB
  2. Новый секрет генерируется и отдаётся вам
  3. Все активные токены (подписанные старым) сразу 401
  4. Вы обновляете env на своём бекенде. Виджет получит новый токен через /api/supporthub/token на следующем запросе.

Downtime — минуты (пока обновится env + перекатится pod). Все залогиненные пользователи продолжат видеть свои тикеты после обновления, ничего не теряется.

Тестирование локально

Сгенерировать токен из CLI

bash
# 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}')
"

Проверить что бэк его принимает

bash
# /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: старый токен ещё работает пока новый не получен.

См. также

Была ли страница полезной?