diary.sorah.jp

クラウド間の ID フェデレーションで固定シークレットから解放される

GitHub Actions などで ID token を取ってきてクラウドにログインさせる, package の trusted publishing をする, というのは一般的になってきた。多くは Workload Identity Federation などと呼ばれ、近年はついに AWS も公式で ID token の発行をサポートするようになったし、各種クラウドプロバイダで受入れおよび払出しにだいたい対応しきったといって良いんじゃないだろうか。

GitHub Actions で利用する例は世の中に溢れているが、たとえば AWS や Google Cloud 間での利用や、Terraform での設定方法などはあんまりまとまっていない印象がある。そのため本稿では自分の身の回りで必要な、ID token をベースとしたクラウドプロバイダ間の ID フェデレーション方法、特にそれぞれのクラウドプロバイダでの概念、受け入れや発行に必要な Terraform での設定、また使い方についてまとめてみる。

なお、わたしは AWS 利用経験がだいぶ他に比べて重いため、AWS にバイアスがかかったり、他プロバイダの発行について不正確な点があるかもしれない。間違えてたら教えてください。

ID token を利用したフェデレーションの仕組み

ID token 自体は OIDC で定義される、本来は OAuth 2 + OIDC の認可フローで発行される non-opaque な token である。フォーマットは JWT で、ID プロバイダ (issuer) の鍵で署名されており、受取手 (audience) は issuer の鍵を OIDC discovery などを通して取得し、検証を行える。検証にあたり issuer の API を呼び出す必要はない (鍵の取得などでリクエストすることはあるが、キャッシュ可能)、それ自体は issuer の配下にある resource server への権限は持たない (アクセストークンではないため)、というのが特徴だろう。そして、issuer, audience, subject の 3 点を利用して、どの ID プロバイダが、誰に向けて、誰の identity を証明するか、というのが書き込まれたトークンになっている。有効期限やトークン ID を含めてこれらを検証することで、事前に信頼したプロバイダ (issuer) から、自分宛 (audience) に、誰 (subject) がやってきたかどうか確認できる。

そして近年各種クラウドプロバイダでは OAuth 認可フローに依らず、ワークロードに対応した ID token を払出す API や仕掛けを用意するようになってきた。クラウド上で動作するシステムは大抵、そのクラウドの世界で通用する短命な資格情報を取得できることが多い。AWS であれば IAM role, Google であれば service account, Azure であれば managed identity, Kubernetes であれば service account といったものだ。もちろんそれはそのプロバイダ内に閉じてしか使えないプロプライエタリな資格情報であるが、各社が用意している ID token 発行の仕組みを利用することで、他所で通じるトークンとして持ち出すことができるようになった。

他社より発行された ID token を検証して受け入れ、改めて受け入れ側の ID にマッピングし、受け入れ側のクラウドサービスで利用できる短命な資格情報を払い出す。こうして静的・短命ではない API シークレットを用意せずにプロバイダを跨いだ API 利用などができるのだ。

ID token の発行

GitHub Actions

permissions: {id-tokens: write} の権限がある workflow で require('@actions/core').getIDToken() もしくは $ACTIONS_ID_TOKEN_REQUEST_URL$ACTIONS_ID_TOKEN_REQUEST_TOKEN を bearer token とした POST request を投げると ID token が取得できる。Issuer は https://token.actions.githubusercontent.com だ。sub は workflow や environment に応じていい感じに設定される。

尤も、通常は各社プロバイダの用意している actions を利用すればログインまでマネージドにやってくれるはず。

AWS

sts:GetWebIdentityToken API コールを利用する。事前に AWS アカウント全体で有効にした上で、各 identity の IAM ポリシーで sts:GetWebIdentityToken を許可しておく必要がある。Issuer URL は AWS アカウントごとに異なる。

# iam:EnableOutboundWebIdentityFederation でアカウント全体で有効化
resource "aws_iam_outbound_web_identity_federation" "issuer" {}

output "issuer_url" {
  value = aws_iam_outbound_web_identity_federation.issuer.issuer_identifier
}

以下のような IAM ポリシーを持たせる。 sts:IdentityTokenAudience の condition で ID token の発行先 (aud) を絞れる。sub は固定で IAM role ARN や IAM user ARN になる。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["sts:GetWebIdentityToken"],
      "Resource": ["*"],
      "Condition": {
        "Null": {
          "sts:IdentityTokenAudience": "false"
        },
        "ForAllValues:StringEquals": {
          "sts:IdentityTokenAudience": ["https://audience.invalid"]
        }
      }
    }
  ]
}
$ aws sts get-web-identity-token --signing-algorithm ES384 --duration-seconds 300 --audience https://audience.invalid
{
  "WebIdentityToken": "eyJ…"
}

