Electron アプリで Firebase Auth を実装し、AppStore のリジェクトを乗り越えた話

Web アプリのデスクトップ版を Electron を使って開発していました。この Web アプリは Firebase Auth を使ったログイン機能を持つので、デスクトップ版でも同等の機能を実装したかったのですが、技術的な制約があり色々と難しかったです。そしていざ実現しても AppStore に申請すると何度もリジェクトされてしまいました。今回はどのように実装したか、どうリジェクト対策したかについて共有します。

結論

  • Web アプリ側にデスクトップ版用のログイン画面を用意する
  • ログイン時に外部ブラウザを開くとリジェクトされるので、Electron の BrowserWindow で開く

Firebase Auth の制限

そのまま Electron でも動作するかと思っていましたが、ローカルファイルの HTML から Firebase Auth での認証ができない問題がありました。Firebase Auth は指定したドメインからのみ利用できますが、ローカルファイルは対象外となります。サーバ上に配置した HTML をロードするように変更すれば解決することもできるのですが、本アプリはデスクトップ版のメリットとしてオフライン動作を実現したかったのでこの選択はしたくありませんでした。

実装方法1

Firebase Auth のポップアップを表示できるようにしました。しかしこれは前述したようにローカルファイルからは認証ができないため動作しません。

mainWindow.webContents.setWindowOpenHandler(({ url }) => {
    if (
      url.startsWith("https://<project id>.firebaseapp.com/__/auth/")
    ) {
      return { action: "allow" }
    }
})

実装方法2

Web 側にデスクトップ版用のログイン画面を用意し、リダイレクトで認証情報を受け取るように実装しました。今思うとなぜこんなにややこしいことをしたのか思い出せませんが、やっているときは賢いと思っていました。

  • Electron 側ではログイン画面をデフォルトブラウザで開きます
  • Web 側ではリダイレクト URL を受け取り、認証成功時に認証情報をクエリパラメータとして付加してリダイレクト URL へ遷移します。
  • Electron 側では Express でサーバを立ち上げ、これをリダイレクト URL とします
  • Express のリクエストで認証情報を受け取ったらレンダラープロセスに ipcMain で送信します
  • レンダラープロセスでは Firebase Auth の signInWithCredential を使ってサインインを行います

リジェクト

デフォルトブラウザを開いている動作が問題でリジェクトされました。

Guideline 4.0 - Design

The user is taken to the default web browser to sign in or register for an account, which provides a poor user experience.

Next Steps

It is acceptable to take users to the default web browser for some sign in or account registration options if ASWebAuthenticationSession is used for the session. If the app already uses this instance, reply to App Review in App Store Connect and confirm the app uses ASWebAuthenticationSession.

Resources

- Learn more about design requirements in guideline 4.
- Note that apps that support account creation must also offer account deletion, per App Review Guideline 5.1.1(v). Learn more about offering account deletion in your app.

リジェクト対策

同じようなリジェクトで調べていたところ、デフォルトブラウザを使って認証を行っているライブラリが ASWebAuthenticationSession を使っているとちゃんとアピールすれば通るという旨の情報を見つけました。https://github.com/google/GoogleSignIn-iOS/issues/388#issuecomment-2079207560

私の実装では最初は ASWebAuthenticationSession を使っていませんでしたが、確かにレビューでも ASWebAuthenticationSession について言及していますし、ASWebAuthenticationSession のドキュメントを読む限り、Web を使った認証ではこれを使ってやるのが筋が良さそうです。(結論から言うとダメでした)

実装方法3: ASWebAuthenticationSession を使う

Node.js ではネイティブコードを使ったパッケージを作ることができるのですが、Electron に含めると色々面倒そうだったので、独立したコマンドラインプログラムとして ASWebAuthenticationSession を使った認証処理を実装しました。

import Foundation
import AuthenticationServices

let url = URL(string: CommandLine.arguments[1])!
let callbackURLScheme = CommandLine.arguments[2]

class AuthSessionDelegate: NSObject, ASWebAuthenticationPresentationContextProviding {
    func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
        return ASPresentationAnchor()
    }
}

let delegate = AuthSessionDelegate()

let session = ASWebAuthenticationSession(url: url, callbackURLScheme: callbackURLScheme) { callbackURL, error in
    if let error = error {
        print("Error: \(error.localizedDescription)")
        exit(1)
    }
    if let callbackURL = callbackURL {
        print(callbackURL.absoluteString)
        exit(0)
    }
}

session.presentationContextProvider = delegate
session.start()

RunLoop.current.run()

このコードで作ったプログラムをビルドし、バイナリを Electron のリソースに含め、メインプロセスから起動します。動作は直接デフォルトブラウザを開くものと大差ありませんが、ASWebAuthenticationSession が生成するブラウザのウィンドウが使われます。

