Chrome拡張アプリを作っていて、ユーザのデータをユーザアカウント(email)単位にCloud FireStoreに保存したいと思っています。ユーザがアクセスできる情報をセキュリティルールを設定して制限したいので、Firebase AuthenticationでGoogle認証を行うことにしました。紆余曲折あり、いくつかの方法を試したので記録しておきます。

なお、単にGoogle認証したいだけなら google-api-javascript-client を使うのが一般的かもしれません。

【不採用】Firebase Authentication in ポップアップページ

公式ページで紹介されている方法を試してみました。参考にしたのは以下の二つのページです。

Firebase側の設定

  1. Firebase プロジェクト作成
    • こちらから作成します。とりあえず、Google AnalyticsはOFFにしました。
  2. アプリを登録
    • Firebaseコンソールのプロジェクトの概要ページのWEBアイコンからアプリを登録します。Hostingは今回利用しないのでOFFにしました。
  3. Google ログインを有効化
    • Firebaseコンソールの Authentication -> Sign-in method で Google を有効にします
  4. Chrome拡張を承認済みドメインに追加
    • こちらに書いてある通り、Authentication -> Sign-in method -> 承認済みドメイン にchrome-extension://CHROME_EXTENSION_IDの形式でドメインを追加します
    • CHROME_EXTENSION_IDはChrome拡張の管理ページで確認できます

FirebaseのSDKをインストール

npm install firebase

manifest.jsonの設定

さらに、 https://apis.google.com/ 上のスクリプトを利用できるようにするため、manifest.jsoncontent_security_policyに以下のように追加します。

"content_security_policy": "script-src 'self' https://apis.google.com/; object-src 'self'",

Firebaseの初期化および認証処理

最後に、ポップアップページのスクリプトで、Firebaseを初期化して認証処理を記載します。firebaseConfigの内容は、Firebaseコンソール > プロジェクトの概要 -> 対象のアプリ のSetting画面からコピペしてきます。ソースコード上にapiKeyが露出していることに不安を感じますが、こちらを参照すると大丈夫なようです。

import * as firebase from "firebase/app";
import "firebase/auth";

// Firebaseの初期化
const firebaseConfig = {
  apiKey: "xxxxx",
  authDomain: "xxxxx.firebaseapp.com",
  databaseURL: "https://xxxxx.firebaseio.com",
  projectId: "xxxxx",
  storageBucket: "xxxxx.appspot.com",
  messagingSenderId: "xxxxx",
  appId: "xxxxx"
};
firebase.initializeApp(firebaseConfig);

// 認証処理
const provider = new firebase.auth.GoogleAuthProvider()
provider.addScope('https://www.googleapis.com/auth/userinfo.email');
firebase.auth().useDeviceLanguage()
firebase.auth().signInWithPopup(provider).then(function(result) {
    console.log('userInfo: ' + JSON.stringify(result.user))
}).catch(function(error) {
    console.log(error)
});

typescriptの場合も手順は特に変わりません。型を書いたり、アロー関数にしたりするだけです。

実行結果

ここまで実装したらChrome拡張のポップアップページを開くと、Google認証画面がさらにポップアップされ、user情報を取得することができるようになります。出力結果は以下のような感じです。

userInfo: {"uid":"xxxxx","displayName":"xxxxx","photoURL":"xxxxx","email":"xxxxx@gmail.com","emailVerified":true,"phoneNumber":xxxxx,...省略...}

一見良さそうなのですが、Google認証画面がWindowの裏側にポップアップされて気づかなかったり、なんどもChrome拡張のアイコンをクリックすると、いくつもGoogle認証画面が開かれたり、裏側に画面が残ってしまったりと、UXが最低です。。

【不採用】Firebase Authentication in オプションページ

世の中のChrome拡張を色々試していると、オプションページでGoogle認証を行なっているアプリを発見しました。そんなにUXも悪くないので、やってみました。

Firebase側の設定、SDKインストール、manifest.jsonの設定

Firebase Authentication in ポップアップページと全く同一です。

Firebaseの初期化および認証処理

オプションページのスクリプトにオプションページと全く同じコードを組み込んでみました。
すると、新しいblankのタブが立ちあがり、アドレスバーにabout:blank#blockedと表示されます。何かよく分からないけどポップアップブロックされたようです。

manifest.jsonにopen_in_tabを追加

色々試したところ、manifest.json"open_in_tab": trueを指定して、オプションページ自体を新しいタブで表示するようにすると回避できました。

  "options_ui": {
    "page": "options/options.html",
    "open_in_tab": true, // これを追加
    "chrome_style": true
  },

実行結果

オプションページを開くと、認証画面がポップアップ表示され、認証を行うことができした。別タブで開いていたせいか、ポップアップ画面が裏に隠れるようなこともあまりないので、UXもそれほど悪くないと感じます。
ただ、、、一つ大きな問題があります。
open_in_tabというオプションは非推奨で、いずれ削除されてしまう予定なのです。後々苦労しそうなので、別の方法を探すことにしました。

【採用】chrome.identity API と Firebase Authentication のハイブリッド

さらに探していると、
Firebase Auth w/ Google Sign-In in Chrome Extensions
という素晴らしいページを発見しました。

ここでは、Chrome拡張をインストールしたGoogleアカウントのaccess tokenをchrome.identity APIを使って取得して、access tokenを元にGoogle OAuth2認証を行うという順番で処理をしています。