Google

だいたい metadata service から発行可能。特段の権限は必要ないはず。

$ curl -H 'metadata-flavor: Google' 'http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://audience.invalid'
eyJ…

issuer は https://accounts.google.com, subject は取得に利用した service account ID になる (email も email claim としてついてくる)。

一応 Google Cloud 外 (ローカルの開発中とか) で環境を問わない方法として google-auth-library といった SDK を利用する方法が推奨されていたりする。

Azure

いずれの方法でも VM や function につけた managed identity を subject としたものを access token として JWT を取得できる。厳密に得られる文字列は ID token ではない JWT (Entra ID V1.0 access token) なのだが ID token としても互換性があるので利用できる(とされている)。 resource として指定したものが aud になる。これも同様に各種 SDK を通していい感じに取得する方法がある。 https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/azidentity#NewManagedIdentityCredential とか。

Issuer は https://sts.windows.net/.../ (Entra ID directory ごとに異なる)。sub は managed identity の object ID になる。

なお AKS の pod からは https://learn.microsoft.com/en-us/azure/aks/workload-identity-overview と Kubernetes native な方法を利用することになっている(はず)。後述。

Kubernetes

Pod には service account に対応した JWT を projected volume 経由で見せる方法が用意されている。これも Kubernetes API への認証に使えるものだが、ID token として互換のため audience を調整すればフェデレーションに利用できる。

下記は pod の app コンテナに /run/sa/token ファイルとしてマウントさせておく例:

apiVersion: v1
kind: Pod
spec:
  serviceAccountName: test-sa
  containers:
  - name: app
    volumeMounts:
    - { name: token, mountPath: /run/sa, readOnly: true }
  volumes:
  - name: token
    projected:
      sources:
      - serviceAccountToken: { path: token, expirationSeconds: 3600, audience: https://audience.invalid }

AWS EKS で当初用意されていた pod identity webhook は、この設定 + AWS SDK 用の環境変数の設定を annotation を見て inject する admission webhook だったりしたのだ。今は違う方法もあるけど。

なお、 /.well-known/openid-configuration や jwks_url の向き先を作って公開したりそれに合わせて issuer URL をいい感じに設定したりといった作業は別途必要になるだろう。

ID token の受け入れ

GitHub

GitHub には残念ながら無い。みんな ID token を受け取って GitHub Apps の installation token を返す隙間家具を作っているので、好きなものを利用するとよいだろう。GitHub は ID token を発行するようになったところまで偉いけど GitHub 自体の API は全然 long-lived token ばかりで残念だなと思う。だから supply chain attack の餌食になるんだぞ。

願わくば GitHub が公式でサポートして欲しいが、こういうものをデプロイしておくと鍵を AWS KMS や Google Cloud KMS みたいな安全なところに置いて各所の ID token で GitHub API を利用できるので便利。

AWS

ID token の issuer を事前に登録、IAM role で subject を含めた信頼を設定した上で sts:AssumeRoleWithWebIdentity に ID token と IAM role ARN を渡すと AWS access key + session token が得られる。これは Cognito でも使われているし前述の EKS pod identity webhook でも利用されている機能だ。

設定

まず事前に Issuer URL を OIDC identity provider として登録しておく必要がある。以下は GitHub Actions を信頼する例。

# iam:CreateOpenIDConnectProvider
resource "aws_iam_openid_connect_provider" "github-actions" {
  url = "https://token.actions.githubusercontent.com"

  # 信頼する `aud` の値を事前に列挙しておく。この上で IAM role の trust policy でも制御できる
  client_id_list = [
    "sts.amazonaws.com",
  ]
}

そして IAM role を作成して、trust policy で sts:AssumeRoleWithWebIdentity で上記 provider 経由での利用を許可する。subject 等は trust policy で条件として記載すればよい。

resource "aws_iam_role" "ExampleRole" {
  name = "ExampleRole"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = ["sts:AssumeRoleWithWebIdentity"]
        Principal = {
          Federated = "arn:aws:iam::123456123456:oidc-provider/token.actions.githubusercontent.com"
        }
        Condition = {
          StringEquals = {
            "token.actions.githubusercontent.com:aud" = [...]
            "token.actions.githubusercontent.com:sub" = [...]
          }
        }
      }
    ]
  })
}

