使用已簽署的標頭確保應用程式安全無虞

本頁面說明如何使用已簽署的 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 未簽署的身分標頭 x-goog-authenticated-user-{email,id}。IAP JWT 則提供更安全的替代方案。

如果有人略過 IAP,經過簽署的標頭可提供次要安全性。啟用 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。這個網址包含 JSON 字典,可將 kid 聲明對應至公開金鑰值。
  • https://www.gstatic.com/iap/verify/public_key-jwk。這個網址包含 JWK 格式的 IAP 公開金鑰。

取得公開金鑰後,請使用 JWT 程式庫驗證簽章。

IAP 會定期輪替公開金鑰。為確保您隨時可以驗證 JWT,請參閱「自動快取公開金鑰」。

驗證 JWT 酬載

驗證 JWT 的酬載是否符合下列限制:

JWT 酬載憑證附加資訊
exp 到期時間 必須是未來的時間。時間以秒為單位,從世界標準時間指定期間開始計算。允許 30 秒的時間誤差。 權杖的有效期限上限為 10 分鐘 + 2 * 誤差。
iat 核發時間 必須是過去的日期。時間以秒為單位,從世界標準時間指定期間開始計算。允許 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 憑證附加資訊 如果要求適用一或多個存取層級,系統會將這些層級的名稱儲存在 google 宣告的 JSON 物件中,並放在 access_levels 鍵下方,做為字串陣列。

指定裝置政策且機構可存取裝置資料時,DeviceId 也會儲存在 JSON 物件中。請注意,傳送至其他機構的要求可能沒有查看裝置資料的權限。

您可以存取Google Cloud 控制台,或使用 Google Cloud CLI,取得上述 aud 字串的值。

如要從 Google Cloud 控制台取得aud字串值,請前往專案的「Identity-Aware Proxy settings」(Identity-Aware Proxy 設定),按一下負載平衡器資源旁的「More」(更多),然後選取「Signed Header JWT Audience」(已簽署標頭 JWT 目標對象)。隨即顯示的「Signed Header JWT」(經過簽署的標頭 JWT) 對話方塊中,會顯示所選資源的 aud 權杖附加資訊。

如要使用 gcloud CLI 取得 aud 字串值,您必須知道專案 ID。您可以在Google Cloud 控制台的「Project info」(專案資訊) 資訊卡中找到專案 ID,然後針對每個值執行指定指令。

專案編號

如要使用 gcloud CLI 取得專案編號,請執行下列指令:

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 CLI 取得服務 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 權杖酬載使用者身分
sub 主旨 使用者的專屬固定 ID。請使用這個值,而非 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 控制台的「Health checks」(健康狀態檢查) 頁面。
    前往「Health checks」(健康狀態檢查) 頁面
  2. 按一下您要針對應用程式使用的健康狀態檢查,然後按一下 [Edit] (編輯)。
  3. 在「Request path」(要求路徑) 下,新增非機密路徑名稱。這會指定 Google Cloud 傳送健康狀態檢查要求時使用的網址路徑。如果省略,健康狀態檢查要求會傳送至 /。
  4. 按一下 [儲存]。

設定 JWT 驗證

在呼叫 JWT 驗證常式的程式碼中,新增條件來為健康狀態檢查路徑提供 200 HTTP 狀態。例如:

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

自動快取公開金鑰

IAP 會定期輪替公開金鑰。為確保您隨時可以驗證 IAP JWT,建議您快取金鑰,避免每次要求都從公開網址擷取金鑰,並自動更新快取金鑰。如果應用程式在網路受限的環境中執行 (例如 VPC Service Controls perimeter),這種方法特別實用。

VPC Service Controls perimeter 可防止直接存取金鑰的公開網址。將金鑰快取到 Cloud Storage bucket 後,應用程式就能從 VPC-SC 範圍內的位置擷取金鑰。

下列 Terraform 設定會將函式部署至 Cloud Run,從 https://www.gstatic.com/iap/verify/public_key-jwk 擷取最新的 IAP 公開金鑰,並儲存在 Cloud Storage bucket。Cloud Scheduler 工作每 12 小時會觸發這項函式,確保金鑰保持最新狀態。

這項設定包含下列項目:

  • 已啟用必要的 Google Cloud API,可使用 Cloud Run 並儲存及快取鍵
  • Cloud Storage bucket:用於儲存擷取的 IAP 公開金鑰
  • 用於暫存 Cloud Run functions 原始碼的 Cloud Storage bucket
  • Cloud Run functions 和 Cloud Scheduler 的服務帳戶,具備適當的 IAM 權限
  • 用於擷取及儲存金鑰的 Python 函式
  • 一項 Cloud Scheduler 工作,每 12 小時觸發一次函式

目錄結構

├── 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 bucket 的名稱
  • 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 名稱
  • BUCKET_NAME_FUNCTION:儲存 Cloud Run functions 原始碼的 Cloud Storage bucket 名稱

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 並視需要自訂 bucket 名稱:

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 bucket。

外部身分的 JWT

如果您將 IAP 與外部身分搭配使用,IAP 仍會針對每個已驗證的要求核發已簽署的 JWT,就像使用 Google Identity 一樣。不過,兩者間還是有一些差異。

供應商資訊

使用外部身分時,JWT 酬載會包含名為 gcip 的要求。這項要求包含使用者資訊,例如電子郵件、相片網址,以及任何其他供應商專屬屬性。

以下是使用 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"
}',

email 和 sub 欄位

如果使用者是由 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 不支援外部身分,但您可以使用 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.
}

您可以使用 gcipClaims.gcip.firebase.sign_in_attributes 巢狀宣告,從 Identity Platform SAML 和 OIDC 提供者存取其他使用者屬性。

IdP 憑證大小限制

使用者透過 Identity Platform 登入後,額外的使用者屬性會傳播至無狀態 Identity Platform ID 權杖酬載,並安全地傳遞至 IAP。接著,IAP 會發出自己的無狀態不透明 Cookie,其中也包含相同的憑證。IAP 會根據 Cookie 內容產生已簽署的 JWT 標頭。

因此,如果工作階段啟動時含有許多要求,可能會超過 Cookie 大小上限 (在大多數瀏覽器中通常約為 4 KB)。這會導致登入作業失敗。

請確保只在 IdP SAML 或 OIDC 屬性中傳播必要的憑證。另一個方法是使用封鎖函式,篩除授權檢查不需要的聲明。

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,
    };
  }
});