Skip to main content
Канал — это доверенное приложение, где пользователь подтверждает вход. Veriqa поставляет четыре адаптера — MAX, Telegram, WhatsApp и Email — и настройка идентична, запускаете ли вы Veriqa встроенно или self-hosted. Каждый канал проходит одни и те же три шага: зарегистрировать адаптер в коде, заполнить его секцию конфигурации Veriqa:Channels:{Channel} и сделать его вебхук доступным из интернета.
Program.cs
Каждый канал — свой пакет. Базовый набор ставится одним метапакетом Veriqa.Core.BaseChannels (Telegram, WhatsApp, Email); MAX в него не входит и ставится явно — dotnet add package Veriqa.Core.ChannelAdapter.Max. Нужен один канал — ставьте только его пакет (Veriqa.Core.ChannelAdapter.Telegram, .WhatsApp, .Max, .Email): вместе с ним приедет только его SDK, и почтовая библиотека не окажется в установке, которая пускает пользователей через Telegram. Вызов регистрации от выбора пакета не зависит — adapters.AddTelegram() пишется одинаково в обоих случаях. Если пакета канала нет, а Add*() вызван — это ошибка компиляции, а не молчаливо отсутствующий канал.
Регистрация адаптера — не то же самое, что его включение. Адаптер активируется только когда его секция несёт "Enabled": true — иначе регистрируются лишь его опции и стартовый валидатор, а канал никогда не появляется на странице входа. Добавляйте только те каналы, которые используете.
Токены ботов, access-токены и SMTP-пароли — секреты. Держите их в user-secrets в разработке и в secret-store в продакшне — никогда в закоммиченном appsettings.json.

Уровень проверки входящего события

У каждой канальной секции есть обязательный ключ InboundVerification — он объявляет, как проверяется подлинность входящего события этого канала. Факт объявляете вы: Veriqa видит лишь то, что проверка выполнилась и прошла, но не то, чем именно она была.
Развёртывание, где включён поставляемый канал, а значение не задано или не входит в словарь, не стартует. Проставьте значение и в секциях выключенных каналов — тогда переключение Enabled в true не уронит старт. Стартовая проверка перебирает тот же набор каналов, из которого ядро строит список включённых: все четыре поставляемых канала в нём есть, а канал стороннего адаптера, подключённый одним вызовом AddChannel, — нет: ключ его секции обязателен, но на старте не проверяется, см. Свой канальный адаптер.
Значения для поставляемых каналов — по режиму, в котором вы их запускаете: Канал в режиме Polling, которому объявили что-то кроме outbound_fetch, даёт Warning при старте — старт при этом продолжается. Полный словарь значений — в справочнике конфигурации.

Telegram

Что нужно: бот, созданный через @BotFather, и его токен.
appsettings.json
В режиме Webhook Veriqa регистрирует вебхук сам на старте (и снимает при остановке) — вы не вызываете setWebhook вручную. Эндпоинт — POST {WebhookBaseUrl}/api/channels/telegram/webhook, и каждый запрос аутентифицируется заголовком X-Telegram-Bot-Api-Secret-Token против WebhookSecretToken.
Режим Polling не требует публичного URL, что делает его практичным выбором для локальной разработки — переключайтесь на Webhook для всего задеплоенного. Ещё он допускает только одного опрашивающего на бота: Bot API отдаёт очередь апдейтов единственному клиенту getUpdates, поэтому второй процесс на том же токене не получает ничего, а его цикл опроса повторяет отказ 409 Bot API — в логе он записан как Conflict: terminated by other getUpdates request, без номера. Отчёт здоровья этого не ловит — токен валиден, и канал считает себя healthy (запуск более одного инстанса).

WhatsApp

