API GatewayのPrivate APIがカスタムドメインに対応したのでALB/Cognito統合の閉域化に使ってみる

# はじめに

AWS re:Invent 2024に先駆けて発表されたアップデートの一つとして、[Amazon API GatewayのPrivate APIがカスタムドメインに対応](https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-api-gateway-custom-domain-name-private-rest-apis/)しました。本記事では当該機能の設定方法に加え、実践的（:thinking::question:）な活用の一例として、内部ALBとCognitoを統合する際のALB/Cognito間の経路閉域化にチャレンジしたので、その内容をご紹介します。

当該機能に関するAWS公式情報はこちらをご参照ください：

https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-api-gateway-custom-domain-name-private-rest-apis/

https://aws.amazon.com/jp/blogs/compute/implementing-custom-domain-names-for-private-endpoints-with-amazon-api-gateway/

https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-private-custom-domains.html

# 記事の背景（読み飛ばして構いません）

Amazon API Gatewayは、AWS Lambdaと組み合わせるだけに留まらず、さまざまな使い方ができる面白いサービスだと思っています。一方で、Amazon VPCに対してAPIを提供する [Private API](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/apigateway-private-apis.html) は（ワークアラウンドがなかったわけではありませんが）長らくカスタムドメインに対応していませんでした。

筆者は某SIerに所属しており、エンタープライズのお客様とお仕事をさせていただく機会がありますが、厳格なセキュリティ要件をお持ちで、通信をできるだけAWS Direct Connect経由に寄せて閉域化したいというご要望をいただくケースが多々あります。Private APIがカスタムドメインに非対応だったことがオンプレミス向けの閉域サービスをサーバーレスアーキテクチャで実現するのを妨げる一因だったことは否めず、その観点で地味ながらもありがたいアップデートの一つだと捉えています。

ちなみに筆者がこのアップデートを知ったのは、同じくこれを待ち望んでいたお客様からのご指摘がきっかけでした。re:Invent現地参加の準備で忙しかったという言い訳はありますが、その時期のアップデートを把握するのは並大抵のことではないですね。:cry:

ともかく、アップデートを知ったからには実際に触ってみて使い方を理解したく、どうせなら別の課題と結びつけてソリューションに仕立ててみるかと思い至ったのが本記事執筆の背景です。

# 第1段階｜まずはCustom Domain Name for Private APIを触ってみる

何はともあれ触ってみましょう。ALB/Cognito統合に興味のない場合は、この章をご覧いただくだけで十分です。

## 構成図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/d4b86463-1deb-616f-c2e5-deb22f0cca43.png)

## 設定方法

### 前提

- Private APIに割り当てるカスタムドメイン用のACMパブリック証明書の発行またはインポートが完了していること

### 手順

0. VPC、サブネット、VPCエンドポイントのセキュリティグループを作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/283c5f04-f346-3e47-8e9c-1159df39240c.png)
0. VPCエンドポイント（execute-api）を作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/8dec64e6-9189-d0d2-a166-4da467a47315.png)
0. Private APIを作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c8dc53d1-f1d2-44cb-c659-7d539d49e31b.png)
0. 適当なリソースとメソッドを定義する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/701b3af5-31f4-6edc-d1cc-0caaf484e7ff.png)
0. Private APIにリソースポリシーを定義する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c0562fc7-acc9-8a00-e4a4-33d9b748ab37.png)
0. Private APIをパブリッシュする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/d79e6854-e88b-1edc-89f1-60b91c75c2b8.png)
0. カスタムドメイン名をAPI Gatewayに登録する（ここでACM証明書が必要）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/6211a5cb-b911-b044-f0dc-01e3085ebbdc.png)
0. Private APIをカスタムドメインにマップする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/fc7c6bdc-0863-9cf5-1e34-fa027701ef4a.png)
0. カスタムドメインにリソースポリシーを定義する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/13b3fbda-1357-6609-a10a-b1c691a70a47.png)
0. VPCエンドポイントにカスタムドメインを関連付ける
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c6464184-2325-d5a3-4a71-bc6f0872c87a.png)
0. （任意）Route 53 Private Hosted Zoneを作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/d3e2ac6a-9353-9486-f802-0df3e26f63e7.png)
0. （任意）PHZにカスタムドメインをVPCエンドポイントの別名（CNAME or ALIAS）として登録する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/4e3147ee-6cf0-0977-9477-147a3b99c513.png)

