stefafafan の fa は3つです

"すてにゃん" こと id:stefafafan のブログです

GitHub Actions で Firebase Authentication の 承認済みドメインを機械的に増減させる

最近私は Firebase Authentication を利用したWebアプリケーションを開発しています。Pull Requestごとのプレビュー環境を用意しようとした際に表題の「承認済みドメイン」をどうするかがネックになり色々と調べて最終的にGitHub Actionsで対応したので、そのときわかったことなどをこちらの記事に書きます。

承認済みドメイン (Authorized Domain) とは

Firebase Authenticationを利用すると、「メール・パスワード」「Google認証」「Twitter認証」などなど色々な方式でのアカウント登録周りの認証を実現できます。

アプリケーションをデフォルトの https://{PROJECT_ID}.firebaseapp.com みたいなドメインではなく、例えば独自ドメインで動かしたい場合はそのドメインからのリクエストが信頼できることを示すために「承認済みドメイン」として管理画面より登録する必要があります。これを登録しないと弾かれて正常にアカウント周りの処理が使えなくなります。

今回の困りごと

Pull Requestごとのプレビュー環境についてはブランチ名をサブドメインとして https://{BRANCH_NAME}.example.com/ という形でアクセスできるようにしましたが、Firebase Authenticationの「承認済みドメイン」にブランチが増えたり減ったりするたびにそのURLを手で追加・削除するのは不便でした。少し調べたところ同じように困ってる人が世間に結構いるようでした。

世間の様子

Firebase AdminのNode.jsのSDKには、この機能がほしいという2019年からOpenなissueがあがっていて、進捗なさそうにみえる。*1
github.com

一方でStackOverflowを眺めてみると、この課題に対処するための案がいくつかありました。例えば、

  • 動作確認用のProjectを別に用意し動作確認したいブランチごとにTraffic Migrationを行う方法*2
  • 固定でN個の承認済みドメインを追加しておいてPull Requestの番号にたいして剰余演算でいずれかのURLに割り当てられるようにする方法 (つまり pr-number % N サブドメインを決める形) *3
  • APIで追加はできないからサポートにFeature Request送りましょうという2020年のコメント *4
  • Identity Toolkit APIを使ったらできたよという2022年のコメント *5

Identity Toolkit APIというのを使えば実現できるのであれば、 gcloudというCLIツールを使って上手いことできるのかなと思いましたが、どうやら現状できないようでした。*6

というわけで今回はAPIを叩くことにしました。

作戦

  • GitHub Actionsを使う
  • 認証はGoogle公式の便利なActionsがあるのでそれで行う
  • Pull Requestを開いたときにAPI叩いて承認済みドメインを追加、閉じたときもAPI叩いて承認済みドメインを取り除く
  • ひとまず素朴に curl と jq でAPIを叩いたりレスポンスを整形したりする

StackOverflowの回答にリンクされていたブログ記事をかなり参考にしました。
pretired.dazwilkin.com

実装

認証をどうするかと、どのAPIをどのように叩けばいいのかと、GitHub Actionsの実装をどうするかのざっくり3つの話題があるので順に紹介します。

認証周り

今回のAPIを叩くにはFirebase周りの操作ができるService Accountを用意する必要があります。

# Identity Toolkitを有効化
gcloud services enable identitytoolkit.googleapis.com --project=${PROJECT}

# Service Accountを作成
gcloud iam service-accounts create ${ACCOUNT} --project=${PROJECT}

# 作ったService Accountに firebaseauth.admin 権限付与
gcloud projects add-iam-policy-binding ${PROJECT} --member=serviceAccount:${EMAIL} --role=roles/firebaseauth.admin

このままこのService Accountのキーを取得してGitHubのRepository Secretsに登録するのが昔ながらのやり方ですが、現代ではWorkload identity federation を使うのがおすすめです(Secretに登録せずに済む)。

cloud.google.com

Workload identity federationの設定の仕方はいろいろと丁寧に書いてあるので以下の gcloud でやるやり方や、Terraformのモジュールなどを参考にすると良いです。

また最近だと以下の日本語の記事がわかりやすいです。
eh-career.com

一点気にするポイントとしては、先に作ったService Accountとして操作を行いたいので、 gcloud iam service-accounts add-iam-policy-binding するときにちゃんとそのアカウントを指定してることを確認しておく必要があります。

