Expo AppleAuthentication+Firebaseで「Sign in with Apple」を導入

## はじめに

「Sign in with Apple」をFirebaseのAuthenticationと連携させてExpoアプリに導入してみます。
すでにyosukesuzukiさんの下記の記事があるので恐縮ですが、こちらを参考にしたうえで、この記事では補足的な事柄を含めて簡単にまとめます。

**expo+firebaseでSign in with Appleを実装する**
https://qiita.com/yosukesuzuki/items/0a6590fc67ad587a693a

## 導入の準備

Apple DeveloperでAppのIdentifierに対して設定をするわけなので、まだ一度もExpoアプリをビルドしていない場合は事前にiOS向けのビルドをしておく必要があります。
まずはapp.jsonの編集を。

```json:app.json
{
  "expo": {
    "ios": {
      "bundleIdentifier": "com.xxx.xxxxxxxxxxxx",
      "usesAppleSignIn": true
    }
  }
}
```

`expo.ios.bundleIdentifier`にバンドル名を設定し、`expo.ios.usesAppleSignIn: true`を追加します。

iOSのビルドをしていない場合はこの後ビルドすることで、Expoにお任せでIdentifierを作ってもらいます。

```console
$ expo build:ios
```

ビルドが完了したら、[Apple Developerページ](https://developer.apple.com/account/resources/identifiers/list)でIdentifiersの中から該当するものを選択し、「Sign In with Apple」のCapabilityを有効にします。
<img width="1440" alt="Certificates__Identifiers___Profiles_-_Apple_Developer.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/04e1ad98-d151-e8af-13c5-ff2931b72ccc.png">
ここでオプションとして既存のAppIDとグループ化することができるようですが、普通に「Enable as a primary App ID」を選択しておきます。

続いてFirebaseプロジェクト側の設定を行います。

「Authentication」の「Sign-in method」タブで「Apple」を有効にし、サービスIDにアプリの`bundleIdentifier`を入力します。プロジェクトにiOSアプリとして登録済みものがある場合はそのアプリのバンドルIDに対してデフォルトで許可が通りますが、ExpoアプリはFirebaseではWebアプリという扱いなので（iOSアプリとして登録していることはないと思います）、ここで入力が必要です。
<img width="1440" alt="Test_Project_–_Authentication_–_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/7760b36f-2a97-11eb-8a3c-26b83f0dc22d.png">

参考記事にもありますが、Expo Clientで確認する際にはバンドルIDは`host.exp.Exponent`となるので、これをバンドルIDとするiOSアプリを追加しておくことで開発時にも認証が通るようになります。
<img width="1440" alt="Test_Project_–_Settings_–_Firebase_console.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/1e3d2a2e-caa3-0447-4d0c-51b653ac2549.png">

これで事前の準備は完了です。

## Sign in with Appleボタンの実装

簡単にApp.jsにまとめて、ボタンだけ設置してみます。
`expo-apple-authentication`、`expo-crypto`、`firebase`をインストールしておいてください。

```jsx:App.js
import React, { useState, useEffect } from 'react';
import { StyleSheet, View, Text } from 'react-native';
import * as AppleAuthentication from 'expo-apple-authentication';
import * as Crypto from 'expo-crypto';
import firebase from 'firebase';

// この時点でfirebase.initializeAppはしている想定

/**
 * ランダム文字列を生成
 * @param length
 * @returns {string}
 */
function nonceGen(length) {
  let result = '';
  let characters =
    'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
  let charactersLength = characters.length;
  for (let i = 0; i < length; i++) {
    result += characters.charAt(Math.floor(Math.random() * charactersLength));
  }
  return result;
}

export default function App() {
  const [appleCredentialState, setAppleCredentialState] = useState(null);
  const [isAvailableAppleAuth, setIsAvailableAppleAuth] = useState(null);

  useEffect(() => {
    AppleAuthentication.isAvailableAsync().then(setIsAvailableAppleAuth); // 使用可能かどうか判定してstateにセット
    firebase.auth().onAuthStateChanged((currentUser) => {
      // Firebase Authenticationの認証状況が呼ばれる
      console.log('currentUser', currentUser);
      if (currentUser) {
        const appleProviderData = firebase.auth().currentUser.providerData.find(data => (data.providerId === 'apple.com'));
        if (appleProviderData) {
          AppleAuthentication.getCredentialStateAsync(appleProviderData.uid).then((stateNum) => {
            const stateKey = Object.keys(AppleAuthentication.AppleAuthenticationCredentialState)
              .find((stateKey) => (AppleAuthentication.AppleAuthenticationCredentialState[stateKey] === stateNum));
            console.log('Credential state: ', stateKey || '');
            setAppleCredentialState(stateKey); // 認証状況を表示
            if (stateNum === AppleAuthentication.AppleAuthenticationCredentialState.REVOKED) { // AppleIDの使用を停止した場合
              firebase.auth().signOut().then(() => { // サインアウト
                setAppleCredentialState(null);
              });
            }
          });
        }
      }
    });
  }, []);

  return (
    <View style={styles.container}>
      {appleCredentialState && <Text>Apple Credential state: {appleCredentialState}</Text>}
      {isAvailableAppleAuth === false && <Text>この端末ではApple Authenticationは使用できません</Text>}
      {isAvailableAppleAuth && (
        <AppleAuthentication.AppleAuthenticationButton
          buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
          buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE_OUTLINE}
          cornerRadius={5}
          style={{ width: 200, height: 64 }}
          onPress={async () => {
            try {
              const nonce = nonceGen(32); // ランダム文字列（ノンス）を生成
              const digestedNonce = await Crypto.digestStringAsync(
                Crypto.CryptoDigestAlgorithm.SHA256,
                nonce
              ); // SHA256でノンスをハッシュ化
              const result = await AppleAuthentication.signInAsync({
                requestedScopes: [ // ユーザー情報のスコープを設定（名前とメールアドレスのみ可）
                  AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
                  AppleAuthentication.AppleAuthenticationScope.EMAIL
                ],
                nonce: digestedNonce // Apple側にはハッシュ化したノンスを渡す
              });
              console.log('Apple Sign In result: ', result);
              let provider = new firebase.auth.OAuthProvider("apple.com");
              let credential = provider.credential({
                idToken: result.identityToken,
                rawNonce: nonce // Firebase側には元のノンスを渡して検証させる
              });
              const firebaseResult = await firebase.auth().signInWithCredential(credential);
              console.log('Firebase Auth result: ', firebaseResult);
            } catch (e) {
              console.error(e);
            }
          }}
        />
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
});
```

機能自体がシンプルなので難しいことはないですが、[ノンス](https://ja.wikipedia.org/wiki/%E3%83%8E%E3%83%B3%E3%82%B9)の部分だけ理解が必要です。
`AppleAuthentication.signInAsync`時に[リプレイ攻撃](https://ja.wikipedia.org/wiki/%E5%8F%8D%E5%B0%84%E6%94%BB%E6%92%83)を防ぐためのオプションとして`nonce`プロパティを渡すことができます。この時、ランダム文字列をSHA256でハッシュ化したものをApple側に送信し、Firebaseには元の文字列を送信することで、両者の照合によりセキュリティが向上するという仕組みです。

## 認証結果の内容

`AppleAuthentication.signInAsync`によるPromiseは[`AppleAuthentication.AppleAuthenticationCredential`オブジェクト](https://docs.expo.io/versions/latest/sdk/apple-authentication/#appleauthenticationappleauthenticationcredential)を返します。
このオブジェクトは下記のプロパティを含んでいます。


* `user`  
ユーザー固有の値。以後`AppleAuthentication.getCredentialStateAsync`で認証状態をチェックする際に使用する。
* `fullName`  
ユーザーの名前に関する情報。`requestedScopes`オプションで`FULL_NAME`を設定している場合に**そのアプリで最初に認証したときのみ**値が入り、[この内容](https://docs.expo.io/versions/latest/sdk/apple-authentication/#appleauthenticationappleauthenticationfullname)を含む。
* `email`  
ユーザーのメールアドレス。ユーザーが認証時に非公開にすることができ、その場合は`[ランダム文字列]@privaterelay.appleid.com`という形式のメールアドレスが生成され、ここに送信するとユーザーの本来のメールアドレスに転送される。これも**最初に認証したときのみ**値が入る。
* `state`  
signInAsyncのオプションとして設定できる任意の文字列。独自のリプレイ攻撃対策などに使用。
* `identityToken`  
ユーザー&アプリごとに生成されるトークン。FirebaseのOAuthProviderにはこれを渡す。
* `authorizationCode`  
セッションごとに生成されるトークン。

## 画面で確認

認証が完了するとこのように表示されます。
<img width="375" alt="PNGイメージ 44.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/09d7aaf6-f63e-7d3b-c9a9-ff3d83679039.png">
ボタンのデザインは`AppleAuthentication.AppleAuthenticationButton`コンポーネントの`buttonStyle`プロパティで`WHITE`（上画像）、`BLACK`（黒背景）、`WHITE_OUTLINE`(白背景に黒枠)から選択することもできます。このコンポーネント自体は単なるビューなので、[デザインガイド](https://developer.apple.com/design/human-interface-guidelines/sign-in-with-apple/overview/buttons/)に従えば独自実装で他のボタンと合わせたりしても問題ないはずです。

## [2020/3/30 追記] スタンドアロンアプリで確認（TestFlight）

スタンドアロンアプリとしてビルドしてTestFlightを使って確認する過程で気づきましたが、Expo Clientでの確認するだけなら上記のApple DeveloperでのIdentifierに関する設定は必要ありません。
スタンドアロンアプリとしてビルドする際には上述した設定をした上で、下記のようにプロビジョニングプロファイルや証明書を新しくするオプションを付けてビルドしてください。

```console
$ expo build:ios --clear-provisioning-profile --revoke-credentials
```

このオプションをつけてビルドするのは一回のみでOKです。
<img width="375" alt="PNGイメージ_46_.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0d41cb25-554c-6639-64b5-35a5c435366a.png">
このようにスタンドアロンアプリとしても問題なくモーダルが表示され、ログインできました。

下記のようなエラーが出る場合は、プロビジョニングプロファイルが古いものになっていたり、app.jsonの`expo.ios.usesAppleSignIn`が設定されていなかったりするようです。

```
com.apple.authenticationservices.authorizationerror error 1000
```

## Androidで見ると

AndroidではAppleAuthenticationモジュール自体が対応していないので、`AppleAuthentication.isAvailableAsync`の判定によりボタンを表示しないようにしました。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/0357c22c-53fd-360f-a1de-9648c25893f1.png" width="320">

iOS 12以下でも同様になります。
ちなみにiOSのレビューガイドラインに準拠するという意味ではiOSのみ対応していれば問題ないはずですが、下記のドキュメントがあるので、AndroidでもネイティブではFirebaseと組み合わせて実装できるようです。

**Android で Apple を使用して認証する**
https://firebase.google.com/docs/auth/android/apple

## 認証をリセットしたい場合

認証時の名前やメールアドレスなどの情報は同一アプリで最初にサインインした時にしか取得できないため、リセットしたい場合もあると思います。
その場合は、iOSの「設定」→「パスワードとアカウント」→「iCloud」→「パスワードとセキュリティ」→「Apple IDを使用中のApp」（使用中のアプリがない場合はメニュー自体表示されない）→アプリを選択→「Apple IDの使用を停止する」でアプリに対するApple IDの紐付けをリセットすることができます。

## その他

ユーザーがメールアドレスを非公開にした場合のために、Firebaseから`privaterelay.appleid.com`ドメインを通してメールを転送できるように[プライベートEメールリレーサービス](https://help.apple.com/developer-account/#/devf822fb8fc)の設定が必要です。
しかし、AppleAuthenticationによって作成されたfirebase.auth.Userでは最初から`emailVerified`は`true`になっているため、お手頃にメールアドレス確認メールを送ってもらって検証したりができませんでした。

## [2020/3/30 追記] プライベートEメールリレーサービスを確認

プライベートEメールリレーサービスによるメール転送についてもFirebaseを経由せずとも通常のメールアドレスで確認できました。
メールアドレスを非公開にした場合に生成されたメールアドレスにGmailから送ってみると、転送先に設定したメールアドレスに届きます。
![gmail.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/b54cfd6f-886f-3ad4-c275-ff2d2390f962.png)

メールの送信元はApple Developerユーザーごとにホワイトリストで設定する必要があります。（最大で個人３２個・組織１００個）
![Certificates__Identifiers___Profiles_-_Apple_Developer.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/514229/89051903-fd07-d88e-9237-9fa0fdb7540a.png)
https://developer.apple.com/account/resources/services/configure
Certificates, Identifiers & Profiles > More > Configure

※ちなみに、Expo Clientでの検証時には当然ながらExpo Clientのプロビジョニングファイルが使用されるので、開発者のアカウントでこれをいくら設定しても反映されません。検証にはスタンドアロンアプリとしてビルドする必要があります。