Skip to main content
Страница входа и поведение Veriqa настраиваются конфигурацией, а ключевые сервисы можно заменить своей реализацией. Ниже — то, что чаще всего трогают при интеграции.

Каналы

Каждый канал подключается в AddChannelAdapters и активируется секцией Veriqa:Channels:{Канал} с "Enabled": true. Доступны MAX, Telegram, WhatsApp и Email. Каждый из них поставляется отдельным пакетом: базовый набор — метапакетом Veriqa.Core.BaseChannels (Telegram, WhatsApp, Email), MAX отдельно (Veriqa.Core.ChannelAdapter.Max), а если нужен один канал — только его пакет. Подробнее — настройка каналов и поддерживаемые каналы.
Program.cs
Email работает в двух режимах: Pull (magic link — пользователь переходит по ссылке из письма) и Push (письмо в один тап — пользователь отправляет готовое письмо). Push по умолчанию выключен и включается флагом Veriqa:Channels:Email:PushEnabled. Эндпоинты Email-канала мапит MapVeriqaAuthServer автоматически при включённом канале — см. быстрый старт, шаг 3.
Telegram получает обновления в режиме Webhook (по умолчанию) или Polling — задаётся Veriqa:Channels:Telegram:UpdateMode. Для Webhook укажите WebhookBaseUrl и WebhookSecretToken.
Клиент может ограничить вход конкретным каналом через acr_values=channel:{тип} — см. быстрый старт.

Email: доставка и приём

Email включается двумя независимыми режимами под секцией Veriqa:Channels:Email:
  • Pull (magic link) — Veriqa отправляет письмо со ссылкой, пользователь переходит по ней. Включён по умолчанию (PullEnabled).
  • Push (письмо в один тап) — пользователь отправляет готовое письмо на входящий адрес, Veriqa обрабатывает входящее. Выключен по умолчанию (PushEnabled) — включается вместе с настройкой приёма и политикой верификации отправителя. Флаг читается при старте: после его изменения сервис нужно перезапустить.
appsettings.json
  • PublicBaseUrl обязателен при включённом канале — из него строятся magic links и QR.
  • Outbound:Smtp — SMTP для Pull-режима (Port 587 = STARTTLS; UseSsl: true = implicit TLS, порт 465). При заданных учётных данных STARTTLS обязателен, если не указано RequireStartTls: false (по умолчанию true). Пароль храните в secret-store, не в appsettings.json.
  • Inbound — приём для Push-режима: InboundAddress, UsePlusAddressing (вкладывает correlation-токен в адрес вида login+{token}@…), WebhookSecretToken для валидации вебхука провайдера.
  • VerificationPolicy — верификация отправителя: DmarcAlignedPass (по умолчанию), AllowListOnly (по списку AllowedDomains). Значение None в продакшне запрещено.

Дизайн-пресеты и тема

Внешний вид страницы задаётся пресетом Veriqa:AuthPageDesign:Preset: Цветовая схема управляется отдельно — Veriqa:AuthPageDesign:Theme: Явный Theme (Light / Dark) приоритетнее цветовой схемы пресета. Тема проставляет на <html> атрибут data-theme, пресет — data-preset. Анимации уважают prefers-reduced-motion.
appsettings.json

Видимость QR-кода

Показывать ли QR на странице входа, задаёт ключ ShowQrCode секции Veriqa:AuthPageDesign:QrCode — в ней же живёт разрешение кода (справочник конфигурации):
appsettings.json
Два свойства DesktopOnly, которых не видно по названию:
  • Планшет считается десктопом. QR сканируют камерой другого устройства, поэтому сомнительное устройство код сохраняет, а не теряет.
  • При промахе определения устройства QR показывается. Устройство определяет сама страница, и любой сбой этой проверки — заблокированный скрипт, нераспознанный User-Agent — оставляет QR и вкладки на месте. Страница, единственный вход на которой — недоступная пользователю кнопка, хуже, чем QR там, где он бесполезен.