利用

AWS SDK にはファイルから読んで自動更新してくれる credentials provider がいるのでそれを使うと良い。環境変数や設定ファイルによる自動検出も用意されている。

require 'aws-sdk-sts'
require 'aws-sdk-s3'
credentials = Aws::AssumeRoleWebIdentityCredentials.new(role_arn:, role_session_name: 'dareka', token_file: '/path/to/token...')
@s3 = Aws::S3::Client.new(credentials:)
p @s3.list_buckets(...)

ファイルに落ちてこない場合は手動で作る。この場合自動更新は効かない。

credentials = Aws::STS::Client.new(credentials: nil).assume_role_with_web_identity(
  role_arn:,
  role_session_name: 'dareka',
  web_identity_token:,
).credentials.then do
  Aws::Credentials.new(it.access_key_id, it.secret_access_key, it.session_token)
end
@s3 = Aws::S3::Client.new(credentials:)
p @s3.list_buckets(...)

Tips: role_session_name は CloudTrail にユーザー名として出てくるので常にある程度固定の値がオススメ!

Google

ざっくり Workload Identity Pool を作り、pool に ID token の issuer を provider として追加、そして RFC 8693 token exchange を通して access token を得る。実際には互換性のためにそれでマッピングされた IAM principalservice account impersonation の権限を付与、 service account の access token に交換するという 2 段階を踏んで利用するのがよい。

設定

まずは provider の追加まで。以下は AWS を信用する例。

resource "google_project_service" "apis" {
  for_each = toset([
    "iam.googleapis.com",
    "sts.googleapis.com",
    "iamcredentials.googleapis.com",
  ])
  service = each.value
}

resource "google_iam_workload_identity_pool" "aws" {
  workload_identity_pool_id = "my-aws"
  display_name              = "aws"
}

resource "google_iam_workload_identity_pool_provider" "aws" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.aws.workload_identity_pool_id
  workload_identity_pool_provider_id = "my-aws"
  display_name                       = "AWS"
  description                        = "AWS"

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

  attribute_condition = "assertion.sub.startsWith('arn:aws:iam::AWS_ACCOUNT_ID:')"

  oidc {
    issuer_uri = "https://….tokens.sts.global.api.aws"

    # allowed_audiences を省略すると pool provider URL を元にデフォルト値が決まる
    # //iam.googleapis.com/projects/$PROJECT_ID/locations/global/workloadIdentityPools/my-aws/providers/my-aws
  }
}

この設定を行うと token exchange で ID token を Google の access_token に交換できるようになる。交換した access token は principal://iam.googleapis.com/${google_iam_workload_identity_pool.aws.id}/subject/${SUBJECT_ATTRIBUTE_VALUE} の principal としてマッピングされた人格を持つ。 SUBJECT_ATTRIBUTE_VALUE はここでは assertion.sub をそのまま google.subject にマッピングする設定を入れているので、AWS であれば IAM role ARN などになるだろう。すなわち、

  • 例: principal://iam.googleapis.com/projects/PROJECT_ID/locations/global/workloadIdentityPools/my-aws/subject/arn:aws:iam::123456123456:role/MyRole
    • SUBJECT_ATTRIBUTE_VALUE というのを含んだこの principal:// URL は Google Cloud Console に表示されるのだが、勝手に置換して埋めてくれというのはドキュメントを読まないと分からない。不親切だなと思う。

ではこの principal:// URL に権限をつければいい… んだな、と思うがもう一手間必要。この時点で実際に権限を付けることはできるのだが、この状態の access token はまだ全てのサービスで受け入れられるわけではないらしい……。そこで、互換性を高めるために service account impersonation を組み合わせる。これは Google Cloud Console でも表示される選択肢だし、ドキュメントでも Alternative choice として紹介されている。

しかし Google Cloud Console では [Grant access using federated identities (Recommended)] なり "You can grant access directly to your workload pool without impersonating a service account." なり、service account を通さず principal:// URL へ直接権限を追加する方法をオススメしてきてはいるが、対応してないサービスが存在するのに Recommended なわけないじゃん、と思う。本当にわりとまだ広範囲に渋い領域で残ってる ので、不慣れであれば余計に、よほどのことがなければまだ service account を通したほうが良い。 というわけで service account を準備する:

resource "google_service_account" "my-role" {
  account_id = "my-role"
}

