[Node.js] GitHub Actions上でgcloud CLIからFirebaseの秘密鍵を生成する

# GitHub Actions上からFirebaseに接続したい

GitHub Actions上でテストを回している時など、GitHub Actions上からFirebaseに接続したい時があります。

このような場合、サービスアカウント用の秘密鍵が入ったJSONファイルを生成して使用するのが一般的だと思います。JSONファイルは以下のような形式です。

```json
{
  "type": "service_account",
  "project_id": "...",
  "private_key_id": "...",
  "private_key": "-----BEGIN PRIVATE KEY-----\n<private key>\n-----END PRIVATE KEY-----\n",
  "client_email": "...",
  "client_id": "...",
  ...
}
```

このJSONファイルですが、秘密鍵が含まれているためgitで管理することはできません。そのため、GitHub Actionsのランナー上でFirebaseの秘密鍵を使うには一工夫必要になります。例えば、以下のような方法があります。

- GitHub Actionsの`secrets`に認証情報を設定する
- gcloud CLIからFirebaseの認証情報を生成する

## 方法1：GitHub Actionsの`secrets`に認証情報を設定する

主に以下のサイトで解説されているような方法を使って、認証情報をGitHub Actionsのsecretsに設定します。

https://zenn.dev/tomon9086/articles/a24a15b610d196

https://blog.ojisan.io/firebase-admin-init/

https://note.com/build_service/n/nd4756ceb103f

まずFirebaseコンソールの[プロジェクトの設定]からサービスアカウントの秘密鍵をダウンロードします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/f1ece4eb-b106-3c66-a621-28821bda1c01.png)

次にfirebase-admin-sdkの`initializeApp`を呼んでいる所を以下のように書き換えます。

```ts
import { cert, initializeApp } from "firebase-admin/app";

initializeApp({
  credential: cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"),
  }),
});
```

最後に、GitHub Actions上で環境変数を設定します。
[Settings] > [Secrets and variables] > [Actions]にある[New repository secret]から設定できます。
設定するのは環境変数として必要な`FIREBASE_PROJECT_ID`, `FIREBASE_CLIENT_EMAIL`, `FIREBASE_PRIVATE_KEY`です。これらのsecretに指定する値は、先ほどダウンロードしたJSONファイルの中で指定されているものと同じ値です。

(ローカル実行時にも`.env`ファイルなどを用いて同じように環境変数を指定します。)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/678625/b55037af-e824-269b-3944-59240437c279.png)

GitHub Actionsのworkflow内で、secretを環境変数として渡してあげれば動きます。

```yaml:.github/workflows/test.yaml
      # テストを実行
      - name: Test
        run: npm run test
        env:
          FIREBASE_PROJECT_ID: ${{ secrets.FIREBASE_PROJECT_ID }}
          FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
          FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
```

以上が環境変数を使用してFirebaseに認証情報を渡す方法です。
上記では`FIREBASE_PROJECT_ID`や`FIREBASE_CLIENT_EMAIL`などを別々に環境変数に入れましたが、上で紹介したサイトの中には、JSONファイルを丸ごとbase64エンコードしてsecretに格納している方もおられました。

## 方法2：gcloud CLIからFirebaseの認証情報を生成する

通常は方法1で説明したやり方でよさそうですが、秘密鍵を自分でダウンロードして管理するのは若干面倒です。筆者はそういうのをどこに設定したかすぐ忘れるタイプです。

既にGitHub Actions内でgcloud cliを使用してGoogle Cloudにログインしている場合、その認証情報を使ってGitHub ActionsからFirebaseに接続することができます。

```yaml:.github/workflows/test.yaml
      # gcloudにログイン -> ここで取得した認証情報を使ってFirebaseに接続したい
      - name: Login with gcloud
        uses: google-github-actions/auth@v1
        with:
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'
        service_account: 'my-service-account@my-project.iam.gserviceaccount.com'
```

流れとしては、

1. gcloud CLIで一時的な秘密鍵を生成
1. 秘密鍵を使用してFirebaseに接続し、テストを実行
1. 最後に秘密鍵を無効化(削除)

という形になります。