## 動作確認

便利で手軽なCloudShell VPC Environmentで確認してみましょう。API Gatewayで定義したレスポンスが返れば成功です。:tada:

```
$ curl https://<custom-domain-name>/
```

名前解決の方法としてRoute 53 Private Hosted Zoneを用いましたが、それ以外のDNSサーバー（Route 53 Public Hosted Zoneを含む）でもよいですし、あるいは、もっと簡易的に以下でも構いません。
```
curl -H 'Host: <custom-domain-name>' https://<vpce-id>-<random-id>.execute-api.<region>.vpce.amazonaws.com/
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/654ee3d4-12d6-056c-1d2b-47b626f0d4e5.png)

## 触ってみた感想

思いのほか、設定にかかる手数が多いという印象を持ちました。また、API本体だけではなくカスタムドメインにもリソースポリシーを記述するという概念がややこしいと感じました。[AWSブログ](https://aws.amazon.com/jp/blogs/compute/implementing-custom-domain-names-for-private-endpoints-with-amazon-api-gateway/) を読むと同一AWSアカウント内でのAPI利用に限らず、あらゆるアカウントに対してAPIを公開することを前提に設計されていることが分かります。その点を踏まえれば、そこそこ合理的な仕様ではあるのでしょうね。

## IaC

試すのに使ったSAMテンプレートはこちらです。

<details><summary>template.yaml</summary>

```template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform:
  - AWS::LanguageExtensions
  - AWS::Serverless-2016-10-31
Description: Custom Domain Name for Private API

Outputs:
  VpceDnsName:
    Description: DNS Name for VPC Endpoint for API Gateway
    Value: !Select
      - 1
      - !Split
        - ":"
        - !Select
          - 0
          - !GetAtt VpceExecuteApi.DnsEntries

Parameters:
  Prefix:
    Description: Prefix of Resource Name
    Type: String
    MinLength: "1"
  VpcCidr:
    Description: VPC CIDR
    Type: String
    MinLength: "1"
  AZ1:
    Description: "Availability Zone #1"
    Type: AWS::EC2::AvailabilityZone::Name
  AZ2:
    Description: "Availability Zone #2"
    Type: AWS::EC2::AvailabilityZone::Name
  CustomDomainName:
    Description: Custom Domain Name for REST API
    Type: String
    MinLength: "1"
  RestApiCertArn:
    Description: Certificate ARN for REST API
    Type: String
    AllowedPattern: "^arn:aws:acm:.+$"
  RestApiStageName:
    Description: Stage Name of REST API
    Type: String
    MinLength: "1"