Что нужно (Meta Cloud API, провайдер по умолчанию): Meta-приложение с WhatsApp Business, phone number ID и постоянный access-токен из консоли Meta for Developers. Доставка через Twilio настраивается ниже.
appsettings.json
BusinessPhoneNumber — номер, которому пишут пользователи, в формате E.164 — именно на него указывают deep-link и QR-код. В отличие от Telegram, вебхук регистрируется на стороне Meta. В панели приложения WhatsApp → Configuration задайте callback URL https://auth.your-domain.com/api/channels/whatsapp/webhook, вставьте тот же WebhookVerifyToken и подпишитесь на поле messages. Meta верифицирует URL запросом GET с hub.mode и hub.verify_token; Veriqa отвечает на него автоматически, как только канал включён и хост публично доступен. Каждый последующий POST подписывается Meta заголовком X-Hub-Signature-256 и валидируется против AppSecret — отсутствующий или неверный AppSecret означает, что валидные доставки отвергаются.
С каналом поставляются два провайдера доставки: Meta Cloud API (MetaCloudApi, по умолчанию) и Twilio (Twilio). Ключ Provider выбирает, какой из них регистрируется, и объявляет, какого провайдера ожидает установка; на старте он сверяется с фактически зарегистрированным. Собственный провайдер, подключённый через UseWhatsAppProvider<TProvider>(), побеждает поставляемый, и в Provider ставится его код. Расхождение останавливает хост с ошибкой, называющей оба кода.

Доставка через Twilio

Что нужно: аккаунт Twilio (Account SID и Auth Token), WhatsApp-отправитель — Twilio Sandbox for WhatsApp, чтобы попробовать, и зарегистрированный отправитель в production, — и контент twilio/quick-reply, созданный в Twilio Content Template Builder.
appsettings.json
BusinessPhoneNumber — номер отправителя Twilio (при Sandbox — номер песочницы). При Provider = Twilio обязательны AccountSid, AuthToken, QuickReplyContentSid и WebhookUrl: без любого из них хост не стартует, а ошибка называет ключ, но не его значение. ApiBaseUrl необязателен, по умолчанию https://api.twilio.com. Тенант хранит креды Twilio в той же группе кредов WhatsApp, что и креды Meta. Контент quick-reply. Вопрос подтверждения уходит одним контентом с двумя кнопками. Создайте его типа twilio/quick-reply ровно с этими переменными — Veriqa заполняет их сама:
twilio/quick-reply
Вопрос отвечает на сообщение самого пользователя, поэтому уходит внутри 24-часовой сессии, и одобрение шаблона WhatsApp ему не нужно. Не отправляйте этот контент на одобрение. Вебхук. В консоли Twilio задайте URL входящих сообщений отправителя (у Sandbox — When a message comes in, метод POST): https://auth.your-domain.com/api/channels/whatsapp/webhook. Twilio подписывает каждый запрос заголовком X-Twilio-Signature, посчитанным над URL, который знает он, поэтому WebhookUrl должен совпадать с этим URL символ в символ — схема, хост, порт, путь и query. За прокси Veriqa видит свой внутренний адрес, поэтому URL берётся из конфигурации, а не из запроса; расхождение отвергает каждое входящее сообщение с 403. Тенант со своим маршрутом вебхука указывает свой WebhookUrl. GET-рукопожатия у Twilio нет: GET на вебхук отвергается. При Sandbox каждый тестовый телефон один раз присоединяется к песочнице, отправив на её номер join <keyword>; ключевое слово показано в консоли Twilio. Этот провайдер работает внутри Veriqa. Пример node/custom-channel-whatsapp-twilio — другое: внешний адаптер, работающий отдельным сервисом под собственным типом канала, см. HTTP-адаптер канала на любом языке.

Текст предзаполненного сообщения