Пресет на видимость QR не влияет: Preset и ShowQrCode — независимые настройки.
После обновления. Раньше по умолчанию было Always. Установка, не задающая ShowQrCode, теперь показывает на телефоне кнопки каналов вместо QR и вкладок; планшет и десктоп не меняются. Чтобы сохранить прежнюю страницу, задайте "ShowQrCode": "Always".

Кнопки каналов

На телефоне при DesktopOnly и на любом устройстве при Never полосы вкладок нет: каналы идут списком кнопок, по одной на канал, в порядке набора каналов.
  • Кнопка мессенджера «Открыть в …» ведёт прямо в него.
  • Email с кнопкой почтового клиента сохраняет эту кнопку и ссылку «Войти по ссылке на почту» под ней.
  • Email с формой адреса получает свою кнопку, которая раскрывает форму на месте. Страница, где этот канал единственный, показывает форму сразу — выбирать не из чего.
  • Подсказка канала стоит под кнопкой своего канала.
При Never эту раскладку рендерит сам сервер, поэтому скрипт ей не нужен. При DesktopOnly страница рендерится с вкладками и переходит на кнопки, распознав телефон.

Текст страницы следует за фактом показа QR

Строки, называющие код (инструкция, ссылка «вернуться к QR-коду»), уезжают в разметку парой редакций — «с QR» и «без QR». Переключаются они тем же условием, что и сам блок кода: классами .veriqa-if-qr / .veriqa-if-no-qr по атрибуту data-qr-visibility корневого элемента. Так страница не обещает QR, которого не показывает. Для интегратора это ограничение на свой CSS (CustomCssPath) и скрипт (CustomJsPath): правило, перебивающее display этих классов, рассогласует текст с картинкой — например оставит «отсканируйте код» на странице без кода. Скрывая или показывая QR своими средствами, переключайте обе редакции текста теми же условиями.
QR генерируется сервером всегда, даже когда страница его не показывает: PNG уезжает в разметку data-URI и остаётся скрытым. При Never это только увеличивает вес ответа.

Брендирование

Логотип и имя бренда клиента заполняют строку бренда вверху карточки входа в любом пресете, кроме Minimal; основной цвет применяется только в пресете Branded:
appsettings.json
  • PrimaryColor — строго формат #RRGGBB; невалидное значение откатывается на нейтральный дефолт. Красит только общие элементы (основную кнопку, фокус-рамки, спиннер).
  • LogoUrl и BrandName вместе заменяют собственный знак продукта. Если ни один уровень не задал ни того, ни другого, строка бренда показывает красный ромб и Veriqa. Заданное любое одно значение заменяет этот знак целиком: одно имя — без ромба, один логотип — без Veriqa. Поэтому незаданный логотип не означает «без строки бренда» — страница без неё задаётся пресетом Minimal. Страницы, кроме окна входа (веб-подтверждение, истёкший вход, страницы писем), строки бренда не выводят вовсе.
  • BrandName — имя рядом с логотипом, вместо собственной подписи продукта. Логотип и имя резолвятся независимо: задайте любое одно, и страница покажет то, что есть, — только имя, только логотип или оба. В отличие от логотипа имя по теме не режется (картинка не может перекраситься под тёмную страницу, а текст берёт цвет страницы), поэтому BrandNameByTheme не существует. Пустое значение не задаёт ничего: имя тогда берётся с уровня ниже. Значение — обычный текст (выводится экранированным) и ключ локализации (см. Локализацию).
  • PrimaryColorByTheme и LogoUrlByTheme задают значение для конкретной темы (light, dark). Тема без своего значения берёт соседний общий ключ (PrimaryColor, LogoUrl). При Theme: Auto тему выбирает браузер, и на страницу уезжают оба значения — светлое и тёмное.
  • Фирменные цвета каналов (Telegram, WhatsApp, MAX) не перекрашиваются ни в одном пресете.
После обновления. Логотип или имя бренда, заданные при пресете, отличном от Branded, раньше игнорировались, а теперь выводятся. Страница Branded без того и другого раньше обходилась без строки бренда, а теперь показывает собственный знак продукта. В Minimal строки бренда нет в разметке, и своя таблица стилей или скрипт, искавшие её, ничего не найдут.

