Google Cloud から AWS へ OIDC Federation で認証してアクセスする

Google Cloud Service Account に対して OIDC Federation を構成して AWS へのアクセスを行う #

前提として、AWS へアクセスするための認証方法は大きく 2 種類あります。

通常、セキュリティ的な観点から後者の方法が推薦されています。

Google Cloud 上で実行される処理から AWS リソースにアクセスを行う場合、そのコンテキストで利用可能なサービスアカウントを利用して AWS の一時的な認証情報を取得できるよう構成するのが推薦されます。具体的には、次のようなことを行います。

  1. (Google Cloud) サービスアカウントを作成します。
  2. (AWS) IAM ロールを作成し、その信頼ポリシーで 1. のサービスアカウントに対する OIDC Federation を構成します。また、必要に応じて適切な IAM ポリシーを割り当てます。
  3. (Google Cloud) サービスアカウントの ID トークンを用いて 2. で作成した IAM ロールに対して AssumeRoleWithWebIdentity を呼び出し、一時的な認証情報を取得します。この認証情報を利用して AWS リソースにアクセスします。
完全な例として Cloud Run Functions から S3 ファイルを取得する実装例を用意しました。
variable "aws_account_id" {
  type = string
}

variable "aws_profile" {
  type     = string
  nullable = true
}

variable "aws_region" {
  type = string
}

variable "google_project" {
  type = string
}

variable "google_region" {
  type = string
}

variable "function_name" {
  type    = string
  default = "web-identity-example"
}

variable "role_name" {
  type    = string
  default = "web-identity-example-role"
}

variable "bucket_name" {
  type    = string
  default = "web-identity-example-bucket"
}