まずgcloud cliでFirebaseの接続に必要な秘密鍵を生成します。[`gcloud iam service-accounts keys create`](https://cloud.google.com/iam/docs/keys-create-delete?hl=ja)コマンドを使用します。
指定したパス(ここでは`./firebase-secret.json`)にFirebaseの認証で使うJSONファイルが出力されます。

```yaml:.github/workflows/test.yaml
      - name: Create Firebase Credeitial JSON
        run: |
          gcloud iam service-accounts keys create ./firebase-secret.json --iam-account=<iam account>
          cat ./firebase-secret.json | jq --raw-output '.private_key' | sed 's/^ */::add-mask::/'
```

:::note warn
ここで、取得する秘密鍵がGitHub Actionsの出力に載らないようにするため、GitHub Actionsのコマンドである`::add-mask::`を使用してマスクしています。（`run`の2行目）

秘密鍵は複数行なので、[こちらのstackoverflow](https://stackoverflow.com/a/77096723)で紹介されている方法を参考にsedで置換してマスクしています。

この設定を行うことで、秘密鍵を`echo`などで出力しても`*****`のようにマスクした状態で出力してくれます。
:::

テスト実行ステップでは、`GOOGLE_APPLICATION_CREDENTIALS`という環境変数に、先ほど作成したJSONのパスを設定します。

```yaml:.github/workflows/test.yaml
      # テストを実行
      - name: Test
        run: npm run test
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ./firebase-secret.json
```

次にソース側の設定です。
admin-sdkは、`GOOGLE_APPLICATION_CREDENTIALS`環境変数を設定しておくと、`initializeApp()`が勝手にそのパスを見に行って認証情報を読み込んでくれます。そのため引数には何も指定する必要がありません。

```ts
import { initializeApp } from "firebase-admin/app";

initializeApp(); // GOOGLE_APPLICATION_CREDENTIALSを自動で読み込むため、引数には何も指定する必要が無い!
```

ローカル開発時も同様に、`.env`ファイルなどを使って`GOOGLE_APPLICATION_CREDENTIALS`に秘密鍵入りのJSONファイルへのパスを設定しておけばよいです。

最後に、作成した秘密鍵を無効化します。`if: ${{ always() }}`で、テストが成功したか失敗したかにかかわらず秘密鍵の削除を実行するのがポイントです。

```yaml:.github/workflows/test.yaml
      # firebase接続用の秘密鍵を削除する
      - name: Delete Firebase Credeitial JSON
        if: ${{ always() }}
        run: gcloud iam service-accounts keys delete $(cat ./firebase-secret.json | jq --raw-output '.private_key_id') --iam-account=<iam account>
```

最終的なGitHub Actionsのworkflowファイルは以下のようになります。

```yaml:.github/workflows/test.yaml
      # gcloudにログイン -> ここで取得した認証情報を使ってFirebaseに接続したい
      - name: Login with gcloud
        uses: google-github-actions/auth@v1
        with:
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'
        service_account: 'my-service-account@my-project.iam.gserviceaccount.com'
      # Firebaseログインに使用する秘密鍵入りのJSONを生成
      - name: Create Firebase Credeitial JSON
        run: |
          gcloud iam service-accounts keys create ./firebase-secret.json --iam-account=<iam account>
          cat ./firebase-secret.json | jq --raw-output '.private_key' | sed 's/^ */::add-mask::/'
      # テストを実行
      - name: Test
        run: npm run test
        env:
          GOOGLE_APPLICATION_CREDENTIALS: ./firebase-secret.json
      # firebase接続用の秘密鍵を削除する
      - name: Delete Firebase Credeitial JSON
        if: ${{ always() }}
        run: gcloud iam service-accounts keys delete $(cat ./firebase-secret.json | jq --raw-output '.private_key_id') --iam-account=<iam account>
```


以上がgcloud CLIからFirebaseの秘密鍵を生成する方法となります。
「テストが実行されている間のみ有効な、一時的な秘密鍵」を使用する事で、万が一秘密鍵が流出しても安心ですし、管理も簡単になります。

:::note warn

#### Note: デフォルトで生成される`GOOGLE_APPLICATION_CREDENTIALS`は(まだ)使えない


実は、最初にgcloudへのログインで使用した`google-github-actions/auth@v1`のドキュメントを見ると、デフォルトで`GOOGLE_APPLICATION_CREDENTIALS`が出力されると書かれています。これをそのままFirebaseで使えば良さそうに見えます。

しかし、`google-github-actions/auth@v1`がら出力される`GOOGLE_APPLICATION_CREDENTIALS`は、そのままでは使うことができません。詳細は以下のissueに記載されています。


- https://github.com/firebase/firebase-admin-node/issues/1703
- https://github.com/firebase/firebase-admin-node/issues/1861
- https://github.com/firebase/firebase-admin-node/issues/1377

どうやら、`GOOGLE_APPLICATION_CREDENTIALS`で指定されるJSONファイルには、typeが「`service_account`」になっているものと「`external_account`」になっているものの2種類あるようです。
`google-github-actions/auth@v1`が出力するものはtypeが「`external_account`」になっているのに対して、firebase-admin-sdkは「`service_account`」にしか現時点では対応していないため、エラーが発生するようです。

そのため、上で紹介した方法では`gcloud iam service-accounts keys create`コマンドで秘密鍵を新たに発行することでエラーを回避しています。
:::

# まとめ

- GitHub Actions上で`firebase-admin-sdk`を使用して`initializeApp`するには以下のような方法がある
  - 方法1：GitHub Actionsの`secret`と環境変数を使用して認証情報を渡す
    - メリット：環境変数を設定するだけで済む
    - デメリット：環境変数を設定する必要がある
  - 方法2：gcloud CLIを使用して認証情報を設定する
    - メリット：既にGitHub Actions上でgcloud cliを使用している場合はこちらの方が設定項目が少なく楽
    - デメリット：Google CloudのIAMで対象のサービスアカウントに適切な権限を付与しないとエラーが出ることがある

どちらを使うかは、本番環境にデプロイした時にどのように秘密鍵を読み込むかで使い分けるとよいと思います。

例えばvercelにデプロイする時は秘密鍵などの情報は環境変数に設定すると思います。その場合前者のやり方を使うことで、本番環境とテスト時に同じ方法で`initializeApp()`できます。

```ts
import { cert, initializeApp } from "firebase-admin/app";

// 本番環境のデプロイ先にもGitHub Actionsにも環境変数を設定しておく。
initializeApp({
  credential: cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, "\n"),
  }),
});
```

逆にCloud Runにデプロイしている場合、本番環境では環境変数などを設定しなくてもデフォルトのFirebaseに接続してくれるようになっています。
そのため、`initializeApp()`の引数を空にできる後者の方法を取ることで、本番環境とテスト時において共通の方法で`initializeApp()`できます。

```ts
import { initializeApp } from "firebase-admin/app";

// Cloud Runでは何も設定しなくても動く。GitHub Actions上ではGOOGLE_APPLICATION_CREDENTIALSを設定すると動く。
initializeApp();
```