WhatsApp — единственный из поставляемых каналов, где deep link несёт текст сообщения, а не служебный параметр: пользователь видит его в поле ввода и отправляет как обычное сообщение. Из коробки это короткое многострочное сообщение с кодом входа на последней строке:
Базовый текст (en)
Текст настраивается там же, где остальные тексты каналов — в locale-файлах вашего хоста (wwwroot/locales/{язык}.json, см. Локализация). Ключей два — полный вариант с именем приложения и запасной без него; второй используется, когда имя приложения недоступно. Отдельной секции в Veriqa:Channels:WhatsApp для текста нет.
wwwroot/locales/ru.json
Свой текст задаётся значением этих ключей (для английского — правкой en.json). Доступны два слота: {app} — имя приложения, к которому идёт вход, и {code} — код входа auth_…. Язык берётся от страницы входа, поэтому переводить стоит все языки, которые вы поставляете. Имя приложения приходит из регистрации вашего OIDC-клиента и подставляется как есть — Veriqa его не сокращает (общий предел значения слота — 128 символов). Держите имя коротким: длинное ломает вёрстку сообщения и целиком уходит в QR-код, увеличивая его плотность (см. предупреждение ниже).
Длина текста напрямую влияет на читаемость QR-кода. В QR кодируется весь wa.me-URL, а текст в нём percent-кодируется: пробел и перевод строки стоят 3 символа, буква кириллицы — 6, символ псевдографики вроде ═ — 9. Чем длиннее текст, тем выше версия QR и тем мельче его модули при том же размере картинки на экране.
Отсюда правила оформления, которые стоит соблюдать:
  • код — последней отдельной строкой: он длиной 48 символов (auth_ + идентификатор транзакции на 43 символа) и не влезает ни в какую рамку;
  • не использовать *, _, ~ как элементы графики — WhatsApp трактует их как разметку (жирный, курсив, зачёркнутый). Безопасны -, ., :, +, |;
  • рамки с вертикальными палками (|…|) выравниваются только внутри моноширинного блока (тройные бэктики) — в обычном сообщении шрифт пропорциональный и рамка разъедется. Часть клиентов к тому же показывает в поле ввода сами бэктики, а моноширинность — уже в отправленном сообщении, поэтому такое оформление проверяйте на устройстве до выкладки;
  • горизонтальные линейки (------) работают в любом клиенте и обёртки не требуют.
Ориентиры цены оформления (генератор QR из поставки Veriqa; телефон 11 цифр, идентификатор транзакции 43 символа, уровень коррекции M, имя приложения «Acme Corp»). Колонка «px на модуль» — размер одного модуля QR при штатном боксе 220 px на странице входа. Порог сканируемости — 2,86 px на модуль. Строки помечены языком текста — нелатинский алфавит стоит заметно дороже за символ: Русские варианты без графики и с линейками-заголовком стоят ровно на пороге — запаса у них нет: любое удлинение текста поднимает версию QR и выводит код из нормы. Английский вариант по умолчанию проходит с запасом. У вариантов с рамкой гейтов два: русские рамки не берут ещё и порог плотности (2.37 и 1.88), а моноширинный блок нужен любой рамке — даже английская, стоящая ровно на пороге, в обычном сообщении разъедется. Отсюда «Нет» в колонке. Практический вывод: если сообщение показывается и как QR-код, держитесь варианта без графики или ограничьтесь горизонтальными линейками — рамки и псевдографика делают QR-код трудным для камеры. Русский текст обходится примерно вдвое дороже английского при том же оформлении: тот же текст по умолчанию стоит 388 символов против 171. Готовые образцы каждого варианта — ниже; {app} и {code} подставит Veriqa.
Без графики — вариант по умолчанию
Линейки из дефисов + центрированный заголовок
Линейки из дефисов
Варианты с рамкой требуют моноширинного блока — сам текст обёрнут в тройные бэктики, а код вынесен под блок (внутрь рамки строка кода не влезает):
Рамка «+--+» — только после проверки на устройстве
Рамка Unicode — самый дорогой вариант, для QR непригоден
Выбранный образец переносится в locale-файл одной строкой — переводы строк записываются как \n:
wwwroot/locales/ru.json — вариант с линейками
Оформляйте оба ключа сразу. Второй — запасной вариант без имени приложения; если оформить только первый, пользователи, у которых имя приложения недоступно, увидят другое сообщение. Выравнивание по {app} — приблизительное: имя приложения у каждого клиента своей длины, поэтому ни центрирование пробелами, ни правая граница рамки не сойдутся точно. Если рамка нужна ровной — берите вариант без имени приложения.

MAX

Что нужно: бот MAX и его токен. Конфигурация зеркалит Telegram:
appsettings.json
Как и с Telegram, Veriqa регистрирует вебхук на старте в режиме Webhook. Эндпоинт — POST {WebhookBaseUrl}/api/channels/max/webhook, аутентифицируется заголовком X-Max-Bot-Api-Secret. BotPublicName — имя, показываемое пользователю на странице входа.

Email

