1. Введение
Мобильное приложение Class Leader Note использует гибридную архитектуру, в которой React Native отвечает за нативную функциональность, а существующее веб-приложение React предоставляется через WebView. Преимущество этой архитектуры заключается в возможности повторно использовать уже реализованные экраны и бизнес-логику. Изначально мы думали, что социальный вход может работать так же, как в веб-версии: переходить на страницу OAuth внутри WebView. Поскольку в браузере этот процесс работал нормально, мы ожидали, что на мобильных устройствах существенных отличий не будет.
Однако при запуске входа через Google в реальной мобильной среде возникла ошибка 403 disallowed_useragent. Причиной был не OAuth-ключ и не URL перенаправления, а тот факт, что экран аутентификации открылся во встроенном WebView, контролируемом приложением. Политика Google и RFC 8252 рекомендуют нативным приложениям не использовать встроенный user-agent для OAuth-аутентификации. Поскольку хост-приложение может получить доступ к содержимому и cookie в WebView, провайдер аутентификации может заблокировать такой процесс.
Самым простым способом решить проблему было открыть URL аутентификации во внешнем браузере. Однако после входа Class Leader Note по-прежнему должен был использовать существующую веб-логику для поиска участника, ветвления регистрации нового пользователя, выбора роли и создания сессии. Если бы весь процесс был перенесён во внешний браузер, нам также пришлось бы заново проектировать deep linking для возврата в приложение и восстановления состояния. И наоборот, если бы мы повторно реализовали всё — от аутентификации до создания сессии сервиса — в React Native, одни и те же доменные правила дублировались бы в веб-приложении и приложении.
Поэтому в этой работе мы разделили ответственность на три части. React Native аутентифицирует пользователей через нативный SDK каждого Provider, Bridge безопасно передаёт результат в WebView, а веб-приложение React продолжает выполнять существующую проверку Gate и ветвление входа/регистрации. В этой статье описываются причины выбора такой архитектуры, проблемы, возникшие во время реализации, и наш опыт использования bridge как небольшого протокола, а не простого postMessage.
2. Почему мы не могли продолжать использовать WebView OAuth без изменений
В веб-OAuth после нажатия пользователем кнопки входа выполняется переход к конечной точке авторизации Provider, а после завершения аутентификации пользователь возвращается на зарегистрированный URI перенаправления. Этот процесс естественен для стандартного браузера, но с точки зрения провайдера аутентификации WebView нативного приложения является встроенным user-agent. В частности, Google прямо указывает в своей политике, что разработчики не должны отправлять OAuth-запросы через контролируемый ими встроенный user-agent. Поэтому даже при неизменном использовании URL и Client ID, успешно работавших в веб-версии, они могут быть заблокированы в мобильном WebView.
Сначала экран ошибки создавал впечатление, что конфигурация Google OAuth или ключ указаны неверно. Однако та же конфигурация успешно работала в браузере на компьютере, а Kakao также работал в WebView. Нам пришлось учитывать, что у каждого Provider своя политика allowlist. Успешная работа с одним Provider не доказывала корректность всей архитектуры социального входа.
Мы рассмотрели три варианта.
-
Сохранить OAuth в WebView: это обеспечивает максимальное повторное использование веб-кода, но не соответствует политике Google.
-
Использовать внешний системный браузер и deep links: этот вариант ближе к стандартному, но требует заново спроектировать обработку возврата в приложение, состояние callback и интеграцию с веб-сессией.
-
Нативный SDK Provider + повторное использование существующего веб-callback: в нативную часть переносится только интерфейс аутентификации, а логика после Gate может оставаться в веб-приложении.
Class Leader Note выбрал третий подход. Политики входа через Google, Kakao и Apple, API сторонней проверки Gate и ветвление регистрации уже были реализованы в веб-приложении. Ключевым было передать учётные данные, полученные от нативного SDK, в форме, понятной существующему веб-callback.
3. Разделение ответственности с помощью RN-Bridge-React
Принцип проектирования был следующим: «Переносить в React Native только то, что может быть выполнено исключительно на нативной стороне». RN отвечает за запуск SDK Provider, платформенную конфигурацию и хранение в SecureStore. Существующее приложение React определяет, существует ли участник, выполняет проверку учётных данных через Gate, вход по SSO и переход к экрану регистрации нового пользователя. Bridge соединяет эти две области, но не заменяет доменную логику ни одной из них.
[Рисунок 1. Процесс социальной аутентификации через React Native-Bridge-React-Gate]
Приложение RN работает в три этапа: booting, native-login и web. При запуске приложения оно проверяет access token и выбранную роль в SecureStore. Если присутствуют оба значения, приложение сразу открывает WebView; если хотя бы одно из них отсутствует или неполно, сохранённые значения очищаются и отображается нативный экран входа. После успешной аутентификации результат сохраняется в состоянии pending, после чего приложение переходит к этапу WebView.
После завершения подготовки веб-приложения внутри WebView оно отправляет WEB_APP_READY. RN передаёт учётные данные только после получения этого сигнала. React проверяет результат, временно сохраняет его в sessionStorage и переходит к существующему пути /oauth/callback/:provider. Далее выполняются проверка политики Gate и подтверждение пользователя так же, как в callback веб-OAuth.
Важный момент этой архитектуры заключается в том, что RN не определяет, существует ли CitizenUser и требуется ли регистрация. Нативный SDK лишь формирует результат аутентификации Provider. После проверки учётных данных через Gate выполняется вход по SSO, если аккаунт уже зарегистрирован; в противном случае пользователь направляется в процесс регистрации, содержащий registrationToken. Даже при изменении платформы правила сервиса остаются в одном месте.
4. Сосредоточение React Native на аутентификации через Provider
При открытии экрана входа RN сначала получает общую политику входа Gate. Вместо жёсткого задания Client ID и grantType в приложении мы настраиваем кнопки на основе активного Provider и политики клиента. Согласно конфигурации Gate, Google в настоящее время использует профиль веб-клиента, тогда как Kakao и Apple отдают приоритет профилю клиента приложения. Поля, предназначенные только для сервера, например clientSecret, не копируются в модель RN из ответа с политикой.
Ответы SDK различаются у разных Provider. Google может предоставить access token, ID token, server authorization code и профиль пользователя. Kakao использует access token или ID token, и даже если получение профиля завершается ошибкой, мы не прерываем вход немедленно, поскольку Gate может повторно проверить токен Provider. Apple использует ID token или authorization code, а state и nonce формируются и проверяются вместе с ответом.
Если раскрыть эти различия до уровня вызывающего кода, экран входа RN усложнится условными конструкциями, специфичными для Provider. Поэтому адаптер каждого SDK создаёт общий объект результата, а поток верхнего уровня проверяет только providerCode и grantType из политики Gate.
type NativeSocialAuthResultPayload = {
requestId: string;
providerCode: 'google' | 'kakao' | 'apple';
credentials: { accessToken?: string; idToken?: string; authorizationCode?: string };
profile?: { userId?: string; email?: string; displayName?: string };
parameters?: Record<string, string>;
issuedAt: number;
};
Мы не считали возврат SDK некоторого значения немедленным доказательством успеха. Если политика Gate требует AccessToken, а результат SDK содержит только ID token, следующий шаг, скорее всего, завершится ошибкой. Поэтому RN сначала проверяет соответствие grantType фактическому типу учётных данных. Отмена отделяется от ошибок, и когда пользователь закрывает окно входа, исходный экран входа остаётся открытым вместо отображения Snackbar с ошибкой.
5. Рассмотрение Bridge как контракта, а не как postMessage
Коммуникационная функция React Native WebView в основном представляет собой postMessage, отправляющий одну строку. Передачу и получение только строк легко реализовать, но опечатки в действиях или изменения payload обнаруживаются лишь во время выполнения. Для функции, включающей несколько этапов и конфиденциальную информацию, нам требовался контракт с чётко определёнными направлениями и типами, а не простой обмен строками.
В общем пакете bridge мы разделили карты событий RN→WebView и WebView→RN. NATIVE_SOCIAL_AUTH_RESULT — событие, через которое RN передаёт результат аутентификации, а NATIVE_SOCIAL_AUTH_RESULT_RECEIVED — ACK, указывающий, что веб-приложение получило результат. REQUEST_NATIVE_LOGIN используется, когда экран входа веб-приложения повторно запрашивает нативный экран. Каждое действие относится к такой функции, как auth, session или lifecycle, и содержит метаданные, включая messageId, correlationId и sentAt.
interface AuthReactNativeToWebViewEventMap {
NATIVE_SOCIAL_AUTH_RESULT: NativeSocialAuthResultPayload;
}
interface AuthWebViewToReactNativeEventMap {
NATIVE_SOCIAL_AUTH_RESULT_RECEIVED: { requestId: string };
REQUEST_NATIVE_LOGIN: void;
WEB_AUTH_LOG: WebAuthLogPayload;
}
Одних типов недостаточно для обеспечения безопасности входных данных во время выполнения. Мы ограничили максимальный размер строк, получаемых из WebView, разбирали их как JSON и проверяли, разрешено ли действие, а также тип и длину полей payload. Неизвестные действия и сообщения чрезмерной длины не обрабатываются. Мы также проверяем учётные данные, полученные из веб-приложения, включая providerCode, issuedAt, длину credential, длину profile, а также количество и размер параметров.
Для логов также потребовалась отдельная граница. Если для устранения неполадок выводить весь payload, access token, ID token, адреса электронной почты и идентификаторы пользователей могут остаться в логах разработки. Логгер bridge заменяет конфиденциальные значения на REDACTED на основе имён ключей и записывает только информацию об их наличии, используя такие поля, как hasAccessToken и hasIdToken, вместо самих значений.
6. Снижение потерь доставки с помощью READY-Retry-ACK
Если открыть WebView сразу после нативной аутентификации и немедленно отправить результат, сообщение может прийти до регистрации React-обработчика. Даже на одном и том же устройстве эта проблема может проявляться или не проявляться в зависимости от скорости сети и времени загрузки веб-бандла. Сначала казалось, что после нажатия кнопки ничего не происходит, а Snackbar в веб-приложении не отображается, из-за чего было трудно определить, на каком этапе остановился процесс.
Чтобы решить эту проблему, мы сделали готовность WebView и доставку учётных данных явным рукопожатием. После регистрации обработчика React отправляет WEB_APP_READY, а RN отправляет NATIVE_SOCIAL_AUTH_RESULT после перехода в состояние готовности. Веб-приложение возвращает NATIVE_SOCIAL_AUTH_RESULT_RECEIVED сразу после успешного сохранения результата. RN удаляет ожидающий результат только после получения ACK для того же requestId.
WEB_APP_READY
-> NATIVE_SOCIAL_AUTH_RESULT(requestId)
-> NATIVE_SOCIAL_AUTH_RESULT_RECEIVED(requestId)
Если ACK не приходит, RN повторно отправляет результат до трёх раз с интервалом в две секунды. Веб-приложение определяет уже обработанные запросы по requestId и предотвращает повторную навигацию callback. Если подготовка WebView не завершается за установленное время или после всех трёх попыток ACK не получен, приложение переходит на явный экран ошибки. Это не полноценная очередь сообщений, но такой механизм эффективно снижает временные потери, вызванные различиями в жизненном цикле WebView и моменте регистрации обработчика.
При добавлении повторных попыток нам также пришлось учитывать идемпотентность. Поскольку одни и те же учётные данные могут прийти несколько раз, веб-приложение сохраняет их один раз в sessionStorage на основе requestId и сразу удаляет после чтения в callback. RN также помечает процесс завершённым только тогда, когда текущий pending requestId совпадает с requestId из ACK. Повторные попытки повышают надёжность доставки, но без предотвращения повторной обработки они, наоборот, могли бы несколько раз выполнить запрос входа.
7. Повторное использование существующего потока аутентификации Gate в React
Получив NATIVE_SOCIAL_AUTH_RESULT, React не раскрывает payload напрямую и полностью в URL query. Сначала он сохраняет проверенный результат в sessionStorage на основе requestId и передаёт в URL callback только native_request_id. Экран callback использует это значение один раз и преобразует его с помощью URLSearchParams. Из access_token, id_token и authorization code выбираются учётные данные, соответствующие grantType из политики Gate.
Последующий процесс совпадает с существующим веб-OAuth. Получаются политики Pavilion и Provider, после чего через useThirdPartyOAuth в Gate отправляется запрос на проверку учётных данных. Если ответ Gate указывает, что CitizenUser существует, выполняется вход по SSO с использованием зашифрованного идентификатора пользователя. Если такого пользователя нет, registrationToken, email и name сохраняются в состоянии регистрации, после чего пользователь переходит к согласию с условиями и вводу дополнительной информации.
Такое разделение означало больше, чем простое повторное использование существующей функциональности. Определение дубликатов CitizenUser, правила связывания аккаунтов Provider с аккаунтами сервиса и ветвление регистрации нового пользователя уже существуют в потоке Gate и React. Если бы те же решения были реализованы в RN, веб-приложение и приложение могли бы развиваться по разным правилам. Мы сохранили границу: нативная область сообщает, «кто успешно прошёл аутентификацию у Provider», а область сервиса решает, «как обрабатывать этого пользователя в нашем сервисе».
Callback различает clientType и redirectUriType в зависимости от того, был ли запрос инициирован нативно. В текущей реализации нативный SDK Google использует web client ID, поэтому clientType передаётся как web, тогда как для Kakao и Apple передаётся app. Это нельзя определить только по типу SDK Provider; значения должны соответствовать профилю клиента и grantType политики Gate POLISH.
8. Синхронизация сессии и срок хранения данных
После успешной проверки учётных данных Provider и входа в сервис веб-приложение синхронизирует с RN access token, выбранную роль и loginProvider. RN сохраняет их в expo-secure-store в соответствии с политикой автоматического входа. При перезапуске приложения этап WebView восстанавливается, если присутствуют и token, и role; если остаётся только одно из значений, сессия считается неполной и очищается.
Здесь мы разделили роли токена SDK Provider и access token сервиса. Токен Provider используется только во время короткой передачи данных, в рамках которой Gate проверяет личность пользователя. Постоянная сессия приложения управляется на основе access token сервиса, выданного после входа через Gate. Refresh token не передаётся в WebView и также не определяется в payload bridge.
loginProvider — это информация, отображаемая на экране управления аккаунтом для указания способа текущего входа пользователя. Сроки хранения web sessionStorage и RN SecureStore согласованы с действующей политикой access token. При выходе token, role и loginProvider удаляются одновременно, а пользователь возвращается на экран входа RN. Вместо объединения хранилищ в одном месте каждая среда владеет необходимыми ей значениями, а переходы состояний синхронизируются через SYNC_USER_CONTEXT и CLEAR_USER_CONTEXT.
У Apple также есть ограничение: имя и адрес электронной почты могут быть предоставлены только во время первоначального согласия. Текущий процесс регистрации использует и профиль Provider, и ответ Gate, но если пользователь пытается зарегистрироваться снова с аккаунтом, который ранее дал согласие, имя или адрес электронной почты могут быть пустыми. Поэтому в рабочей среде нам нужны как политика безопасного хранения первоначального ответа, так и резервный процесс, предлагающий пользователю ввести данные напрямую, если они недоступны.
9. Сделать ранее невидимые мобильные ошибки наблюдаемыми
В веб-разработке вкладки Network и Console браузера можно сразу проверить, но такой подход затруднён при использовании Expo Development Build или WebView на физическом устройстве. Во время реального тестирования также возникла проблема: нажатие кнопки дополнительной информации не переводило пользователя на следующий экран, и Snackbar не отображался. По одному только интерфейсу было невозможно определить, не сработало ли событие нажатия, потерялось ли сообщение WebView или завершился ли ошибкой запрос к Gate.
Поэтому мы зафиксировали основные точки процесса аутентификации как события этапов. В RN мы записываем LOGIN_ATTEMPT_STARTED, SDK_RESULT_RECEIVED, CREDENTIAL_VALIDATED, BRIDGE_AUTH_SENT и BRIDGE_AUTH_RECEIVED. React отправляет POLICY_LOAD_STARTED, GATE_VERIFY_STARTED, SSO_LOGIN_STARTED, а также события успеха, ошибки и тайм-аута для каждого из них в консоль RN через WEB_AUTH_LOG. Использование requestId в качестве correlationId позволяет отследить одну попытку — от вызова нативного SDK до проверки через Gate.
Мы не записываем исходные учётные данные в логи. Вместо этого фиксируются только состояния, необходимые для анализа первопричины, такие как grantType, clientType, redirectUriType, hasRegistrationToken и citizenUserExists. Например, если SDK возвращает ID token, а политика Gate требует AccessToken, мы можем подтвердить, что процесс завершился из-за несоответствия политике до этапа CREDENTIAL_VALIDATED, не выводя сам токен.
Тайм-ауты также разделены по областям. Получение политики Gate в RN, ожидание READY от WebView, ожидание ACK от Bridge, а также получение политики Gate и проверка учётных данных в React являются отдельными точками отказа. Объединение их в одно сообщение «вход не выполнен» может упростить пользовательский интерфейс, но затрудняет поиск причины во время разработки. Внутренние логи подробно разделяют этапы, а пользовательские сообщения упрощены до форм, допускающих повторную попытку.
10. Результаты и компромиссы реализации
Благодаря этой архитектуре нам удалось выполнять аутентификацию Google без запуска её внутри WebView, сохранив существующий процесс входа React/Gate. Kakao и Apple также были подключены с использованием той же общей структуры данных, что уменьшило количество ветвлений, специфичных для провайдеров, в процессе входа более высокого уровня. Главное преимущество заключалось в том, что нам не пришлось дублировать правила сервиса в RN — например, логику разделения новых и существующих участников, выбора роли и ввода дополнительной информации.
-
Мы централизовали SDK провайдеров и конфигурацию платформы в RN, сохранив правила предметной области сервиса в React/Gate.
-
С помощью типизированной карты событий и проверок во время выполнения мы смогли на раннем этапе обнаруживать опечатки в сообщениях и ошибки формата.
-
Повторная отправка READY, ACK и однократная обработка на основе requestId уменьшили неопределённость во время загрузки WebView.
-
Маскирование конфиденциальных данных и пошаговые корреляционные логи улучшили возможность диагностики во время тестирования Expo на физических устройствах.
С другой стороны, теперь Bridge является протоколом, поэтому необходимо управлять совместимостью версий. Если приложение и веб-часть развёртываются в разное время, одна из сторон может не распознать новое действие. При добавлении полей в структуру данных необходимо решить, сделать ли их необязательными или повысить минимальную версию приложения. Повторную отправку и обработку ACK также следует настраивать на основе измеренного времени загрузки и частоты сбоев, а не произвольно увеличивать значение тайм-аута.
Кроме того, не все нативные SDK и конфигурации можно проверить только с помощью Expo Go. Возможности, требующие настройки нативного проекта, такие как GoogleService-Info.plist, схемы URL и права iOS, необходимо проверять в development build. Если считать успешные веб-тесты, тесты в Expo Go и фактические тесты development build эквивалентными, отсутствующая конфигурация может быть обнаружена слишком поздно.
11. Заключение
Изначально проблема выглядела как единичная ошибка: «Вход через Google заблокирован в WebView». Однако для её решения потребовалось одновременно спроектировать несколько аспектов: где запускать интерфейс аутентификации, по какому контракту передавать токен провайдера, как повторно использовать существующий процесс входа сервиса, как обрабатывать потерю сообщений во время загрузки WebView и как отслеживать ошибки на физических устройствах.
Этот опыт показал нам, что в гибридном приложении Bridge — это не просто удобный инструмент для вызова нативных функций; это API между двумя средами выполнения. Как и любой API, он должен иметь чётко определённые направления передачи данных и структуры данных, проверять входные данные, обрабатывать дубликаты и тайм-ауты, а также предотвращать запись конфиденциальной информации в логи. Коммуникация, начинающаяся как простой postMessage, сразу после добавления аутентификации приобретает характеристики небольшой распределённой системы.
Перенос каждого экрана и всей логики предметной области в React Native не всегда является правильным решением. В Banjangnote мы решили оставить в RN только те части, которым требуются нативные возможности, например аутентификацию через провайдеров, а уже проверенную логику работы с участниками и сессиями из React и Gate повторно использовать, тем самым уменьшив объём изменений и дублирование. Даже по мере расширения возможностей аутентификации за счёт связывания аккаунтов, сценариев возврата по deep link и дополнительного согласия для каждого провайдера важно сохранять ту же границу ответственности.
В дальнейшем мы планируем указать версии схемы Bridge для приложения и веб-части и автоматизировать E2E-тесты на основе Development Build для сценариев успешной аутентификации, отмены, дублирующего аккаунта и истечения срока действия сессии, специфичных для каждого провайдера. Нам также необходимо учесть ограничение Apple, связанное с предоставлением профиля во время первоначальной авторизации, а также сценарий возврата из внешнего приложения в соответствии с операционными сценариями. Эта работа стала опытом корректировки границ ответственности между RN, Bridge и React в рамках реальных процессов сервиса, чтобы соответствовать политикам нативной аутентификации и одновременно сохранить веб-ресурсы.
Ссылки
Google for Developers, политики OAuth 2.0
https://developers.google.com/identity/protocols/oauth2/policies
IETF, RFC 8252: OAuth 2.0 для нативных приложений
https://www.rfc-editor.org/rfc/rfc8252.html
React Native WebView, взаимодействие между JS и нативным кодом
https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md
Документация Expo, AppleAuthentication
https://docs.expo.dev/versions/latest/sdk/apple-authentication/
Kakao Developers, вход через Kakao для Android
https://developers.kakao.com/docs/latest/ko/kakaologin/android
Kakao Developers, вход через Kakao для iOS
https://developers.kakao.com/docs/latest/ko/kakaologin/ios
jyyou