Resources:
  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-VPC
      CidrBlock: !Ref VpcCidr
      EnableDnsHostnames: true
      EnableDnsSupport: true

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-Subnet-Private1
      VpcId: !Ref Vpc
      CidrBlock: !Select
        - 0
        - !Cidr [!GetAtt Vpc.CidrBlock, 2, 5]
      AvailabilityZone: !Ref AZ1
      MapPublicIpOnLaunch: false

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-Subnet-Private2
      VpcId: !Ref Vpc
      CidrBlock: !Select
        - 1
        - !Cidr [!GetAtt Vpc.CidrBlock, 2, 5]
      AvailabilityZone: !Ref AZ2
      MapPublicIpOnLaunch: false

  VpceSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-SG-VPCE
      GroupName: !Sub ${Prefix}-SG-VPCE
      GroupDescription: for VPC Endpoint
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: from local
          IpProtocol: tcp
          CidrIp: 10.0.0.0/8
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 172.16.0.0/12
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 192.168.0.0/16
          FromPort: 443
          ToPort: 443
      SecurityGroupEgress:
        - Description: dummy
          IpProtocol: "-1"
          CidrIp: 127.0.0.1/32

  VpceExecuteApi:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      VpcEndpointType: Interface
      ServiceName: !Sub com.amazonaws.${AWS::Region}.execute-api
      PrivateDnsEnabled: true
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      SecurityGroupIds:
        - !Ref VpceSg

  RestApi:
    Type: AWS::Serverless::Api
    Properties:
      Tags:
        Name: !Sub ${Prefix}-RestApi
      Description: Private API
      Name: !Sub ${Prefix}-RestApi
      StageName: !Ref RestApiStageName
      EndpointConfiguration:
        Type: PRIVATE
      OpenApiVersion: 3.0.1
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: ./openapi.yaml
      Auth:
        ResourcePolicy:
          CustomStatements:
            - Effect: Deny
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*
              Condition:
                StringNotEquals:
                  aws:sourceVpc: !Ref Vpc
            - Effect: Allow
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*

  RestApiCustomDomain:
    Type: AWS::ApiGateway::DomainNameV2
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-CustomDomain
      DomainName: !Ref CustomDomainName
      CertificateArn: !Ref RestApiCertArn
      EndpointConfiguration:
        Types:
          - PRIVATE
      Policy:
        Fn::ToJsonString:
          Statement:
            - Effect: Deny
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*
              Condition:
                StringNotEquals:
                  aws:SourceVpc: !Ref Vpc
            - Effect: Allow
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*

  DomainNameAccessAssociation:
    Type: AWS::ApiGateway::DomainNameAccessAssociation
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-DomainNameAccess
      AccessAssociationSourceType: VPCE
      AccessAssociationSource: !Ref VpceExecuteApi
      DomainNameArn: !Ref RestApiCustomDomain

  RestApiMapping:
    # NOTE: 実際の依存性はありません。SAMがStageのデプロイを完了するまで遅延させるにあたり、VPCエンドポイントのプロビジョニング所要時間がちょうどよいというだけです。。。
    DependsOn: VpceExecuteApi
    Type: AWS::ApiGateway::BasePathMappingV2
    Properties:
      DomainNameArn: !Ref RestApiCustomDomain
      RestApiId: !Ref RestApi
      Stage: !Ref RestApiStageName

  PrivateHostedZone:
    Type: AWS::Route53::HostedZone
    Properties:
      HostedZoneTags:
        - Key: Name
          Value: !Sub ${Prefix}-PHZ
      HostedZoneConfig:
        Comment: !Sub ${Prefix}-PHZ
      Name: !Ref CustomDomainName
      VPCs:
        - VPCId: !Ref Vpc
          VPCRegion: !Ref AWS::Region

  RecordSet:
    Type: AWS::Route53::RecordSet
    Properties:
      Comment: !Sub ${Prefix}-RecordSet
      HostedZoneId: !Ref PrivateHostedZone
      Type: A
      Name: !Ref CustomDomainName
      AliasTarget:
        HostedZoneId: !Select
          - 0
          - !Split
            - ":"
            - !Select
              - 0
              - !GetAtt VpceExecuteApi.DnsEntries
        DNSName: !Select
          - 1
          - !Split
            - ":"
            - !Select
              - 0
              - !GetAtt VpceExecuteApi.DnsEntries
```

</details>
<details><summary>openapi.yaml</summary>


```openapi.yaml
openapi: 3.0.1
info:
  title: na
  version: na
paths:
  /:
    get:
      responses:
        "200":
          description: "200 response"
          content:
            application/json:
              schema:
                type: string
      x-amazon-apigateway-integration:
        type: mock
        passthroughBehavior: when_no_templates
        requestTemplates:
          application/json: '{ "statusCode": 200 }'
        responses:
          default:
            statusCode: "200"
            responseTemplates:
              application/json: '{ "statusCode": 200 }'