resource "google_service_account_iam_member" "my-role" {
  service_account_id = google_service_account.my-role.name
  role               = "roles/iam.workloadIdentityUser" # iam.serviceAccounts.{getAccessToken, getOpenIdToken}
  member             = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.aws.id}/subject/${SUBJECT_ATTRIBUTE_VALUE}"
}

なお、Google Cloud Console 上の identity pool のページで [Grant Access] → [Grant access using service account impersonation] からする操作は、上記の通り SA へ権限を付けているだけである。これもただの IAM 操作なんだ (workload identity pool の API ではないんだ) というのが微妙に隠蔽されていて分かりづらい。そして、service account impersonation を通さない場合は上記と同じように principal:// URL を利用して直接リソースやプロジェクトに権限をつければよい。上記は直接 SA の利用者として権限をつけているだけの例なのだ。

Tips: Workload Identity Pool のプロバイダとして AWS が表示されるが、それはレガシーな手段と考えられる。これは Google は AWS 向けに sts:GetWebIdentityToken がなかった頃に sts:GetCallerIdentity リクエストの sigv4 署名を利用して (Google が STS API をユーザーにかわって呼んで) federate する方法を用意していた。 https://google.aip.dev/auth/4117#determining-the-subject-token-in-aws . ついに ID token を AWS から直接出せる今、使うべきではない。なぜなら Google Cloud SDK に入っている AWS provider のサポートは AWS credentials を環境変数か EC2 IMDSv2 くらいしか見てくれず、ECS や Lambda では一手間加えないと動かない。だまされないようにしよう。

利用

Google Cloud SDK には ID token を Google access token に exchange し、さらに service account impersonation まで行うための credentials configuration が存在する。

手動で作成した service account key が入った JSON や、gcloud で作成した application default credentials の JSON と同じようなもので、ただし workload identity federation を通さない場合はシークレットを含まず、ID tokenをどこから取ってくればいいか指示するというイメージだ。ファイル、URL、Executable のどれかから取ってくるように指定することができる。

クラウドプロバイダによっては Executable が最適解となりうるだろう。たとえば AWS の sts:GetWebIdentityToken であれば @draftcodehttps://github.com/draftcode/aws-gcp-token を作っていたので紹介する。

// credentials.json
{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/$PROJECT_ID/locations/global/workloadIdentityPools/aws/providers/aws",
  "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/$SA_EMAIL:generateAccessToken",
  "credential_source": {
    "executable": {
      "command": "aws-gcp-token",
      "timeout_millis": 5000
    }
  }
}
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/credentials.json
export GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES=1
require 'google-cloud-storage'
@gcs = Google::Cloud::Storage.new
p @gcs.buckets

Google Cloud SDK に頼らずとも、token_url は RFC 8693 token endpoint なのでそのようなリクエストを送れば access_token が得られる。Impersonation はプロプライエタリ API だけど。

require 'httpx'
sa = 'my-role@PROJECT_ID.iam.gserviceaccount.com'
# RFC 8693 token-exchange
token = HTTPX.post(
  'https://sts.googleapis.com/v1/token',
  form: {
    audience:,
    subject_token:,
    subject_token_type: 'urn:ietf:params:oauth:token-type:jwt',
    requested_token_type: 'urn:ietf:params:oauth:token-type:access_token',
    grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
    scope: 'https://www.googleapis.com/auth/iam',
  },
).raise_for_status.json.fetch('access_token')
# projects.serviceAccounts.generateAccessToken
sa_token = HTTPX.plugin(:auth).bearer_auth(token).post(
  "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/#{sa}:generateAccessToken",
  json: {scope: ['https://www.googleapis.com/auth/iam'], lifetime: '3600s'},
).raise_for_status.json.fetch('accessToken')
p sa_token

Entra ID / Azure

User-assigned managed identityApp registration を作成して federated credentials として Issuer URL と subject を登録しておく。ID token を RFC 7523 と組み合わせて OAuth 2 client_credentials grant flow で token endpoint に送信すれば client_id としての access_token を得られる、というのが概要だ。

2種類の設定方法

最終的に得られるものは Entra の service principal としての人格になり、いずれの方法でも Entra ID, Azure role を割り当て可能であるため、どちらを選択してもほとんど変わりはない(はず)。管理において若干の違いがあるというところだとおもう。

  • Managed identity: Azure 側のリソースになるため Azure の権限で管理できる。Subscription と resource group へ属する
  • App registration: Entra ID 側のリソースになるため Entra ID の権限になる (Owners等)。Entra ID directory に属する

