Как завернуть коммерческий биометрический SDK в React Native: разбор на примере MyID

от автора

Если вы делаете мобильный банкинг, финтех или госсервисы для узбекского рынка, рано или поздно вы столкнётесь с MyID — национальной системой биометрической идентификации от UZINFOCOM. Лицо в камеру, liveness-проверка, сверка с госбазой — и удалённое открытие счёта готово. MyID поставляет нативные SDK для iOS и Android, а вот React Native-разработчикам исторически предлагалось выкручиваться самостоятельно.

В 2024 году я написал React Native-мост для MyID, который сегодня лежит в их официальном репозитории. Тот мост был для SDK второго поколения — и вместе с ним устарел: в 3.x старый флоу интеграции удалён целиком. Плюс Apple завёз privacy manifest, а Expo окончательно стал стандартом — и почти всё, что написано в интернете про «MyID + React Native», перестало работать. Поэтому появилась новая, независимая опенсорсная обёртка для 3.x, которую я теперь поддерживаю.

В этой статье разберу, как правильно заворачивать такой SDK в React Native в 2026 году: session flow вместо паспортных данных на клиенте, три айосные грабли, на которые наступает каждая первая интеграция, устройство Expo config plugin и таксономию ошибок. Всё — с проверкой на реальном железе.

Что такое MyID технически

MyID — это платформа биометрической идентификации: мобильный SDK открывает камеру, проводит liveness-проверку (что перед камерой живой человек, а не фото или экран), отправляет снимок на бэкенд MyID, где он сверяется с государственной базой. Приложение получает одноразовый код, а ваш бэкенд обменивает его на подтверждённый профиль.

Важная особенность: это закрытая, контрактная система. Учётные данные (clientHashclientHashId для SDK и client_id/client_secret для API) выдаёт отдел продаж MyID банкам, финтеху, телекому и госструктурам по договору. Никакая обёртка этот барьер не уберёт — и это правильно: речь о доступе к персональным данным граждан. Что обёртка убрать может — так это интеграционную боль, которой тут исторически много.

Второй важный факт: SDK распространяется как коммерческий бинарник. Подспека iOS-пода прямо декларирует license: Commercial. Из этого следует юридическое ограничение для любой обёртки: нативный SDK нельзя вкладывать в npm-пакет — только ссылаться на него как на внешнюю зависимость, которую сборка потребителя резолвит из официальных каналов дистрибуции (CocoaPods trunk для iOS, Maven-репозиторий MyID для Android).

Session flow: как это работает в 3.x

До третьего поколения SDK интеграция выглядела подозрительно просто: приложение передавало в SDK clientId и паспортные данные пользователя прямо с клиента. В 3.x этот флоу удалён из SDK полностью — если вы читаете туториал, где в конфиг кладут passportData, он описывает API, которого больше не существует.

Современный флоу — сессионный, трёхсторонний (классический three-legged):

┌─────────────┐      ┌──────────────┐      ┌─────────────┐│  Приложение  │      │  Ваш бэкенд   │      │  MyID API   │└──────┬──────┘      └──────┬───────┘      └──────┬──────┘       │                    │  POST /api/v1/auth/ │       │                    │  clients/access-token       │                    │────────────────────>│       │                    │   JWT (7 дней)      │       │  «хочу пройти      │<────────────────────│       │   идентификацию»   │                     │       │───────────────────>│  POST /api/v2/sdk/  │       │                    │  sessions           │       │                    │────────────────────>│       │    session_id      │  { session_id }     │       │<───────────────────│<────────────────────│       │                    │                     │       │  identify(sessionId, clientHash, …)      │       │  ← нативный флоу: камера, liveness →     │       │                    │                     │       │  одноразовый code  │                     │       │───────────────────>│  GET /api/v1/sdk/   │       │                    │  data?code=…        │       │                    │────────────────────>│       │   «подтверждён ✓»  │  профиль,           │       │<───────────────────│  comparison_value   │

Ключевые константы, которые надо знать наизусть, потому что все они умеют стрелять:

  • session_id — это UUID; живёт около 10 минут и строго одноразовый;

  • код, который возвращает SDK после успешного прохождения, живёт 5 минут;

  • токен доступа живёт 7 дней (expires_in: 604800) — кэшируйте, не запрашивайте на каждую сессию;

  • client_secret никогда не покидает бэкенд. Выпуск (минтинг) сессий — строго server-to-server.

Минимальный минтинг сессии на бэкенде выглядит так:

