🔐

iOSアプリを譲渡するときにFirebase Authのユーザーを引き継ぐ🔑

2020/09/23に公開

はじめに

アプリを他のデベロッパーに譲渡すると、譲渡以降のアプリアップデートでKeychainが読み取れなくなります。Firebase Authを使って認証機能を提供している場合、全ユーザーがログアウトされてしまいます。この事象を発生させない対策をまとめました。

対策フロー

  1. ログインされているユーザーで、KeychainからFirebase Authの認証情報をUserDefaultsにコピーしておく
  2. アプリ起動時に「Keychainに認証情報がない かつ UserDefaultsに認証情報がある」場合はKeychainへ認証情報を書き戻す

たったこれだけですが、1はアプリ譲渡前に行う必要があります。Firebase Authに限らず、Keychainの情報を引き継ぎたい場合はこの流れでの対策が必要になるでしょう。ID Tokenなどあまり漏れてほしくない情報などは暗号化してUserDefaultsに保存しておくことをオススメします。

前提

  • Firebase/Auth: 6.24.0
  • iOS 13.6
  • KeychainSharingは使用していない(useUserAccessGroupは使っていない)

Keychainの操作は複雑であるため、私が愛用しているKeychainAccessというライブラリを使用している前提で話を進めます。

実装したコード

UserDefaultDaoというのが出てきますが、UserDefaultsのラッパーなのであまり気にしないでください。ポイントは以下です。

  • ログイン時やアプリ起動時に、Keychainにある認証情報をUserDefaultsに保存する
  • ログアウトしたときにはUserDefaultsの認証情報は削除する
  • FirebaseAppNameやGoogleAppIDをFirebaseの初期化前にmigrateAuthDataIfNeedで使用したいので今回はUserDefaultsにキャッシュしている
import Foundation
import KeychainAccess
import Firebase
import FirebaseAuth

class MigrateKeychainService {
    private let firebaseAppName: String
    private let googleAppID: String

    init(firebaseAppName: String, googleAppID: String) {
        self.firebaseAppName = firebaseAppName
        self.googleAppID = googleAppID
    }

    private lazy var keychain = Keychain(service: "firebase_auth_\(googleAppID)")
    private let prefix = "firebase_auth_1_"
    private var userKey: String { "\(firebaseAppName)_firebase_user" }
    private var authKey: String { "\(prefix)\(userKey)" }

    private let userDefaultDao = UserDefaultDao()

    private func getAuthDataFromKeychain(_ key: String) -> Data? {
        do {
            return try keychain.getData(key)
        } catch {
            // TODO: handling
            return nil
        }
    }

    private func setAuthDataToKeychain(_ data: Data) {
        do {
            try keychain.set(data, key: authKey)
        } catch {
            // TODO: handling
        }
    }

    private func migrateAuthDataIfNeed() {
        // keychainに認証情報がない かつ UserDefaultsには認証情報がある
        if getAuthDataFromKeychain(authKey) == nil, let authData = userDefaultDao.authMigrationData {
            setAuthDataToKeychain(authData)
        }
    }

    /// Firebaseの初期化前に呼び出す
    static func migrateAuthDataIfNeed() {
        let userDefault = UserDefaultDao()
        guard let appName = userDefault.authMigrationFirebaseAppName, let googleID = userDefault.authMigrationGoogleID else { return }

        let service = MigrateKeychainService(firebaseAppName: appName, googleAppID: googleID)
        service.migrateAuthDataIfNeed()
    }

    /// UserDefaultsに退避している認証情報を更新する
    func updateAuthData() {
        guard let data = getAuthDataFromKeychain(authKey) else {
            return
        }

        userDefaultDao.updateAuthMigrationData(data)
        userDefaultDao.updateAuthMigrationFirebaseAppName(firebaseAppName)
        userDefaultDao.updateAuthMigrationGoogleAppID(googleAppID)
    }

    /// UserDefaultsに退避している認証情報を削除する(ログアウト時)
    func deleteAuth() {
        userDefaultDao.updateAuthMigrationData(nil)
        userDefaultDao.updateAuthMigrationFirebaseAppName(nil)
        userDefaultDao.updateAuthMigrationGoogleAppID(nil)
    }
}

※ このコードをご利用になる場合は、必ず動作確認を行なってください。何か問題が発生した場合に責任は一切負いません。