Блок в подвале страницы входа

FooterHtml ставит вашу собственную разметку внизу карточки входа — ссылки на политику конфиденциальности и условия использования, короткую правовую сноску, контакт поддержки. Блок показывается во всех пресетах, включая Minimal и режим со своей таблицей стилей, и на обеих страницах, где рисуется окно входа: на самом входе и на странице, открывающей подтверждение. Остальные страницы, которые генерирует Veriqa, его не несут — ни страницы подтверждения, ни страница истёкшей ссылки, ни страницы входа по почте.
appsettings.json
Блок задаётся этой глобальной секцией и записью клиента (Veriqa:OpenIddict:Clients), где приложение задаёт свой блок вместо глобального. Запись ui_config его не задаёт: запись выбирается параметром запроса, а запрос может выбирать оформление страницы, но не разметку на ней.
Уровень Tenant. Настройка стоит и на уровне тенанта, но читатель этого уровня из коробки не зарегистрирован — его пишет интегратор. Без него резолюция идёт к записи клиента и к этой секции.
Пустое значение не задаёт ничего — одинаково на любом уровне. Поэтому приложение, в записи которого FooterHtml пуст, покажет блок глобальной секции: нижний уровень не гасит блок верхнего, а только заменяет его. Чтобы одно приложение осталось без блока, задавайте блок в записях тех приложений, которым он нужен, а не глобально.

Что может содержать разметка

Значение проверяется на строгий формат и при выходе за него отвергается целиком — ничего не вырезается и не чинится:
  • текст — любые символы, кроме < и >. Голый & допустим, голый > — нет, пишите &gt;;
  • теги a, span, div, p, strong, em и пустой br (<br>, <br/>, <br /> — закрывающего </br> нет). Имена тегов и атрибутов регистронезависимы;
  • атрибуты href, target, rel и class, каждый не более раза в теге, значение — в двойных кавычках и без ", <, >. Всё прочее — style, id, любой обработчик on* — отвергается, как и значения в одинарных кавычках или без кавычек;
  • никаких классов самой страницы: class отвергается, если одно из его имён — veriqa или начинается с veriqa-, в любом регистре букв и после декодирования символьных ссылок. Имя, лишь начинающееся с этого слова, — veriqable — допустимо, как и пустой class;
  • у каждой ссылки есть href и target="_blank". Адрес проверяется в том виде, в каком его читает браузер, — после декодирования символьных ссылок — и должен быть https://…, http://… либо корневым путём /path. Протокол-относительный //host, адрес javascript: или mailto:, обратный слеш и управляющий символ внутри адреса отвергаются;
  • теги сбалансированы: закрывающий закрывает последний незакрытый, к концу незакрытых нет;
  • комментарии, <!DOCTYPE и любой тег вне списка отвергаются.
target="_blank" здесь не вопрос вкуса. Уход со страницы в той же вкладке обрывает идущий на ней вход, а возврат заново запрашивает GET /connect/authorize и создаёт новую транзакцию — ссылка, открывающаяся на месте, стоит пользователю начатого входа.
Блок выводится как есть — он не очищается. Проверка выше решает, пустить ли значение на страницу, но не чистит его. За то, что блок говорит, куда ведут его ссылки и что они сделают с тем, кто по ним перешёл, отвечаете вы. Своего rel Veriqa тоже не добавляет: атрибуты ваших ссылок задаёте вы.
Почему классы veriqa- отвергаются. Они принадлежат разметке страницы: её скрипт достаёт свои элементы по классу (.veriqa-tab, .veriqa-panel, .veriqa-tooltip-host, .veriqa-tooltip), и блок с таким классом доставал бы до поведения страницы, на которой стоит, — class="veriqa-panel" внутри блока погасили бы вкладки каналов. Называйте свои классы своим префиксом и оформляйте их из своей таблицы стилей (CustomCssPath).
Обновление, когда такой класс уже задан. Прежние версии пропускали класс veriqa- в этом блоке; теперь значение недопустимо. Заданное в глобальной секции, оно не даёт хосту стартовать; заданное в записи клиента — выбрасывает эту запись (см. таблицу ниже). Переименуйте класс до обновления.

