WebViewのソーシャル認証をRNブリッジへ移行する

WebViewのソーシャル認証をRNブリッジへ移行する

1. はじめに

班長ノートのモバイルアプリは、React Nativeがネイティブ機能を担当し、既存のReactウェブアプリケーションをWebViewで提供するハイブリッド構造です。この構造には、すでに実装された画面とビジネスロジックを再利用できるという利点があります。最初は、ソーシャルログインもウェブと同様にWebView内でOAuthページへ遷移すればよいと考えていました。ブラウザで正常に動作していたフローだったため、モバイルでも大きな違いはないだろうと予想していました。

しかし、Googleログインを実際のモバイル環境で実行すると、403 disallowed_useragentエラーが発生しました。原因はOAuthキーやリダイレクトURLではなく、認証画面がアプリの制御する組み込みWebViewで開かれていたことでした。GoogleのポリシーとRFC 8252では、ネイティブアプリのOAuth認証にembedded user-agentを使用しないよう推奨しています。WebViewではホストアプリがコンテンツやCookieにアクセスできる可能性があるため、認証プロバイダーによってブロックされる場合があります。

問題を解決する最も単純な方法は、認証URLを外部ブラウザで開くことでした。しかし、班長ノートではログイン後も、既存ウェブの会員照会、新規登録の分岐、役割の選択、セッション生成ロジックを引き続き使用する必要がありました。フロー全体を外部ブラウザへ移すと、アプリへ戻るためのディープリンクや状態復元まで新たに設計しなければなりません。逆に、React Nativeで認証からサービスセッションの生成までをすべて再実装すると、ウェブとアプリで同じドメインルールが重複します。

そこで今回の作業では、責任を3つに分けました。React Nativeは各ProviderのネイティブSDKでユーザーを認証し、Bridgeはその結果を安全にWebViewへ渡し、Reactウェブは既存のGate検証とログイン・会員登録の分岐をそのまま実行します。この記事では、この構造を選択した背景、実装中に直面した問題、そしてBridgeを単純なpostMessageではなく小さなプロトコルとして扱った経験をまとめます。

2. WebView OAuthをそのまま使用できなかった理由

ウェブOAuthでは、ユーザーがログインボタンを押すとProviderのauthorization endpointへ移動し、認証が完了すると登録済みのredirect URIへ戻ります。一般的なブラウザでは自然なフローですが、ネイティブアプリのWebViewは認証プロバイダーから見るとembedded user-agentです。特にGoogleは、開発者が制御するembedded user-agentからOAuthリクエストを送信しないよう、ポリシーで明記しています。そのため、ウェブで成功したURLとClient IDをそのまま使用しても、モバイルWebViewではブロックされる可能性があります。

最初にエラー画面だけを見ると、Google OAuthの設定やキーが間違っているように見えました。しかし、同じ設定でデスクトップブラウザでは成功し、KakaoはWebViewでも動作しました。Providerごとに許可ポリシーが異なることを分けて考える必要がありました。あるProviderで成功したという事実は、ソーシャルログイン全体の構造が正しい証拠ではありませんでした。

検討した選択肢は3つでした。

  • WebView OAuthを維持:ウェブコードを最も多く再利用できますが、Googleのポリシーを満たせません。

  • 外部システムブラウザとディープリンクを使用:標準に近い方法ですが、アプリへの復帰、callbackの状態、ウェブセッションとの接続を新たに設計する必要があります。

  • ProviderネイティブSDK + 既存ウェブcallbackの再利用:認証UIだけをネイティブへ移し、Gate以降のロジックはウェブに残せます。

班長ノートでは、3つ目の方式を選択しました。すでにGoogle、Kakao、Appleのログインポリシーと、Gateのthird-party検証API、登録分岐がウェブに実装されていたためです。重要なのは、ネイティブSDKが受け取ったcredentialを、既存のウェブcallbackが理解できる形式で渡すことでした。

3. RN-Bridge-Reactで責任を分担する