```

</details>

# 第2段階｜この機能を使ってALB/Cognito統合を閉域化してみる

いよいよタイトルの課題にチャレンジします。このソリューションの骨子は、Amazon API GatewayをAmazon Cognitoに対するリバースプロキシとして構成し、それをPrivate APIとしてVPCに提供することで、ALBに対してCognitoへの代替のエンドポイントを提供することです。実現にあたってのトリックの一つがPrivate APIのカスタムドメイン機能です。

## 構成図

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/7b766beb-f3c5-1333-603a-29f38930f29b.png)

## 前提知識

手順の説明を始める前に、なぜこんなまどろっこしいことを考える必要があるのか前提を整理しておきます。

ALBは [OIDCプロバイダーと統合する機能](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html) を備えており、認証されたユーザーからのリクエストだけをターゲットグループに転送することが可能です。外部のIdPとともに、Cognitoユーザープールとの統合にも対応します。一方で、[ユーザーガイドに掲載されている認証フロー](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html)（下図）をご覧いただくと、ALBとIdPの間で通信が発生することが分かります。
![alb-user-auth-flow.png](https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/images/alb-user-auth-flow.png)

このことはALBを通常のL7-SWの範囲で使う場合にはない新たな考慮事項をもたらします。それは、ALBからIdPへのHTTPSの通信経路を確保する必要があるということです。

外部ALBであれば、もとよりパブリックサブネットに配置されているわけですからセキュリティグループで443/tcpへのアウトバウンドを空けるだけで済みますが、内部ALBとなるとNATゲートウェイ経由でインターネットへ接続可能なサブネット（いわゆるプロテクテッドサブネット）に配置する必要性が生じます[^1]。インターネットへのアウトバウンドが同じVPC内で元々想定されているのであれば大きな問題はないかもしれませんが、仮にOIDC統合のためだけにインターネットゲートウェイとNATゲートウェイを配置するのだとすれば抵抗感があるかもしれませんし、まして統合先がCognitoとなればVPCエンドポイントで何とかならないのかと考えるのが人情（:grey_question:）ではないでしょうか。

関連情報：

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/application/listener-authenticate-users.html

https://repost.aws/questions/QURt8NbCF4QWCWXHWICPlqzA/how-to-use-internal-alb-with-cognito

[^1]: オンプレミス経由でインターネットへ抜ける経路でも構いません。

## 設定方法

### 前提

- <INS>ALB</INS> に割り当てるカスタムドメイン用のACM証明書の発行またはインポートが完了していること
- <INS>Private API</INS> に割り当てるカスタムドメイン用のACM証明書の発行またはインポートが完了していること
- <INS>Cognito</INS> に割り当てるカスタムドメイン用のACM証明書の発行またはインポートが **us-east-1において** 完了していること

### 手順

0. Cognito User Pool、App Clientを作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/021e01ee-19ee-2d6b-b6b1-951ad4d3999b.png)
0. User Poolにカスタムドメインを登録する（ここでus-east-1のACM証明書が必要）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/a128172b-e7ee-3935-6ec8-969b2eab8aee.png)
0. 「Alias target」を控える
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/8bdec7d6-fbf2-67e6-c662-062cf587d51d.png)
0. User PoolのManaged Login[^2]（旧 Hosted UI）をセットアップする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/70d05e2a-3662-f936-602f-2fc681b5161a.png)
0. User Poolに1人以上のユーザーを作成する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/cd91d4ef-6554-5886-7369-e5d53e6b6658.png)
0. VPC、サブネット、VPCエンドポイントとALBのセキュリティグループを作成する【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/283c5f04-f346-3e47-8e9c-1159df39240c.png)
0. VPCエンドポイント（execute-api）を作成する【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/8dec64e6-9189-d0d2-a166-4da467a47315.png)
0. Private APIを作成する【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c8dc53d1-f1d2-44cb-c659-7d539d49e31b.png)
0. CognitoをバックエンドとするHTTPプロキシ統合を構成する[^3]
   - Endpoint URLには手順2で控えたAlias targetを用いて `https://<target-alias>/{proxy}` のように指定する
   - HostリクエストヘッダーにはCognitoのカスタムドメイン名を指定する。このとき、前後のシングルクォート（`'`）を忘れないようにする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/a01b161b-0e2e-3c9f-e769-556425f5a808.png)
0. Private APIにリソースポリシーを定義する【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c0562fc7-acc9-8a00-e4a4-33d9b748ab37.png)
0. Private APIをパブリッシュする【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/d79e6854-e88b-1edc-89f1-60b91c75c2b8.png)
0. <INS>手順2と同一の</INS> カスタムドメイン名をAPI Gatewayに登録する（ここでACM証明書が必要）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/6211a5cb-b911-b044-f0dc-01e3085ebbdc.png)
0. Private APIをカスタムドメインにマップする【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/fc7c6bdc-0863-9cf5-1e34-fa027701ef4a.png)
0. カスタムドメインにリソースポリシーを定義する【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/13b3fbda-1357-6609-a10a-b1c691a70a47.png)
0. VPCエンドポイントにカスタムドメインを関連付ける【再掲】
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c6464184-2325-d5a3-4a71-bc6f0872c87a.png)
0. ALBを作成する（ここでもACM証明書が必要）
   ＜省略＞
