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

本頁面說明如何使用已簽署的 IAP 標頭保護應用程式。設定之後,Identity-Aware Proxy (IAP) 會使用 JSON Web Tokens (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_keyhttps://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 主控台,或使用 gcloud 指令列工具,取得上述 aud 字串的值。

如要從 Google Cloud 主控台取得 aud 字串值,請前往專案的 Identity-Aware Proxy 設定,按一下負載平衡器資源旁邊的「更多」,然後選取「已簽署標頭的 JWT 目標對象」。這時出現的「Signed Header JWT」(已簽署標頭的 JWT) 對話方塊會顯示所選資源的 aud 憑證附加資訊。

如要使用 gcloud CLI gcloud 指令列工具取得 aud 字串值,則需要知道專案 ID。您可以在Google Cloud 控制台的「Project info」(專案資訊) 卡片中找到專案 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 憑證酬載使用者身分識別資訊
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 控制台的「健康狀態檢查」頁面。
    前往「健康狀態檢查」頁面
  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 範圍可防止直接存取金鑰的公開網址。將金鑰快取到 Cloud Storage bucket 後,應用程式就能從 VPC-SC 範圍內的位置擷取金鑰。

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

這項設定包括:

  • 啟用使用 Cloud Run 和儲存/快取金鑰所需的 API Google Cloud
  • 用來儲存擷取到的 IAP 公開金鑰的 Cloud Storage bucket
  • 用來暫存 Cloud Run 函式原始碼的 Cloud Storage bucket
  • Cloud Run 函式和 Cloud Scheduler 的服務帳戶,以及適當的 IAM 權限
  • 用來擷取及儲存金鑰的 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 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_NAME_FUNCTION:Cloud Storage bucket 的名稱,用於儲存 Cloud Run 函式原始碼

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 值區。

外部身分的 JWT

如果您搭配外部身分使用 IAP,IAP 仍會在每個經過驗證的請求中核發簽署的 JWT,就像使用 Google 身分一樣。不過,兩者之間還是有幾項差異。

提供者資訊

使用外部身分時,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"
}',

emailsub 欄位

如果使用者是透過 Identity Platform 驗證,JWT 的 emailsub 欄位會加上 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,
    };
  }
});