設計原則は「ネイティブでしかできないことだけをReact Nativeへ移す」ことでした。Provider SDKの実行、プラットフォームごとの設定、SecureStoreへの保存はRNが担当します。会員の存在確認、Gate credentialの検証、SSOログイン、新規登録画面への遷移は既存のReactが担当します。Bridgeは両方の領域を接続しますが、どちらか一方のドメインロジックを代行することはありません。

image1.png

[図1. React Native-Bridge-React-Gateへ続くソーシャル認証フロー]

RNアプリケーションは、booting、native-login、webの3段階で動作します。アプリ起動時にSecureStoreからaccess tokenと選択した役割を確認します。両方が存在すればすぐにWebViewを開き、どちらか一方でも不完全な場合は保存値を整理したうえでネイティブログイン画面を表示します。認証に成功すると結果をpending状態で保持し、WebView段階へ移行します。

ウェブアプリケーションはWebView内で準備が完了した後、WEB_APP_READYを送信します。RNはこのシグナルを受け取った後にのみcredentialを渡します。Reactは結果を検証してsessionStorageに一時保存し、既存の/oauth/callback/:providerパスへ移動します。その後はブラウザOAuth callbackと同じように、Gateポリシーの照会とユーザー確認を実行します。

この構造で重要なのは、RNがCitizenUserの存在や会員登録の要否を決定しないことです。ネイティブSDKはProviderの認証結果だけを生成します。Gateがcredentialを検証した後、すでに登録済みのアカウントであればSSOログインを実行し、存在しないアカウントであればregistrationTokenを含む登録フローへ送ります。プラットフォームが異なっても、サービスルールは1か所に維持されます。

4. React NativeではProvider認証に集中する

RNのログイン画面が開くと、まずGateの公開ログインポリシーを照会します。アプリにClient IDとgrantTypeを固定せず、有効化されたProviderとclient policyに基づいてボタンを構成しました。Googleは現在のGate設定に合わせてweb client profileを使用し、KakaoとAppleはapp client profileを優先します。ポリシーのレスポンスに含まれるclientSecretのようなサーバー専用フィールドは、RNモデルへコピーしません。

ProviderごとのSDKレスポンスは異なります。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 adapterが共通の結果オブジェクトを作成し、上位フローではproviderCodeとGateポリシーのgrantTypeだけを確認する構成にしました。

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と実際のcredentialの種類が一致するかを先に確認しました。キャンセルはエラーと分離し、ユーザーがログインウィンドウを閉じた場合は失敗Snackbarではなく、元のログイン画面を維持するようにしました。

5. BridgeをpostMessageではなく契約として扱う

React Native WebViewの通信機能は、基本的に1つの文字列を送るpostMessageです。文字列だけを送受信すれば実装は速くなりますが、actionの誤字やpayloadの変更がランタイムで初めて明らかになります。認証のように複数の段階と機密情報がやり取りされる機能では、単純な文字列通信よりも方向と型が明確な契約が必要でした。

共通bridgeパッケージでRN→WebViewとWebView→RNのイベントマップを分離しました。NATIVE_SOCIAL_AUTH_RESULTはRNが認証結果を渡すイベントで、NATIVE_SOCIAL_AUTH_RESULT_RECEIVEDはウェブが結果を受信したことを示すACKです。REQUEST_NATIVE_LOGINは、ウェブのログイン画面が再びネイティブ画面を要求するときに使用します。各actionはauth、session、lifecycleなどのfeatureに分類され、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として解析し、許可されたactionかどうか、payloadフィールドの型と長さを検査しました。不明なactionや過度に長いメッセージは処理しません。ウェブから受け取ったcredentialについても、providerCode、issuedAt、credentialの長さ、profileの長さ、parameterの数とサイズを検査します。

