📃

Chrome拡張+React+Firebaseで認証機能を実装する

2021/05/25に公開
2

経緯

今回、個人開発したBookmark-BoardというWEBアプリのChrome拡張機能を開発しました。
https://chrome.google.com/webstore/detail/bookmark-board/bgnimjpmdfcgchcddbmmadpgadjapicf?hl=ja
このChrome拡張ではFirebase Auth(Google認証)で認証を行っているのですが、その際に色々とつまづいた点があったため、忘備録として登録から公開までの一連の機能開発の手順を整理してみました。

前提

以下の条件を前提に進めます。

  • 既にFirebaseのプロジェクトを作成している
  • ReactおよびTypescriptを使用する

今回はサンプルとして最低限の認証機能を持ったChrome拡張アプリを作成します。

準備

Chrome Web Store developerへの登録

Chrome拡張を開発するためにはChrome Web Store developerに登録します。
以降、Developer Dashboardからアプリのアップロード等行うことになります。

Chrome App ID取得

Chrome拡張でFirebaseのAuth(Google認証)を使うためにはoauth2のclient_idとアプリの公開鍵が必要になります。(client_idはChromeにログイン中ユーザのGoogle認証情報をidentityを用いて取得したいために使用。keyはChrome拡張のIDを一意に固定するため取得するために使用。keyについての詳細はこちら

これらはChrome Web Storeにアップロードしてからでないと入手することができません(アップロードについては公開まで行う必要はなく、ドラフト状態で問題ありません)。
そのために、一度空っぽのアプリを登録しこれらの情報を作成しておきます。準備するものは以下のような一式となります。

ディレクトリ構成
chrome-plugin-sample(フォルダ)
|- manifest.json
manifest.json
{
  "manifest_version": 2,
  "name": "Firebase Auth in Chrome Extension Sample",
  "description": "This sample shows how to authorize Firebase in a Chrome extension using a Google account.",
  "version": "0.1",
  "permissions": [
    "identity"
  ]
}

これをzipしてDeveloper Dashboardにアップロードします

zip -r chrome-plugin-sample.zip chrome-plugin-sample

Tips: client_idとkey(公開鍵)の確認方法

Developer Dashboardから以降の手順で必要になるclient_idと公開鍵を確認することができます。

OAuth Clientの作成

拡張機能をFirebaseアプリに認証させるためにOAuth Client IDが必要となります。
コンソールにアクセスし、先ほどストアにアップロードしたアプリのclient_idを用いて、OAuth Client IDを作成します。

Firebaseプロジェクトの設定

拡張機能ではGoogle認証を用いるため、Firebaseアプリの認証方法としてGoogleによる認証を許可しておく必要があります。

  1. Firebaseコンソール→Authentication→Sign in methodに移動し、Google認証を有効にします
  2. 同様に、信頼済みドメインにchrome-extension://アプリケーションID(ストアにアップロードしたアプリのclient_id)を追加しておきます

アプリの開発

Chrome拡張でFirebaseの認証を行えるまでの実装をここから作っていきます(ここで作成するのは最低限認証機能を持っただけのアプリとなります)

React + Typescript + Chrome拡張の環境設定

Chrome拡張向けのReactではwebpackの設定をカスタマイズする必要があるため、Create-React-Appを使わずにReactの環境を整えていきます。今回はReact + Typescript + Chrome拡張のテンプレート環境を整えてくれるreact-typescript-chrome-extension-boilerplateというボイラープレートが公開されているので、こちらを使用させていただきます。

$ npx degit https://github.com/sivertschou/react-typescript-chrome-extension-boilerplate.git <project-name>
$ cd <project-name>

これによって次のようなディレクトリ構成が出来上がります

<project-name>

├─ dist (ビルドによって作成される)
│   ├── ...
│    ...
├─ public
│   ├── ...
│   ├── manifest.json
│   └── popup.html
├─ src
│   ├── ...
│   ├── App.tsx
│   ├── background.ts (バックグラウンド処理に利用予定)
│   ├── content.ts
│   └── popup.tsx (ポップアップ処理に利用予定)
...
├─ package.json
├─ tsconfig.json
└─ webpack.config.js

それぞれのファイルの意味合いについてはここでは割愛しますが、公式サイト等で詳しく述べられていますのでご参照ください。
https://developer.chrome.com/docs/extensions/mv3/getstarted/
このボイラープレートを使うことで、npm installした後、npm startもしくはnpm buildを行うことで、distディレクトリにビルドされたファイル一式が出力されます

動作確認

現時点で動作確認をしておきます。
まずnpm buildもしくはyarn buildでビルドを行い、distフォルダにビルド成果物が作成されることを確認します。次に、拡張機能の管理ページに移動し、「デベロッパーモードを有効」にした上で、「パッケージ化されていない拡張機能を読み込む」を選択します。

読み込むローカルフォルダを聞かれるので、先ほどビルドしたdistフォルダを選択します。読み込みが問題なく終了すると、次のようにアプリの詳細が表示されます

拡張機能メニューからアプリを選択すると、アプリが動作している様子が確認できます

Firebase連携

firebaseとの連携を行うために、以下の★がついたファイルを編集していきます(今回はサンプルのため最小限の実装となります)

<project-name>
...
├─ public
│   ├── ...
│   ├── manifest.json ★
│   └── popup.html
├─ src
│   ├── ...
│   ├── App.tsx ★
│   ├── background.ts ★
│   ├── firebase.ts ★(新規追加)
│   └── popup.tsx 
...

firebase clientのインストール

firebaseのクライアントライブラリをインストールします。

$ npm install firebase --save

インストールが完了したらfirebase.tsを作成し、初期化処理等を行なっていきます

firebase.ts
import firebase from 'firebase/app';
import 'firebase/auth'
import 'firebase/firestore'

// ※firebase consoleなどを確認し、適宜必要な情報を入力する
const firebaseConfig = {
    apiKey: FIREBASE_API_KEY,
    authDomain: FIREBASE_AUTH_DOMAIN,
    databaseURL: FIREBASE_DATABASE_URL,
    projectId: FIREBASE_PROJECT_ID,
    storageBucket: FIRESTORE_STORAGE_BUCKET,
    appId: FIREBASE_APP_ID
}

if (!firebase.apps.length) {
    firebase.initializeApp(firebaseConfig)
}

export default firebase

manifest.json

今回はfirebaseで認証を行いたいのでmanifest.jsonに対して以下の項目を追記します。

manifest.json
{
  ...
  "permissions": [
    "identity"
  ],
  "content_security_policy":"script-src 'self' https://www.gstatic.com/ https://*.firebaseio.com https://www.googleapis.com https://apis.google.com https://securetoken.googleapis.com; object-src 'self'",
  "oauth2": {
    "client_id": "******apps.googleusercontent.com(準備で入手したOAuth Client ID)",
    "scopes": [
      "https://www.googleapis.com/auth/userinfo.email",
      "https://www.googleapis.com/auth/userinfo.profile"
    ]
  },
  "key": "MIIBIjANBgkqhkiG9w0...2DZA+s0JOsh8YxAua3s(準備で入手した公開鍵。改行は含まないように気をつけて)"
}

必要なcontent_security_policyの設定についてはこちら参照しています
https://cloud.google.com/identity-platform/docs/web/chrome-extension?hl=ja

バックグランド側の処理

認証状態はfirebase/authonAuthStateChangedを用いてウォッチし、ポップアップ側から認証状態が取得できるようにリスナーを設定します。また、ポップアップ側でユーザがサインインの操作を行った際にはバックグラウンド側でサインイン処理を受け付けるようにメッセージリスナーを設定しておきます。

background.ts
import firebase from './firebase'

type MessageType = { type: string }

function onSigninRequest(message: MessageType, _sender: chrome.runtime.MessageSender, _sendResponse: () => void) {
    if (message.type == 'signin') {
        // ポップアップによるGoogle認証
        const provider = new firebase.auth.GoogleAuthProvider()
        firebase.auth().signInWithPopup(provider).catch(() => {
            console.log('サインインに失敗');
        })
	_sendResponse()
    } else if (message.type == 'signout') {
        if (firebase.auth().currentUser) {
            firebase.auth().signOut();
        }
	_sendResponse()
    }
}

function initApp() {
    // ユーザーがサインイン/サインアウトした際に呼び出される
    firebase.auth().onAuthStateChanged(function (user) {
        // ポップアップ側からのサインイン状態取得リクエストを受け付ける
        chrome.runtime.onMessage.addListener(function onReceiveAuthStateRequest(message: MessageType, _sender, _sendResponse) {
            if (message.type == 'signin-state') {
                if (user) {
                    _sendResponse({ type: 'signin-state', user });
                } else {
                    _sendResponse({ type: 'signin-state' });
                }
            }
        })
    })
    // ポップアップ側からのサインインリクエストを受け付ける
    chrome.runtime.onMessage.addListener(onSigninRequest)
}
window.onload = function () {
    initApp()
}

ちなみにChrome 拡張機能では HTTP リダイレクトを使用できないためサインイン処理はsignInWithPopupまたはlinkWithPopupに限られます。また認証ポップアップはブラウザのアクションポップアップをキャンセルするため、バックグラウンドから呼び出す必要があります(Firebase公式より)
https://firebase.google.com/docs/auth/web/google-signin?hl=ja#authenticate_with_firebase_in_a_chrome_extension

ポップアップ(UI)側の処理

ポップアップ(popup.ts)からはApp.tsxが呼び出されるので、こちらにUI側の認証コードを記述していきます。初回はサインインボタン、サインイン後はユーザ名とサインアウトボタンを表示するというシンプルなUIを実装します。

App.tsx
import * as React from "react"
import firebase from './firebase'

const App = () => {
  const [user, setUser] = React.useState<firebase.User>()
  // バックグラウンドにサインイン状態を問い合わせる
  // 一度サインイン後はポップアップを閉じてもバックグラウンドからサインイン状態を復帰できる
  React.useEffect(() => {
    chrome.runtime.sendMessage({ type: 'signin-state' }, function (response) {
      if (response?.type === 'signin-state') {
        setUser(response.user)
      }
    })
  }, [])
  // サインイン状態をウォッチする
  // ポップアップ画面でサインインやサインアウトをした時、即座に画面に反映させるために使用
  React.useEffect(() => {
    firebase.auth().onAuthStateChanged(function (user) {
      if (user) {
        setUser(before => {
          if (before && before.uid === user.uid) {
            return before
          }
          return user
        })
      } else {
        setUser(undefined)
      }
    })
  }, [])
  // サインインボタンを押下した時に呼び出される
  const signinWithPopup = React.useCallback(() => {
    chrome.runtime.sendMessage({ type: 'signin' }) // バックグラウンドに移譲する
  }, [])
  // ブラウザでログインしているユーザのGoogle認証情報を使用してサインイン
  const signinWithChromeUser = React.useCallback((interactive: boolean) => {
    chrome.identity.getAuthToken({ interactive }, function (token) {
      if (chrome.runtime.lastError && !interactive) {
        console.log('トークンの自動取得失敗');
      } else if (chrome.runtime.lastError) {
        console.error(chrome.runtime.lastError);
      } else if (token) {
        // OAuth Access Tokenでfirebase認証
        var credential = firebase.auth.GoogleAuthProvider.credential(null, token);
        firebase.auth().signInWithCredential(credential).catch(function (error) {
          // OAuth Access Tokenが無効なため、キャッシュをクリアして再試行
          if (error.code === 'auth/invalid-credential') {
            chrome.identity.removeCachedAuthToken({ token: token }, function () {
              signinWithChromeUser(interactive);
            });
          }
        });
      } else {
        console.error('トークンがnull');
      }
    });
  }, [])
  // サインアウトボタンを押下した時に呼び出される
  const signout = React.useCallback(() => {
    chrome.runtime.sendMessage({ type: 'signout' }) // バックグラウンドに転送する
  }, [])

  return (
    <div style={{ width: 200, padding: '1rem' }}>
      <h3>Firebaseで認証</h3>
      {user && (<p>{user.displayName}がサインインしています</p>)}
      <div>
        {!user && (
          <div>
            <p>ブラウザでログイン中のGoogleユーザでサインインする場合はこちら</p>
            <button onClick={() => { signinWithChromeUser(true) }}>サインイン(1)</button>
            <p>その他のGoogleユーザでサインインする場合はこちら</p>
            <button onClick={signinWithPopup}>サインイン(2)</button>
          </div>
        )}
        {user && (<button onClick={signout}>サインアウト</button>)}
      </div>
    </div>
  );
};

export default App;

ポイントとしては、サインインの手段として以下の二通り用意しています。

  • ブラウザにログイン中のGoogle認証情報を用いてサインイン
    • ここではgetAuthTokenによってmanifest.jsonに記載したclient_idからアクセストークンを取得します。client_idの連携ができていないとOAuth2 not granted or revokedなどのエラーで失敗するので要注意
  • Googleアカウントを選択してサインイン
    • FirebaseのsigninWithPopupを使用し、任意のGoogleアカウントを選択しログインします。ポップアップ認証自体はバックグラウンドで行うため、ここでは処理の依頼を行います。

ちなみに今回はシンプルなサンプルのため全てApp.tsxに記述しましたが、カスタムフック等を用いて分割すると良いかと思います。
これで、ポップアップ上でサインインとサインアウトができるようになりました🎉

Tips:ログの確認方法

Chrome拡張を初めて開発する場合、console.logが表示されなくて一瞬あれ?ってなるかもしれません。ログについてはポップアップとバックグラウンドでそれぞれ見方が異なります。

  • ポップアップ
    アプリのポップアップを開いた状態で右クリック→[検証]からデベロッパーツール画面が表示されるので、そこからポップアップ側で出力したログなどが確認できます。
  • バックグラウンド
    拡張機能の管理ページからバックグラウンドページをクリックすることでバックグラウンド側のログを確認することができます

公開

開発が完了したらdistフォルダをDeveloper Dashboardにアップロードします。

zip -r chrome-plugin-sample.zip dist


ストアに掲載するために説明欄やアイコンなど、必要事項を記入していきます。今回のようにpermissionidentityを指定している場合は、[プライバシーへの取り組み]項目の記入が必要になります。

必要事項が全て記入できたら[審査のための送信]を行います。
審査が無事終わるとアプリストアに公開されます(自分の場合、審査には3日要しました)

参考

全編を通しての流れは以下のサイトを参考に記述させていただきました。
https://github.com/firebase/quickstart-js/tree/master/auth/chromextension

Discussion

ponpon

こちらの記事、同じようなユースケースを実現しようとする際にとても参考になりました。
ありがとうございます。

1点だけ、現行の Manifest V3 で動かそうとした場合、バックグラウンドページが Service Worker に移行された関係で以下の window が使えなくなっているので、ハマってしまう問題がありました。

window.onload = function () {
    initApp()
}

FYI: https://developer.chrome.com/docs/extensions/develop/migrate/to-service-workers?hl=ja

自分はこの対応として、単純に window.onload 部分を削除して以下のように呼び出すようにしました。

initApp()

以上、共有でした

mktumktu

コメントいただきありがとうございます!
本記事がまだManifest V3に対応できておらずすみません🙇
共有いただきました内容、是非参考にさせていただきます。