Email — единственный канал с двумя независимыми направлениями, и они могут работать вместе:
  • Pull (magic link) — Veriqa отправляет письмо со ссылкой, пользователь по ней кликает. Включено по умолчанию (PullEnabled).
  • Push — письмо в один тап (one-tap email) — пользователь отправляет готовое письмо на входящий адрес (один тап с кнопки или по QR), Veriqa его читает. Выключено по умолчанию (PushEnabled); требует входящей доставки и политики верификации отправителя. Push — значение в конфигурации; «письмо в один тап» — как режим называется для пользователей.
appsettings.json
PublicBaseUrl обязателен всегда, когда канал включён — из него строятся magic-link и QR-код. Port: 587 с UseSsl: false означает STARTTLS; UseSsl: true означает неявный TLS на порту 465. RequireStartTls (по умолчанию true) действует, когда UseSsl равен false и заданы Username и Password: если сервер не предлагает STARTTLS, соединение отвергается — пароль никогда не уходит открытым текстом. Ставьте false только для внутреннего релея, к которому вы сознательно ходите без TLS: тогда STARTTLS применяется, лишь если сервер его предложил. На анонимный релей (без учётных данных) настройка не влияет. Username и Password задаются только вместе. Обе настройки пустые — легальная конфигурация: это релей без аутентификации, типовой вариант для внутреннего SMTP в контуре компании. А вот заданное ровно одно из двух приложение отвергает на старте: аутентификация в таком случае не выполнялась бы вовсе, то есть указанное значение молча никогда не применялось бы. Если Veriqa не стартует с сообщением про Username и Password — удалите лишнее из двух значений либо задайте оба.

Какой режим выбрать

Magic link включён по умолчанию и не требует ничего, кроме исходящей почты, — это более дешёвый старт. Письмо в один тап стоит входящего провайдера, секрета вебхука и политики отправителя — и даёт четыре вещи, которых magic link дать не может:
  • Пользователю ничего не нужно доставлять. Письмо идёт в обратную сторону, поэтому вход больше не зависит от того, дойдёт ли ваше исходящее письмо: ни папки «Спам», ни greylisting, ни репутации домена. При PullEnabled: false каналу вообще не нужен SMTP — валидатор настроек требует блок исходящей почты только при включённом Pull.
  • Нет ссылки, которую сожжёт почтовый шлюз. Корпоративная почтовая защита открывает ссылки во входящих письмах для проверки, и одноразовый magic link может быть израсходован до того, как по нему кликнет человек. В письме, которое отправляет сам пользователь, такой ссылки нет.
  • Доказывается владение ящиком, а не факт получения письма. Переход по ссылке показывает, что письмо до кого-то дошло, — в том числе через правило пересылки или общий ящик. Отправка письма показывает, что отправитель управляет ящиком, и отправитель проверяется политикой VerificationPolicy (по умолчанию DmarcAlignedPass).
  • Это то же действие, что и в мессенджерах. Пользователь отправляет готовое сообщение в один тап — секретное сообщение. Email — тот же паттерн, только письмом вместо сообщения в чате: страница входа учит одной привычке, а не двум.
Режимы независимы и могут работать вместе: PreferredMode решает, какой из них страница входа предлагает первым, а на compose-странице остаётся ручной фолбэк на magic link.

Режим Push — письмо в один тап

Push дополнительно требует блок Inbound:
appsettings.json
Ваш входящий провайдер (Mailgun, Postmark, SendGrid inbound parse, …) постит входящую почту на POST https://auth.your-domain.com/api/channels/email/inbound, аутентифицируясь заголовком X-Email-Webhook-Secret. В мультитенантной установке тот же маршрут принимает и необязательный тенант-сегмент — POST https://auth.your-domain.com/api/channels/email/inbound/{tenant}, — и каждый тенант прописывает свой сегмент в настройках своего входящего провайдера. Письмо, пришедшее туда, обрабатывается настройками Inbound и webhook-секретом этого тенанта. Без сегмента маршрут использует настройки установки целиком. UsePlusAddressing встраивает correlation-токен в reply-адрес (login+{token}@…), чем входящее письмо и привязывается обратно к своей транзакции входа. VerificationPolicy решает, насколько доверять отправителю — From в письме тривиально подделать, поэтому это средство безопасности, а не формальность:
Не отключайте верификацию отправителя в продакшне. Без неё любой, кто может подделать заголовок From, способен завершить чужой вход.

Провайдеры