# ${SERVICE_ACCOUNT} の部分が、先に用意したサービスアカウントのメールアドレス
gcloud iam service-accounts add-iam-policy-binding ${SERVICE_ACCOUNT} \
  --project="${PROJECT_ID}" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/${WORKLOAD_IDENTITY_POOL_ID}/attribute.repository/${REPO}"

APIを叩く

利用するAPIは Identity Platform の getConfig と updateConfig の二つです。
cloud.google.com
cloud.google.com

手元で curl で getConfig を叩きたい場合は以下のように Bearer トークン指定して取得できます。

# $TOKENは gcloud auth print-access-token とかしたときの値を使う
curl --header "Authorization: Bearer $TOKEN" \
  --header "Accept: application/json" \
  "https://identitytoolkit.googleapis.com/v2/projects/${PROJECT}/config"

getConfigの返り値は承認済みドメインに限らず、プロジェクトの管理画面で確認できるようなさまざまな値が返ってきます。この中で承認済みドメインauthorizedDomains というフィールドに配列として入ってるので jq .authorizedDomains という風にフィルターすると現在のドメインの登録状況を一覧して確認できます。

$ curl ...(以下略) | jq .authorizedDomains
{
  "authorizedDomains": [
    "localhost",
    "test.example.com",
  ]
}

updateConfigのAPIはPATCHメソッドで叩く形になっていて「更新したい部分のみ指定して更新」します。authorizedDomains のみ更新したい場合は、?updateMask=authorizedDomains とクエリパラメータで指定した上で、 --data に元々の authorizedDomains と同じ形式のjsonを指定してあげる必要があります。

curl --request PATCH --header "Authorization: Bearer $TOKEN" \
    --header "Accept: application/json" \
    --header "Content-Type: application/json" \
    --data '{"authorizedDomains": ["localhost", "test.example.com", "test2.example.com"]}' \
    "https://identitytoolkit.googleapis.com/v2/projects/$PROJECT/config?updateMask=authorizedDomains"

つまり一度現在の設定の配列を取得し、新しく追加・削除したいURLをその配列から追加・削除した上で、更新APIで上書きするという処理が必要です。

jq 技を使う

こちらの記事にも書いてあるやり方を採用しました。つまりcurlとjqでなんとかしました。

jq には Array construction と Object Construction という機能があり、パイプしながら配列やオブジェクトを操作することができます。
stedolan.github.io

例えば配列にたいして jq '.array | . + ["追加したい"]' みたいにすると "追加したい" という文字列をおもむろに配列末尾に追加できます。
https://jqplay.org/s/9GY95aZGlVc

同様に配列 +- にすると配列から取り除くことができます。
https://jqplay.org/s/AdTArnJhj7i

これをさらにパイプして jq '.array | {"オブジェクトにしたい": . }' とすると"オブジェクトにしたい" をキーとしたオブジェクトにすることができます。
https://jqplay.org/s/S6qEjDtCQSQ

というわけで、getConfig時の結果に jq で今回追加したいURLを追記しつつオブジェクトに変換すれば、updateConfigのAPIに渡すパラメータが出来上がります。

# 現在の設定を取得しつつ、jq を使ってレスポンスを整形
DATA=$(curl --header "Authorization: $TOKEN" \
  --header "Accept: application/json" \
  "https://identitytoolkit.googleapis.com/v2/projects/$PROJECT/config" | \
  jq '.authorizedDomains + ["新しいドメイン"] | {"authorizedDomains": .}')

# 上で組み立てたDATA変数をそのまま --data に渡して更新APIを叩いて更新
curl --request PATCH --header "Authorization: Bearer $TOKEN" \
  --header "Accept: application/json" \
  --header "Content-Type: application/json" \
  --data "$DATA" \
  "https://identitytoolkit.googleapis.com/v2/projects/$PROJECT/config?updateMask=authorizedDomains"

GitHub Actions

あとはGitHub Actions上で実現する方法ですが、google-github-actions/auth という公式Actionsを使えば楽です。

github.com

READMEの Generating an OAuth 2.0 Access Token に書いてあるところを参考にActionsを書いていけば curl するときに必要なBearerトークンが使えるようになります。