リジェクト2

同じ内容でリジェクトされました。そこで、App Review Board に異議申し立てを行うことにしました。Apple のドキュメントの推奨に従い、ASWebAuthenticationSession を通じて認証を行っており、デフォルトブラウザが開かれる挙動はフレームワークにより実装されているという旨のメッセージを送りました。

リジェクト3

ダメでした。

The App Review Board determined that the original rejection feedback was valid. Your app does not comply with:

Guideline 4.0 - Design

The user is taken to the default web browser to sign in or register for an account, which provides a poor user experience.

Next Steps

Authentication with Sign in with Apple should always be completed without leaving the app or extension. Learn more about implementing Sign in with Apple. 

実装方法4: Electron の BrowserWindow で開く

Web 側はそのままで、外部ブラウザではなく Electron のメインプロセスで new BrowserWindow(...) により該当の URL を開きます。また、認証情報はローカルサーバで待ち受けなくとも will-navigate で遷移をフックして取得することができました。

export const signInWithBrowser = async (url: string, callbackUrl: string): Promise<FirebaseCredential> => {
  return new Promise((resolve, reject) => {
    const window = new BrowserWindow({
      width: 800,
      height: 600,
      webPreferences: {
        nodeIntegration: false,
        contextIsolation: true,
        sandbox: false,
      },
    })
    window.loadURL(url)

    window.webContents.on("will-navigate", (event, url) => {
      if (url.startsWith(callbackUrl)) {
        window.close()

        // get ID token from the URL
        const urlObj = new URL(url)
        const credential = urlObj.searchParams.get("credential")

        if (credential === null) {
          reject(new Error("ID Token is missing"))
          return
        }

        resolve(JSON.parse(credential))
      }
    })
  })
}

おわりに

アプリ内のブラウザで開くように修正したことで、レビューも通過し無事にログイン機能をもったデスクトップ版を作ることができました。紆余曲折ありましたが、結果的に実装も比較的シンプルになりました。リダイレクト先をスキーマを使ったディープリンクでトークンを渡すようにするとかも色々やっていたんですが、不要でした。

付録

認証画面

https://github.com/firebase/firebaseui-web-react を使っています。(古くて動かない部分があるのでフォークしたコードを使っています)

認証成功時に呼ばれる signInSuccessWithAuthResult で入ってくる credential を JSON 文字列にシリアライズしてクエリパラメータに付加してリダイレクトを行います。任意の URL にリダイレクトできると怖いのでリダイレクト先の URL をチェックします。

<StyledFirebaseAuth
  uiConfig={{
    signInOptions: [
      GoogleAuthProvider.PROVIDER_ID,
      GithubAuthProvider.PROVIDER_ID,
      "apple.com",
    ],
    callbacks: {
      signInSuccessWithAuthResult: ({ credential }) => {
        const redirectUrl = new URLSearchParams(location.search).get(
          "redirect_uri",
        )
        if (
          redirectUrl &&
          (redirectUrl.startsWith("com.example.app://"))
        ) {
          const url =
            redirectUrl + "?credential=" + JSON.stringify(credential)

          try {
            location.assign(url)
            setIsSucceeded(true)
          } catch {
            alert("Failed to open the app. Please try again.")
          }
        }
        return false
      },
      signInFailure(error) {
        console.error(error)
        alert("Failed to sign in. Please try again.")
      },
    },
    signInFlow: "popup",
  }}
  firebaseAuth={auth}
/>

レンダラー側での認証処理

メインプロセスから IPC で onBrowserSignInCompleted へ認証情報を送ります。signInWithCredential には適切な Provider で生成した Credential を渡す必要があります (GithubAuthProvider.credential など)

type FirebaseCredential =
  | {
      providerId: "google.com"
      idToken: string
      accessToken: string
    }
  | {
      providerId: "github.com"
      accessToken: string
    }
  | {
      providerId: "apple.com"
      idToken: string
      accessToken: string
    }

async function onBrowserSignInCompleted(credentialJSON: FirebaseCredential) {
  const credential = createCredential(credentialJSON)
  try {
    await signInWithCredential(auth, credential)
  } catch (e) {
    toast.error("Failed to sign in")
  }
}

function createCredential(credential: FirebaseCredential) {
  switch (credential.providerId) {
    case "google.com":
      return GoogleAuthProvider.credential(
        credential.idToken,
        credential.accessToken,
      )
    case "github.com":
      return GithubAuthProvider.credential(credential.accessToken)
    case "apple.com":
      let provider = new OAuthProvider("apple.com")
      return provider.credential({
        idToken: credential.idToken,
        accessToken: credential.accessToken,
      })
    default:
      throw new Error("Invalid provider")
  }
}