Поставляемые провайдеры — Smtp для исходящих и Webhook для входящих. Обе настройки также принимают Custom, в этом случае вы регистрируете свой IEmailOutboundSender / IEmailInboundProcessor в DI до вызова AddEmail(). Любое другое значение падает fail-fast на старте, а не молча бездействует. Свой IEmailOutboundSender отправляет только письмо входа, если он не отвечает SupportsClaimCompletionEmail значением true и не реализует SendClaimCompletionEmailAsync: эти два члена отправляют письма с кодом и со ссылкой, подтверждающие адрес почты при доборе claims. Без них способы Code и MagicLink получения email недоступны, и веб-шаг предлагает только оставшиеся.
Оговорка про multi-instance. Токены magic-link Email и Push-correlation токены держатся в памяти поставляемыми хранилищами. На нескольких инстансах ссылка, выданная одним инстансом, неизвестна остальным. Запускайте Email на одном инстансе, маршрутизируйте sticky-сессиями или зарегистрируйте распределённые реализации IEmailActionTokenStore и IEmailPushCorrelationStore. Остальных каналов это не касается.Своя реализация IEmailPushCorrelationStore должна переопределить StoreOrReuseAsync — идемпотентную регистрацию токена («у транзакции уже есть живой токен — верни его и ничего не пиши»). Реализация по умолчанию в интерфейсе просто пишет переданный токен, поэтому без переопределения прямой mailto:-режим (Inbound.TokenInLocalPart = true) выпускает новый correlation-токен на каждый рендер страницы входа, и все они живут до TTL или погашения. Захват индекса «транзакция → живой токен» обязан быть атомарным (compare-and-swap / SET NX / транзакция), а TTL переиспользованного токена — не продлеваться.

Своё тело письма

Письмо входа — обычное сообщение механизма текстов: и тема, и HTML-документ тела целиком живут значением ключа Veriqa:MessageTemplates:sign-in-mail (устройство ключа — в Справочнике конфигурации). Поэтому переписать письмо можно настройкой, не трогая код: задайте свою лестницу вариантов на нужном уровне.
appsettings.json
Что здесь важно знать:
  • разметку в тексте варианта продукт не экранирует — её пишет владелец конфигурации. Экранируются значения слотов: они приходят снаружи, в том числе от приложения по API;
  • видимые фразы остаются ключами локализации. {button}, {qr_instruction}, {qr_alt} и прочие подобные — слоты локализованной строки: контракт сообщения указывает, на какой ключ смотрит слот, а подставится перевод на язык получателя. Разметка при этом одна на все языки, и переводить её копиями не нужно;
  • форму QR решает текст, а не настройка. Вариант, сославшийся на {qr_cid}, получает PNG-вложение; вариант, сославшийся на {qr_url} (ссылку на QR-эндпоинт, если её кладёт ваш композер), получает картинку по ссылке и вложения не несёт; вариант, не назвавший ни одного из них, — письмо без QR. Лишнего вложения, на которое никто не ссылается, не бывает;
  • письмо одно, а редакций у ступени две. Ступень лестницы — либо строка (текст, годный любому приёмнику), либо объект { "Subject": …, "Html": …, "Plain": … }. Каждая редакция необязательна, но хотя бы одну ступень обязана заявить: ступень без единой редакции роняет старт с указанием рода сообщения и номера ступени. Ступень, заявившая только Html, для текстовой части не годится — подбор просто идёт ниже по лестнице, так что две части одного письма могут прийти с разных ступеней. Тема при этом берётся у ступени, выбранной для Html;
  • последняя ступень лестницы обязана опираться только на гарантированные слоты (app, valid_until, link, lang). Детали инициатора и QR гарантированными не являются: транзакция без них штатно доезжает до ступени, которая их не называет, — и в письме не появляется ни пустого абзаца, ни литерала {browser}. Считается это по каждой редакции отдельно: подбор идёт среди ступеней, заявивших нужную редакцию, поэтому у лестницы две «последних» ступени — последняя с Html и последняя с Plain, — и требование распространяется на обе. Лестница, где последняя ступень с Html называет {browser}, а бесслотовой сделана только Plain, роняет старт;
  • письмо только в html (или только текстом) — законное объявление. Если Plain не заявляет ни одна ступень, письмо уходит без текстовой части: ошибки в логе нет, подмены редакций нет. Ровно поэтому проверьте, что односоставное письмо — ваш выбор, а не забытая редакция: в данных эти два случая неотличимы.
