署名済みヘッダーによるアプリの保護

このページでは、署名付きの IAP ヘッダーを使用してアプリを保護する方法について説明します。Identity-Aware Proxy(IAP)を構成すると、JSON Web Token(JWT)によってアプリに対するリクエストが承認されます。アプリは次のようなリスクから保護されます。

  • IAP が誤って無効化される
  • ファイアウォールの構成が誤っている
  • プロジェクト内からの不正アクセス

アプリを保護するには、すべてのアプリの種類に対して署名付きヘッダーを使用する必要があります。

また、App Engine スタンダード環境アプリを使用している場合は、Users API を使用できます。

Compute Engine と GKE のヘルスチェックには JWT ヘッダーが含まれていません。このため、IAP はヘルスチェックを処理しません。ヘルスチェックでアクセスエラーが返された場合は、ヘルスチェックが Google Cloud コンソールで正しく構成されていることと、JWT ヘッダーの検証でヘルスチェックのパスが許可されていることを確認してください。詳細については、ヘルスチェックの例外を作成するをご覧ください。

始める前に

署名済みヘッダーを使用してアプリを保護するには、次のものが必要になります。

IAP ヘッダーによるアプリの保護

IAP JWT でアプリを保護するには、JWT のヘッダー、ペイロード、署名を確認します。JWT は、HTTP リクエスト ヘッダー x-goog-iap-jwt-assertion にあります。IAP をバイパスすると、IAP の署名がない ID ヘッダー x-goog-authenticated-user-{email,id} の偽造が可能になります。IAP JWT はより安全な代替手段を提供します。

IAP がバイパスされた場合に備えて、署名付きヘッダーを使用することで補助的なセキュリティを提供します。IAP が有効になっている場合、リクエストが IAP の処理インフラストラクチャを通過するときに、クライアントが設定した x-goog-* ヘッダーが削除されます。

JWT ヘッダーの検証

JWT ヘッダーが次の制約に従っていることを確認します。

JWT ヘッダー クレーム
alg アルゴリズム ES256
kid キー ID IAP キーファイルにある公開鍵のいずれかに対応する必要があります。https://www.gstatic.com/iap/verify/public_key または https://www.gstatic.com/iap/verify/public_key-jwk のいずれかの形式で使用できます。

JWT が、トークンの kid クレームに対応する秘密鍵で署名されていることを確認します。まず、次のいずれかから公開鍵を取得します。

  • https://www.gstatic.com/iap/verify/public_key: この URL には、kid クレームを公開鍵の値にマッピングする JSON 辞書が含まれています。
  • https://www.gstatic.com/iap/verify/public_key-jwk: この URL には、JWK 形式の IAP 公開鍵が含まれています。

公開鍵を取得したら、JWT ライブラリを使用して署名を検証します。

IAP は公開鍵を定期的にローテーションします。JWT を常に検証できるようにするには、公開鍵のキャッシュ保存を自動化するをご覧ください。

JWT ペイロードの検証

JWT ペイロードが次の制約に従っていることを確認します。

JWT ペイロード クレーム
exp 有効期限 将来の時点にする必要があります。この時間は、UNIX エポック時刻からの秒数です。スキューには 30 秒かかります。トークンの最大有効期間は、10 分 + 2 * スキューです。
iat 発行時 過去の時点にする必要があります。この時間は、UNIX エポック時刻からの秒数です。スキューには 30 秒かかります。
aud 対象 次の値を持つ文字列にする必要があります。
  • App Engine: /projects/PROJECT_NUMBER/apps/PROJECT_ID
  • Compute Engine と GKE: /projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID
  • Cloud Run: /projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_NAME
iss 発行元 https://cloud.google.com/iap にする必要があります。
hd アカウント ドメイン アカウントがホスト型ドメインに属する場合、アカウントが関連付けられているドメインを区別するために hd クレームが設定されます。
google Google クレーム リクエストにアクセスレベルが 1 つ以上適用されている場合、その名前は google クレームの JSON オブジェクトの access_levels キーの下に、文字列配列として格納されます。

デバイス ポリシーを指定していて、組織がデバイスデータにアクセスできる場合は、DeviceId も JSON オブジェクトに保存されます。別の組織に送信されるリクエストには、デバイスデータを表示する権限がない可能性があります。

上記の aud 文字列の値を取得するには、Google Cloud コンソールまたは gcloud コマンドライン ツールを使用します。