terraform {
  required_providers {
    archive = {
      source  = "hashicorp/archive"
      version = "~> 2.0"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    google = {
      source  = "hashicorp/google"
      version = "~> 7.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.0"
    }
  }
}

provider "aws" {
  allowed_account_ids = [var.aws_account_id]
  profile             = var.aws_profile
  region              = var.aws_region
}

provider "google" {
  project = var.google_project
  region  = var.google_region
}

resource "random_password" "id_token_audience" {
  keepers = { revision = 0 }
  length  = 32
  special = false
}

#
# Google IAM
#
resource "google_service_account" "this" {
  account_id = var.function_name
  project    = var.google_project
}

#
# Google Cloud Functions
#
resource "google_storage_bucket" "this" {
  force_destroy               = true
  location                    = var.google_region
  name                        = "${var.google_project}-${var.function_name}"
  project                     = var.google_project
  public_access_prevention    = "enforced"
  uniform_bucket_level_access = true
}

data "archive_file" "this" {
  output_path = "${path.module}/src.zip"
  type        = "zip"

  source {
    content  = <<-EOT
    import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
    import { fromWebToken } from "@aws-sdk/credential-providers";
    import { http } from "@google-cloud/functions-framework";
    import { GoogleAuth } from "google-auth-library";

    function fromGoogleToken(config) {
      const { audience, durationSeconds, refreshOffsetSeconds, roleArn, roleSessionName } = config;
      const googleAuth = new GoogleAuth();
      const state = { credentials: null };

      return async () => {
        // check expiration
        if (state.credentials?.expiration) {
          const tokenExpiration = state.credentials.expiration.getTime();
          let expirationThreshold = Date.now();
          if (typeof refreshOffsetSeconds === "number" && refreshOffsetSeconds > 0) {
            expirationThreshold += refreshOffsetSeconds * 1000;
          }
          if (tokenExpiration > expirationThreshold) {
            return state.credentials;
          }
        }
        // refresh access token
        const client = await googleAuth.getClient();
        const webIdentityToken = await client.fetchIdToken(audience);
        const provider = fromWebToken({ durationSeconds, roleArn, roleSessionName, webIdentityToken });
        const credentials = await provider();
        state.credentials = credentials;
        return credentials;
      };
    }

    const { ID_TOKEN_AUDIENCE, ROLE_ARN, S3_BUCKET } = process.env;
    const credentials = fromGoogleToken({
      audience: ID_TOKEN_AUDIENCE,
      durationSeconds: 15 * 60,
      refreshOffsetSeconds: 1 * 60,
      roleArn: ROLE_ARN
    });

    http("main", async (req, res) => {
      try {
        const s3Client = new S3Client({ credentials });
        const response = await s3Client.send(new GetObjectCommand({ Bucket: S3_BUCKET, Key: "index.html" }));
        const content = await response.Body.transformToString();
        res.status(200).setHeader("Content-Type", "text/html").send(content);
      } catch (error) {
        res.status(500).send({ success: false, error: error.message });
      }
    });
    EOT
    filename = "index.js"
  }
  source {
    content  = <<-EOT
    {
      "name": "example",
      "version": "0.0.1",
      "private": true,
      "type": "module",
      "main": "index.js",
      "dependencies": {
        "@aws-sdk/client-s3": "^3.937.0",
        "@aws-sdk/credential-providers": "^3.936.0",
        "@google-cloud/functions-framework": "^4.0.1",
        "google-auth-library": "^10.5.0"
      }
    }
    EOT
    filename = "package.json"
  }
}

resource "google_storage_bucket_object" "this" {
  bucket = google_storage_bucket.this.name
  name   = "${data.archive_file.this.output_md5}.zip"
  source = data.archive_file.this.output_path
}

resource "google_cloudfunctions2_function" "this" {
  location = var.google_region
  name     = var.function_name
  project  = var.google_project

  build_config {
    entry_point = "main"
    runtime     = "nodejs24"

    source {
      storage_source {
        bucket = google_storage_bucket_object.this.bucket
        object = google_storage_bucket_object.this.name
      }
    }
  }
  service_config {
    available_cpu         = "1"
    available_memory      = "512M"
    ingress_settings      = "ALLOW_ALL"
    max_instance_count    = 1
    min_instance_count    = 0
    service_account_email = google_service_account.this.email
    timeout_seconds       = 30
    environment_variables = {
      AWS_REGION        = var.aws_region
      ID_TOKEN_AUDIENCE = random_password.id_token_audience.result
      ROLE_ARN          = aws_iam_role.this.arn
      S3_BUCKET         = aws_s3_bucket.this.id
    }
  }
}

resource "google_cloud_run_service_iam_binding" "this" {
  location = google_cloudfunctions2_function.this.location
  members  = ["allUsers"]
  project  = google_cloudfunctions2_function.this.project
  role     = "roles/run.invoker"
  service  = google_cloudfunctions2_function.this.service_config[0].service
}

#
# S3
#
resource "aws_s3_bucket" "this" {
  bucket        = var.bucket_name
  force_destroy = true
  region        = var.aws_region
}

resource "aws_s3_bucket_public_access_block" "this" {
  bucket = aws_s3_bucket.this.id
  region = aws_s3_bucket.this.region

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_s3_object" "this" {
  bucket  = aws_s3_bucket.this.id
  content = "<h1>Hello from AWS S3!</h1>"
  key     = "index.html"
  region  = aws_s3_bucket.this.region
}

#
# AWS IAM
#
data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"

    condition {
      test     = "StringEquals"
      values   = [random_password.id_token_audience.result]
      variable = "accounts.google.com:oaud"
    }
    condition {
      test     = "StringEquals"
      values   = [google_service_account.this.unique_id]
      variable = "accounts.google.com:sub"
    }
    principals {
      identifiers = ["accounts.google.com"]
      type        = "Federated"
    }
  }
}

resource "aws_iam_role" "this" {
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
  name               = var.role_name
}

data "aws_iam_policy_document" "role_policy" {
  statement {
    actions   = ["s3:GetObject"]
    effect    = "Allow"
    resources = ["${aws_s3_bucket.this.arn}/*"]
  }
}

resource "aws_iam_policy" "this" {
  name   = var.role_name
  policy = data.aws_iam_policy_document.role_policy.json
}

resource "aws_iam_role_policy_attachment" "this" {
  policy_arn = aws_iam_policy.this.arn
  role       = aws_iam_role.this.name
}

以降ではこの例を踏まえた主要な部分を解説します。

1. Google Cloud サービスアカウントの作成 #

IAM ロールの設定に必要な値はサービスアカウント作成後にしか得られないため、まずサービスアカウントを作成しておきます。また、合わせて ID token audience として利用する値を準備しておきます。例としてランダム文字列を生成して利用するものとします。