ログにも別の境界が必要でした。エラーを追跡するためにpayload全体を出力すると、access token、ID token、メールアドレス、ユーザーIDが開発ログに残る可能性があります。bridge loggerはkey名を基準に機密値をREDACTEDとして処理し、実際の値の代わりにhasAccessToken、hasIdTokenのように存在の有無だけを記録するようにしました。

6. READY-再送-ACKで伝達損失を減らす

ネイティブ認証直後にWebViewを開き、すぐに結果を送信すると、Reactのlistenerが登録される前にメッセージが到着する可能性があります。この問題は同じ端末でも、ネットワーク速度やウェブバンドルの読み込み時間によって再現するかどうかが変わります。最初はボタンを押しても何も反応しないように見え、ウェブのSnackbarも表示されなかったため、どの領域で停止したのかを確認するのが困難でした。

これを解決するため、WebViewの準備とcredentialの伝達を明示的なハンドシェイクにしました。Reactがlistenerを登録した後にWEB_APP_READYを送信し、RNはready状態になってからNATIVE_SOCIAL_AUTH_RESULTを送信します。ウェブは正常に保存した直後、NATIVE_SOCIAL_AUTH_RESULT_RECEIVEDを返します。RNは同じrequestIdのACKを受け取って初めて、pending結果を削除します。

WEB_APP_READY
  -> NATIVE_SOCIAL_AUTH_RESULT(requestId)
  -> NATIVE_SOCIAL_AUTH_RESULT_RECEIVED(requestId)

ACKが届かない場合、RNは2秒間隔で最大3回、結果を再送します。ウェブはrequestIdで処理済みのリクエストを識別し、重複したcallbackへの遷移を防ぎます。WebViewの準備自体が一定時間内に完了しない、または3回ともACKを受け取れない場合は、明示的なエラー画面へ切り替えます。この方式は完全なメッセージキューではありませんが、WebViewのライフサイクルとlistener登録時点のずれによって発生する一時的な損失を減らすうえで効果的でした。

再送を追加する際は、冪等性も同時に考慮する必要があります。同じcredentialが複数回届く可能性があるため、ウェブはrequestIdを基準にsessionStorageへ一度だけ保存し、callbackで読み取った直後に削除します。RNも現在のpending requestIdとACKのrequestIdが一致する場合にのみ、完了として処理します。再試行は伝達の信頼性を高めますが、重複処理を防がなければ、逆にログインリクエストを複数回実行する可能性があります。

7. Reactでは既存のGate認証フローを再利用する

ReactはNATIVE_SOCIAL_AUTH_RESULTを受信すると、payloadをURL queryへ直接長く露出させません。まず検証済みの結果をrequestIdベースのsessionStorageに保存し、callback URLにはnative_request_idだけを渡します。callback画面は値を一度だけconsumeしてURLSearchParamsへ変換します。access_token、id_token、authorization codeのうち、GateポリシーのgrantTypeに合ったcredentialを選択します。

その後のフローは既存のウェブOAuthと同じです。PavilionとProviderのポリシーを照会し、useThirdPartyOAuthを通じてGateへcredential検証を要求します。GateのレスポンスにCitizenUserが存在すれば、暗号化されたユーザー識別子でSSOログインを実行します。存在しなければregistrationToken、メールアドレス、名前を登録状態に保持し、規約への同意と追加情報入力の段階へ移動します。

この分離には、既存機能を再利用する以上の意味がありました。CitizenUserの重複判定、Providerアカウントとサービスアカウントの連携ルール、新規登録の分岐は、すでにGateとReactのフローに存在します。RNで同じ判定を実装すると、ウェブとアプリが異なるルールで進化する可能性があります。ネイティブ領域は「誰がProvider認証に成功したのか」を渡し、サービス領域は「そのユーザーを自社サービスでどのように扱うか」を決定するという境界を維持しました。

callbackでは、ネイティブリクエストかどうかに応じてclientTypeとredirectUriTypeを区別します。現在の実装では、GoogleネイティブSDKがweb client IDを使用するため、clientTypeをwebとして渡し、KakaoとAppleはappとして渡します。この部分はProvider SDKの種類だけを見て固定することはできず、Gate POLISHポリシーのclient profileおよびgrantTypeと合わせる必要があります。