Google Cloud コンソールから aud 文字列値を取得するには、プロジェクトの Identity-Aware Proxy の設定に移動し、ロードバランサ リソースの横にある [さらに表示] をクリックして、[署名済みヘッダー JWT のユーザー] を選択します。[署名済みヘッダー JWT] ダイアログが開き、選択したリソースの aud クレームが表示されます。

gcloud CLI の gcloud コマンドライン ツールを使用して aud 文字列の値を取得する場合は、プロジェクト ID を知っている必要があります。Google Cloud コンソールまたはプロジェクト情報カードでプロジェクト ID を確認し、その値ごとに指定されたコマンドを実行します。

プロジェクト番号

gcloud コマンドライン ツールを使用してプロジェクト番号を取得するには、次のコマンドを実行します。

gcloud projects describe PROJECT_ID

コマンドから次の出力が返されます。

createTime: '2016-10-13T16:44:28.170Z'
lifecycleState: ACTIVE
name: project_name
parent:
  id: '433637338589'
  type: organization
projectId: PROJECT_ID
projectNumber: 'PROJECT_NUMBER'

サービス ID

gcloud コマンドライン ツールを使用してサービス ID を取得するには、次のコマンドを実行します。

gcloud compute backend-services describe SERVICE_NAME --project=PROJECT_ID --global

コマンドから次の出力が返されます。

affinityCookieTtlSec: 0
backends:
- balancingMode: UTILIZATION
  capacityScaler: 1.0
  group: https://www.googleapis.com/compute/v1/projects/project_name/regions/us-central1/instanceGroups/my-group
connectionDraining:
  drainingTimeoutSec: 0
creationTimestamp: '2017-04-03T14:01:35.687-07:00'
description: ''
enableCDN: false
fingerprint: zaOnO4k56Cw=
healthChecks:
- https://www.googleapis.com/compute/v1/projects/project_name/global/httpsHealthChecks/my-hc
id: 'SERVICE_ID'
kind: compute#backendService
loadBalancingScheme: EXTERNAL
name: my-service
port: 8443
portName: https
protocol: HTTPS
selfLink: https://www.googleapis.com/compute/v1/projects/project_name/global/backendServices/my-service
sessionAffinity: NONE
timeoutSec: 3610

ユーザー ID の取得

上記の検証がすべて成功したら、ユーザー ID を取得します。ID トークンのペイロードには次のユーザー情報が含まれています。

ID トークン ペイロードのユーザー ID
sub 件名 ユーザー固有の安定した識別子。この値は、x-goog-authenticated-user-id ヘッダーの代わりに使用します。
email ユーザーのメール ユーザーのメールアドレス。
  • この値は、x-goog-authenticated-user-email ヘッダーの代わりに使用します。
  • ヘッダーや sub クレームと異なり、この値には名前空間の接頭辞がありません。

次のサンプルコードでは、署名付き IAP ヘッダーを使用してアプリを保護しています。

C#


using Google.Apis.Auth;
using Google.Apis.Auth.OAuth2;
using System;
using System.Threading;
using System.Threading.Tasks;

public class IAPTokenVerification
{
    /// <summary>
    /// Verifies a signed jwt token and returns its payload.
    /// </summary>
    /// <param name="signedJwt">The token to verify.</param>
    /// <param name="expectedAudience">The audience that the token should be meant for.
    /// Validation will fail if that's not the case.</param>
    /// <param name="cancellationToken">The cancellation token to propagate cancellation requests.</param>
    /// <returns>A task that when completed will have as its result the payload of the verified token.</returns>
    /// <exception cref="InvalidJwtException">If verification failed. The message of the exception will contain
    /// information as to why the token failed.</exception>
    public async Task<JsonWebSignature.Payload> VerifyTokenAsync(
        string signedJwt, string expectedAudience, CancellationToken cancellationToken = default)
    {
        SignedTokenVerificationOptions options = new SignedTokenVerificationOptions
        {
            // Use clock tolerance to account for possible clock differences
            // between the issuer and the verifier.
            IssuedAtClockTolerance = TimeSpan.FromMinutes(1),
            ExpiryClockTolerance = TimeSpan.FromMinutes(1),
            TrustedAudiences = { expectedAudience },
            TrustedIssuers = { "https://cloud.google.com/iap" },
            CertificatesUrl = GoogleAuthConsts.IapKeySetUrl,
        };

        return await JsonWebSignature.VerifySignedTokenAsync(signedJwt, options, cancellationToken: cancellationToken);
    }
}

Go

import (
	"context"
	"fmt"
	"io"

	"google.golang.org/api/idtoken"
)

