Expo WebViewでログイン情報を渡すまでの試行錯誤


## 背景

Expoを使ってWebViewを表示するネイティブアプリを作成し、その中で自作のWebアプリにログインできるようにしようとしました。しかし、ログイン情報をWebView側に渡せない問題に直面し、さまざまな試行錯誤を行いました。本記事では、その経緯を記録します。

現在、完全に迷路に迷い込み中…

---

## Webアプリ側のログイン機能

WebアプリはNext.jsで作成しており、NextAuth.jsとGoogleのOAuth 2.0認証を使用してログイン機能を実装しています。

吉井健文さんの著書『実践Next.js』の第7章「認証機能」を大いに参考にしているので、興味がある方は読んでみてください。

https://www.amazon.co.jp/%E5%AE%9F%E8%B7%B5TypeScript-BFF%E3%81%A8Next-js-Nuxt-js%E3%81%AE%E5%9E%8B%E5%AE%9A%E7%BE%A9-%E5%90%89%E4%BA%95-%E5%81%A5%E6%96%87/dp/483996937X

---

## Expo WebViewでログインを試みる

まず、Expoでネイティブアプリを作成し、WebView内でWebアプリを表示してログインを試みました。

### エラー発生

しかし、ログインしようとすると、以下のエラーが発生しました。

```
403: disallowed_useragent
```

これは、GoogleのOAuth 2.0認証がWebView内での認証を禁止しているために発生するエラーです。Googleはセキュリティの観点からWebView内でのOAuth認証を制限しています。

### 解決策の検討

このエラーを回避するためには、

- WebViewでログイン処理を行わず、Expo側でログイン処理を実装
- ネイティブアプリでログイン後にaccessTokenをWebView側に渡す

という方法が必要であることが調べてわかりました。

---

## Expo側のログイン処理を実装

### WebViewの実装
webアプリを表示するためのWebviewコンポーネントに`ref`, `injectedJavaScript`, `onMessage`を設定します。
`injectedJavascript`では、webview内のwebアプリからnativeアプリ側に`postMessage`するためのwindow関数を設定しています。この`window`関数をwebアプリ側のログインボタンを押下した時に発火させることでnativeアプリ側に特定のメッセージ「google-login」を送信できるようにします。
`onMessage`ではnativeアプリ側の`message`イベントを検知し、それが「google-login」であればnativeアプリ側で設定したログイン処理（`promptAsync`）を発火させるようにしています。


```typescript:index.tsx
import { WebView } from "react-native-webview";
import { useLoginOnGoogleAuth } from "@/hooks/useLoginOnGoogleAuth";

export default function HomeScreen() {
  const { webViewRef, promptAsync } = useLoginOnGoogleAuth();

  return (
    <WebView
      ref={webViewRef}
      source={{ uri: "https://web_application/" }}
      injectedJavaScript={`
          window.requestGoogleLogin = function() {
          window.ReactNativeWebView.postMessage("google-login");
        };
      `}
      onMessage={(event) => {
        const message = event.nativeEvent.data;
        if (message === "google-login") {
          promptAsync();
        }
      }}
      userAgent="ExpoWebView"
    />
  );
}
```

### Expo側のGoogleログイン処理
`message`イベントを検知し発火させる`promptAsync`を返している`useLoginOnGooleAuth`関数では、Expo側で提供されているライブラリを使ってgoogleへの認証処理を実装しています。
`useEffect`では認証が成功したら取得した`accessToken`をwebview表示しているwebアプリ側にaccessTokenを渡すようにしています。

