🪤 はじめに:「動くまで」が長いクロスクラウド認証
GCP の サービスアカウント や GitLab CI(id_tokens)から AWS リソースを キーレス で操作する構成は、WIF でキーレス認証 で扱った通り 公式手順上はシンプル に見えます。実際にやってみると、手順通りに作業しても、トークンを送った瞬間に InvalidIdentityTokenException で弾かれる。3 行ほどの YAML を直すだけで通ったり、oaud という見慣れないクレームがしれっと検証対象になっていたりと、 公式ドキュメントの行間に苦労ポイントが集中 しています。
本記事では、CTS で実装中に踏んだ落とし穴を 「症状 → 原因 → 解決策」 の形で整理します。GCP/GitLab → AWS のキーレス認証を実装する人が、 同じ場所で詰まらないため のチェックリストとして使える内容を目指しました。
関連の前提知識は WIF でキーレス認証 と GitLab → GCP 認証方式の 3 世代 を参照してください。
🧭 想定する 2 つのフロー
「GCP → AWS」と一言で言っても、 発信元の違いで JWT の中身が変わり、Trust Policy の設計も変わります 。
| 発信元 | issuer | JWT の主キー | AWS OIDC Provider URL |
|---|---|---|---|
| A. Cloud Run / GCE / Cloud Run Jobs(GCP ワークロード) | https://accounts.google.com | azp = SA の uniqueId | accounts.google.com |
| B. GitLab CI(GitLab OIDC を直接 AWS に渡す) | https://gitlab.com | sub = project_path:...:ref:... | gitlab.com |
A は「GCP のワークロードが GCP 内部で発行された JWT を AWS に持ち込む」、B は「GitLab で発行された JWT を AWS に持ち込む」フローで、 AWS 側の OIDC Provider と Trust Policy が完全に別物 になります。本記事の落とし穴の多くは A 側で集中して発生します。
🪤 落とし穴 1:AWS STS は aud と azp の両方があると azp を優先する
最も詰まりやすいのがこれです。GCP のワークロードから取得した JWT(Service Account Impersonation 経由)には aud と azp の両方 が入ることがあります。
{
"aud": "<aws-account-id>",
"azp": "<gcp-sa-uniqueId>",
"sub": "system:serviceaccount:<namespace>:<sa-name>",
"iss": "https://accounts.google.com",
"exp": 1700000000
}
AWS STS の検証ロジックは:
| JWT の状態 | AWS が aud として扱う値 |
|---|---|
aud のみ | aud |
azp のみ | azp |
aud と azp の両方 | azp ← これがハマりどころ |
つまり aud を AWS アカウント ID にしても、 AWS は azp(= SA の uniqueId)と OIDC Provider の client_id_list を突き合わせる ため、client_id_list に SA uniqueId を登録していないと Incorrect token audience で拒否されます。
解決策
AWS の OIDC Provider の client_id_list には、 AWS アカウント ID と Service Account の uniqueId(数値)の両方 を登録します。
resource "aws_iam_openid_connect_provider" "google" {
url = "https://accounts.google.com"
client_id_list = [
"<aws-account-id>", # 旧コードで aud に指定していた値
"<gcp-sa-uniqueId-stg>", # ← azp に対応、必須
"<gcp-sa-uniqueId-prod>", # 環境ごとに別 uniqueId
]
thumbprint_list = [
"<google-cert-thumbprint>", # Google ルート証明書の SHA1
]
}
🪤 落とし穴 2:Service Account の email ではなく uniqueId(数値) を使う
Trust Policy の condition で SA を識別したいとき、 email を入れても通りません 。GCP は JWT に email クレームも入れますが、AWS の accounts.google.com:aud 条件で照合されるのは uniqueId(数値) だけです。
# uniqueId の取得(環境ごと)
gcloud iam service-accounts describe \
<sa-name>@<gcp-project>.iam.gserviceaccount.com \
--format='value(uniqueId)'
# → 21 桁の数値が返る
Trust Policy への反映
Condition = {
StringEquals = {
"accounts.google.com:aud" = "<gcp-sa-uniqueId>" # uniqueId、email ではない
"accounts.google.com:sub" = "system:serviceaccount:<namespace>:<sa-name>"
}
}
🪤 落とし穴 3:oaud(Original Audience)クレームを活用する
GCP の generateIdToken API で audience パラメータに 任意の固定文字列 を渡すと、JWT の oaud(Original Audience)クレームに入ります。 AWS の Trust Policy は accounts.google.com:oaud 条件をサポート しているため、この仕組みを使うとアプリケーション固有の識別子で Trust Policy を絞り込めます。
# GCP 側:固定の oaud を持つ JWT を取得
curl -H "Metadata-Flavor: Google" \
"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=<my-app-cross-cloud>"
# AWS Trust Policy:oaud を必須条件に
Condition = {
StringEquals = {
"accounts.google.com:aud" = "<gcp-sa-uniqueId>"
"accounts.google.com:oaud" = "<my-app-cross-cloud>" # アプリ識別子
"accounts.google.com:sub" = "system:serviceaccount:<namespace>:<sa-name>"
}
}
これにより、 同じ SA でも別の用途では AssumeRole できない という細かい権限分離が可能になります。
🪤 落とし穴 4:sub クレームの形式は system:serviceaccount:<namespace>:<sa-name>
GCP のサービスアカウントの sub は、 SA の email でも uniqueId でもない独自フォーマット です。Kubernetes の SA 命名規則に近い形式で発行されます。
sub: system:serviceaccount:<namespace>:<sa-name>
<namespace> は GCP の場合「プロジェクト名相当」、<sa-name> は SA のローカル名(email の @ より前)。 Trust Policy ではこの完全文字列で StringEquals 比較 する必要があります。
🪤 落とし穴 5:AWS OIDC Provider は アカウントあたり 1 つの制約
accounts.google.com の OIDC Provider は、 AWS アカウントに 1 つしか作成できません 。STG と PROD を 同一 AWS アカウント で運用している場合、両環境が同じ OIDC Provider を共有することになり、Terraform で扱う際に 「stg のスタックで resource 化、prod のスタックでも resource 化」とすると後者が EntityAlreadyExists で失敗 します。
解決策:account-level リソースは Bootstrap 側で 1 度だけ作成、業務スタックでは data 参照
# アプリケーション側のスタックでは data source で参照
data "aws_iam_openid_connect_provider" "google" {
url = "https://accounts.google.com"
}
resource "aws_iam_role" "cross_cloud" {
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Principal = {
Federated = data.aws_iam_openid_connect_provider.google.arn
}
# ... condition ...
}]
})
}
OIDC Provider 自体は infra-bootstrap のような account-level 管理スタック で 1 度だけ resource として作成するのが鉄則です。
🪤 落とし穴 6:Thumbprint は 手動管理、定期更新が必要
aws_iam_openid_connect_provider の thumbprint_list には、IdP のルート証明書の SHA-1 ハッシュが必要です。Google が証明書をローテーションすると、 古い thumbprint で署名された JWT が拒否されます 。
# Google ルート証明書の thumbprint 取得
echo | openssl s_client -showcerts -servername accounts.google.com -connect accounts.google.com:443 2>/dev/null \
| openssl x509 -fingerprint -sha1 -noout \
| sed 's/SHA1 Fingerprint=//;s/://g' \
| tr 'A-Z' 'a-z'
運用上の注意
- thumbprint の更新は Terraform で定期チェック→更新 にする(手動だと事故のもと)
- 一定期間は 新旧両方を
thumbprint_listに並列で登録 すると、ローテーション中も継続稼働可能
🪤 落とし穴 7:GitLab → AWS(直接)と GCP → AWS(経由)の 混同
実装スクリプトを書くとき、「GitLab CI から AWS を叩く」コードと「Cloud Run から AWS を叩く」コードの OIDC_TOKEN の中身が別物 であることが見落とされがちです。変数名を GOOGLE_OIDC のように曖昧にすると、 どちらの JWT を渡すべきかが現場で混乱 します。
# シナリオ B: GitLab CI から直接 AWS(issuer = gitlab.com)
# → AWS OIDC Provider URL = "https://gitlab.com"
# → Trust Policy の sub = "project_path:<group>/<repo>:ref_type:branch:ref:main"
# シナリオ A: Cloud Run/CI が GCP SA に impersonate して AWS(issuer = accounts.google.com)
# → AWS OIDC Provider URL = "https://accounts.google.com"
# → Trust Policy の sub = "system:serviceaccount:<namespace>:<sa-name>"
両者を 1 つの AWS アカウントで共存させる場合、OIDC Provider は 2 つ別々に登録 する必要があります(gitlab.com と accounts.google.com で URL が違うため、これは衝突しません)。 シナリオごとに変数名を分ける (例: GITLAB_OIDC / GCP_OIDC)ことで、現場の混乱を防げます。
🛠️ ミニマルな AWS 認証スクリプト(CLI 不要)
実装の参考までに、AWS CLI も SDK も使わずに curl + jq だけで AssumeRoleWithWebIdentity を叩く例です。CI イメージを最小化したい場合に有効。
#!/usr/bin/env bash
set -euo pipefail
AWS_ROLE_ARN="${AWS_ROLE_ARN:?AWS_ROLE_ARN is required}"
OIDC_TOKEN="${OIDC_TOKEN:?OIDC_TOKEN is required}"
RESPONSE=$(curl -sf "https://sts.amazonaws.com/" \
--data-urlencode "Action=AssumeRoleWithWebIdentity" \
--data-urlencode "Version=2011-06-15" \
--data-urlencode "RoleArn=${AWS_ROLE_ARN}" \
--data-urlencode "RoleSessionName=${SESSION_NAME:-cross-cloud-job}" \
--data-urlencode "WebIdentityToken=${OIDC_TOKEN}" \
--data-urlencode "DurationSeconds=3600" \
-H "Accept: application/json")
CREDS=".AssumeRoleWithWebIdentityResponse.AssumeRoleWithWebIdentityResult.Credentials"
export AWS_ACCESS_KEY_ID=$(echo "$RESPONSE" | jq -r "${CREDS}.AccessKeyId")
export AWS_SECRET_ACCESS_KEY=$(echo "$RESPONSE" | jq -r "${CREDS}.SecretAccessKey")
export AWS_SESSION_TOKEN=$(echo "$RESPONSE" | jq -r "${CREDS}.SessionToken")
OIDC_TOKEN は呼び出し側で どちらのシナリオの JWT か を意識して埋める設計にしておきます。
🧪 デバッグ:JWT の中身を必ず見る
ハマったらまず JWT の中身を見ます。aud か azp のどちらに何が入っているかを確認するだけで、原因の半分は判明します。
# JWT のペイロード部分(2 番目のセクション)を base64 デコード
echo "$OIDC_TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq .
確認すべきフィールド:
| フィールド | 想定値 |
|---|---|
iss | https://accounts.google.com または https://gitlab.com |
aud | AWS アカウント ID または Provider URL |
azp | GCP SA の uniqueId(GCP 経由の場合) |
oaud | アプリケーション固有の Original Audience |
sub | system:serviceaccount:... または project_path:... |
exp | 1 時間以内の Unix time |
🛡️ ベストプラクティスのまとめ
| 項目 | 推奨 |
|---|---|
aud/azp/oaud/sub | Trust Policy で 3 つ以上の条件を AND で重ねる |
| uniqueId vs email | aud 条件には uniqueId(数値) を使う |
| OIDC Provider | account-level リソースとして 1 度だけ作成、業務スタックは data 参照 |
| Thumbprint | 新旧並列登録でローテーション中もダウンタイムなし |
| シナリオ分離 | GitLab 用 / GCP SA 用で OIDC Provider・変数名・Trust Policy を別管理 |
| JWT デバッグ | 詰まったら JWT を base64 デコード して中身を確認するのが最短 |
🎯 結論
GCP → AWS のキーレス認証は、 公式ドキュメントだけ読んで実装すると必ず詰まる クラスのトピックです。本記事の 7 つの落とし穴は、いずれも公式ドキュメントには 明示的には書かれていない(または記述が分散している) 挙動が原因です。
「動くまでは長いが、動いてしまえば鍵がない」というのが、このアーキテクチャの本質的なメリット。投資としての価値は高いので、 本記事を踏み台にして、最初の実装で詰まる時間を圧縮 していただければ幸いです。
関連記事
- WIF でキーレス認証 — GCP/AWS キーレス認証の基本構造
- GitLab → GCP 認証方式の 3 世代と CTS の選択 — GCP 側の WIF 設計
- Terraform IaC 実践ガイド:CI/CD パイプライン編 — マルチクラウドパイプラインで本記事の認証フローを利用
関連用語
- Workload Identity Federation — クロスクラウド認証の中心概念
- Service Account Impersonation — GCP 側の SA 借用の仕組み
- GitLab id_tokens — GitLab CI 側の OIDC トークン発行
外部資料