// validateJWTFromAppEngine validates a JWT found in the
// "x-goog-iap-jwt-assertion" header.
func validateJWTFromAppEngine(w io.Writer, iapJWT, projectNumber, projectID string) error {
	// iapJWT := "YmFzZQ==.ZW5jb2RlZA==.and0" // req.Header.Get("X-Goog-IAP-JWT-Assertion")
	// projectNumber := "123456789"
	// projectID := "your-project-id"
	ctx := context.Background()
	aud := fmt.Sprintf("/projects/%s/apps/%s", projectNumber, projectID)

	payload, err := idtoken.Validate(ctx, iapJWT, aud)
	if err != nil {
		return fmt.Errorf("idtoken.Validate: %w", err)
	}

	// payload contains the JWT claims for further inspection or validation
	fmt.Fprintf(w, "payload: %v", payload)

	return nil
}

// validateJWTFromComputeEngine validates a JWT found in the
// "x-goog-iap-jwt-assertion" header.
func validateJWTFromComputeEngine(w io.Writer, iapJWT, projectNumber, backendServiceID string) error {
	// iapJWT := "YmFzZQ==.ZW5jb2RlZA==.and0" // req.Header.Get("X-Goog-IAP-JWT-Assertion")
	// projectNumber := "123456789"
	// backendServiceID := "backend-service-id"
	ctx := context.Background()
	aud := fmt.Sprintf("/projects/%s/global/backendServices/%s", projectNumber, backendServiceID)

	payload, err := idtoken.Validate(ctx, iapJWT, aud)
	if err != nil {
		return fmt.Errorf("idtoken.Validate: %w", err)
	}

	// payload contains the JWT claims for further inspection or validation
	fmt.Fprintf(w, "payload: %v", payload)

	return nil
}

Java


import com.google.api.client.http.HttpRequest;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.auth.oauth2.TokenVerifier;

/** Verify IAP authorization JWT token in incoming request. */
public class VerifyIapRequestHeader {

  private static final String IAP_ISSUER_URL = "https://cloud.google.com/iap";

  // Verify jwt tokens addressed to IAP protected resources on App Engine.
  // The project *number* for your Google Cloud project via 'gcloud projects describe $PROJECT_ID'
  // The project *number* can also be retrieved from the Project Info card in Cloud Console.
  // projectId is The project *ID* for your Google Cloud Project.
  boolean verifyJwtForAppEngine(HttpRequest request, long projectNumber, String projectId)
      throws Exception {
    // Check for iap jwt header in incoming request
    String jwt = request.getHeaders().getFirstHeaderStringValue("x-goog-iap-jwt-assertion");
    if (jwt == null) {
      return false;
    }
    return verifyJwt(
        jwt,
        String.format("/projects/%s/apps/%s", Long.toUnsignedString(projectNumber), projectId));
  }

  boolean verifyJwtForComputeEngine(HttpRequest request, long projectNumber, long backendServiceId)
      throws Exception {
    // Check for iap jwt header in incoming request
    String jwtToken = request.getHeaders().getFirstHeaderStringValue("x-goog-iap-jwt-assertion");
    if (jwtToken == null) {
      return false;
    }
    return verifyJwt(
        jwtToken,
        String.format(
            "/projects/%s/global/backendServices/%s",
            Long.toUnsignedString(projectNumber), Long.toUnsignedString(backendServiceId)));
  }

  private boolean verifyJwt(String jwtToken, String expectedAudience) {
    TokenVerifier tokenVerifier =
        TokenVerifier.newBuilder().setAudience(expectedAudience).setIssuer(IAP_ISSUER_URL).build();
    try {
      JsonWebToken jsonWebToken = tokenVerifier.verify(jwtToken);

      // Verify that the token contain subject and email claims
      JsonWebToken.Payload payload = jsonWebToken.getPayload();
      return payload.getSubject() != null && payload.get("email") != null;
    } catch (TokenVerifier.VerificationException e) {
      System.out.println(e.getMessage());
      return false;
    }
  }
}

Node.js

/**
 * TODO(developer): Uncomment these variables before running the sample.
 */
// const iapJwt = 'SOME_ID_TOKEN'; // JWT from the "x-goog-iap-jwt-assertion" header

let expectedAudience = null;
if (projectNumber && projectId) {
  // Expected Audience for App Engine.
  expectedAudience = `/projects/${projectNumber}/apps/${projectId}`;
} else if (projectNumber && backendServiceId) {
  // Expected Audience for Compute Engine
  expectedAudience = `/projects/${projectNumber}/global/backendServices/${backendServiceId}`;
}