```typescript:useLoginOnGoogleAuth.ts
import { useEffect, useRef } from "react";
import * as Google from "expo-auth-session/providers/google";
import { WebView } from "react-native-webview";
import * as WebBrowser from "expo-web-browser";

export function useLoginOnGoogleAuth() {
  WebBrowser.maybeCompleteAuthSession(); // 設定すると別タブでログイン後、タブを閉じてアプリ側に戻る
  
  const webViewRef = useRef<WebView | null>(null);
  const [request, response, promptAsync] = Google.useAuthRequest({
    clientId: process.env.EXPO_PUBLIC_GOOGLE_ID || "",
    redirectUri: process.env.EXPO_PUBLIC_GOOGLE_REDIRECT_URI || "",
    scopes: ["profile", "email"],
  });

  useEffect(() => {
  // google認証に成功したらaccessTokenを取得しwebview表示しているwebアプリ側にaccessTokenを渡す。
    if (response?.type === "success") {
      const { authentication } = response;
      if (authentication?.accessToken) {
        webViewRef.current?.postMessage(
          JSON.stringify({ token: authentication.accessToken })
        );
      }
    }
  }, [response]);

  return { webViewRef, promptAsync };
}
```
上記の処理を図示化した時は以下のようになります。
![無題のプレゼンテーション (4).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/adc9a8af-3fb5-4189-a4be-36726102382b.png)


この実装により、WebView内のWebアプリのログインボタンを押すと、Expo側のログイン処理が発火し、Googleの認証画面が表示されます。

しかし、ログイン後にリダイレクトされた画面で、

```
Something went wrong trying to finish signing in. Please close this screen to go back to the app.
```

というエラーが発生しました。

## auth.expo.ioが問題？

エラーが発生した時のリダイレクトURIを確認すると、

```
https://auth.expo.io/@useName/appName?state=...&code=...
```

となっており、Googleの認可コード（`code`）は発行されていることがわかりました。

Expoには `auth.expo.io` というOAuthプロキシがあり、本来であれば`code`がExpo側に返されればOAuthプロキシが`accessToken`を取得するためにOAuth認証を進めてくれるはずです。

![無題のプレゼンテーション (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/1003e9aa-d6ca-49f6-95e3-6c12a9daa76d.png)

つまり、これを利用すると開発者が`code`取得後から`accessToken`取得までの処理を自動でやってくれるはずですが、なぜかうまくいきません。。Expoを使えばバックエンドを用意しなくてもOAuth認証が完結するはずなのに。。

エラーについて調べてみると`auth.expo.io`の代わりに自分でバックエンドAPIを実装する方法もありました。
BEの環境はwebアプリを構築しているNext.jsの`api routes`を使えば実装できると思ったのでこの方法を試してみることにしました。

---

## 自作のバックエンドAPIでトークン取得を試す

`auth.expo.io` を使わず、自作のバックエンドAPIを `redirectUri` に設定し、accessTokenを取得するように変更しました。webアプリ側のNext.jsの環境でapi routesを使ってバックエンドAPIを作成しました。

![無題のプレゼンテーション (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2982501/a916fdd2-df28-43aa-aefa-51ad142244a0.png)

```typescript:googleAuthCallback.ts
import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');

  if (!code) {
    return NextResponse.json({ error: 'Missing code' }, { status: 400 });
  }

  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.GOOGLE_CLIENT_ID || "",
      client_secret: process.env.GOOGLE_CLIENT_SECRET || "",
      redirect_uri: 'https://your-backend.com/api/auth/google/callback',
      grant_type: 'authorization_code',
      code, // 認可コード（code）をパラメータに設定する
    }),
  });

  const tokenData = await tokenResponse.json();
  if (!tokenResponse.ok) {
    return NextResponse.json({ error: tokenData }, { status: 400 });
  }

  return NextResponse.json({ access_token: tokenData.access_token });
}
```

これで

1. ExpoアプリでGoogleログイン
2. 認可コードを取得
3. 自作のNext.js APIへリダイレクト
4. GoogleにaccessTokenをリクエスト
5. アプリにaccessTokenを返す
6. WebViewにトークンを渡す

という流れが構築されました。


しかし、今度は `code_verifier` がないというエラーが発生。

まだ解決していないので、引き続き試行錯誤中です。。

---

## まとめ

ExpoのWebView内でGoogle OAuthを使う場合、直接WebViewでログインはできないため、ネイティブアプリ側でログイン処理を行い、`accessToken`をWebViewに渡す必要があります。しかし、auth.expo.ioの制約や、独自バックエンドを用いた場合の`code_verifier`の問題など、まだ課題が残っている状況です。

今後も解決策を模索しつつ、進捗あれば追記したいです。