Firebase Auth の力を 120% 引き出すためのハック集

22942 ワード

Ubie Discovery の です。

症状検索エンジン「ユビー」では Firebase Auth (GCP Identity Platform) をヘビーに使っています。その中で一部 Firebase Auth の想定を超えた使い方をしていて、それを実現するための無理矢理なハックを紹介します。

Capacitor 上で OAuth を動かす

Capacitor (=WebView) 上で Web ブラウザと同じように OAuth をやろうとすると、以下のような問題に直面します。

  1. Google などの認証プロバイダは WebView 内でのアクセスを弾く (参考)
  2. 認証プロバイダからのコールバックが端末のデフォルトブラウザで開かれてしまい、ネイティブアプリに戻ってこれない

Capacitor の類似技術である Cordova でも同様の問題がありますが、Firebase JS SDK は Cordova をサポートしています。これがどう実現されているかというと、Cordova はグローバルの window にネイティブ機能を操作する関数を生やしていて、Firebase JS SDK がそれを叩くことで、以下のような解決策を実装しています。

  1. 認証プロバイダを WebView 内ではなく In-App Browser で開く
  2. コールバックを Dynamic Link にすることでネイティブアプリに戻ってくる

Firebase JS SDK が期待する window のインターフェースはここで定義されています。

https://github.com/firebase/firebase-js-sdk/blob/2820674b848e918ab164e7d0ec9d5b838bbfa6e0/packages/auth/src/platform_cordova/plugins.ts#L18-L46

これを参考に、同じインターフェースで Capacitor を呼び出す関数群を window に注入することで、Cordova サポートを無理矢理 Capacitor に応用することができます。

import { App } from "@capacitor/app";
import { Browser } from "@capacitor/browser";

export async function initCapacitorWindow(): Promise<void> {
  const appInfo = await App.getInfo();

  window.BuildInfo = {
    packageName: appInfo.id,
    displayName: appInfo.name,
  };

  window.universalLinks = {
    subscribe: (
      _: null,
      cb: (event: Record<string, string> | null) => void
    ) => {
      App.addListener("appUrlOpen", (data) => {
        cb({ url: data.url });
      });
    },
  };

  window.cordova.plugins = window.cordova.plugins || {};
  window.cordova.plugins.browsertab = {
    openUrl: (url: string) => {
      Browser.open({ url });
    },
    close: () => {
      Browser.close();
    },
    isAvailable(cb: (available: boolean) => void) {
      cb(true);
    },
  };

  window.cordova.InAppBrowser = {
    open() {
      // `window.cordova.plugins.browsertab` が優先されるため、これは呼ばれない
      throw new Error("Unexpected window.cordova.InAppBrowser call");
    },
  };
}

とってもメンテが大変そうですね。

OAuth のコールバック先を動的に設定する

signInWithRedirectlinkWithRedirect でリダイレクト経由の認証をするとき、コールバックを明示的に指定することはできず、signInWithRedirectlinkWithRedirect を呼んだ時のページにコールバックします。各認証プロバイダに渡す redirect_uri は Firebase が提供するコールバックページになり、それを挟んで戻ってくるからです。

コールバック先を動的に設定するためには、Firebase からのコールバック専用のプロキシ的なページを作るとよいです。以下は Next.js での例です。

auth-redirect.tsx
import { NextPage } from 'next';
import { useEffect } from 'react';
import { useRouter } from 'next/router';
import * as firebaseAuth from 'firebase/auth';

const AuthRedirectPage: NextPage = () => {
  const router = useRouter();

  useEffect(() => {
    if (!router.isReady) {
      return;
    }

    (async () => {
      const result = await firebaseAuth.getRedirectResult(firebaseAuth.getAuth());
      if (result == null) {
        // result がない時は認証前
	// `auth/redirect-cancelled-by-user` 等のエラー検証が必要だが、ここでは省略
        await firebaseAuth.signInWithRedirect(firebaseAuth.getAuth(), new firebaseAuth.GoogleAuthProvider());
      } else {
        // result がある時は認証済み
        // オープンリダイレクタ等を回避するために検証が必要だが、ここでは省略
        const redirectUri = router.query['redirect_uri'] as string | undefined;
        router.push(redirectUri || '/');
      }
    })();
  }, [router.isReady]);

  return <Loading />;
};

export default AuthRedirectPage;

以下のようなフローで利用します。

  1. ログインページから /auth-redirect?redirect_uri=<ログイン後に遷移するURL> に遷移
  2. getRedirectResult が null になり、signInWithRedirect で認証プロバイダに飛ぶ
  3. 認証プロバイダから Firebase を通して /auth-redirect?redirect_uri=<ログイン後に遷移するURL> にコールバック
  4. getRedirectResult が non-null になり、redirect_uri に遷移

OAuth で初回ログイン時のみリンクする

Firebase Auth では、現在ログインしているアカウントに別の認証プロバイダをリンクすることができます。

ユビーではユーザー登録していないユーザーも Firebase 上で匿名ユーザーとして扱っていて、ユーザー登録時にリンクすることで、登録前のデータを保持するようにしています。

このとき、Email/Password 認証では createUserWithEmailAndPassword の代わりに linkWithCredential を用いて、既存の匿名ユーザーにリンクすることができます。しかし、OAuth においては新規登録とログインが区別されないので、linkWithRedirect をするか signInWithRedirect をするか決定できません。

そこで、まず linkWithRedirect を試みて、それが失敗した時に signInWithCredential にフォールバックします。これにより、「初回ログイン時はリンクし、それ以降はログイン」という挙動を達成できます。

import { FirebaseError } from "firebase/app";
import * as firebaseAuth from "firebase/auth";

try {
  const currentUser = firebaseAuth.getAuth().currentUser;
  const provider = new firebaseAuth.GoogleAuthProvider();
  await firebaseAuth.linkWithRedirect(currentUser, provider);
} catch (e: unknown) {
  if (
    e instanceof FirebaseError &&
    e.code === "auth/credential-already-in-use"
  ) {
    // 既に使われている認証情報(=リンク済み)の時はそれを使ってログイン
    const credential = firebaseAuth.OAuthProvider.credentialFromError(e);
    if (!credential) {
      throw new Error("no credential");
    }
    await firebaseAuth.signInWithCredential(firebaseAuth.getAuth(), credential);
  } else {
    throw e;
  }
}

結論

120% 引き出すとめっちゃ大変でいつ壊れるかわからんので、できるだけ引き出さないほうがいいですね。Ubie では認証基盤の内製を進めています。