const oAuth2Client = new OAuth2Client();

async function verify() {
  // Verify the id_token, and access the claims.
  const response = await oAuth2Client.getIapPublicKeys();
  const ticket = await oAuth2Client.verifySignedJwtWithCertsAsync(
    iapJwt,
    response.pubkeys,
    expectedAudience,
    ['https://cloud.google.com/iap'],
  );
  // Print out the info contained in the IAP ID token
  console.log(ticket);
}

verify().catch(console.error);

PHP

namespace Google\Cloud\Samples\Iap;

# Imports Google auth libraries for IAP validation
use Google\Auth\AccessToken;

/**
 * Validate a JWT passed to your App Engine app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloudProjectNumber The project *number* for your Google
 *     Cloud project. This is returned by 'gcloud projects describe $PROJECT_ID',
 *     or in the Project Info card in Cloud Console.
 * @param string $cloudProjectId Your Google Cloud Project ID.
 */
function validate_jwt_from_app_engine(
    string $iapJwt,
    string $cloudProjectNumber,
    string $cloudProjectId
): void {
    $expectedAudience = sprintf(
        '/projects/%s/apps/%s',
        $cloudProjectNumber,
        $cloudProjectId
    );
    validate_jwt($iapJwt, $expectedAudience);
}

/**
 * Validate a JWT passed to your Compute / Container Engine app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $cloudProjectNumber The project *number* for your Google
 *     Cloud project. This is returned by 'gcloud projects describe $PROJECT_ID',
 *     or in the Project Info card in Cloud Console.
 * @param string $backendServiceId The ID of the backend service used to access the
 *     application. See https://cloud.google.com/iap/docs/signed-headers-howto
 *     for details on how to get this value.
 */
function validate_jwt_from_compute_engine(
    string $iapJwt,
    string $cloudProjectNumber,
    string $backendServiceId
): void {
    $expectedAudience = sprintf(
        '/projects/%s/global/backendServices/%s',
        $cloudProjectNumber,
        $backendServiceId
    );
    validate_jwt($iapJwt, $expectedAudience);
}

/**
 * Validate a JWT passed to your app by Identity-Aware Proxy.
 *
 * @param string $iapJwt The contents of the X-Goog-IAP-JWT-Assertion header.
 * @param string $expectedAudience The expected audience of the JWT with the following formats:
 *     App Engine:     /projects/{PROJECT_NUMBER}/apps/{PROJECT_ID}
 *     Compute Engine: /projects/{PROJECT_NUMBER}/global/backendServices/{BACKEND_SERVICE_ID}
 */
function validate_jwt(string $iapJwt, string $expectedAudience): void
{
    // Validate the signature using the IAP cert URL.
    $token = new AccessToken();
    $jwt = $token->verify($iapJwt, [
        'certsLocation' => AccessToken::IAP_CERT_URL
    ]);

    if (!$jwt) {
        print('Failed to validate JWT: Invalid JWT');
        return;
    }

    // Validate token by checking issuer and audience fields.
    assert($jwt['iss'] == 'https://cloud.google.com/iap');
    assert($jwt['aud'] == $expectedAudience);

    print('Printing user identity information from ID token payload:');
    printf('sub: %s', $jwt['sub']);
    printf('email: %s', $jwt['email']);
}

Python

from google.auth.transport import requests
from google.oauth2 import id_token


def validate_iap_jwt(iap_jwt, expected_audience):
    """Validate an IAP JWT.

    Args:
      iap_jwt: The contents of the X-Goog-IAP-JWT-Assertion header.
      expected_audience: The Signed Header JWT audience. See
          https://cloud.google.com/iap/docs/signed-headers-howto
          for details on how to get this value.

    Returns:
      (user_id, user_email, error_str).
    """

    try:
        decoded_jwt = id_token.verify_token(
            iap_jwt,
            requests.Request(),
            audience=expected_audience,
            certs_url="https://www.gstatic.com/iap/verify/public_key",
        )
        return (decoded_jwt["sub"], decoded_jwt["email"], "")
    except Exception as e:
        return (None, None, f"**ERROR: JWT validation error {e}**")

Ruby

# iap_jwt = "The contents of the X-Goog-Iap-Jwt-Assertion header"
# project_number = "The project *number* for your Google Cloud project"
# project_id = "Your Google Cloud project ID"
# backend_service_id = "Your Compute Engine backend service ID"
require "googleauth"