8. セッション同期と保存期間

Provider credentialの検証とサービスログインに成功すると、ウェブからaccess token、選択した役割、loginProviderをRNへ同期します。RNは自動ログインポリシーに従ってexpo-secure-storeへ保存します。アプリを再起動した際にtokenとroleが両方存在すればWebView段階へ復元し、どちらか一方だけが残っている場合は不完全なセッションとみなして整理します。

ここではProvider SDK tokenとサービスaccess tokenの役割を区別しました。Provider tokenは、Gateがユーザーの身元を確認する短い伝達過程でのみ使用します。アプリの継続セッションは、Gateログイン後に発行されたサービスaccess tokenを基準に管理します。refresh tokenはWebViewへ渡さず、bridge payloadにも定義していません。

loginProviderは、ユーザーが現在どの方法でログインしているかをアカウント管理画面に表示するための情報です。ウェブのsessionStorageとRN SecureStoreの保存期間は、現在のaccess tokenポリシーに合わせました。ログアウト時にはtoken、role、loginProviderをまとめて削除し、RNのログイン画面へ戻ります。保存場所を1つに統合するのではなく、各ランタイムが必要な値を所有し、SYNC_USER_CONTEXTとCLEAR_USER_CONTEXTで状態遷移を合わせる方式です。

Appleには、名前とメールアドレスが初回同意時にのみ提供される場合があるという制約もあります。現在の登録フローではProvider profileとGateのレスポンスを併用していますが、ユーザーが過去に同意したアカウントで再び登録を試みると、名前やメールアドレスが空になっている可能性があります。そのため運用段階では、初回レスポンスを安全に保存するポリシーと、情報がない場合にユーザーへ直接入力してもらう代替フローを併せて準備する必要があります。

9. モバイルで見えなかったエラーを可観測にする

ウェブ開発ではブラウザのNetworkタブとConsoleをすぐに確認できますが、Expo Development Buildや実機のWebViewでは同じ方法で確認するのが困難です。実際のテストでも、追加情報入力ボタンを押したのに次の画面へ移動せず、Snackbarも表示されない現象がありました。UIだけでは、クリックイベントが実行されなかったのか、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と、それぞれの成功・失敗・timeoutをWEB_AUTH_LOGとしてRNコンソールへ渡します。requestIdをcorrelationIdとして使用すれば、ネイティブSDKの呼び出しからGate検証まで、1回の試行を追跡できます。

ログにはcredentialの原文を残しません。代わりにgrantType、clientType、redirectUriType、hasRegistrationToken、citizenUserExistsのように、原因分析に必要な状態だけを記録します。たとえばSDKがID tokenを返したものの、GateポリシーがAccessTokenの場合、実際のトークンを出力しなくても、CREDENTIAL_VALIDATED以前にポリシー不一致で失敗したことを確認できます。

timeoutも領域ごとに分けました。RNのGateポリシー照会、WebView READY待機、Bridge ACK待機、ReactのGateポリシー照会とcredential検証は、それぞれ異なる失敗地点です。1つの「ログイン失敗」メッセージにまとめれば、ユーザー体験は単純に見えるかもしれませんが、開発段階で原因を見つけるのが難しくなります。内部ログでは段階を細分化し、ユーザーメッセージは再試行可能な形に簡略化しました。

10. 適用結果とトレードオフ

今回の構成により、Google認証をWebView内で実行せずに、既存のReact/Gateログインフローを維持できました。KakaoとAppleも同じ共通payloadで接続されるため、上位のログインフローにおけるProviderごとの分岐が減りました。新規会員と既存会員の分岐、役割選択や追加情報の入力といったサービスルールをRNに複製しなかったことが、最大の効果でした。

  • Provider SDKとプラットフォーム設定はRNに集約し、サービスドメインのルールはReact/Gateに維持しました。

  • typed event mapとruntime guardにより、メッセージのタイプミスや形式エラーを早期に発見できるようにしました。

  • READY-再送-ACKとrequestIdに基づく一回限りの消費により、WebViewの読み込みタイミングにおける不確実性を減らしました。

  • 機密情報のマスキングと段階ごとのcorrelation logにより、Expo実機テストの診断可能性を高めました。