Что делает недопустимое значение

Перевод блока

Значение — такой же Natural Key, как любой другой текст страницы (см. Локализацию): страница ищет его в locale-файлах хоста, а значение, которого там нет, выводится как есть. Перевод проверяется на тот же формат, что и значение, — перевод вне формата оставляет страницу без блока. Контекстный суффикс работает и здесь, и с разметкой его стоит использовать: блок — длинная строка, и метка страхует её от совпадений.
appsettings.json
ru.json
Учтите, что | зарезервирован под эту метку: всё после последнего | отрезается от текста, который увидит пользователь. Разметка, которая сама должна содержать |, этой идиоме не подходит — используйте сущность &#124; или другой разделитель.

Инструкция после скана

В hop-режиме страница узнаёт, что пользователь продолжил на телефоне, ещё до прихода подтверждения: QR-код отсканирован, страница открылась на телефоне, мессенджер запущен. Каждый такой шаг называет строка статуса. На первом из них страница убирает выбор канала — вкладки, QR-коды и кнопки каналов вместе с описывающей их инструкцией под заголовком — и показывает на их месте инструкцию: по умолчанию «Продолжите на телефоне и подтвердите вход в открывшемся приложении.» Предмет подтверждения, строка статуса и обратный отсчёт остаются. Перезагрузка страницы возвращает выбор канала; когда вход истекает, уходит и инструкция, а показывается ссылка перезапуска. HopInstructionHtml заменяет текст ядра вашей разметкой:
appsettings.json
Настройка следует блоку в подвале во всех правилах, кроме одного: те же уровни (эта глобальная секция, запись клиента, тенант — не запись ui_config), пустое значение ничего не задаёт, тот же допустимый формат и тот же исход недопустимого значения, значение — Natural Key. Исключение — перевод вне формата: страница тогда показывает собственный текст ядра, а не пустоту, потому что блок стоит на месте выбора канала и пустой оставил бы пользователя без слова о том, что делать дальше. В лог уходит предупреждение с именем настройки и языком. Собственного стиля у настройки нет. Оформляйте блок своей таблицей стилей (CustomCssPath) через его класс veriqa-hop-instruction; правила самой страницы задают только цвет текста, размер и отступы.

CSS-переменные

Страницы стилизуются через CSS-переменные с префиксом --veriqa-*. Их можно переопределить своим CSS. Ниже — основные имена; полный набор объявлен в таблице стилей пакета и включает, помимо перечисленных, шкалу отступов --veriqa-spacing-* и цвета служебных плашек:

Внешний CSS

Подключите свой файл стилей через CustomCssPath — он загружается после базовых стилей и позволяет переопределить любую переменную. При заданном CustomCssPath пресет игнорируется: оформление полностью определяет ваш CSS. Стили подключаются ко всем страницам, которые генерирует Veriqa: к окну входа, к странице подтверждения входа, к странице истёкшей ссылки и к страницам входа по email. Ваш CSS красит их одинаково — отдельной настройки для служебных страниц нет.
appsettings.json
Задавайте CssSriHash (Subresource Integrity), если стили подключаются с внешнего адреса — внешний ресурс на странице входа по умолчанию под запретом.

Конфигурации UI по клиенту (ui_config)

Помимо глобального оформления, можно задать именованные конфигурации UI и привязать их к конкретному OIDC-клиенту. Каталог записей — словарь Records секции Veriqa:UiConfigurations («код записи → оформление»), то есть полный путь записи — Veriqa:UiConfigurations:Records:<код>; запись переопределяет глобальное оформление для своего кода.
appsettings.json
  • DefaultUiConfig — код записи, применяемой по умолчанию для клиента.
  • AllowedUiConfigs — коды, которые клиенту разрешено запрашивать. Код из этого списка валиден и без записи в каталоге — тогда берётся глобальное оформление.

Локализация