audience = nil
if project_number && project_id
  # Expected audience for App Engine
  audience = "/projects/#{project_number}/apps/#{project_id}"
elsif project_number && backend_service_id
  # Expected audience for Compute Engine
  audience = "/projects/#{project_number}/global/backendServices/#{backend_service_id}"
end

# The client ID as the target audience for IAP
payload = Google::Auth::IDTokens.verify_iap iap_jwt, aud: audience

puts payload

if audience.nil?
  puts "Audience not verified! Supply a project_number and project_id to verify"
end

検証コードのテスト

secure_token_test クエリ パラメータを使用してアプリにアクセスすると、IAP に無効な JWT が設定されます。これを使用して、JWT 検証ロジックがさまざまな失敗事例に対応し、無効な JWT を受信したときのアプリの動作を確認します。

ヘルスチェックの例外の作成

前述のように、Compute Engine と GKE のヘルスチェックには JWT ヘッダーが含まれていません。このため、IAP はヘルスチェックを処理しません。ヘルスチェックへのアクセスを許可するように、ヘルスチェックとアプリを構成する必要があります。

ヘルスチェックの構成

ヘルスチェックのパスが未設定の場合は、Google Cloud コンソールでヘルスチェックの非機密パスを設定します。このパスは他のリソースと共有しないでください。

  1. Google Cloud コンソールの [ヘルスチェック] ページに移動します。
    [ヘルスチェック] ページに移動
  2. アプリに使用しているヘルスチェックをクリックしてから、[編集] をクリックします。
  3. [リクエストパス] で、機密でないパス名を追加します。ヘルスチェック リクエストの送信時に Google Cloud が使用する URL パスを指定します。省略すると、ヘルスチェック リクエストは / に送信されます。
  4. [保存] をクリックします。

JWT 検証の構成

JWT 検証ルーチンを呼び出すコードに、ヘルスチェック パスに対して HTTP ステータス 200 を返す条件を追加します。次に例を示します。

if HttpRequest.path_info = '/HEALTH_CHECK_REQUEST_PATH'
  return HttpResponse(status=200)
else
  VALIDATION_FUNCTION

公開鍵のキャッシュ保存を自動化する

IAP は公開鍵を定期的にローテーションします。IAP JWT を常に検証できるようにするには、各リクエストで公開 URL から鍵を取得しないように鍵をキャッシュに保存し、キャッシュに保存された鍵を更新するプロセスを自動化することをおすすめします。このアプローチは、VPC Service Controls の境界など、ネットワーク制限のある環境で実行されるアプリケーションに特に役立ちます。

VPC Service Controls の境界により、鍵の一般公開 URL への直接アクセスを防止できます。Cloud Storage バケットに鍵をキャッシュに保存することで、アプリケーションは VPC-SC 境界内のロケーションから鍵を取得できます。

次の Terraform 構成は、https://www.gstatic.com/iap/verify/public_key-jwk から最新の IAP 公開鍵を取得し、Cloud Storage バケットに保存する関数を Cloud Run にデプロイします。Cloud Scheduler ジョブは、この関数を 12 時間ごとにトリガーして、鍵を最新の状態に保ちます。

この設定には、次の作業が含まれます。

  • Cloud Run を使用してキーを保存し、キャッシュに保存するために必要な Google Cloud API が有効になっている
  • 取得した IAP 公開鍵を保存する Cloud Storage バケット
  • Cloud Run functions のソースコードをステージングする Cloud Storage バケット
  • 適切な IAM 権限を持つ Cloud Run functions と Cloud Scheduler のサービス アカウント
  • キーを取得して保存する Python 関数
  • 関数を 12 時間ごとにトリガーする Cloud Scheduler ジョブ

ディレクトリ構造

├── function_source/
│   ├── main.py
│   └── requirements.txt
├── main.tf
├── outputs.tf
├── variables.tf
└── terraform.tfvars

function_source/main.py

import functions_framework
import requests
from google.cloud import storage
import os

# Environment variables to be set in the function configuration
BUCKET_NAME = os.environ.get("BUCKET_NAME")
OBJECT_NAME = os.environ.get("OBJECT_NAME", "iap_public_keys.jwk")
IAP_KEYS_URL = "https://www.gstatic.com/iap/verify/public_key-jwk"