また、managed identity では当然 OAuth 2 でユーザーとしての認可を得ることはできない。個人的には Azure のリソースを使わないのであれば App registration で良いんだろうなという気がしている…。

設定

App registration で AWS を信頼する例を示す。

resource "azuread_application" "MyRole" {
  display_name = "MyRole"
  owners = ["…"]
}

resource "azuread_service_principal" "MyRole" {
  client_id                    = azuread_application.MyRole.client_id
  app_role_assignment_required = false
  owners                       = azuread_application.MyRole.owners
}

resource "azuread_application_federated_identity_credential" "MyRole_aws" {
  application_id = azuread_application.MyRole.id
  display_name   = "aws-MyRole"

  issuer    = "https://….tokens.sts.global.api.aws"
  subject   = "arn:aws:iam::123456123456:role/MyRole"
  audiences = ["api://AzureADTokenExchange"]
}

resource "azurerm_role_assignment" "MyRole_storage" {
  scope                = "…"
  role_definition_name = "Storage Blob Data Owner"
  principal_id         = azuread_service_principal.MyRole.object_id
}

output "client_id" {
    value = azuread_application.MyRole.client_id
}

Managed identity の場合は下記の通り。ほぼ変わらない。Resource group まで作るならちょっと記述量が多いとも言える。どっちもどっちですね。App registration だと azurerm に加え azuread provider と併用しなきゃいけないという差もあるし。

resource "azurerm_user_assigned_identity" "MyRole" {
  location            = "…"
  resource_group_name = "…"
  name                = "MyRole"
}

resource "azurerm_federated_identity_credential" "MyRole_aws" {
  parent_id = azurerm_user_assigned_identity.MyRole.id

  name     = "aws-MyRole"
  issuer   = "https://….tokens.sts.global.api.aws"
  audience = ["api://AzureADTokenExchange"]
  subject  = data.aws_iam_role.MyRole.arn
}

resource "azurerm_role_assignment" "MyRole_storage" {
  scope                = "…"
  role_definition_name = "Storage Blob Data Owner"
  principal_id         = azurerm_user_assigned_identity.MyRole.principal_id
} 

output "client_id" {
  value = azurerm_user_assigned_identity.MyRole.client_id
}

利用

service principal としてのトークンを得るのはどちらの場合も client_id と Entra ID tenant ID (directory ID) が分かれば RFC 7523 と client_credentials フローで取得できる。

require 'httpx'
p HTTPX.post(
  "https://login.microsoftonline.com/#{TENANT_ID}/oauth2/v2.0/token", form: {
    grant_type: "client_credentials",
    client_id: CLIENT_ID,
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion:,
    scope: "https://storage.azure.com/.default",
  },
).raise_for_status.json.fetch("access_token")

Azure は公式に Ruby SDK を提供していないのでこのようなコード片でしか紹介できないのだが、一応 SDK を見ると $AZURE_CLIENT_ID, $AZURE_TENANT_ID, $AZURE_FEDERATED_TOKEN_FILE 環境変数などを通して自動検出はある気がする。ドキュメントに standardized behavior があんま見当たらないので良くわからない……。

おまけ: OAuth 認可コードフローでも使える

App registration でユーザーの OAuth 2 authorization code grant flow も利用して認可や user の ID token を得ている場合でも client の ID token を使える。単に client_secret の代わりに client_assertion を送れば良い。

p HTTPX.post(
  "https://login.microsoftonline.com/#{TENANT_ID}/oauth2/v2.0/token", form: {
    grant_type: "authorization_code",
    code: "...",
    client_id: CLIENT_ID,
    client_assertion_type: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    client_assertion:,
    # ... 以下省略 ...
  },
).raise_for_status.json.fetch("access_token")

その他

Claude や OpenAI の API も ID token を受け入れる仕組みがあるので固定シークレットを撤廃できる。

なおどちらも公式 Terraform provider はない。

Outro

駆け足ではあるが主要クラウドプロバイダでのワークロードの ID token 発行と受け入れについて紹介してみた。現職 IVRy も AWS を中心にインフラを組んでいるが、LLM 利用なり Entra ID の API なりで複数のクラウドプロバイダに触れる機会は以前よりも多く、さらに Terraform 自動化なりで AWS 以外もセキュアに強い権限をクラウドプロバイダを跨いで引き回す必要があったのでまとめた次第。参考になれば嬉しい。

Published at