Expo + Firebase Auth でのメールアドレス確認完了をリアルタイムで検知する

# はじめに
ユーザーがメールアドレス確認リンクを開いて認証が完了した、というのをアプリ側でリアルタイムに検知する仕組みを作りたいと思います。
今のところFunctions等でもイベントが発生するような仕組みにはなっていないので、要件のわりにちょっと骨が折れる実装になっています。。。
メールアドレス確認の実装自体は[こちらの記事](https://qiita.com/mildsummer/items/e6c9588865b5a661aebc)にまとめています。

### 実装の流れ
大まかに以下のような感じです。

1. ユーザー情報をAuthenticationとは別にDB（Firestore）に保存させる
2. メール認証ページ（送られるメールのリンク先）をカスタマイズし、認証が成功したらDBのユーザー情報に認証完了フラグを追加させる
3. アプリ側でDBのユーザー情報の変更を検知させる

# Firestoreにユーザー情報を保存する
Firebaseコンソールを開き、「Database」→「データ」→「コレクションを開始」で`users`コレクションを追加します。
セキュリティのためユーザー本人のみ読み書きができるようにルールを変更しておきます。

```
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
      allow read, write: if request.auth.uid == userId;
    }
  }
}
```

DBの処理はアプリ側でもできますが、CloudFunctionsでユーザーの認証情報が作成されたイベントを処理できるので、
以下のようにDBにドキュメントを作成する関数を定義し、デプロイします。
ドキュメントのIDには`uid`をそのまま使用し、`email`フィールドを入れておきます。

```javascript:functions/index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
admin.initializeApp();

exports.createUser = functions.auth.user().onCreate((user) => {
  admin.firestore().collection('/users')
    .doc(user.uid)
    .set({
      email: user.email
    });
});
```
これで、認証情報が作成されたと同時にDBにもユーザー情報が作成されるようになりました。

# メール認証ページをカスタマイズする

[公式ドキュメント](https://firebase.google.com/docs/auth/custom-email-handler?hl=ja)を参考にしながらメール認証ページを作成していきます。
ここはお好みで作っていただければいいと思うので大枠は割愛しますが、まずFirebaseの静的サイトホスティングでデプロイするHTMLを作成します。

```html:public/action.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Expo Auth Practice</title>
</head>
<body>
<div id="app"></div>
<script src="/assets/action.js"></script>
</body>
</html>
```

この`<div id="app"></div>`にReactコンポーネントをrenderするとして、
以下のようなコンポーネントを実装してみます。

```javascript:ActionApp.js
import React, { Component } from 'react';
import queryString from 'query-string';
import { auth, functions } from '../utils/firebase';

const query = queryString.parse(window.location.search);
if (query.mode !== 'verifyEmail') {
  window.location.href = `https://${process.env.FIREBASE_AUTH_DOMAIN}/__/auth/action${window.location.search}`;
}

export default class ActionApp extends Component {
  constructor(props) {
    super(props);
    this.state = {
      completed: false
    };
  }

  componentDidMount() {
    const { oobCode, continueUrl } = query;
    let email = null;
    auth.checkActionCode(oobCode)
      .then((actionCodeInfo) => {
        email = actionCodeInfo.data.email;
        return auth.applyActionCode(oobCode)
      })
      .then(() => (functions.httpsCallable('verifyEmail')({ email })))
      .then(() => {
        if (continueUrl) {
          window.location.href = continueUrl;
        }
        this.setState({
          completed: true
        });
        })
      .catch((error) => {
        console.log(error);
      });
  }

  render() {
    const { completed } = this.state;
    const { continueUrl } = query;
    return (
      <div className='app-container'>
        ActionApp
        <button
          type='button'
          onClick={continueUrl ? () => {
            window.open(continueUrl, '_blank');
          } : null}
          style={{
            display: completed ? 'block' : 'none'
          }}
        >
          Continue to app
        </button>
      </div>
    );
  }
}
```

上から説明していくと、

```javascript
const query = queryString.parse(window.location.search);
if (query.mode !== 'verifyEmail') {
  window.location.href = `https://${process.env.FIREBASE_AUTH_DOMAIN}/__/auth/action${window.location.search}`;
}
```

メールアドレス確認で送信されるリンク先は、パスワードのリセットやメールアドレス変更の取り消しの場合と同じものなので、URLパラメータの`mode`からどのアクションに対応するべきか判別する必要があります。
今回はメールアドレス確認だけ実装したいので、ひとまず他のアクションの場合はデフォルトのページへリダイレクトするようにします。。。
`process.env.FIREBASE_AUTH_DOMAIN`はdotenvで取得するようにしています。
ちなみに簡単なリダイレクトはfirebase.jsonで定義できるのですが、パラメータを含んだURLにうまく対応できなかったので諦めました。

```javascript
  componentDidMount() {
    const { oobCode, continueUrl } = query;
    let email = null;
    auth.checkActionCode(oobCode)
      .then((actionCodeInfo) => {
        email = actionCodeInfo.data.email;
        return auth.applyActionCode(oobCode)
      })
      .then(() => (functions.httpsCallable('verifyEmail')({ email })))
      .then(() => {
        if (continueUrl) {
          window.location.href = continueUrl;
        }
        this.setState({
          completed: true
        });
        })
      .catch((error) => {
        console.log(error);
      });
  }
```

コンポーネント初期化後、クエリに含まれるアクションコードを使って認証を行いますが、DB側のユーザー情報を変更するためにユーザーを特定する必要があります。
アクションコードがわかればクライアントSDK側の`firebase.Auth.checkActionCode`メソッドでメールアドレスを取得することができるので、これを利用します。
メールアドレスの取得ができたら、`applyActionCode`で認証を完了し、これから実装する関数（`verifyEmail`）にメールアドレスを渡してcallし、それも成功したらクエリの`continueUrl`にリダイレクトして完了です。

補足ですが、firebaseモジュールはinitializeApp含め別ファイルにしてexportしています。

```javascript:utils/firebase.js
import firebase from 'firebase';
import firebaseConfig from './firebaseConfig';

firebase.initializeApp(firebaseConfig);

export const auth = firebase.auth();
export const db = firebase.firestore();
export const functions = firebase.functions();
export default firebase;
```

`verifyEmail`関数では渡されたメールアドレスからユーザーを特定し、`emailVerifiedAt`フィールドを追加します。
デプロイをお忘れなく。

```javascript:functions/index.js
exports.verifyEmail = functions.https.onCall((data) => {
  return auth.getUserByEmail(data.email).then((user) => {
    return db.collection('/users')
      .doc(user.uid)
      .update({ emailVerifiedAt: admin.firestore.Timestamp.fromDate(new Date()) })
      .then(() => {
        return { message: 'success' };
      }).catch(({ message }) => {
        throw new functions.https.HttpsError(message);
      });
  });
});
```

# アプリ側でDBのユーザー情報の変更を検知させる

DBが変更できれば、あとは簡単です。
Firestoreでは`DocumentReference.onSnapshot`メソッドで変更の監視ができます。
リスナーが呼ばれ、先ほど書き込んだユーザー情報の`emailVerifiedAt`が存在していれば認証完了です。
これでリアルタイムにメールアドレス確認完了を検知する流れができました。

```javascript
const db = firebase.firestore();
db.collection('/users')
  .doc(<current user uid>)
  .onSnapshot((docSnapshot) => {
    const dbUser = docSnapshot.data();
    if (dbUser && dbUser.emailVerifiedAt) {
      Alert.alert('Email verification has succeeded.');
      user.reload().then(() => {
        console.log(user);
      });
    }
  });
```

# 確認完了後にユーザーをアプリ画面に戻すには

例えばアプリ側で認証メール送信が処理されてから、スマホ画面はそのままにし、認証メールをPCで開いた場合はリアルタイムでアプリの画面が切り替わるようになりました。
しかし大体の場合はスマホ端末上でメール認証が完結されると思いますので、認証が終わったら自動的にアプリ画面に戻したいところです。

先ほど作成したメール認証画面では、クエリ内の`continueUrl`へリダイレクトする処理を行っています。
このURLは`sendEmailVerification`にオプションとして渡せるようになっていますので、ディープリンクを使って工夫してみます。

```javascript
user.sendEmailVerification({ url: Linking.makeUrl(<プロジェクトドメイン>) })
```

ExpoのLinkngで生成されるURLは以下のようになっています。

 * ExpoClientで開発時： `exp://127.0.0.1/--/<任意のパス>`
 * スタンドアロンアプリとしてビルド後： `<app.jsonのexpo.scheme>://`

このとき渡すURLはFirebase側でチェックされるため、Firebaseコンソールの「Authentication」→[ログイン方法]で承認済みのドメインでなければいけないようです。
ExpoClientでの開発時は`127.0.0.1`をホワイトリストに追加しておけば大丈夫でした。

また、ビルド後は`Linking.makeUrl('')`で生成された空のURLだとドメイン部分が存在せずエラーが発生してしまうので、何かしらパスをいれる必要があります。
この場合もドメインチェックが入るので、上記のように最初から承認されているプロジェクトのドメイン（`<プロジェクト名>.firebaseapp.com`）を入れると問題なくメール送信できました。

# Github
サンプルソースはこちら
https://github.com/mildsummer/expo-auth-practice
https://github.com/mildsummer/expo-auth-practice-firebase