署名付きヘッダーによるアプリの保護

このページでは、署名付きの 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 ライブラリを使用して署名を検証します。

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
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 クレームが表示されます。

[署名済みヘッダー JWT のユーザー] オプションを含むオーバーフロー メニュー

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

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