@functions_framework.http
def update_iap_keys(request):
    """Fetches IAP public keys from the public URL and stores them in a Cloud Storage bucket."""
    if not BUCKET_NAME:
        print("Error: BUCKET_NAME environment variable not set.")
        return "BUCKET_NAME environment variable not set.", 500

    try:
        # Fetch the keys
        response = requests.get(IAP_KEYS_URL)
        response.raise_for_status()  # Raise an exception for bad status codes
        keys_content = response.text
        print(f"Successfully fetched keys from {IAP_KEYS_URL}")

        # Store in Cloud Storage
        storage_client = storage.Client()
        bucket = storage_client.bucket(BUCKET_NAME)
        blob = bucket.blob(OBJECT_NAME)

        blob.upload_from_string(keys_content, content_type='application/json')

        print(f"Successfully wrote IAP keys to gs://{BUCKET_NAME}/{OBJECT_NAME}")
        return f"Successfully updated {OBJECT_NAME} in bucket {BUCKET_NAME}", 200

    except requests.exceptions.RequestException as e:
        print(f"Error fetching keys from {IAP_KEYS_URL}: {e}")
        return f"Error fetching keys: {e}", 500
    except Exception as e:
        print(f"Error interacting with Cloud Storage: {e}")
        return f"Error interacting with Cloud Storage: {e}", 500

次のように置き換えます。

  • BUCKET_NAME: Cloud Storage バケットの名前
  • OBJECT_NAME: 鍵を保存するオブジェクトの名前

function_source/requirements.txt

functions-framework==3.*
requests
google-cloud-storage

variables.tf

variable "project_id" {
  description = "The Google Cloud project ID."
  type        = string
  default     = PROJECT_ID
}

variable "region" {
  description = "The Google Cloud region."
  type        = string
  default     = "REGION"
}

variable "iap_keys_bucket_name" {
  description = "The name of the Cloud Storage bucket to store IAP keys."
  type        = string
  default     = BUCKET_NAME"
}

variable "function_source_bucket_name" {
  description = "The name of the Cloud Storage bucket to store the function source code."
  type        = string
  default     = "BUCKET_NAME_FUNCTION"
}

