React18 + Firebase9 で CustomToken Login する

## はじめに
Firebase は Auth Provider として、色々なものをデフォルトでサポートしているが、例えば LINE でログインしたいとか、Metamask でログインしたいとか、そういったサポートされていない Provider の場合、CustomToken を利用してログインする。

今回は、React 18 + Firebase 9 + react-router-dom 6 + TypeScript および、Cloud Functions を利用して、CustomToken Login を実装した。

https://github.com/kurab/react-typescript-firebase/tree/develop

CustomToken を発行する Cloud Functions でかなり躓いたので、その話。


## 環境

React
- React: 18.0.2
- Firebase: 9.9.0
- react-router-dom: 6.3.0

CloudFunctions
- firebase-admin: 10.2.0
- firebase-functions: 3.21.0

## 方針

- 以前作ったもの（[React+Typescript+Firebase v9](https://qiita.com/kurab/items/e1f78008cc87cdafebf4)）の React, react-router-dom を最新のものにアップグレードする。
    - react-router-dom のアップグレードは、[React Router v6 にアップグレードしたメモ](https://qiita.com/kurab/items/8aa30074b703ca15157b) を頼りに実施する

この作業は実は結構大変だったが、内容としては以前作ったものとたいして変わらないので、今回は省略。firebase の Blaze プランになってるとか、IAM で権限ついてるとか、そういうのは一通り済んでいるものとする。

- CloudFunctions で CustomToken を発行する API を作成する
- React から、その API にアクセスし CustomToken を取得する
- 取得した CustomToken を利用して Firebase でログインする

ひとまず、CloudFunctions とのやり取りの流れを見たかったので、

- [Firebase Authentication の開始方法](https://firebase.google.com/docs/auth/where-to-start?hl=ja)
- [【Firebase】 カスタム認証を使ってユーザー登録をする](https://www.kamo-it.org/blog/fireauth_custom_auth/)
- [【Flutter】Firebase AuthとLINE SDKでLINEログインを実装する](https://zenn.dev/yskuue/articles/410e5b787b354a#firebase-admin-sdk%E3%81%AE%E6%BA%96%E5%82%99)

などの記事を熟読し、コピペしてみた。
が、うまく動かなかった。

## 課題

コピペしたコードが思ったように動かなかったのは、大きく分けると、以下の3点が問題だった。

- CloudFunctions 側: CORS
- React 側: Request
- CloudFunctions 側: Request

それに加えて、

- CloudFunctions 側: 環境変数を使う
- セキュリティ

を検討した。

## CloudFunctions をローカルで動かす
まず、いちいち deploy しないと動きがみられないのは面倒なので、ローカルで動かして Debug する。

```
$ cd cloudfunctions/functions
$ yarn serve
```

React 側では、 ```connectFunctionsEmulator(functions, "localhost", 5001);``` とすることで、ローカルにつないでくれる。

ただし、<b>このローカルで起動した Emulator、deploy したものと動きが違う。結構違う。</b>困ったものだ。全く使えないわけではないので、ローカルで動くからといって、deploy したものが動くとは限らないとの前提で便利に使う。


## CloudFunctions の CORS を解決する

開発中のローカルや、AWS 等別のところから呼び出すと CORS エラーが発生するので、解決する。

CORS は、以前戦ったので、余裕である。

> [Bearer認証付きAPIでCORSエラーとたたかった記録](https://qiita.com/kurab/items/84573b3092c8db4ba750)

Before

```typescript
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

const serviceAccount = require("XXXX.json");

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

exports.fetchCustomToken = functions
  .region("asia-northeast1")
  .https.onRequest(async (req, res) => {
    const userId = req.body.data.userId;

    if (userId && typeof userId === "string") {
      const customToken = await admin.auth().createCustomToken(userId);
      res.status(200).send({
        data: {
          customToken,
        },
      });
    } else {
      res.status(400).send({ error: "Bad Request" });
    }
    return;
  });
```

※ 参考にしたコードからは、エラーレスポンスを変更した。404かなー？と思ったので。

After
```typescript
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

const serviceAccount = require("XXXX.json");

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

exports.fetchCustomToken = functions
  .region("asia-northeast1")
  .https.onRequest(async (req, res) => {
    const userId = req.body.data.userId;

    res.set("Access-Control-Allow-Origin", "*");
    if (req.method === "OPTIONS") {
      res.set("Access-Control-Allow-Methods", "POST");
      res.set("Access-Control-Allow-Headers", "Content-Type");
      res.set("Access-Control-Max-Age", "3600");
      res.status(204).send("");
      return;
    }

    if (userId && typeof userId === "string") {
      const customToken = await admin.auth().createCustomToken(userId);
      res.status(200).send({
        data: {
          customToken,
        },
      });
    } else {
      res.status(400).send({ error: "Bad Request" });
    }
    return;
  });
```

これで CORS 対策は終わりだ。なのだが、この後も CORS エラーが出る。出るが、CORS エラーじゃないので、上記さえやっておけば、CORS エラーは気にしなくて良い（はず）。上記コードを deploy してアクセスすると、CORS エラーが出るが、それは別の問題。とりあえず、エラーにならない Emulator で進める。

なお、CloudFunctions の POST で CORS エラーが発生するのは、```Content-Type``` で ```application/json``` を使った時だ。React で使う firebase モジュールは、json でリクエストするので、解決した方がよかろう。


## React 側 Request の問題

エミュレータで動いたので、とりあえず POSTMAN で ```{ "userId": "kurab" }``` を POST してみると、400エラーが返ってきてしまう。

これは、CloudFunctions側 で、uesrId を ```req.body.data``` から取得しているからだ。上記のように POST すると、当然 ```req.body``` に入る。なんで、data なんて入ってるんだろう？　CloudFunctions 側の ```req.body.data.userId``` を ```req.body.userId``` にしたら、CustomToken が発行された。YAY!

のだが、これはやってはいけない。

React 側では、

```typescript:useAuth.ts
import {
  getFunctions,
  httpsCallable,
  //connectFunctionsEmulator,
} from "firebase/functions";

...

      const functions = getFunctions(getApp(), "asia-northeast1");
      //connectFunctionsEmulator(functions, "localhost", 5001);
      const fetchCustomToken = httpsCallable(functions, "fetchCustomToken");

      fetchCustomToken({
        userId: "kurab",
      })
```

こんな風にアクセスする。fetchCustomToken API に ```{ "userId": "kurab" }``` を送っているようにしか見えないが、実際は、

```json
{
    "data": {
        "userId": "kurab"
    }
}
```

が送られている。という訳で、CloudFunctions 側は、 ```req.body.data.userId``` だったのだ。
Request ではなく、Response だが、React 側で firebase モジュールを使って CloudFunctions の API を呼び出す際に、Response に data オブジェクトがないとエラーになってしまうため、CloudFunctions 側の戻り値は、data で包んであげないといけない。
エラーレスポンスは、React 側の firebase モジュールでは、何を送っても読み込んでくれないので（ステータスコードだけからエラーを返している模様）、別に data で包む必要はない。ステータスコードだけしっかりしておけば良い。


## CloudFunctions 側: Request

ここまでの内容で、Emulator ではきちんと動くようになるが、deploy すると動かない。なぜだ！！Type エラーだ！！

```typescript
const userId = req.body.data.userId;
```

こいつがエラーになる。

```typescript
const userId = req.body.data?.userId;
```

こうしないといけない。firebase モジュールでかってに data つけるなら良きに計らってくれれば良いのにと思うが、そんなに賢くなかった。ただの request オブジェクトだ。

という訳で、ここまでやると、CloudFunctions は意図した通りに動くようになる。ここで終わっても良いのだが、もう少し続ける。


## CloudFunctions 側: 環境変数を使う

firebase-admin のサービスアカウントのキーを json でダウンロードし、それがハードコードされているが、嫌だったので、環境変数にすることにした。

dotEnv がサポートされていると、どこかに書いてあったが、されてなかったのでインストールして使った。

```
$ cd cloudfunctions/functions
$ yarn add dotEnv
```

```typescript
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
require("dotenv").config();

const serviceAccount = require(`../account/${process.env.SERVICE_ACCOUNT}`);
...
```

```text:.env
SERVICE_ACCOUNT="XXXX.json"
```

ちょっとかっこ悪いが、まぁよしとしよう。
認証情報を github に上げたくないとうい理由からこんなことをしたが、```cloudfunctions/.firebaserc``` もちょっとしたあげたくない情報が含まれているので、push する前に全部調べよう。


## セキュリティ

できたけど、この誰でも CustomToken が取得できてしまう状態、どうでしょう。と思ったので、何らか認証をかけたかった。

- [ID トークンを検証する](https://firebase.google.com/docs/auth/admin/verify-id-tokens?hl=ja)

これが良いじゃん。```idToken``` を React 側で発行し、CloudFunctions 側で Bearer 認証で verify すれば良い。
と思ったが、2つ問題があった。

1. ログインしてないので、```currentUser``` がいない（```idToken``` が発行できない）
1. React 側から ```Authorization``` ヘッダーが送れない

1つめが致命的である。発行できないんじゃ仕方がない。2つめは、node_modules の中に入って探してみたが、timeout 以外の Option が送れなさそうだった。axios とか fetch でやれば良いんだろうけど、いまさらなので、headers でなにかするのはやめる。

仕方がないので、request で API SECRET を送り、突き合わせることにする。

react 側
```typescript:useAuth.ts
      fetchCustomToken({
        userId,
        api_secret: process.env.REACT_APP_FETCHCUSTOMTOMTOKEN_SECRET,
      })
```

CloudFunctions 側

```typescript
        const userId = req.body.data?.userId;
        const api_secret = req.body.data?.api_secret;

        if (api_secret !== process.env.SECRET) {
          res.status(401).send({ error: "Unauthorized" });
        } else if (userId && typeof userId === "string") {...} else {...}
```

secret というパラメータ名にすると、Internal Error になる。

あとは、POST 以外の Method でアクセスされた時に、CORS エラーじゃないのに CORS エラー返すのがいやだったので、405エラーを返すようにした。

```typescript
    res.set("Access-Control-Allow-Origin", "*");
    switch (req.method) {
      case "OPTIONS":
        // pre flight の処理
        break;
      case "POST":
        // 通常の処理
        break;
      default:
        res.status(405).send({ error: "Method Not Allowed" });
        break;
    }
    return;
```

## 完成

CloudFunctions
```typescript:index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
require("dotenv").config();

const serviceAccount = require(`../account/${process.env.SERVICE_ACCOUNT}`);

admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
});

exports.fetchCustomToken = functions
  .region("asia-northeast1")
  .https.onRequest(async (req, res) => {
    res.set("Access-Control-Allow-Origin", "*");
    switch (req.method) {
      case "OPTIONS":
        res.set("Access-Control-Allow-Methods", "POST");
        res.set("Access-Control-Allow-Headers", "Content-Type");
        res.set("Access-Control-Max-Age", "3600");
        res.status(204).send("");
        break;
      case "POST":
        const userId = req.body.data?.userId;
        const api_secret = req.body.data?.api_secret;

        if (api_secret !== process.env.SECRET) {
          res.status(401).send({ error: "Unauthorized" });
        } else if (userId && typeof userId === "string") {
          const customToken = await admin.auth().createCustomToken(userId);
          res.status(200).send({
            data: {
              customToken,
            },
          });
        } else {
          res.status(400).send({ error: "Bad Request" });
        }
        break;
      default:
        res.status(405).send({ error: "Method Not Allowed" });
        break;
    }
    return;
  });
```

React 側
```typescript:useAuth.ts: loginWithCustomToken
  const loginWithCustomToken = useCallback(
    async (userId: string) => {
      const functions = getFunctions(getApp(), "asia-northeast1");
      //connectFunctionsEmulator(functions, "localhost", 5001);
      const fetchCustomToken = httpsCallable(functions, "fetchCustomToken");

      fetchCustomToken({
        userId,
        api_secret: process.env.REACT_APP_FETCHCUSTOMTOMTOKEN_SECRET,
      })
        .then((res: any) => {
          signInWithCustomToken(auth, res.data.customToken)
            .then((userCredential) => {
              const user = userCredential.user;
              setLoginUser(user);
              navigate("/");
            })
            .catch((err) => {
              alert(err);
            });
        })
        .catch((err) => {
          console.error(err);
        });
    },
    [navigate, setLoginUser]
  );
```

https://github.com/kurab/react-typescript-firebase/tree/develop


思ったより手強かった。おしまい。