Firebase側の設定、SDKのインストール

まず、Firebase Authentication in ポップアップページに書いてある内容と同様に、Firebase側の設定とFirebase SDKのインストールを行います。

OAuth クライアントIDの作成

次に、こちらから、認証情報を作成 -> OAuth クライアントID -> Chrome アプリ -> アプリケーションIDにCHROME_EXTENSION_IDを指定 を行い、OAuth クライアントIDを作成します。

manifest.jsonの設定

さらに、manifest.jsonに以下のように記述します。client_idには先ほど作成したOAuth クライアントIDを指定しscopesuserinfo.emailを指定します。その他のスコープはこちらを参照ください。

  "permissions": [
    "identity"
  ],
  "oauth2": {
    "client_id": "xxxxxxxxxx.apps.googleusercontent.com",
    "scopes": ["https://www.googleapis.com/auth/userinfo.email"]
  }

Firebaseの初期化および認証処理

あとは、認証処理を実装します。ポップアップページのスクリプトにFirebaseの初期化および認証処理を追加します。実装は、こちらをほぼそのまま使っています。

import * as firebase from "firebase/app";
import "firebase/auth";

// Firebaseの初期化
const firebaseConfig = {
  apiKey: "xxxxx",
  authDomain: "xxxxx.firebaseapp.com",
  databaseURL: "https://xxxxx.firebaseio.com",
  projectId: "xxxxx",
  storageBucket: "xxxxx.appspot.com",
  messagingSenderId: "xxxxx",
  appId: "xxxxx"
};
firebase.initializeApp(firebaseConfig);

// 認証処理
function startAuth(interactive: any) {
  chrome.identity.getAuthToken({interactive: !!interactive}, (token: string) => {
    if (chrome.runtime.lastError && !interactive) {
      console.log('It was not possible to get a token programmatically.');
    } else if(chrome.runtime.lastError) {
      console.error(chrome.runtime.lastError);
    } else if (token) {
      const credential = firebase.auth.GoogleAuthProvider.credential(null, token);
      firebase.auth().signInWithCredential(credential)
      .then(function(result) {
        console.log('userinfo: ' + JSON.stringify(result.user))
      })
      .catch((error: any) => {
        if (error.code === 'auth/invalid-credential') {
          chrome.identity.removeCachedAuthToken({token: token}, function() {
            startAuth(interactive);
          });
        }
      });
    } else {
      console.error('The OAuth Token was null');
    }
  });
}

startAuth(true);

普通はFirebaseの認証状態が変更されたことを検知して再度認証したい場合は、以下を追加します。userオブジェクトを取得できなかったら、再度startAuth()を呼び出しています。

firebase.auth().onAuthStateChanged(function(user) {
  if (user) {
    console.log('userinfo: ' + JSON.stringify(user));
  } else {
    startAuth(true);
  }
});

typescriptでも記法は違えど同様に実装すれば大丈夫です。

実行結果

実際の動きは以下のようになります。

Chrome拡張のアイコンをクリックした際に、ログインしてない状態だとchrome.identity.getAuthToken()interactive: trueを指定しているため、別タブでGoogleの認証画面が表示されます。

ログイン後、もう一度Chrome拡張のアイコンをクリックすると、今度はauth_tokenを取得できて、FirebaseのAuthenticationのSignInもうまくいって、user情報を取得できます。

userInfo: {"uid":"xxxxx","displayName":"xxxxx","photoURL":"xxxxx","email":"xxxxx@gmail.com","emailVerified":true,"phoneNumber":xxxxx,...省略...}

ちなみに、認証済みの場合にuser情報を取得するまでのリードタイムも、firebase.auth().signInWithPopupに比べるとかなり早く体感で0.5秒ぐらいという感じなので、user情報をどこかにキープしておくような工夫がいらないのも、とても助かります。

【参考】chrome.identity API

Firebaseとか関係なく、Chrome拡張をインストールしたGoogleアカウントに紐づくemailが欲しいだけなら、chrome.identity APIを使う方法がもっともシンプルだと思います。このAPIは、OAuth2のaccess tokenを取得するのが主目的のようなのですが、access tokenを取得する以外に、getProfileUserInfo()を使ってemailidを取得することができます。

manifest.jsonの設定

manifest.jsonに以下のpermissionを追加します。

  "permissions": [
    "identity",  // chrome.identity APIを使う権限
    "identity.email"  // emaiを取得する権限
  ],

なお、指定できるpermissionの種類はこちらを参照いただければと思います。

必要な権限が追加されたことは、Chrome拡張の詳細ページで確認できます。

ユーザ情報取得処理

あとはオプションページやポップアップページでAPIを呼び出します。

javascriptだとこれだけです。

chrome.identity.getProfileUserInfo(function(userInfo) {
  console.log('userInfo: ' + JSON.stringify(userInfo));
});

typescriptだと、型を解決するために少し記述が増えます。

型定義を追加して、

npm install @types/chrome

tsconfig.jsonに以下を追記します。

    "types": [
      "chrome"
    ],

あとは、呼び出すだけです。

chrome.identity.getProfileUserInfo((userInfo: chrome.identity.UserInfo) => {
    console.log('userInfo: ' + JSON.stringify(userInfo))
})

実行結果

出力結果はこんな感じです

userInfo: {"email":"xxxx@gmail.com","id":"xxxxxxxxxxxxxxxxx"}