一方で、ブリッジは現在では1つのプロトコルであるため、バージョン互換性を管理する必要があります。アプリとWebのデプロイ時期が異なると、一方が新しいactionを認識できない可能性があります。payloadにフィールドを追加する際は、任意値にするのか、アプリの最低バージョンを引き上げるのかを決定しなければなりません。再送とACKについても、timeout値を無条件に延長するのではなく、実際の読み込み時間と失敗率を測定して調整する必要があります。

また、Expo Goだけでは、すべてのネイティブSDKと設定を検証することはできません。GoogleService-Info.plist、URL scheme、iOS entitlementのようにネイティブプロジェクトの設定が必要な機能は、development buildで確認する必要があります。Webで成功したテスト、Expo Goのテスト、実際のdevelopment buildのテストを同じレベルのものと見なすと、設定漏れの発見が遅れる可能性があります。

11. まとめ

最初の問題は、「GoogleログインがWebViewでブロックされる」という単一のエラーのように見えました。しかし解決の過程では、認証UIをどこで実行するのか、Provider tokenをどのような契約で渡すのか、既存のサービスログインフローをどのように再利用するのか、WebViewの読み込み中に失われるメッセージをどのように扱うのか、実機でエラーをどのように追跡するのかを、併せて設計する必要がありました。

今回の経験を通じて、ハイブリッドアプリにおけるBridgeは、ネイティブ機能を呼び出すための便利なツールではなく、2つのランタイム間のAPIであると実感しました。APIである以上、方向とpayloadが明確でなければならず、入力を検証し、重複とtimeoutを処理し、機密情報がログに残らないようにする必要があります。単純なpostMessageから始めた通信も、認証を扱う瞬間に、小規模な分散システムに近い性質を持つようになります。

React Nativeですべての画面とドメインロジックを移行することが、常に正解とは限りません。バンジャンノートでは、Provider認証のようにネイティブが必要な部分だけをRNに置き、すでに検証済みのReactとGateの会員・セッションロジックを再利用することで、変更範囲と重複を減らす方針を採りました。今後、アカウント連携、ディープリンクからの復帰、Providerごとの追加同意など認証機能が拡張されても、同じ境界を維持することが重要です。

今後は、アプリとWebのbridge schemaのバージョンを明示し、Providerごとの成功・キャンセル・重複アカウント・セッション期限切れのシナリオを、Development Buildを基盤としたE2Eテストで自動化する予定です。Appleにおける初回プロフィール提供の制約や、アプリ外部からの復帰フローも、運用シナリオに合わせて補完する必要があります。今回の作業は、Web資産を維持しながらネイティブ認証ポリシーを満たすために、RN-Bridge-Reactの責任境界を実際のサービスフローの中で調整した経験でした。

参考文献

Google for Developers, OAuth 2.0 Policies
https://developers.google.com/identity/protocols/oauth2/policies

IETF, RFC 8252: OAuth 2.0 for Native Apps
https://www.rfc-editor.org/rfc/rfc8252.html

React Native WebView, Communicating between JS and Native
https://github.com/react-native-webview/react-native-webview/blob/master/docs/Guide.md

Expo Documentation, AppleAuthentication
https://docs.expo.dev/versions/latest/sdk/apple-authentication/

Kakao Developers, Kakao Login for Android
https://developers.kakao.com/docs/latest/ko/kakaologin/android

Kakao Developers, Kakao Login for iOS
https://developers.kakao.com/docs/latest/ko/kakaologin/ios

jyyou

Site footer