0. ターゲットグループまたは固定レスポンスで、認証後に表示されるべきレスポンスを生成できるようにする
   ＜省略＞
0. HTTPSリスナーでCognito統合をセットアップする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/da0387aa-c530-9124-eba9-66696bb8802e.png)
0. 任意のパブリック権威DNSサーバーにおいて、Private API（＝ Cognito）のカスタムドメイン名をVPCエンドポイントの別名として設定する
   （筆者はAmazon Lightsailユーザーなのでこんな感じ:point_down:[^4]）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/665215a7-d8ac-d417-46ae-a916ce873bbf.png)
0. 任意の手段でクライアントがALBのカスタムドメイン名を解決できるようにする（Lightsailの場合は:point_up_2:）

[^2]: [What's New at AWS | Amazon Cognito がエンドユーザー体験に対する豊富なブランディングをサポートする Managed Login を導入](https://aws.amazon.com/jp/about-aws/whats-new/2024/11/amazon-cognito-managed-login/)
[^3]: [AWS Developer Guide | チュートリアル: HTTP プロキシ統合を使用して REST API を作成する](https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/api-gateway-create-api-as-simple-proxy-for-http.html)
[^4]: 筆者は貧乏なのでRoute 53 Hosted Zoneの[$0.5/月](https://aws.amazon.com/jp/route53/pricing/)を払う余裕がないのです :cry: Lightsail DNSは[無料利用枠が豊富](https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-frequently-asked-questions-faq-billing-and-account-management.html#what-does-lightsail-dns-management-cost
)なのでお財布に優しいですね :v:

## 動作確認

それでは実際にクライアントからALBにアクセスしてみましょう。オンプレミスを模した別VPCを用意するのがベターでしょうが、面倒なので同じVPC内にパブリックサブネットを作成して、WindowsのEC2インスタンスを起動します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/53c8044b-80f2-1fd5-b230-de287b2e8e24.png)

### 確認手順

0. Fleet Managerでリモートデスクトップ接続する
0. Edgeを起動してALBのカスタムドメイン名にアクセスする
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/7d0d365a-6ad0-2b1b-d880-133d665a918d.png)
0. Cognito Managed Loginへリダイレクトされるので、ユーザー名と仮パスワードを入力する
   （Hosted UIに比べて華やかになりましたね :cherry_blossom::cherry_blossom:）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/c8df6a21-33cb-d197-1ad3-c35d46e702bb.png)
0. 正式なパスワードを設定する
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/ea876062-cc08-cdaa-8b65-2d8033482611.png)
0. ALBへ戻ってきて期待どおりの表示が出れば成功 :tada: :tada:
   （Managed Loginに比べて画面がしょぼすぎる :sob:）
   ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3957214/43c746eb-1ef4-602f-af39-6aac9d2c5b5f.png)

## 考察

### 果たしてこれは閉域と言えるのか

ALB/Cognitoの通信を真に閉域化できているかというとそうではなく、VPC側のインターネット出口を不要にした一方で、API Gatewayという裏口をつくっているのも事実です。したがって、セキュリティ要件によっては、ここで示したアプローチでは要件を充足できないケースもあるはずです[^5]。

加えて、Managed LoginまたはHosted UIの静的コンテンツ（CSS、JavaScript）はCloudFrontから配信される構成であり、クライアントがインターネットにも接続できることは依然として必要です。

[^5]: それでもCognitoエンドポイントのみに対するリバースプロキシに過ぎないので、具体的な危険があるとは思えないです。

### VPCエンドポイントのカスタムドメイン名はPrivate Hosted Zoneでホストすればいいのではないか

そんなふうに考えていた時期が私にもありました。

すでに述べたとおり、ALBからIdPエンドポイントへのHTTPSトラフィックはカスタマーVPCを流れますが、ALBによるIdPエンドポイントの名前解決はカスタマーVPCを介さず、AWSマネージドの空間に完結しているようです。したがって、カスタマーVPCにPrivate Hosted Zoneを割り当てたとしてもALBがそれを参照することはなく、パブリックに名前解決できることが必須となります[^6]。