いちばん大事なこと

このマイグレーションの処理を含んだアプリバージョンを譲渡前にリリースし、そのバージョンのアプリを起動させ、UserDefaultsへ保存する処理をユーザーに実行してもらう必要があります。譲渡までに起動して貰う必要があるので、適宜プッシュ通知を飛ばすなどするとよいかもしれません。

--- 以下、詳しく知りたい方向けです。

Firebase Authの認証情報はどこにあるのか

FirebaseAuthはOSSとして公開されています。こちらを読み解けばどういう形で認証情報がKeychainに保持されているかわかるでしょう。
https://github.com/firebase/firebase-ios-sdk/tree/master/FirebaseAuth
(私はObjective-Cを書いたことがなく、正確に読めるわけではないこと、全てを読んだわけではないことにご留意ください。間違っていたら教えていただけると助かります。)

まずFirebaseAuthが使用しているKeychainのservice名はこちらにあります。
https://github.com/firebase/firebase-ios-sdk/blob/4356c91e068b1eafa7027c6be8dcd1e9ee8a452c/FirebaseAuth/Sources/Auth/FIRAuth.m#L1756

このように取得することができます。

let serviceName = "firebase_auth_\(FirebaseApp.app()!.options.googleAppID)"

肝心の認証情報がどこにあるのかを探り当てるのは少し大変なので、このサービスに保存されている情報をすべて書き出してみましょう。ログイン状態でアプリ起動時にこの処理を走らせてみてください。

import KeychainAccess

let keychain = Keychain(service: serviceName)
print(keychain.allItems()) // [[String: Any]] が出力される

するとこのような情報が出力されるでしょう。

[[
  "value": 2809 bytes,
  "accessibility": "AfterFirstUnlockThisDeviceOnly",
  "accessGroup": "TEAM_ID.BUNDLE_ID",
  "key": "firebase_auth_1___FIRAPP_DEFAULT_firebase_user",
  "service": "firebase_auth_GOOGLE_APP_ID",
  "class": "GenericPassword",
  "synchronizable": "false"
]]

ここから分かる情報はこちらです。

  • keyはfirebase_auth_1___FIRAPP_DEFAULT_firebase_user
  • valueはbyte数が表示されているのでおそらくData型
  • このkeyでしか情報は保存されていない模様

keyはどのように組み立てられているのかを見てみます。

  • firebase_auth_1_ というのはprefixのようでこちらに定義されています。1という数字は、今後Schemaの情報が変わったときのバージョニングとして使用するとコメントに書かれていました。よく考えられていてすごいです。
  • __FIRAPP_DEFAULTFirebaseApp.app()?.nameで取得することができます。複数の環境に接続できるように、appには初期化時に名前をつけることができるのですが、デフォルトはこの文字列になっています。
  • _firebase_userこちらで宣言されていて、例えばこのあたりで使用されていて、firebaseAppNameとともに利用されていることがわかります。

すなわち今回のバージョンでは**firebase_auth_1_\(FirebaseAppName)_firebase_userがkeyとなることがわかりました。ここにおそらくData型として何らかの認証情報が保存されています。この情報を取得してUserDefaultsに保存しておけばよい**ことがわかりました。

let data = try? keychain.getData("firebase_auth_1___FIRAPP_DEFAULT_firebase_user")
// TODO: save data to UserDefaults

たったこれだけで取り出せます。エラーハンドリングは各自のロジックにあった形で実装しましょう。

Keychainからいつ取り出しているのか

Keychainから認証情報を取り出して使用していそうなメソッドはおそらくこちらだと思います。コメントにもRetrieves the saved user associated, if one exists, from the keychain.と書いてあります。ここでは、Keychainからデータを取得して、Swiftで言うFirebase.User型にデコードしていることがわかります。そして、このメソッドは初期化処理に該当するであろう部分で呼び出されています(こちら)。この位置にブレークポイントを張って実行すると、Firebase.configure()メソッド内で呼び出されることがわかりました。

よって、UserDefaultsへ退避していた認証情報をKeychainへ書き戻すタイミングは、Firebaseの初期化前でなければなりません。このタイミングで、Keychainに認証情報が無い場合に、認証情報をKeychainへ書き戻しておけば、ログイン状態が引き継がれるということです。

Discussion