Страница входа использует подход Natural Keys: ключ локализации — английский текст, базовый язык — en (перевод для него не нужен). Реестр поддерживаемых языков строится из locale-файлов, которые поставляет ваш хост: JSON-словари {язык}.json в wwwroot/locales (путь настраивается через Veriqa:Channels:Localization:LocalesPath) плюс базовый en. Из коробки страница отображается на английском; чтобы добавить язык, положите его {язык}.json в каталог локалей — изменения кода не требуются. Русский и китайский переводить не нужно вовсе: они уже есть в поставке — см. Готовые переводы ниже. Сам каталог необязателен: хост без него пишет на старте Locale files directory not found — channel messages are sent in the base language., и это предупреждение ожидаемо.

Готовые переводы

Весь каталог — 106 ключей, всё, что пользователь читает при аутентификации, — публикуется отдельным пакетом без сборок: Veriqa.Core.ChannelLocales. В нём лежат en.json, ru.json, zh.json и pseudo.json (это локаль для проверки вёрстки, а не язык для продакшена: её можно получить явным Accept-Language, но как DefaultLanguage она отклоняется).
PackageReference
Сам по себе пакет язык не включает. Его targets кладут файлы рядом с вашей входной сборкой, в каталог veriqa-channel-locales/, и намеренно не в wwwroot/locales: каталог ядра не должен затирать ваш — их сравнивают, а не сливают. Пока не сделан один из двух шагов ниже, хост по-прежнему отвечает на базовом языке.
Использовать можно двумя способами:
  • скопировать нужные файлы из veriqa-channel-locales/ в свой каталог локалей. Дальше это ваши файлы, и любую формулировку в них можно править;
  • указать на него: задать в Veriqa:Channels:Localization:LocalesPath тот каталог, который разложил пакет. Относительный путь отсчитывается от content root хоста, поэтому указывайте путь, верный для вашего способа запуска, — или абсолютный. Править там нечего: следующая версия пакета заменит эти файлы целиком.
Тот же пакет — список ключей для хоста со своим каталогом: en.json задаёт полный набор ключей, а ключ, которого в ваших файлах нет, молча уходит для пользователя в английский.

Какой язык получит пользователь

У страницы входа и у сообщений в канале разные источники языка, и различаться они могут вполне законно. Страница входа идёт от запроса: сначала OIDC-параметр ui_locales — явный выбор, сделанный на стороне доверяющей стороны, — затем Accept-Language браузера. Значение, не совпавшее ни с одним языком реестра, уходит в язык по умолчанию:
appsettings.json
DefaultLanguage валидируется при старте: значение должно входить в реестр (locale-файлы хоста + en), иначе приложение не стартует. Например, "DefaultLanguage": "ru" требует, чтобы хост поставлял wwwroot/locales/ru.json. Язык, на котором отрисована страница, едет на транзакции, и к нему откатывается каждая следующая страница и каждое сообщение этого входа — в том числе страница автопоста form_post, которой доставляется ответ авторизации: она отображается на нём, а не определяет язык по заголовку браузера заново. Accept-Language с языком по умолчанию остаётся фолбэком для ответов, у которых транзакции не было, — например, для ошибок, применяемых к redirect_uri. У подтверждения, созданного сервер-сервер, страницы нет вовсе — там едет поле locale запроса. Сообщение внутри канала идёт, наоборот, от получателя. Канал, который знает язык профиля пользователя, сообщает его с каждым событием, и этот язык старше всего, что сказал браузер: пользователь с русским Telegram получит русский запрос со страницы, отрисованной по-английски. Канал, который ничего не сообщает, откатывается к локали транзакции, а при её отсутствии — к базовому языку. Email — третий случай, потому что его тексты читают не в мессенджере. Письмо входа уходит до того, как кто-либо ответил, поэтому профиля, у которого можно спросить язык, ещё нет: письмо формулируется на языке запроса, по которому его отправляли (Accept-Language того браузера), с откатом на локаль транзакции. А страница подтверждения, которую открывает magic link, поступает наоборот: она предпочитает локаль транзакции заголовку Accept-Language браузера почтового клиента — чтобы совпасть с окном входа, в котором пользователь начал. Так что одна установка вполне может говорить в рамках одного входа на двух языках, и это штатное поведение, а не ошибка настройки. Требуется от неё одно: чтобы каждый задействованный язык был в locale-файлах — язык, который никто не перевёл, молча деградирует до базового.