もちろん、[第1段階](#第1段階まずはcustom-domain-name-for-private-apiを触ってみる) で紹介した使い方においてはPrivate Hosted Zoneを使用することに何ら問題はありません。

[^6]: [The DNS entries for the endpoints must be publicly resolvable, even if they resolve to private IP addresses.](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html#oidc-requirements)

## IaC

試すのに使ったSAMテンプレートはこちらです。

<details><summary>template.yaml</summary>

```template.yaml
AWSTemplateFormatVersion: "2010-09-09"
Transform:
  - AWS::LanguageExtensions
  - AWS::Serverless-2016-10-31
Description: Internal ALB with Cognito and Private API

Outputs:
  UserPoolId:
    Description: User Pool ID
    Value: !Ref UserPool
  AlbDnsName:
    Description: DNS Name for ALB
    Value: !GetAtt Alb.DNSName
  VpceDnsName:
    Description: DNS Name for VPC Endpoint for API Gateway
    Value: !Select
      - 1
      - !Split
        - ":"
        - !Select
          - 0
          - !GetAtt VpceExecuteApi.DnsEntries

Parameters:
  Prefix:
    Description: Prefix of Resource Name
    Type: String
    MinLength: "1"
  VpcCidr:
    Description: VPC CIDR
    Type: String
    MinLength: "1"
  AZ1:
    Description: "Availability Zone #1"
    Type: AWS::EC2::AvailabilityZone::Name
  AZ2:
    Description: "Availability Zone #2"
    Type: AWS::EC2::AvailabilityZone::Name
  AlbCustomDomainName:
    Description: Custom Domain Name for ALB
    Type: String
    MinLength: "1"
  UserPoolCustomDomainName:
    Description: Custom Domain Name for Cognito User Pool
    Type: String
    MinLength: "1"
  AlbCertArn:
    Description: Certificate ARN for ALB
    Type: String
    AllowedPattern: "^arn:aws:acm:.+$"
  RestApiCertArn:
    Description: Certificate ARN for REST API
    Type: String
    AllowedPattern: "^arn:aws:acm:.+$"
  UserPoolCertArn:
    Description: Certificate ARN for Cognito User Pool
    Type: String
    AllowedPattern: "^arn:aws:acm:us-east-1:.+$"
  RestApiStageName:
    Description: Stage Name of REST API
    Type: String
    MinLength: "1"
  CreateEC2ForTesting:
    Type: String
    Default: "false"
    AllowedValues:
      - "true"
      - "false"
  KeyPairName:
    Type: String

Conditions:
  IsDebug: !Equals
    - !Ref CreateEC2ForTesting
    - "true"

Resources:
  Ec2:
    Condition: IsDebug
    Type: AWS::EC2::Instance
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-EC2-Windows
      ImageId: "{{resolve:ssm:/aws/service/ami-windows-latest/Windows_Server-2025-English-Full-Base}}"
      InstanceType: t3a.medium
      SubnetId: !Ref PublicSubnet
      SecurityGroupIds:
        - !Ref Ec2Sg
      IamInstanceProfile: !Ref InstanceProfile
      KeyName: !Ref KeyPairName

  Ec2Sg:
    Condition: IsDebug
    Type: AWS::EC2::SecurityGroup
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-SG-EC2
      GroupName: !Sub ${Prefix}-SG-EC2
      GroupDescription: for EC2
      VpcId: !Ref Vpc

  Ec2Role:
    Condition: IsDebug
    Type: AWS::IAM::Role
    Properties:
      Description: EC2 Role
      RoleName: !Sub ${Prefix}-Role-EC2
      Path: /
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Action: sts:AssumeRole
            Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  InstanceProfile:
    Condition: IsDebug
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub ${Prefix}-Role-EC2
      Path: /
      Roles:
        - !Ref Ec2Role

  PublicSubnet:
    Condition: IsDebug
    Type: AWS::EC2::Subnet
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-Subnet-Public
      VpcId: !Ref Vpc
      CidrBlock: !Select
        - 2
        - !Cidr [!GetAtt Vpc.CidrBlock, 3, 5]
      AvailabilityZone: !Ref AZ1
      MapPublicIpOnLaunch: true

  RTA:
    Condition: IsDebug
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

  PublicRouteTable:
    Condition: IsDebug
    Type: AWS::EC2::RouteTable
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-RTB-Public
      VpcId: !Ref Vpc

  Igw:
    Condition: IsDebug
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-IGW

  IgwAttachment:
    Condition: IsDebug
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref Vpc
      InternetGatewayId: !Ref Igw

  RouteToIgw:
    Condition: IsDebug
    DependsOn: IgwAttachment
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PublicRouteTable
      GatewayId: !Ref Igw
      DestinationCidrBlock: 0.0.0.0/0

  Vpc:
    Type: AWS::EC2::VPC
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-VPC
      CidrBlock: !Ref VpcCidr
      EnableDnsHostnames: true
      EnableDnsSupport: true

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-Subnet-Private1
      VpcId: !Ref Vpc
      CidrBlock: !Select
        - 0
        - !Cidr [!GetAtt Vpc.CidrBlock, 2, 5]
      AvailabilityZone: !Ref AZ1
      MapPublicIpOnLaunch: false

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-Subnet-Private2
      VpcId: !Ref Vpc
      CidrBlock: !Select
        - 1
        - !Cidr [!GetAtt Vpc.CidrBlock, 2, 5]
      AvailabilityZone: !Ref AZ2
      MapPublicIpOnLaunch: false

  VpceSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-SG-VPCE
      GroupName: !Sub ${Prefix}-SG-VPCE
      GroupDescription: for VPC Endpoint
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: from local
          IpProtocol: tcp
          CidrIp: 10.0.0.0/8
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 172.16.0.0/12
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 192.168.0.0/16
          FromPort: 443
          ToPort: 443
      SecurityGroupEgress:
        - Description: dummy
          IpProtocol: "-1"
          CidrIp: 127.0.0.1/32

  VpceExecuteApi:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcId: !Ref Vpc
      VpcEndpointType: Interface
      ServiceName: !Sub com.amazonaws.${AWS::Region}.execute-api
      PrivateDnsEnabled: true
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      SecurityGroupIds:
        - !Ref VpceSg

  AlbSg:
    Type: AWS::EC2::SecurityGroup
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-SG-ALB
      GroupName: !Sub ${Prefix}-SG-ALB
      GroupDescription: for ALB
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - Description: from local
          IpProtocol: tcp
          CidrIp: 10.0.0.0/8
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 172.16.0.0/12
          FromPort: 443
          ToPort: 443
        - Description: from local
          IpProtocol: tcp
          CidrIp: 192.168.0.0/16
          FromPort: 443
          ToPort: 443
      SecurityGroupEgress:
        - Description: to VPC
          IpProtocol: tcp
          CidrIp: !GetAtt Vpc.CidrBlock
          FromPort: 443
          ToPort: 443

  Alb:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-ALB
      Name: !Sub ${Prefix}-ALB
      Type: application
      Scheme: internal
      IpAddressType: ipv4
      Subnets:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      SecurityGroups:
        - !Ref AlbSg

  Listener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref Alb
      Protocol: HTTPS
      Port: 443
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates:
        - CertificateArn: !Ref AlbCertArn
      DefaultActions:
        - Order: 1
          Type: authenticate-cognito
          AuthenticateCognitoConfig:
            UserPoolArn: !GetAtt UserPool.Arn
            UserPoolClientId: !Ref UserPoolClient
            UserPoolDomain: !Ref UserPoolDomain
            OnUnauthenticatedRequest: authenticate
        - Order: 2
          Type: fixed-response
          FixedResponseConfig:
            StatusCode: "200"
            ContentType: text/html
            MessageBody: "<html><body><h1>It works!</h1></body></html>"

  UserPool:
    DeletionPolicy: Delete
    UpdateReplacePolicy: Delete
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolTags:
        Name: !Sub ${Prefix}-UserPool
      UserPoolName: !Sub ${Prefix}-UserPool
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: true
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: admin_only
            Priority: 1
      EmailConfiguration:
        EmailSendingAccount: COGNITO_DEFAULT

  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName: !Sub ${Prefix}-UserPoolClient
      UserPoolId: !Ref UserPool
      ExplicitAuthFlows:
        - ALLOW_REFRESH_TOKEN_AUTH
      SupportedIdentityProviders:
        - COGNITO
      AllowedOAuthFlows:
        - code
      AllowedOAuthScopes:
        - openid
      AllowedOAuthFlowsUserPoolClient: true
      GenerateSecret: true
      PreventUserExistenceErrors: ENABLED
      CallbackURLs:
        - !Sub https://${AlbCustomDomainName}/oauth2/idpresponse
      LogoutURLs: []

  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      UserPoolId: !Ref UserPool
      Domain: !Ref UserPoolCustomDomainName
      CustomDomainConfig:
        CertificateArn: !Ref UserPoolCertArn
      ManagedLoginVersion: 2

  ManagedLoginBranding:
    Type: AWS::Cognito::ManagedLoginBranding
    Properties:
      UserPoolId: !Ref UserPool
      ClientId: !Ref UserPoolClient
      UseCognitoProvidedValues: true

  RestApi:
    Type: AWS::Serverless::Api
    Properties:
      Tags:
        Name: !Sub ${Prefix}-RestApi
      Description: Private API
      Name: !Sub ${Prefix}-RestApi
      StageName: !Ref RestApiStageName
      EndpointConfiguration:
        Type: PRIVATE
      OpenApiVersion: 3.0.1
      DefinitionBody:
        Fn::Transform:
          Name: AWS::Include
          Parameters:
            Location: ./openapi.yaml
      Auth:
        ResourcePolicy:
          CustomStatements:
            - Effect: Deny
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*
              Condition:
                StringNotEquals:
                  aws:sourceVpc: !Ref Vpc
            - Effect: Allow
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*

  RestApiCustomDomain:
    Type: AWS::ApiGateway::DomainNameV2
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-CustomDomain
      DomainName: !Ref UserPoolCustomDomainName
      CertificateArn: !Ref RestApiCertArn
      EndpointConfiguration:
        Types:
          - PRIVATE
      Policy:
        Fn::ToJsonString:
          Statement:
            - Effect: Deny
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*
              Condition:
                StringNotEquals:
                  aws:SourceVpc: !Ref Vpc
            - Effect: Allow
              Principal: "*"
              Action: execute-api:Invoke
              Resource: execute-api:/*

  DomainNameAccessAssociation:
    Type: AWS::ApiGateway::DomainNameAccessAssociation
    Properties:
      Tags:
        - Key: Name
          Value: !Sub ${Prefix}-DomainNameAccess
      AccessAssociationSourceType: VPCE
      AccessAssociationSource: !Ref VpceExecuteApi
      DomainNameArn: !Ref RestApiCustomDomain

  RestApiMapping:
    # NOTE: 実際の依存性はありません。SAMがStageのデプロイを完了するまで遅延させるにあたり、VPCエンドポイントのプロビジョニング所要時間がちょうどよいというだけです。。。
    DependsOn: VpceExecuteApi
    Type: AWS::ApiGateway::BasePathMappingV2
    Properties:
      DomainNameArn: !Ref RestApiCustomDomain
      RestApiId: !Ref RestApi
      Stage: !Ref RestApiStageName
```

</details>
<details><summary>openapi.yaml</summary>

```openapi.yaml
openapi: 3.0.1
info:
  title: na
  version: na
paths:
  /{proxy+}:
    x-amazon-apigateway-any-method:
      parameters:
        - in: path
          name: proxy
          required: true
          schema:
            type: string
      x-amazon-apigateway-integration:
        httpMethod: ANY
        type: http_proxy
        uri:
          Fn::Sub: https://${UserPoolDomain.CloudFrontDistribution}/{proxy}
        requestParameters:
          integration.request.path.proxy: method.request.path.proxy
          integration.request.header.Host:
            Fn::Sub: "'${UserPoolDomain}'"
```

</details>

# おわりに

本記事では、実例を交えてPrivate APIにカスタムドメインを付ける方法をご紹介しました。[第2段階](#第2段階この機能を使ってalbcognito統合を閉域化してみる) で示した手法は非常にトリッキーであり、ここまで無理をせずに済むのが望ましく、必要に迫られればこのようなアプローチもあるというぐらいの受け止めが適切に思えます。

本記事が皆さまの理解の一助となりましたら幸いです。