resource "google_service_account" "this" {
  account_id = "oidc-federation-example"
}

resource "random_password" "id_token_audience" {
  keepers = { revision = 0 }
  length  = 32
  special = false
}

2. AWS IAM ロールの作成 #

ステップ 1 で準備したサービスアカウントに対応した信頼ポリシーを持つ IAM ロールを作成します。ID token の検証項目として次の条件を適切に設定する必要があります。

data "aws_iam_policy_document" "assume_role_policy" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    effect  = "Allow"
    principals {
      type        = "Federated"
      identifiers = ["accounts.google.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "accounts.google.com:oaud"
      values   = [random_password.id_token_audience.result]
    }
    condition {
      test     = "StringEquals"
      variable = "accounts.google.com:sub"
      values   = [google_service_account.this.unique_id]
    }
  }
}

resource "aws_iam_role" "this" {
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy.json
  name               = "oidc-federation-example"
}

3. OIDC Federation による一時的な認証情報の取得 #

Google Cloud 上で実行される処理として、ここでは AWS SDK for JavaScript v3 を利用した例を示します。fromWebToken は AssumeRoleWithWebIdentity を利用するための認証情報プロバイダーです。この呼び出しに必要なサービスアカウントの ID token を取得するには google-auth-library を利用するのが簡単でしょう。

import { fromWebToken } from "@aws-sdk/credential-providers";
import { GoogleAuth } from "google-auth-library";

// Get AWS access credentials by using AssumeRoleWithWebIdentity.
const audience = process.env.AWS_OIDC_AUDIENCE; // random_password.id_token_audience.result
const roleArn = process.env.AWS_ROLE_ARN; // aws_iam_role.this.arn
const googleAuth = new GoogleAuth();
const googleClient = await googleAuth.getClient();
const webIdentityToken = await googleClient.fetchIdToken(audience);
const credentials = fromWebToken({ roleArn, webIdentityToken });

// Usage: For example, get s3 object.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ credentials });
const obj = await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "my-file.txt" }));

fromWebToken で得られるのは一時的な認証情報であり、有効期限が切れた場合には再取得が必要です。

次のような認証情報のリフレッシュを行う wrapper を実装しておくのがよいでしょう。
import { fromWebToken } from "@aws-sdk/credential-providers";
import { GoogleAuth } from "google-auth-library";

function fromGoogleToken(config) {
  const { audience, durationSeconds, refreshOffsetSeconds, roleArn, roleSessionName } = config;
  const googleAuth = new GoogleAuth();
  const state = { credentials: null };

  return async () => {
    // check expiration
    if (state.credentials?.expiration) {
      const tokenExpiration = state.credentials.expiration.getTime();
      let expirationThreshold = Date.now();
      if (typeof refreshOffsetSeconds === "number" && refreshOffsetSeconds > 0) {
        expirationThreshold += refreshOffsetSeconds * 1000;
      }
      if (tokenExpiration > expirationThreshold) {
        return state.credentials;
      }
    }

    // refresh access token
    const client = await googleAuth.getClient();
    const webIdentityToken = await client.fetchIdToken(audience);
    const provider = fromWebToken({ durationSeconds, roleArn, roleSessionName, webIdentityToken });
    const credentials = await provider();
    state.credentials = credentials;
    return credentials;
  };
}

// Intiialize provider.
const audience = process.env.AWS_OIDC_AUDIENCE; // random_password.id_token_audience.result
const roleArn = process.env.AWS_ROLE_ARN; // aws_iam_role.this.arn
const credentials = fromGoogleToken({ audience, roleArn });

// Usage: For example, get s3 object.
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";

const s3 = new S3Client({ credentials });
const obj = await s3.send(new GetObjectCommand({ Bucket: "my-bucket", Key: "my-file.txt" }));

まとめ #

Google Cloud サービスアカウントで OIDC Federation (AssumeRoleWithWebIdentity) を利用して AWS の一時的な認証情報の取得をする方法を説明しました。この方法では、アクセスキーのようなセキュリティリスクの高い認証情報を扱う必要が無く、より安全に AWS リソースへアクセスできます。

関連する話題