はじめに
「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の編集を。
{
"expo": {
"ios": {
"bundleIdentifier": "com.xxx.xxxxxxxxxxxx",
"usesAppleSignIn": true
}
}
}
expo.ios.bundleIdentifier
にバンドル名を設定し、expo.ios.usesAppleSignIn: true
を追加します。
iOSのビルドをしていない場合はこの後ビルドすることで、Expoにお任せでIdentifierを作ってもらいます。
$ expo build:ios
ビルドが完了したら、Apple DeveloperページでIdentifiersの中から該当するものを選択し、「Sign In with Apple」のCapabilityを有効にします。
ここでオプションとして既存のAppIDとグループ化することができるようですが、普通に「Enable as a primary App ID」を選択しておきます。
続いてFirebaseプロジェクト側の設定を行います。
「Authentication」の「Sign-in method」タブで「Apple」を有効にし、サービスIDにアプリのbundleIdentifier
を入力します。プロジェクトにiOSアプリとして登録済みものがある場合はそのアプリのバンドルIDに対してデフォルトで許可が通りますが、ExpoアプリはFirebaseではWebアプリという扱いなので(iOSアプリとして登録していることはないと思います)、ここで入力が必要です。
参考記事にもありますが、Expo Clientで確認する際にはバンドルIDはhost.exp.Exponent
となるので、これをバンドルIDとするiOSアプリを追加しておくことで開発時にも認証が通るようになります。
これで事前の準備は完了です。
Sign in with Appleボタンの実装
簡単にApp.jsにまとめて、ボタンだけ設置してみます。
expo-apple-authentication
、expo-crypto
、firebase
をインストールしておいてください。
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',
},
});
機能自体がシンプルなので難しいことはないですが、ノンスの部分だけ理解が必要です。
AppleAuthentication.signInAsync
時にリプレイ攻撃を防ぐためのオプションとしてnonce
プロパティを渡すことができます。この時、ランダム文字列をSHA256でハッシュ化したものをApple側に送信し、Firebaseには元の文字列を送信することで、両者の照合によりセキュリティが向上するという仕組みです。
認証結果の内容
AppleAuthentication.signInAsync
によるPromiseはAppleAuthentication.AppleAuthenticationCredential
オブジェクトを返します。
このオブジェクトは下記のプロパティを含んでいます。
-
user
ユーザー固有の値。以後AppleAuthentication.getCredentialStateAsync
で認証状態をチェックする際に使用する。 -
fullName
ユーザーの名前に関する情報。requestedScopes
オプションでFULL_NAME
を設定している場合にそのアプリで最初に認証したときのみ値が入り、この内容を含む。 -
email
ユーザーのメールアドレス。ユーザーが認証時に非公開にすることができ、その場合は[ランダム文字列]@privaterelay.appleid.com
という形式のメールアドレスが生成され、ここに送信するとユーザーの本来のメールアドレスに転送される。これも最初に認証したときのみ値が入る。 -
state
signInAsyncのオプションとして設定できる任意の文字列。独自のリプレイ攻撃対策などに使用。 -
identityToken
ユーザー&アプリごとに生成されるトークン。FirebaseのOAuthProviderにはこれを渡す。 -
authorizationCode
セッションごとに生成されるトークン。
画面で確認
認証が完了するとこのように表示されます。
ボタンのデザインはAppleAuthentication.AppleAuthenticationButton
コンポーネントのbuttonStyle
プロパティでWHITE
(上画像)、BLACK
(黒背景)、WHITE_OUTLINE
(白背景に黒枠)から選択することもできます。このコンポーネント自体は単なるビューなので、デザインガイドに従えば独自実装で他のボタンと合わせたりしても問題ないはずです。
[2020/3/30 追記] スタンドアロンアプリで確認(TestFlight)
スタンドアロンアプリとしてビルドしてTestFlightを使って確認する過程で気づきましたが、Expo Clientでの確認するだけなら上記のApple DeveloperでのIdentifierに関する設定は必要ありません。
スタンドアロンアプリとしてビルドする際には上述した設定をした上で、下記のようにプロビジョニングプロファイルや証明書を新しくするオプションを付けてビルドしてください。
$ expo build:ios --clear-provisioning-profile --revoke-credentials
このオプションをつけてビルドするのは一回のみでOKです。
このようにスタンドアロンアプリとしても問題なくモーダルが表示され、ログインできました。
下記のようなエラーが出る場合は、プロビジョニングプロファイルが古いものになっていたり、app.jsonのexpo.ios.usesAppleSignIn
が設定されていなかったりするようです。
com.apple.authenticationservices.authorizationerror error 1000
Androidで見ると
AndroidではAppleAuthenticationモジュール自体が対応していないので、AppleAuthentication.isAvailableAsync
の判定によりボタンを表示しないようにしました。
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メールリレーサービスの設定が必要です。
しかし、AppleAuthenticationによって作成されたfirebase.auth.Userでは最初からemailVerified
はtrue
になっているため、お手頃にメールアドレス確認メールを送ってもらって検証したりができませんでした。
[2020/3/30 追記] プライベートEメールリレーサービスを確認
プライベートEメールリレーサービスによるメール転送についてもFirebaseを経由せずとも通常のメールアドレスで確認できました。
メールアドレスを非公開にした場合に生成されたメールアドレスにGmailから送ってみると、転送先に設定したメールアドレスに届きます。
メールの送信元はApple Developerユーザーごとにホワイトリストで設定する必要があります。(最大で個人32個・組織100個)
https://developer.apple.com/account/resources/services/configure
Certificates, Identifiers & Profiles > More > Configure
※ちなみに、Expo Clientでの検証時には当然ながらExpo Clientのプロビジョニングファイルが使用されるので、開発者のアカウントでこれをいくら設定しても反映されません。検証にはスタンドアロンアプリとしてビルドする必要があります。