Тексты каналов и писем — те же locale-файлы

Через тот же каталог задаются не только тексты страницы входа, но и почти всё, что пользователь видит в мессенджере и в письме: подписи кнопок, статусы после ответа, видимые строки письма. Правило одно: ключ — английский текст, значение — то, что увидит пользователь. Чтобы изменить формулировку, добавьте ключ со своим значением в locale-файл нужного языка — код трогать не нужно. Основные ключи:
Это продуктовые дефолты инсталляции: locale-файлы — ассет хоста, один комплект на процесс. Текст «как у конкретного тенанта» задаётся не здесь.
Предложения письма, в которых стоит подстановка, живут не здесь. Заголовок, вступление, строка срока действия и строка с деталями инициатора несут {app}, {valid_until}, {browser} — они часть текста варианта шаблона sign-in-mail, а не ключи локализации: разметка письма языконезависима и переводится не копиями, а слотами. Перевести эти предложения — значит задать свою лестницу вариантов письма (см. Своё тело письма); правка locale-файла на них не действует.

Разные переводы одной фразы: контекстный суффикс

Ключ — это английский текст, поэтому одна фраза даёт один перевод на язык. Если одну и ту же английскую фразу нужно перевести по-разному в разных местах (например, для одного канала — иначе, чем для остальных), к ключу добавляется контекстная метка через |:
ru.json
Что нужно знать:
  • метка — служебная, пользователь её не видит. Если перевода нет, показывается часть ключа до последнего | — то есть чистый английский текст, а не … | telegram;
  • не записывайте ключ сам в себя ("X | telegram": "X | telegram") — это единственный способ показать метку пользователю;
  • | зарезервирован под метку: обычный текст интерфейса не должен его содержать;
  • заводите контекст только при реальном расхождении. По умолчанию одна фраза — один ключ без суффикса; плодить контексты там, где перевод совпадает, не нужно.

Тексты, заданные настройкой, — тоже ключи локализации

Тексты, которые задаёт запись ui_config, проходят ту же резолюцию, что и встроенные: заданное значение и есть Natural Key. Отдельного «нелокализуемого» текста в продукте нет.
Текст подтверждения задаётся не полем записи, а шаблоном сообщения. Формулировка запроса подтверждения — это сообщение, и задаётся она лестницей его шаблона: Veriqa:MessageTemplates:confirmation-prompt:Templates (устройство ключа и его уровни — в Справочнике конфигурации). Правило этой секции на неё распространяется полностью: заданный текст — такой же Natural Key, локализацию он не обходит. Если формулировка нужна своя у конкретного приложения, задайте её в записи клиента (уровень Application), а не глобально.
Что это значит на практике:
  • значение без записи в locale-файлах выводится как есть. Ключ — это и есть текст, поэтому промах ничего не ломает: пользователь видит ровно ту строку, которую вы задали, просто непереведённую. Для базового языка запись не нужна вообще;
  • чтобы текст перевёлся, заведите его ключ в своих locale-файлах — тех же wwwroot/locales/{язык}.json, что и остальные тексты. Каталог локалей — ассет хоста, а хост это вы: ключи ваших записей ui_config заводятся там наравне с продуктовыми.
Как задать значение, которое заведомо переводится. Отдельного маркера для этого нет — используется штатный контекстный суффикс (см. выше): значением настройки задаётся строка <текст> | <метка>, а перевод кладётся в locale-файлы.
appsettings.json
ru.json
Что нужно знать:
  • метка — служебная: всё после последнего | пользователь не видит. Значение Privacy | Terms выведется как Privacy — настройка, текст которой сам должен содержать |, этой идиоме не подходит;
  • промах не даёт отказа: без записи в locale-файле пользователь увидит Sign in to the portal — правильный текст, просто непереведённый. Ни метки, ни пустой строки, ни ошибки;
  • базовый текст выбираете вы: значение Политика | authpage без перевода на английский покажет англоязычному пользователю русский. Поведение предсказуемое, но базовым текстом разумно делать формулировку на базовом языке (en);
  • метка нужна не всегда. Если ваш текст и так уникален, переводите его без суффикса — ключом будет само значение. Метка страхует от совпадения с продуктовым ключом и разводит одну и ту же фразу по разным записям ui_config.