jobs:
  job_id:
    # Workload identity federation 使うときに必要
    permissions:
      contents: 'read'
      id-token: 'write'

    steps:
    - uses: 'actions/checkout@v3'

    - id: 'auth'
      name: 'Authenticate to Google Cloud'
      uses: 'google-github-actions/auth@v0'
      with:
        # 後のStepで access_token を使えるようにするのにこの指定が必要
        token_format: 'access_token'
        workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'
        # ここのservice_accountはfirebaseの権限を持ったものにする
        service_account: 'my-service-account@my-project.iam.gserviceaccount.com'
        access_token_lifetime: '300s'

   # 後のStepで上記のstepのoutputs.access_tokenを使うことができる
    - id: 'example'
      run: |-
        curl --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" https://example.com/

こういう風にすればあとは GitHub Actions 内で普通に curl や jq 使えるので、Pull Request open時やclose時にお好みでAPIを叩けば完成です。

余談ですがもしプレビュー環境をGoogle Cloud Runを活用してデプロイしたい場合、生成されるURLがわからないなんてことがあるかもしれません。その場合は google-github-actions/deploy-cloudrun GitHub Actionを使うと outputs.url でURLを取得できて便利かもしれません。
github.com

完成系のGitHub Actions

最終的にこういう感じのyamlになりました。curl周りが初見だとちょっと難しいのが少し気になりますが思ってたより短い行数でできたので一旦許容しています。

name: Update Firebase authorized domain

on:
  pull_request:
    types: [opened, reopened, closed]

env:
  PROJECT: my-project

jobs:
  update-auth-domain:
    runs-on: ubuntu-latest
    permissions:
      contents: "read"
      id-token: "write"

    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - id: "auth"
        name: Authenticate Google Cloud
        uses: "google-github-actions/auth@v0"
        with:
          token_format: "access_token"
          access_token_lifetime: "300s"
          workload_identity_provider: 'projects/123456789/locations/global/workloadIdentityPools/my-pool/providers/my-provider'
          service_account: "my-service-account@${{ env.PROJECT }}.iam.gserviceaccount.com"

      - name: Construct preview url
        id: "construct_url"
        shell: bash
        run: |
          # _ や / を含むブランチ名をハイフンに置換してURLを生成
          BRANCH=$(echo ${{github.head_ref}} | sed -e 's/[_/]/-/g')
          PREVIEW_URL=$BRANCH.example.com
          echo "##[set-output name=url;]$(echo $PREVIEW_URL)"

      - name: Add to Firebase Auth domain
        if: ${{ github.event.action == 'opened' ||  github.event.action == 'reopened' }}
        shell: bash
        run: |
          # 現状の設定を取得し、jqを活用して PREVIEW_URL を追加する
          DATA=$(curl --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
                --header "Accept: application/json" --silent --compressed \
                "https://identitytoolkit.googleapis.com/v2/projects/${{ env.PROJECT }}/config" | \
                jq --arg url ${{ steps.construct_url.outputs.url }} '.authorizedDomains + [$url] | {"authorizedDomains": .}')

          # authorizedDomainsをPATCHで更新
          curl --request PATCH --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
              --header "Accept: application/json" \
              --header "Content-Type: application/json" \
              --data "$DATA" --silent --compressed \
              --output /dev/null -w %{http_code} \
              "https://identitytoolkit.googleapis.com/v2/projects/${{ env.PROJECT }}/config?updateMask=authorizedDomains"

      - name: Delete from Firebase Auth domain
        if: ${{ github.event.action == 'closed' }}
        shell: bash
        run: |
          # 現状の設定を取得し、jqを活用して PREVIEW_URL を配列から削除する
          DATA=$(curl --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
                --header "Accept: application/json" --silent --compressed \
                "https://identitytoolkit.googleapis.com/v2/projects/${{ env.PROJECT }}/config" | \
                jq --arg url ${{ steps.construct_url.outputs.url }} '.authorizedDomains - [$url] | {"authorizedDomains": .}')

          # authorizedDomainsをPATCHで更新
          curl --request PATCH --header "Authorization: Bearer ${{ steps.auth.outputs.access_token }}" \
              --header "Accept: application/json" \
              --header "Content-Type: application/json" \
              --data "$DATA" --silent --compressed \
              --output /dev/null -w %{http_code} \
              "https://identitytoolkit.googleapis.com/v2/projects/${{ env.PROJECT }}/config?updateMask=authorizedDomains"