Vercel OIDCを利用してGoogle Cloudリソースへアクセスする

自分の昔の記憶(2022年頃)ではVercelから Google Cloudへの認証に Workload Identity Federation が使えず、Service Account の秘密鍵をVercelの環境変数に登録する必要があり微妙だなーと思っていました。 久しぶりに調べてみたところ Vercel OIDC がサポートされており、秘密鍵なしでフェデレーションできるようになっていました。

vercel.com

vercel.com

例えば、Next.js のサーバー上で Firestore へアクセスするみたいなユースケースが死ぬほどあります(個人的にあんまり好きではない)が、雑に調べるとみんな Service Account の 秘密鍵環境変数に登録して〜みたいなことやってます。鍵の漏洩リスクやローテーション管理を考えると、そんなことしないほうが良いです。

とりあえず使ってみる

Workload Identity Federation は、外部 IdP のトークンを STS で検証し一時的な認証情報と交換する仕組みです。Service Account impersonation で外部ワークロードがService Accountの権限を使いGoogle Cloud リソースにアクセスできます。

Vercel OIDC の場合、Vercel が発行したOIDC トークンSTS で検証 → SA を借用、という流れです。図にすると↓

このフローでは google-cloud-node-coreExternalAccountClient を使い、subject_token_suppliergetVercelOidcToken を動的に渡します。

vercel.com

Firestoreのアクセスで使ってみる


Provider と SA を用意します。

resource "google_iam_workload_identity_pool" "vercel" {
  workload_identity_pool_id = "vercel-pool"
  display_name              = "Vercel Pool"
}

resource "google_iam_workload_identity_pool_provider" "vercel" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.vercel.workload_identity_pool_id
  workload_identity_pool_provider_id = "vercel-provider"
  display_name                       = "Vercel Provider"

  oidc {
    issuer_uri = "https://oidc.vercel.com/${var.vercel_team_slug}"
  }

  attribute_mapping = {
    "google.subject" = "assertion.sub"
    "attribute.sub"  = "assertion.sub"
  }

  attribute_condition = "assertion.sub == 'owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}'"
}

resource "google_service_account" "vercel_app" {
  account_id   = "app"
  display_name = "Service Account"
}

resource "google_project_iam_member" "firestore_user" {
  project = var.project_id
  role    = "roles/datastore.user"
  member  = "serviceAccount:${google_service_account.vercel_app.email}"
}

resource "google_service_account_iam_member" "vercel_impersonation" {
  for_each           = toset(var.vercel_environments)
  service_account_id = google_service_account.vercel_app.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.vercel.name}/attribute.sub/owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}"
}

resource "google_service_account_iam_member" "vercel_token_creator" {
  for_each           = toset(var.vercel_environments)
  service_account_id = google_service_account.vercel_app.name
  role               = "roles/iam.serviceAccountTokenCreator"
  member             = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.vercel.name}/attribute.sub/owner:${var.vercel_team_slug}:project:${var.vercel_project_id}:environment:${each.value}"
}

attribute_condition でproject + environmentを絞りましょう。これがないと同じ team 内の別プロジェクトからもアクセスできてしまいます。

次にアプリケーション側。後述する理由でfirebase-adminは使わず、@google-cloud/firestoreauthClientを直接渡します。

import { Firestore } from "@google-cloud/firestore";
import { ExternalAccountClient } from "google-auth-library";
import { getVercelOidcToken } from "@vercel/oidc";

const authClient = ExternalAccountClient.fromJSON({
  type: "external_account",
  audience: `//iam.googleapis.com/projects/${GCP_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}`,
  subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
  token_url: "https://sts.googleapis.com/v1/token",
  service_account_impersonation_url: `https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SERVICE_ACCOUNT_EMAIL}:generateAccessToken`,
  subject_token_supplier: {
    getSubjectToken: getVercelOidcToken,
  },
});

const firestore = new Firestore({
  projectId: "my-project",
  databaseId: "my-database",
  authClient,
});

これだけでService Accountが発行したトークンを利用してFirestoreをクエリできます。

firebase-adminに関して

firebase-adminのFirestoreモジュール(firebase-admin/firestore)は、内部で@google-cloud/firestoreを初期化する際にcredentialをチェックしています。

firestore-internal.tsgetFirestoreOptions では:

  1. credential instanceof ServiceAccountCredential → OK
  2. isApplicationDefault(credential) → OK
  3. それ以外firestore/invalid-credential エラー

ExternalAccountClient はどちらにも該当しないため、以下のエラーになります。

Failed to initialize Google Cloud Firestore client with the available credentials. Must initialize the SDK with a certificate credential or application default credentials to use Cloud Firestore API.

firebase-admin v13 は external_account 型の credential ファイルをサポートしましたが、Vercel OIDC には使えません。

github.com

applicationDefault() の内部経路

  1. ApplicationDefaultCredentialnew GoogleAuth({ scopes }) を呼ぶだけで、authClientsubject_token_supplier を渡す手段がない
  2. GoogleAuthGOOGLE_APPLICATION_CREDENTIALS から JSON を読み込む
  3. type: "external_account" を検出し ExternalAccountClient.fromJSON を呼ぶ
  4. JSON から IdentityPoolClient を生成する

ここで問題になるのが subject_token_supplier です。これは関数を含むオブジェクトなので JSONシリアライズできません。applicationDefault()JSON ファイルからしか credential を読まないため、getVercelOidcToken を渡す方法がないわけです。

No valid Identity Pool "credential_source" provided, must be either file, url, or certificate.

なのでVercel OIDCを使う時は@google-cloud/firestoreを直接使う必要があります。@google-cloud/firestore なら authClient を直接渡せます。経路は面倒なので説明しませんが興味がある人はこの辺を↓を見てください。

github.com

github.com

github.com

github.com

まとめ

Firestoreの例で示しましたが、他も同様な感じでOIDCが使えると思います。