// 1. Токен (кэшируем на ~7 дней)const { access_token } = await fetch(`${MYID_HOST}/api/v1/auth/clients/access-token`, {  method: 'POST',  headers: { 'Content-Type': 'application/json' },  body: JSON.stringify({    client_id: process.env.MYID_CLIENT_ID,    client_secret: process.env.MYID_CLIENT_SECRET, // только env бэкенда!  }),}).then(r => r.json());// 2. Сессия — одноразовая, ~10 минутconst { session_id } = await fetch(`${MYID_HOST}/api/v2/sdk/sessions`, {  method: 'POST',  headers: { Authorization: `Bearer ${access_token}`, 'Content-Type': 'application/json' },  body: JSON.stringify({}), // пустое тело = SDK сам покажет экран ввода паспортных данных}).then(r => r.json());

А на клиенте вся интеграция сводится к одному типизированному вызову:

import { identify, isMyIdError } from '@softwhere-uz/react-native-myid';const result = await identify({  sessionId,       // UUID с вашего бэкенда  clientHash,      // от отдела продаж MyID  clientHashId,  environment: 'SANDBOX',  // или 'PRODUCTION'  locale: 'UZ',            // 'UZ' | 'RU' | 'EN'});// result.code → на бэкенд → GET /api/v1/sdk/data?code=…

И финальное правило session flow: результату с клиента не верятresult.code — это заявка, а не подтверждение. Подтверждение — это когда ваш бэкенд успешно обменял код на профиль и проверил comparison_value против своего порога.

Три айосные грабли

Вот здесь начинается то, ради чего я пишу эту статью. На Android интеграция почти скучная: добавить Maven-репозиторий, разрешения CAMERA/INTERNET — всё. На iOS же MyID (как и многие вендорские биометрические SDK) собран так, что куда ни ступи — грабли.

Грабля №1: статические фреймворки — глобально

MyIdSDK.xcframework — это бинарный Swift-фреймворк, который требует статической линковки на уровне всего приложения:

use_frameworks! :linkage => :static

Не для одного пода — для всех. Это значит, что ваш Podfile меняет режим линковки всем зависимостям сразу, и если среди них есть, например, Firebase — здравствуйте, ошибки про non-modular headers. Лечится флагом CLANG_ALLOW_NON_MODULAR_INCLUDES_IN_FRAMEWORK_MODULES в post_install; в обёртке для этого есть опциональный проп firebaseWorkaround, выключенный по умолчанию, чтобы не трогать проекты без Firebase.

Грабля №2: privacy manifest, который Apple не прочитает

С 2024 года Apple требует, чтобы приложения декларировали использование «required reason API» в PrivacyInfo.xcprivacy. MyID SDK такой манифест внутри своего xcframework имеет. Казалось бы, всё хорошо?

Нет. При статической линковке Apple может попросту не увидеть манифест самого пода — декларации должны быть в манифесте приложения. Об этом не пишет ни один туториал по MyID, и это грабля с отложенным срабатыванием: собирается всё прекрасно, а отклонение прилетает на ревью в App Store.

Конкретные декларации, извлечённые из манифеста внутри поставляемого фреймворка (не выдумывайте свои — коды причин должны соответствовать реальному использованию):

Категория API

Код причины

NSPrivacyAccessedAPICategoryFileTimestamp

0A2A.1

NSPrivacyAccessedAPICategorySystemBootTime

35F9.1

NSPrivacyAccessedAPICategoryDiskSpace

85F4.1

NSPrivacyAccessedAPICategoryUserDefaults

CA92.1

Грабля №3: банальная, но обязательная

NSCameraUsageDescription в Info.plist. Без неё приложение упадёт при открытии камеры, а билд отклонят при обработке в App Store Connect. Микрофон, кстати, не нужен — liveness в актуальных версиях SDK работает только с камерой, и добавлять лишнее разрешение — значит зря пугать пользователя.

Как это автоматизирует Expo config plugin

Сначала о терминах, потому что тут вечная путаница: «поддержка Expo» не означает Expo Go. Проприетарный нативный код в Expo Go не запустится никогда. Речь про Continuous Native Generation: нативные проекты генерируются командой npx expo prebuild, а config plugin программно вносит в них все нужные правки. Запускается это как dev-билд или EAS-билд.

Config plugin — это, по сути, набор чистых функций-трансформаций над нативным проектом. Для MyID нужно шесть модификаций (четыре айосные + разрешения и Maven-репозиторий на Android) — вот самые показательные:

// 1. Статические фреймворки — одна строчка в Podfile.properties.jsonconfig = withPodfileProperties(config, (mod) => {  mod.modResults['ios.useFrameworks'] = 'static';  return mod;});// 2. Разрешение камеры (+ опциональный микрофон)config = withInfoPlist(config, (mod) => {  mod.modResults.NSCameraUsageDescription =    props.cameraPermission ?? DEFAULT_CAMERA_PERMISSION;  return mod;});// 3. Privacy manifest: мерджим required-reason API в манифест приложенияconfig.ios.privacyManifests = {  ...manifests,  NSPrivacyAccessedAPITypes: mergePrivacyAccessedApiTypes(existing),};// 4. Android: Maven-репозиторий MyID в allprojects.repositories//    (только groovy: kts-проект плагин честно попросит поправить руками)config = withProjectBuildGradle(config, (mod) => {  mod.modResults.contents = addMavenRepository(mod.modResults.contents, url);  return mod;});

Два инженерных требования к таким трансформациям, которые легко упустить:

Идемпотентность. prebuild могут запускать многократно (и с --clean, и без), поэтому каждая модификация сначала проверяет, не применена ли она уже — иначе Podfile обрастёт дублями. Маркерные комментарии в сгенерированном коде — простой и надёжный способ.

Мердж, а не перезапись. Privacy manifest приложения может уже содержать декларации от других SDK. Функция мерджа объединяет категории и коды причин, а не заменяет список целиком — иначе плагин молча сломает чужие декларации.

Для потребителя всё это сворачивается в одну запись в app.json:

{  "expo": {    "plugins": [      ["@softwhere-uz/react-native-myid",        { "cameraPermission": "Камера нужна для подтверждения личности через MyID." }]    ]  }}

Bare React Native: тоже работает, но с одним сюрпризом от Xcode 26

Обёртка написана на Expo Modules API, а он прекрасно живёт и в bare-проектах — нужен только рантайм Expo Modules:

npm install @softwhere-uz/react-native-myidnpx install-expo-modules@latest

Дальше руками то, что в Expo делает плагин: use_frameworks! :linkage => :static в Podfile (сам под MyIdSDK приедет из CocoaPods автоматически — он объявлен зависимостью подспеки), NSCameraUsageDescription, четыре декларации privacy manifest, Maven-репозиторий в root build.gradle.

Сюрприз, который я поймал на свежем проекте с Xcode 26: кодмод install-expo-modules пишет в AppDelegate.swift обычный import Expo, и при статической линковке компилятор падает с ошибкой:

ambiguous implicit access level for import of 'Expo';it is imported as 'internal' elsewhere

Это следствие свежих правил Swift про access-level imports. Лечение — одна строчка:

internal import Expo

Дизайн моста: почему нативный слой никогда не реджектит

Теперь самая, на мой взгляд, интересная инженерная деталь — то, что отличает «мост, который работает в демо» от «моста, который работает в проде».

Нативные SDK общаются колбэками делегата: onSuccess(result)onError(exception)onUserExited(). Наивный мост маппит их на promise прямолинейно: success → resolve, всё остальное → reject со строкой. И вот тут зарыты две проблемы.

Первая: отмена — это не ошибка. Пользователь, закрывший экран идентификации, — это самый частый неуспешный исход воронки онбординга. Если мост превращает его в generic exception, продуктовая аналитика слепнет, а в Sentry сыпется мусор.

Вторая: при reject через мост структурированные данные превращаются в строку. Числовой код ошибки SDK, нативное сообщение, флаг «это отмена» — всё это склеивается в message, из которого потом на JS-стороне парсят подстроки. Видел такое в проде не раз.

Решение, к которому я пришёл: нативный слой всегда резолвит промис — внутренним объектом с дискриминантом, а типизацию исходов делает JS-обёртка:

// Внутренний протокол (нативный слой всегда резолвит):type NativeOutcome =  | { status: 'success'; code: string; base64Image?: string; comparison?: number }  | { status: 'cancelled' }  | { status: 'error';      kind: 'permission' | 'network' | 'sdk' | 'no_activity' | 'unknown';      code?: number; message?: string };// Публичный API (JS-обёртка конвертирует):try {  const result = await identify(config);} catch (e) {  if (isMyIdError(e)) {    switch (e.kind) {      case 'cancelled':  return;                    // не ошибка!      case 'permission': return askCameraAccess();  // код 102      case 'network':    return showRetry();      default:           return report(e.kind, e.code, e.nativeMessage);    }  }}

Так числовой код и нативное сообщение переживают мост нетронутыми, отмена — отдельный first-class исход, а публичный контракт — один класс ошибки с дискриминантом kind.

Из той же серии — кроссплатформенные нормализации, которые приходится делать обёртке, потому что референсные реализации платформ друг с другом не согласованы. Снимок лица iOS-SDK отдаёт как JPEG, Android — как PNG; обёртка нормализует к PNG base64 на обеих платформах. Поле comparison на iOS 3.1.3 отсутствует вовсе — в типах оно опционально, и это честно.

Ошибки, которые вы обязательно встретите

Публичная таблица кодов MyID существует, но два кода вы выучите и без неё. Оба — с реального устройства, окружение SANDBOX:

kind=sdk  code=103  "Input should be a valid UUID, invalid character…"

Ваш sessionId — не UUID. Сессию нужно минтить через API, а не изобретать.

kind=sdk  code=103  "Session is expired"

Сессия уже использована (они одноразовые), старше 10 минут — или выпущена в другом окружении. Sandbox-сессия с production-конфигом (и наоборот) падает именно так.

Обратите внимание: оба случая — 103. Это «универсальный» код ошибки MyID, и официальная рекомендация — читать сопровождающее сообщение. Поэтому мост обязан доносить nativeMessage до JS дословно, а не заменять своим текстом.

Ещё три кода, о которых стоит знать заранее: 101 — внутренняя ошибка SDK, 102 — отказ в доступе к камере (в обёртке маппится в kind: 'permission'), и 122 — пользователь забанен, причём SDK отдаёт TTL бана.

И отдельная строчка из официальной документации, которую часто пропускают: проверки на root/эмулятор в SDK нет намеренно — это ответственность родительского приложения. Если ваша модель угроз их требует, добавляйте сами.

Разработка без контракта: мок-режим

Учётные данные MyID выдаются по договору, и ждать их, блокируя вёрстку и продуктовую логику, — расточительство. Плюс симулятор физически не может пройти liveness (нет камеры). Поэтому в обёртку встроен мок-режим, который не трогает нативный код вообще:

import { setMockMode } from '@softwhere-uz/react-native-myid';setMockMode({ outcome: 'success', delayMs: 800 }); // фейковый результат + снимокsetMockMode({ outcome: 'cancelled' });             // отмена пользователемsetMockMode({ outcome: 'sdk', code: 103 });        // ошибка SDKsetMockMode(null);                                  // обратно к реальному SDK

Вся воронка — успех, отмена, каждый вид ошибки — прогоняется в юнит-тестах и на симуляторе задолго до первого реального запуска.

Проверка на железе

Слова «works on my machine» для библиотеки, которая заводит камеру и проприетарный бинарник, стоят немного, поэтому финальная проверка выглядела так: физический iPhone, два независимых приложения — Expo dev-билд (config plugin → prebuild) и bare React Native 0.86, где обёртка установлена из упакованного npm-тарболла (это заодно проверяет корректность packaging).

В обоих: регистрация нативного модуля, все мок-сценарии, валидация конфига — и живой вызов MyIdClient.start с корректным по формату, но не выпущенным UUID. SDK честно сходил на SANDBOX-бэкенд MyID и вернул типизированную ошибку Session is expired — то самое поведение, которое и должно быть у незнакомой бэкенду сессии. Дальше этой точки без действующего контракта пройти нельзя: следующий шаг — камера — открывается только для сессии, которую бэкенд MyID признал.

Выводы

Если обобщить опыт до чек-листа «как заворачивать вендорский нативный SDK в React Native»:

  1. Изучите актуальный флоу, а не туториалы. У MyID сменилось поколение API — половина текстов в интернете описывает удалённый механизм.

  2. Не бандлите коммерческие бинарники. Ссылка на официальный канал дистрибуции — единственный юридически чистый путь.

  3. На iOS проверяйте манифесты, а не только сборку. Static frameworks + privacy manifest — грабля с отложенным срабатыванием на ревью.

  4. Нативный слой резолвит промис, JS типизирует исходы. Отмена — first-class, коды и сообщения переживают мост дословно.

  5. Мок-режим — не роскошь. Для контрактных SDK это единственный способ не блокировать команду.

  6. Проверяйте на железе из тарболла. Симулятор не заведёт камеру, а source-linked проверка не ловит ошибки packaging.

Код, документация и трекер — на GitHub: github.com/softwhere-uz/react-native-myid. Дисклеймер напоследок: проект неофициальный, с MyID/UZINFOCOM не аффилирован; для работы в проде нужен договор с MyID.

Два вопроса к сообществу. Если вы интегрировали MyID (или другой национальный eKYC) в React Native и наступали на другие грабли — расскажите, соберём полную карту минного поля. И шире: как вы решаете проблему privacy manifest для статически слинкованных сторонних SDK — руками, скриптом в CI или уже есть инструмент лучше?

ссылка на оригинал статьи https://habr.com/ru/articles/1062676/