次のように置き換えます。

  • PROJECT_ID: 実際の Google Cloud プロジェクト ID
  • REGION: リソースをデプロイするリージョン(例: us-central1
  • BUCKET_NAME: IAP 鍵を保存する Cloud Storage バケットの名前
  • BUCKET_NAME_FUNCTION: Cloud Run 関数のソースコードを格納する Cloud Storage バケットの名前

main.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = ">= 4.50.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = ">= 4.50.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

provider "google-beta" {
  project = var.project_id
  region  = var.region
}

# Enable necessary APIs
resource "google_project_service" "services" {
  for_each = toset([
    "storage.googleapis.com",
    "cloudfunctions.googleapis.com",
    "run.googleapis.com", # Cloud Functions v2 uses Cloud Run
    "cloudscheduler.googleapis.com",
    "iamcredentials.googleapis.com",
    "cloudbuild.googleapis.com" # Needed for Cloud Functions deployment
  ])
  service            = each.key
  disable_on_destroy = false
}

# Cloud Storage Bucket to store the IAP public keys
resource "google_storage_bucket" "iap_keys_bucket" {
  name                        = var.iap_keys_bucket_name
  location                    = var.region
  uniform_bucket_level_access = true
  versioning {
    enabled = true
  }
  lifecycle {
    prevent_destroy = false # Set to true in production to prevent accidental deletion
  }
}

# Cloud Storage Bucket to store the Cloud Function source code
resource "google_storage_bucket" "function_source_bucket" {
  name                        = var.function_source_bucket_name
  location                    = var.region
  uniform_bucket_level_access = true
}

# Archive the function source code
data "archive_file" "function_source_zip" {
  type        = "zip"
  source_dir  = "${path.module}/function_source"
  output_path = "${path.module}/function_source.zip"
}

# Upload the zipped source code to the source bucket
resource "google_storage_bucket_object" "function_source_object" {
  name   = "function_source.zip"
  bucket = google_storage_bucket.function_source_bucket.name
  source = data.archive_file.function_source_zip.output_path
}

# Service Account for the Cloud Function
resource "google_service_account" "iap_key_updater_sa" {
  account_id   = "iap-key-updater"
  display_name = "IAP Key Updater Function SA"
}

# Grant the function's SA permission to write to the IAP keys bucket
resource "google_storage_bucket_iam_member" "keys_bucket_writer" {
  bucket = google_storage_bucket.iap_keys_bucket.name
  role   = "roles/storage.objectAdmin"
  member = "serviceAccount:${google_service_account.iap_key_updater_sa.email}"
}

# Cloud Function (v2)
resource "google_cloudfunctions2_function" "update_iap_keys_func" {
  provider = google-beta # CFv2 often has newer features in google-beta
  name     = "update-iap-keys-function"
  location = var.region

  build_config {
    runtime     = "python312"
    entry_point = "update_iap_keys"
    source {
      storage_source {
        bucket = google_storage_bucket.function_source_bucket.name
        object = google_storage_bucket_object.function_source_object.name
      }
    }
  }

  service_config {
    max_instance_count = 1
    available_memory   = "256M"
    timeout_seconds    = 60
    ingress_settings   = "ALLOW_ALL"
    service_account_email = google_service_account.iap_key_updater_sa.email
    environment_variables = {
      BUCKET_NAME = google_storage_bucket.iap_keys_bucket.name
      OBJECT_NAME = "iap_public_keys.jwk"
    }
  }

  depends_on = [
    google_project_service.services,
    google_storage_bucket_iam_member.keys_bucket_writer
  ]
}

# Service Account for the Cloud Scheduler job
resource "google_service_account" "iap_key_scheduler_sa" {
  account_id   = "iap-key-scheduler"
  display_name = "IAP Key Update Scheduler SA"
}

# Grant the Scheduler SA permission to invoke the Cloud Function
resource "google_cloudfunctions2_function_iam_member" "invoker" {
  provider       = google-beta
  project        = google_cloudfunctions2_function.update_iap_keys_func.project
  location       = google_cloudfunctions2_function.update_iap_keys_func.location
  cloud_function = google_cloudfunctions2_function.update_iap_keys_func.name
  role           = "roles/cloudfunctions.invoker"
  member         = "serviceAccount:${google_service_account.iap_key_scheduler_sa.email}"
}

# Cloud Scheduler Job
resource "google_cloud_scheduler_job" "iap_key_update_schedule" {
  name        = "iap-key-update-schedule"
  description = "Fetches IAP public keys and stores them in Cloud Storage every 12 hours"
  schedule    = "0 */12 * * *" # Every 12 hours
  time_zone   = "Etc/UTC"
  region      = var.region

  http_target {
    uri = google_cloudfunctions2_function.update_iap_keys_func.service_config[0].uri
    http_method = "POST"

    oidc_token {
      service_account_email = google_service_account.iap_key_scheduler_sa.email
    }
  }

  depends_on = [
    google_cloudfunctions2_function_iam_member.invoker,
    google_project_service.services
  ]
}

outputs.tf

output "iap_keys_bucket_url" {
  description = "The Cloud Storage bucket URL where IAP public keys are stored."
  value       = "gs://${google_storage_bucket.iap_keys_bucket.name}"
}

output "cloud_function_url" {
  description = "The URL of the Cloud Function endpoint that triggers key updates."
  value       = google_cloudfunctions2_function.update_iap_keys_func.service_config[0].uri
}

terraform.tfvars

terraform.tfvars ファイルを作成して、プロジェクト ID を指定し、必要に応じてバケット名をカスタマイズします。

project_id = "your-gcp-project-id"
# Optional: Customize bucket names
# iap_keys_bucket_name = "custom-iap-keys-bucket"
# function_source_bucket_name = "custom-func-src-bucket"

Terraform を使用してデプロイする

  1. ファイルを前述のディレクトリ構造に保存します。
  2. ターミナルでディレクトリに移動し、Terraform を初期化します。
    terraform init
  3. 変更を計画します。
    terraform plan
  4. 変更を適用します。
    terraform apply

これにより、インフラストラクチャがデプロイされます。Cloud Scheduler ジョブは 12 時間ごとに関数をトリガーし、IAP 鍵を取得してデフォルトで gs://BUCKET_NAME/iap_public_keys.jwk に保存します。これで、アプリケーションはこのバケットから鍵を取得できるようになりました。

リソースのクリーンアップ

Terraform で作成されたリソースを削除するには、次のコマンドを実行します。

gsutil rm -a gs://BUCKET_NAME/**

terraform destroy -auto-approve

BUCKET_NAME は、鍵の Cloud Storage バケットに置き換えます。

外部 ID 用の JWT

Google Identity と同様に、外部 ID を使用して IAP を使用すると、IAP は認証済みのリクエストごとに署名付き JWT を発行します。相違点もいくつかあります。

プロバイダ情報

外部 ID を使用する場合、JWT ペイロードには gcip という名前のクレームが含まれます。このクレームには、メール、写真の URL、プロバイダ固有の属性など、ユーザーに関する情報が含まれます。

次の例は、Facebook でログインしたユーザーの JWT を示します。

"gcip": '{
  "auth_time": 1553219869,
  "email": "facebook_user@gmail.com",
  "email_verified": false,
  "firebase": {
    "identities": {
      "email": [
        "facebook_user@gmail.com"
      ],
      "facebook.com": [
        "1234567890"
      ]
    },
    "sign_in_provider": "facebook.com",
  },
  "name": "Facebook User",
  "picture: "https://graph.facebook.com/1234567890/picture",
  "sub": "gZG0yELPypZElTmAT9I55prjHg63"
}',