Лестница — сеть под данными, а не замена настройке. Она существует потому, что детали инициатора ({browser}, {os}, {region}) и QR в рантайме могут отсутствовать, — а не потому, что формулировки можно не дописывать. Объявление заменяет лестницу целиком: интегратор, объявивший одну ступень, чтобы «поменять формулировку», молча теряет всю поставляемую ядром лестницу вместе со всеми обеднёнными вариантами. И последняя ступень — не техническая заглушка: именно её текст пользователь прочитает чаще всего, когда контекста не будет.Оба этих случая ядро говорит вслух один раз при старте записью уровня Warning: когда объявленная лестница короче поставляемой по тому же адресу (в сообщении — род, адрес и оба числа ступеней) и когда Plain не заявляет ни одна ступень. Старт при этом не блокируется — короткая лестница и односоставное письмо остаются законными объявлениями. Проверяется то, что видно при старте: секция развёртывания и поставляемый файл, то есть уровень Core. Значения, объявленные тенантом, приложением или записью ui_config, при старте перечислить нельзя — их отсутствие в предупреждениях не означает, что там всё в порядке.
Если письмо нужно собрать кодом — взять разметку из своей CMS, подставить собственные значения слотов или решать тему письма самостоятельно, — остаётся один шов: публичный IEmailMessageComposer. Он получает данные отправки и возвращает готовое тело письма (EmailBodyContent: тема, html-часть, текстовая часть и признак QR-вложения), а провайдер доставки занимается только транспортом.
Program.cs
Регистрация подчиняется общему правилу подмены через DI: своя реализация, зарегистрированная до AddVeriqa*, уже стоит в контейнере и поставляемая не добавляется; после — RemoveAll + Add. Разметку письма задаёт текст варианта в конфигурации выше (её пример — в начале этого раздела); для полного контроля — IEmailMessageComposer. Своя реализация композера берёт на себя всё тело письма целиком: лестницу деградации, локализацию видимых фраз через Natural Key и решение о QR-вложении она реализует сама.

Нормализация адреса

Секция Normalization определяет, какую форму адреса Veriqa считает идентификатором пользователя:
appsettings.json
ProviderSpecificCanonicalization включает провайдер-специфичную канонизацию. Поддержано одно семейство — Gmail (gmail.com и googlemail.com): точки в локальной части отбрасываются, +-тег отсекается, googlemail.com приводится к gmail.com. Так u.ser+shop@googlemail.com и user@gmail.com становятся одним пользователем. Домены вне этого списка не канонизируются никогда — у многих провайдеров + является частью адреса, и общее правило склеило бы разных людей.
Включение на работающем деплое меняет identity. Ранее входившие пользователи получат другой sub: адреса, которые считались разными, склеиваются в один. Миграцию ядро не выполняет — это операционное решение, ровно поэтому настройка выключена по умолчанию. Решайте её до первого входа пользователей, а не после.

Как дать клиенту выбрать канал

Клиент может ограничить конкретный вход определёнными каналами через стандартный OIDC-параметр acr_values, в форме channel:{type}:
Перечислите несколько (channel:telegram channel:whatsapp), чтобы сузить выбор, не фиксируя его. Без параметра предлагается каждый включённый канал.

Чеклист

1

Адаптер зарегистрирован и включён

AddXxx() вызван и у секции стоит "Enabled": true.
2

Уровень проверки объявлен осознанно

У каждой канальной секции стоит InboundVerification, и значение соответствует режиму, в котором канал реально работает.
3

Вебхук доступен

https://…/api/channels/… публично доступен по HTTPS и не заблокирован вашим прокси.
4

Секреты в secret-store

Ни один токен или пароль не лежит в закоммиченном файле конфигурации.
5

Вход подтверждён от начала до конца

Реальное подтверждение с телефона завершает поток и выдаёт токены.

Дальше

Справочник конфигурации

Каждая секция, ключ и дефолт в одном месте.

Конфигурация и кастомизация

Дизайн страницы входа, локализация, точки расширения.

Продакшн-харденинг

Что проверить перед выкаткой.

Архитектура

Как адаптеры встроены в остальной коннектор.