Заданное значение ищется в locale-файлах. Если оно случайно совпало с ключом продуктового текста — например, Title задан ровно как встроенный заголовок окна входа, — на неанглийских языках подставится продуктовый перевод, а не ваша строка. Если такой текст должен оставаться вашим, добавьте к значению контекстный суффикс: ключ перестанет совпадать с продуктовым, а пользователь увидит ту же строку до последнего |.

Точки расширения

Ключевые сервисы заменяются своей реализацией — регистрируйте их в блоке AddVeriqaAuthServer.

Рендерер страницы входа

Реализуйте IAuthPageRenderer для полного контроля над HTML страницы аутентификации:
AuthPageRenderContext приходит с уже вычисленными значениями: Design — эффективный дизайн страницы (глобальная секция, переопределённая уровнями владения по полям), Title и Instruction — тексты, если их задал какой-то уровень (иначе null, и текст ваш), QrVisibility — эффективная видимость QR. Свой рендерер ничего не складывает сам и потому не может потерять переопределение. Title и Instruction приходят ключами локализации, а не готовым текстом (см. «Тексты, заданные настройкой, — тоже ключи локализации»). Встроенный рендерер прогоняет их через locale-файлы; свой рендерер делает то же вызовом IConfirmationPromptLocalizer.ResolveOrBaseText(значение, язык) — иначе заданный уровнем текст перестанет переводиться, а значение с контекстным суффиксом покажет метку.

Claims-маппер

Реализуйте IClaimsMapper для своего маппинга resolved identity в OIDC claims:
UseClaimsMapper<T>() регистрирует маппер как scoped — по экземпляру на запрос авторизации. Поэтому scoped-зависимость (DbContext, unit of work, любой пер-реквестный сервис) берётся прямо в конструктор, как IMyDirectory выше: IServiceScopeFactory не нужен. Встроенный маппер остаётся singleton — он без состояния и без зависимостей. ClaimsMappingContext говорит, для кого строятся claims: Атрибуция — это то, что нужно pairwise-субъекту (OIDC Core 1.0 §8.1): один и тот же пользователь получает разный sub для разных RP. Встроенный маппер её не использует — выдаваемый им sub глобален по всем RP.

Обработчик событий транзакции

Реализуйте ITransactionEventHandler, чтобы реагировать на события транзакций (например, подключать канал уведомлений при входе):
Обработчик добавляется рядом со встроенными, а не вместо них: порт потребляется как IEnumerable<ITransactionEventHandler>, поэтому каждое событие получают все зарегистрированные обработчики. Встроенные продолжают работать — окно входа по-прежнему получает событие завершения, журнал аудита — свои записи.

Идемпотентность создания транзакции

CreateTransactionRequest принимает пару IdempotencyKey + IdempotencyScope: повтор запроса с той же парой возвращает уже созданную транзакцию, а не заводит вторую (при расхождении параметров создания — отказ idempotency_key_conflict). Из встроенных входов пару заполняет POST /api/transaction/confirmation — создание транзакции подтверждения по client credentials: ключ берётся из поля idempotency_key тела запроса, а область — из аутентифицированного client_id, поэтому одинаковый ключ двух приложений означает два разных запроса. Повтор с тем же ключом отвечает той же транзакцией и заново собранной точкой входа. Браузерный authorization endpoint пару не заполняет: там повторный запрос означает новый вход, а не повтор прежнего. Если вы управляете движком напрямую, пару заполняете вы.

Дальше

Хранилища

Стор транзакций и база OpenIddict: InMemory / EF Core / Redis, PostgreSQL.

Контекст инициатора

Что показывается в подтверждении: кто и откуда инициировал вход.

Hardening к продакшну

Чек-лист перед выпуском в продакшн.

Быстрый старт .NET

Базовая интеграция за несколько минут.