emailsub フィールド

ユーザーが Identity Platform で認証されると、JWT の email フィールドと sub フィールドの接頭辞として Identity Platform トークン発行者または使用中のテナント ID(存在する場合)が追加されます。次に例を示します。

"email": "securetoken.google.com/PROJECT-ID/TENANT-ID:demo_user@gmail.com",
"sub": "securetoken.google.com/PROJECT-ID/TENANT-ID:gZG0yELPypZElTmAT9I55prjHg63"

sign_in_attributes によるアクセスの制御

IAM は外部 ID をサポートしていませんが、sign_in_attributes フィールドに埋め込まれたクレームを使用してアクセスを制御できます。たとえば、SAML プロバイダを使用してユーザーがログインしたとします。

{
  "aud": "/projects/project_number/apps/my_project_id",
  "gcip": '{
    "auth_time": 1553219869,
    "email": "demo_user@gmail.com",
    "email_verified": true,
    "firebase": {
      "identities": {
        "email": [
          "demo_user@gmail.com"
        ],
        "saml.myProvider": [
          "demo_user@gmail.com"
        ]
      },
      "sign_in_attributes": {
        "firstname": "John",
        "group": "test group",
        "role": "admin",
        "lastname": "Doe"
      },
      "sign_in_provider": "saml.myProvider",
      "tenant": "my_tenant_id"
    },
    "sub": "gZG0yELPypZElTmAT9I55prjHg63"
  }',
  "email": "securetoken.google.com/my_project_id/my_tenant_id:demo_user@gmail.com",
  "exp": 1553220470,
  "iat": 1553219870,
  "iss": "https://cloud.google.com/iap",
  "sub": "securetoken.google.com/my_project_id/my_tenant_id:gZG0yELPypZElTmAT9I55prjHg63"
}

アプリケーションに次のようなロジックを追加し、有効な役割を持つユーザーによるアクセスを制限します。

const gcipClaims = JSON.parse(decodedIapJwtClaims.gcip);
if (gcipClaims &&
    gcipClaims.firebase &&
    gcipClaims.firebase.sign_in_attributes &&
    gcipClaims.firebase.sign_in_attribute.role === 'admin') {
  // Allow access to admin restricted resource.
} else {
  // Block access.
}

Identity Platform SAML および OIDC プロバイダからの追加のユーザー属性には、gcipClaims.gcip.firebase.sign_in_attributes のネストクレームを使用してアクセスできます。

IdP クレームによるサイズ制限

ユーザーが Identity Platform でログインすると、追加のユーザー属性がステートレス Identity Platform ID トークン ペイロードに反映され、IAP に安全に渡されます。IAP は独自の不透明なステートレス Cookie を発行し、この Cookie にも同じクレームが含まれます。IAP は、Cookie の内容に基づいて署名付き JWT ヘッダーを生成します。

そのため、多数のクレームを使用してセッションが開始されると、Cookie の最大許容サイズ(ほとんどのブラウザでは通常 4 KB)を超えることがあります。これにより、ログイン操作が失敗します。

IdP SAML 属性または OIDC 属性に必要なクレームのみが伝播されることを確認します。もう 1 つの方法は、ブロッキング関数を使用して、承認チェックに不要なクレームを除外することです。

const gcipCloudFunctions = require('gcip-cloud-functions');

const authFunctions = new gcipCloudFunctions.Auth().functions();

// This function runs before any sign-in operation.
exports.beforeSignIn = authFunctions.beforeSignInHandler((user, context) => {
  if (context.credential &&
      context.credential.providerId === 'saml.my-provider') {
    // Get the original claims.
    const claims = context.credential.claims;
    // Define this function to filter out the unnecessary claims.
    claims.groups = keepNeededClaims(claims.groups);
    // Return only the needed claims. The claims will be propagated to the token
    // payload.
    return {
      sessionClaims: claims,
    };
  }
});