CognitoでオンプレミスAD認証を実現する

こんにちは、SCSKでAWSの内製化支援『テクニカルエスコートサービス』を担当している貝塚です。

先日、顧客内製開発中のWebシステムの認証について、こんなご相談をいただきました。
  • 社内のAD(Active Directory)で管理しているユーザーIDとパスワードで、クラウド上のWebアプリケーションにログインさせたい
  • ただし、社員IDはメールアドレスではなくsAMAccountName(Active Directoryでユーザーを一意に識別するログイン名属性。例: testuser01)を使用している
本記事では、AWS上にADFS(Active Directory Federation Services)を構築し、Amazon Cognitoと連携してSAML 2.0ベースのSSO認証を実現する構成について説明します。さらに、ADFSをインターネットに安全に公開するため、Web Application Proxy(WAP)を導入した最終的なアーキテクチャも紹介します。
 

CognitoとADを連携させるには

Cognito User Poolは外部のSAML 2.0 Identity Provider(IdP)と連携することで、SSOを実現できます。

しかし、AD自体はLDAP/Kerberosベースのディレクトリサービスであり、SAML IdPの機能を持ちません。そのため、CognitoとADを連携させるには、ADの認証情報をSAML Assertionに変換して発行できるIdPを別途用意する必要があります。

AWS Directory Service AD Connectorは使えるか

このケースでAWS Directory Service AD Connectorは使えるでしょうか?AD ConnectorはオンプレミスADへのプロキシとして動作し、AWSマネジメントコンソールへのSSO、Amazon WorkSpaces、RDSのWindows認証などに利用できます。

しかし、AD ConnectorはSAML IdPではありません。Cognito User PoolのSAMLフェデレーションに必要なSAMLメタデータやSAML Assertionを発行する機能を持たないため、CognitoとADの連携には使用できないということが分かります。

結論: AWS上にADFSを構築する

AD Connectorの検討を経て、AWS上にADFSサーバーを構築する方式を選択しました。

ADFSはWindows Serverの役割(ロール)の一つで、ADの認証情報をSAML 2.0やWS-Federationといったフェデレーションプロトコルで外部に提供します。つまり、ADをSAML IdPとして機能させるのがADFSの中核的な役割です。
今回のケースでは、このADFSをSAML IdPとしてCognitoと連携させます。

 

全体アーキテクチャの検討

ADFSとCognitoを連携させるアーキテクチャを検討します。

当初案: ALB → ADFS → AD

当初は、Internet → ALB → ADFS → AD という構成を検討していました。ADFSサーバはSSL証明書を持つので、ALBでHTTPSを終端した後、再びHTTPSでADFSサーバと通信する想定です。ALBにAWS WAFをアタッチすればセキュリティ面も安心です。

ALBからADFSへの通信

ALBはHTTPSターゲットグループを使えばバックエンドへの再暗号化自体は可能です。
しかし、ADFSはSNI(Server Name Indication)の処理が特殊で、ALBのようなL7ロードバランサーの背後に配置すると、ヘルスチェックやプロキシ動作が正しく機能しないことが分かりました。

修正案: NLB → ADFS → AD

そこで、NLBを使い Internet → NLB → ADFS → AD という 構成に変更しました。
NLBはL4(TCP)パススルーで動作するため、TLS終端はADFSが行います。SNIの問題も発生しません。ただし、NLBにはAWS WAFを直接アタッチできないという制約があります。

 

暫定アーキテクチャ

インターネットからADFSへの通信のセキュリティ確保は別途考えることにして、まずは以下の構成としました。

こちらは完全に検証用の構成であることにご注意ください。実際はウェブサイトと同じところ(またはその「奥」)に認証サイトがあるべきですが、Cognito、ADFS、ウェブサーバ間の通信要件が明確になるようにあえてVPCを分けました。また実際には可用性要件も踏まえて構成する必要があります。

認証データフロー

認証データの流れとしては クライアントPC → Cognito → ADFS → AD → ADFS → Cognito → Client ですが、実際の通信はクライアントPCのブラウザがCognitoとADFSの間をHTTPリダイレクトで仲介する形になります。CognitoとADFSの間に直接の通信は発生しません。

通信フロー

具体的な通信フローは以下のとおりです。

  1. Client → ALB(Webアプリ): Webアプリにアクセス
  2. ALB → Client: Cognitoの認証エンドポイントへリダイレクト(HTTP 302)
  3. Client → Cognito: 認証リクエスト
  4. Cognito → Client: ADFS(SAML IdP)へリダイレクト(HTTP 302、SAMLRequestを含む)
  5. Client → ADFS: SAMLRequestを送信(ADFSのログイン画面が表示される)
  6. ADFS ←→ AD: ADFSがADに対してユーザー認証を実行(サーバー間通信)
  7. ADFS → Client: SAMLResponseを返却(認証成功時)
  8. Client → Cognito: SAMLResponseをCognitoのACSエンドポイントにPOST
  9. Cognito → Client: 認証トークンを発行し、ALBのコールバックURLへリダイレクト
  10. Client → ALB(Webアプリ): 認証済みとしてWebアプリにアクセス

重要なのは、CognitoとADFSの間に直接の通信は発生しないという点です。唯一の例外は、CognitoがADFSのメタデータXMLを取得する際のみ、Cognito → ADFSの直接通信が発生します。ただしメタデータXML取得は後述の通り構築時にADFSで出力されたメタデータをCognitoにアップロードすることで代替が可能です。

自己署名証明書とCognitoのメタデータ取得

今回の検証環境ではADFSに信頼できる第三者機関認証局(CA)の証明書は準備できず自己署名証明書を使用しました。

CognitoがADFSのメタデータURLにアクセスする方式では自己署名証明書のSSL検証に失敗するためエラーが発生し連携ができません。この問題を回避するため、メタデータのファイルをCognitoにアップロードする方式を採用しました。ADFSのメタデータXMLをS3バケットに格納し登録するようにしています。

sAMAccountNameでの認証

今回の要件では、メールアドレスではなくsAMAccountName(Active Directoryでユーザーを一意に識別するログイン名属性)でログインする必要がありました。

ADFSの Claim Rulesで sAMAccountNameを Name IDとして発行するよう設定し、Cognito側でこの値をユーザー識別子として使用しています。ウェブサイト側では認証済みリクエストの x-amzn-oidc-data ヘッダー(JWT形式)をデコードすることで、sAMAccountName を取得できます。

 

アーキテクチャ修正:Web Application Proxy (WAP)の導入

上記構成で一旦動作を確認した後、インターネットにさらされることになったADFSのセキュリティ強化を検討することになりました。

Web Application Proxy (WAP)はMicrosoft推奨のADFS公開方式で、ADFSのSNI処理を正しくハンドリングするリバースプロキシです。WAPはドメイン非参加で運用するため、万が一WAPが侵害されてもADドメインへの影響を限定できます。WAPが侵害された場合は、ADFS側で「プロキシ信頼の取り消し」を実行することで、侵害されたプロキシからのリクエストを即座に拒否できます。

本案件はクライアントPCのグローバルIPアドレスが限定できるためセキュリティグループに頼る選択肢もありましたが、ネットワーク層以外の防御を組み合わせるという視点からWAPを導入する方向で検討を進めました。とはいえ顧客運用対象のEC2サーバが増えるため手放しでは喜べない結果となりました。

まとめ

本記事では、CognitoとオンプレミスADをSAML連携するために、AWS上にADFSを構築し、WAPを経由して安全にインターネットに公開する構成を紹介しました。

ADFSの前にALBを置けないことからNLBを採用し、さらにWAPを導入することで、セキュリティと可用性を両立しています。自己署名証明書環境でのCognitoメタデータ取得の回避策や、sAMAccountNameによる認証の実現方法など、実装時に直面した課題とその解決策も共有しました。これらのノウハウが皆様のお役に立てば幸いです。

 

実装手順

本検証構成をデプロイするためのCloudFormationテンプレートとコマンドを以下に掲載します。

デプロイは5つのCloudFormationテンプレートと、テンプレート以外で必要な対応手順から構成されます。

  • [Step 1] CloudFormation: スタック1 (擬似オンプレミスVPC) デプロイ
  • [Step 2] CloudFormation: スタック2 (AD/ADFSサーバ) デプロイ
  • [自動実行] UserDataスクリプト(ADドメイン作成、ADFSドメイン参加)
  • [Step 3] CloudFormation: スタック3 (WAP) デプロイ
  • [Step 4] SSM Run Command: ADテストユーザー作成
  • [Step 5] 手動: ADFSファーム作成(Fleet Manager 経由)
  • [Step 6] ADFSメタデータXMLのS3アップロード
  • [Step 7] CloudFormation: スタック4 (ウェブアプリ用VPC) デプロイ
  • [Step 8] CloudFormation: スタック5 (Cognito/ウェブアプリ) デプロイ
  • [Step 9] WAP構成(Fleet Manager 経由)

Step 1: スタック1(擬似オンプレミスVPC)のデプロイ

コマンド

aws cloudformation create-stack \
--stack-name adfs-pseudo-vpc \
--template-body file://cfn-pseudo-onprem-vpc.yaml \
--parameters file://cfn-pseudo-onprem-vpc_adfs-pseudo-vpc.json \
--region ap-northeast-1

cfn-pseudo-onprem-vpc.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Stack 1: Pseudo On-Premises VPC with NLB for ADFS access'

Parameters:
  VpcCidr:
    Type: String
    Description: 'CIDR block for the VPC'
    Default: '10.0.0.0/16'
  
  PublicSubnet1aCidr:
    Type: String
    Description: 'CIDR block for the public subnet in AZ 1a'
    Default: '10.0.10.0/24'
  
  PublicSubnet1cCidr:
    Type: String
    Description: 'CIDR block for the public subnet in AZ 1c'
    Default: '10.0.11.0/24'
  
  WapSubnet1aCidr:
    Type: String
    Description: 'CIDR block for the WAP subnet in AZ 1a'
    Default: '10.0.20.0/24'
  
  WapSubnet1cCidr:
    Type: String
    Description: 'CIDR block for the WAP subnet in AZ 1c'
    Default: '10.0.21.0/24'
  
  InternalNlbSubnet1aCidr:
    Type: String
    Description: 'CIDR block for the Internal NLB subnet in AZ 1a'
    Default: '10.0.30.0/24'
  
  InternalNlbSubnet1cCidr:
    Type: String
    Description: 'CIDR block for the Internal NLB subnet in AZ 1c'
    Default: '10.0.31.0/24'
  
  PrivateSubnet1aCidr:
    Type: String
    Description: 'CIDR block for the private subnet in AZ 1a'
    Default: '10.0.40.0/24'
  
  PrivateSubnet1cCidr:
    Type: String
    Description: 'CIDR block for the private subnet in AZ 1c'
    Default: '10.0.41.0/24'

Resources:
  # VPC
  PseudoOnPremVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-vpc'
  
  # Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-igw'
  
  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      InternetGatewayId: !Ref InternetGateway
  
  # Public Subnet 1a
  PublicSubnet1a:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref PublicSubnet1aCidr
      AvailabilityZone: 'ap-northeast-1a'
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-subnet-1a'
  
  # Public Subnet 1c
  PublicSubnet1c:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref PublicSubnet1cCidr
      AvailabilityZone: 'ap-northeast-1c'
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-subnet-1c'
  
  # WAP Subnet 1a
  WapSubnet1a:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref WapSubnet1aCidr
      AvailabilityZone: 'ap-northeast-1a'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-subnet-1a'
  
  # WAP Subnet 1c
  WapSubnet1c:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref WapSubnet1cCidr
      AvailabilityZone: 'ap-northeast-1c'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-subnet-1c'
  
  # Internal NLB Subnet 1a
  InternalNlbSubnet1a:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref InternalNlbSubnet1aCidr
      AvailabilityZone: 'ap-northeast-1a'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-internal-nlb-subnet-1a'
  
  # Internal NLB Subnet 1c
  InternalNlbSubnet1c:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref InternalNlbSubnet1cCidr
      AvailabilityZone: 'ap-northeast-1c'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-internal-nlb-subnet-1c'
  
  # Private Subnet 1a
  PrivateSubnet1a:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref PrivateSubnet1aCidr
      AvailabilityZone: 'ap-northeast-1a'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-subnet-1a'
  
  # Private Subnet 1c
  PrivateSubnet1c:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      CidrBlock: !Ref PrivateSubnet1cCidr
      AvailabilityZone: 'ap-northeast-1c'
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-subnet-1c'
  
  # Elastic IP for NAT Gateway
  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties:
      Domain: vpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-nat-eip'
  
  # NAT Gateway
  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1a
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-nat-gateway'
  
  # Public Route Table
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-rt'
  
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: InternetGatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      GatewayId: !Ref InternetGateway
  
  PublicSubnet1aRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1a
      RouteTableId: !Ref PublicRouteTable
  
  PublicSubnet1cRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1c
      RouteTableId: !Ref PublicRouteTable
  
  # Private Route Table
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-rt'
  
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref NatGateway
  
  PrivateSubnet1aRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1a
      RouteTableId: !Ref PrivateRouteTable
  
  PrivateSubnet1cRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1c
      RouteTableId: !Ref PrivateRouteTable
  
  # WAP Route Table
  WapRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-rt'
  
  WapRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref WapRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref NatGateway
  
  WapSubnet1aRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref WapSubnet1a
      RouteTableId: !Ref WapRouteTable
  
  WapSubnet1cRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref WapSubnet1c
      RouteTableId: !Ref WapRouteTable
  
  # Internal NLB Route Table (VPC local only, no internet route)
  InternalNlbRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref PseudoOnPremVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-internal-nlb-rt'
  
  InternalNlbSubnet1aRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref InternalNlbSubnet1a
      RouteTableId: !Ref InternalNlbRouteTable
  
  InternalNlbSubnet1cRouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref InternalNlbSubnet1c
      RouteTableId: !Ref InternalNlbRouteTable
  
  # Security Group for AD
  ADSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security group for Active Directory server'
      VpcId: !Ref PseudoOnPremVpc
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-ad-sg'
  
  # Security Group for ADFS
  ADFSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security group for ADFS server'
      VpcId: !Ref PseudoOnPremVpc
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-adfs-sg'
  
  # Security Group Ingress Rules (defined separately to avoid circular dependency)
  ADSecurityGroupIngressFromADFS:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref ADSecurityGroup
      IpProtocol: -1
      SourceSecurityGroupId: !Ref ADFSSecurityGroup
      Description: 'Allow all traffic from ADFS Security Group'
  
  ADFSSecurityGroupIngressFromAD:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref ADFSSecurityGroup
      IpProtocol: -1
      SourceSecurityGroupId: !Ref ADSecurityGroup
      Description: 'Allow all traffic from AD Security Group'
  
  ADFSSecurityGroupIngressAllTraffic:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref ADFSSecurityGroup
      IpProtocol: -1
      CidrIp: '0.0.0.0/0'
      Description: 'Allow all traffic (test environment)'
  
  # Security Group for WAP
  WapSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security group for WAP server'
      VpcId: !Ref PseudoOnPremVpc
      SecurityGroupIngress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
          Description: 'Allow all traffic (test environment)'
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-sg'
  
  # External Network Load Balancer
  ExternalNetworkLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub '${AWS::StackName}-ext-nlb'
      Type: network
      Scheme: internet-facing
      Subnets:
        - !Ref PublicSubnet1a
        - !Ref PublicSubnet1c
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-ext-nlb'
  
  # External NLB Target Group (WAP Target)
  ExternalNlbTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub '${AWS::StackName}-wap-tg'
      VpcId: !Ref PseudoOnPremVpc
      Port: 443
      Protocol: TCP
      TargetType: instance
      HealthCheckProtocol: TCP
      HealthCheckPort: '443'
      TargetGroupAttributes:
        - Key: preserve_client_ip.enabled
          Value: 'false'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-tg'
  
  # External NLB Listener
  ExternalNlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref ExternalNetworkLoadBalancer
      Port: 443
      Protocol: TCP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref ExternalNlbTargetGroup
  
  # Internal Network Load Balancer
  InternalNetworkLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub '${AWS::StackName}-int-nlb'
      Type: network
      Scheme: internal
      Subnets:
        - !Ref InternalNlbSubnet1a
        - !Ref InternalNlbSubnet1c
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-int-nlb'
  
  # Internal NLB Target Group (ADFS Target)
  InternalNlbTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub '${AWS::StackName}-adfs-tg'
      VpcId: !Ref PseudoOnPremVpc
      Port: 443
      Protocol: TCP
      TargetType: instance
      HealthCheckProtocol: TCP
      HealthCheckPort: '443'
      TargetGroupAttributes:
        - Key: preserve_client_ip.enabled
          Value: 'false'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-adfs-tg'
  
  # Internal NLB Listener
  InternalNlbListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref InternalNetworkLoadBalancer
      Port: 443
      Protocol: TCP
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref InternalNlbTargetGroup

Outputs:
  VpcId:
    Description: 'VPC ID'
    Value: !Ref PseudoOnPremVpc
    Export:
      Name: !Sub '${AWS::StackName}-VpcId'
  
  PublicSubnet1aId:
    Description: 'Public Subnet 1a ID'
    Value: !Ref PublicSubnet1a
    Export:
      Name: !Sub '${AWS::StackName}-PublicSubnet1aId'
  
  PrivateSubnet1aId:
    Description: 'Private Subnet 1a ID'
    Value: !Ref PrivateSubnet1a
    Export:
      Name: !Sub '${AWS::StackName}-PrivateSubnet1aId'
  
  WapSubnet1aId:
    Description: 'WAP Subnet 1a ID'
    Value: !Ref WapSubnet1a
    Export:
      Name: !Sub '${AWS::StackName}-WapSubnet1aId'
  
  ADSecurityGroupId:
    Description: 'AD Security Group ID'
    Value: !Ref ADSecurityGroup
    Export:
      Name: !Sub '${AWS::StackName}-ADSecurityGroupId'
  
  ADFSSecurityGroupId:
    Description: 'ADFS Security Group ID'
    Value: !Ref ADFSSecurityGroup
    Export:
      Name: !Sub '${AWS::StackName}-ADFSSecurityGroupId'
  
  WapSecurityGroupId:
    Description: 'WAP Security Group ID'
    Value: !Ref WapSecurityGroup
    Export:
      Name: !Sub '${AWS::StackName}-WapSecurityGroupId'
  
  ExternalNlbTargetGroupArn:
    Description: 'External NLB Target Group ARN'
    Value: !Ref ExternalNlbTargetGroup
    Export:
      Name: !Sub '${AWS::StackName}-ExternalNlbTargetGroupArn'
  
  InternalNlbTargetGroupArn:
    Description: 'Internal NLB Target Group ARN'
    Value: !Ref InternalNlbTargetGroup
    Export:
      Name: !Sub '${AWS::StackName}-InternalNlbTargetGroupArn'
  
  ExternalNlbDnsName:
    Description: 'External NLB DNS Name'
    Value: !GetAtt ExternalNetworkLoadBalancer.DNSName
    Export:
      Name: !Sub '${AWS::StackName}-ExternalNlbDnsName'

cfn-pseudo-onprem-vpc_adfs-pseudo-vpc.json

[
  {
    "ParameterKey": "VpcCidr",
    "ParameterValue": "10.0.0.0/16"
  },
  {
    "ParameterKey": "PublicSubnet1aCidr",
    "ParameterValue": "10.0.10.0/24"
  },
  {
    "ParameterKey": "PublicSubnet1cCidr",
    "ParameterValue": "10.0.11.0/24"
  },
  {
    "ParameterKey": "WapSubnet1aCidr",
    "ParameterValue": "10.0.20.0/24"
  },
  {
    "ParameterKey": "WapSubnet1cCidr",
    "ParameterValue": "10.0.21.0/24"
  },
  {
    "ParameterKey": "InternalNlbSubnet1aCidr",
    "ParameterValue": "10.0.30.0/24"
  },
  {
    "ParameterKey": "InternalNlbSubnet1cCidr",
    "ParameterValue": "10.0.31.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet1aCidr",
    "ParameterValue": "10.0.40.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet1cCidr",
    "ParameterValue": "10.0.41.0/24"
  }
]

Step 2: スタック2(AD/ADFS)のデプロイ

ADサーバ、ADFSサーバを作成します。ADFSDomainNameに、ADFSを外部公開するためのFQDNを指定し、Route 53でそのDNS名をStep1で作成される外側NLBのFQDNに向けるレコードを作成しておいてください。

コマンド

aws cloudformation create-stack \
--stack-name adfs-ad \
--template-body file://cfn-ad-adfs.yaml \
--parameters file://cfn-ad-adfs_adfs-ad.json \
--capabilities CAPABILITY_IAM \
--region ap-northeast-1

cfn-ad-adfs.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Stack 2: AD/ADFS Servers for Cognito ADFS SSO Integration'

Parameters:
  PseudoOnPremVpcStackName:
    Type: String
    Description: 'Name of the Pseudo On-Premises VPC stack'
    Default: 'adfs-pseudo-vpc'
  
  ADInstanceType:
    Type: String
    Description: 'Instance type for AD server'
    Default: 't3.medium'
    AllowedValues:
      - t3.medium
      - t3.large
      - t3.xlarge
  
  ADFSInstanceType:
    Type: String
    Description: 'Instance type for ADFS server'
    Default: 't3.medium'
    AllowedValues:
      - t3.medium
      - t3.large
      - t3.xlarge
  
  ADAdminPassword:
    Type: String
    Description: 'Administrator password for AD domain'
    NoEcho: true
    MinLength: 8
  
  ADDomainName:
    Type: String
    Description: 'AD domain name (e.g., corp.local)'
    Default: 'corp.local'
  
  ADFSDomainName:
    Type: String
    Description: 'ADFS federation service name (e.g., adfs.example.com)'
    Default: 'adfs.example.com'
  
  WindowsAMI:
    Type: AWS::SSM::Parameter::Value
    Description: 'Latest Windows Server 2022 AMI from SSM Parameter Store'
    Default: '/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base'

Resources:
  # IAM Role for SSM
  SSMInstanceRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-ssm-role'
        - Key: Cost
          Value: 'cognitoadfs'
  
  # Instance Profile for SSM
  SSMInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref SSMInstanceRole
  
  # AD Server Instance
  ADInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref WindowsAMI
      InstanceType: !Ref ADInstanceType
      IamInstanceProfile: !Ref SSMInstanceProfile
      SubnetId:
        Fn::ImportValue: !Sub '${PseudoOnPremVpcStackName}-PrivateSubnet1aId'
      SecurityGroupIds:
        - Fn::ImportValue: !Sub '${PseudoOnPremVpcStackName}-ADSecurityGroupId'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-ad-server'
        - Key: Cost
          Value: 'cognitoadfs'
      PropagateTagsToVolumeOnCreation: true
      UserData:
        Fn::Base64: !Sub |
          
          # Get instance metadata
          $instanceId = Invoke-RestMethod -uri http://169.254.169.254/latest/meta-data/instance-id
          
          # Install AD DS role
          Install-WindowsFeature -Name AD-Domain-Services -IncludeManagementTools
          
          # Disable Network Level Authentication for RDP (for Fleet Manager access)
          Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -Value 0
          
          # Set local Administrator password before creating domain
          # This password will become the domain Administrator password after domain creation
          $AdminUser = [ADSI]"WinNT://./Administrator,user"
          $AdminUser.SetPassword("${ADAdminPassword}")
          
          # Create AD domain (computer name will be the default AWS name)
          $SafeModePassword = ConvertTo-SecureString "${ADAdminPassword}" -AsPlainText -Force
          Install-ADDSForest -DomainName "${ADDomainName}" -DomainNetbiosName "CORP" -SafeModeAdministratorPassword $SafeModePassword -InstallDns -Force -NoRebootOnCompletion:$false
          
          # Server will restart automatically after domain creation
          
  
  # ADFS Server Instance
  ADFSInstance:
    Type: AWS::EC2::Instance
    DependsOn: ADInstance
    Properties:
      ImageId: !Ref WindowsAMI
      InstanceType: !Ref ADFSInstanceType
      IamInstanceProfile: !Ref SSMInstanceProfile
      SubnetId:
        Fn::ImportValue: !Sub '${PseudoOnPremVpcStackName}-PrivateSubnet1aId'
      SecurityGroupIds:
        - Fn::ImportValue: !Sub '${PseudoOnPremVpcStackName}-ADFSSecurityGroupId'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-adfs-server'
        - Key: Cost
          Value: 'cognitoadfs'
      PropagateTagsToVolumeOnCreation: true
      UserData:
        Fn::Base64:
          Fn::Sub:
            - |
              
              # Get instance metadata
              $instanceId = Invoke-RestMethod -uri http://169.254.169.254/latest/meta-data/instance-id
              
              # Install ADFS role
              Install-WindowsFeature -Name ADFS-Federation -IncludeManagementTools
              
              # Disable Network Level Authentication for RDP (for Fleet Manager access)
              Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -Value 0
              
              # Generate self-signed certificate
              $cert = New-SelfSignedCertificate -DnsName "${ADFSDomainName}" -CertStoreLocation "cert:\LocalMachine\My" -KeySpec KeyExchange
              
              # Wait for AD server to be ready (20 minutes for domain creation and restart)
              Start-Sleep -Seconds 1200
              
              # Get AD server IP
              $ADServerIP = "${ADInstancePrivateIp}"
              
              # Set DNS server to AD server
              # Get the first active network adapter (dynamically)
              $adapter = Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1
              Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -ServerAddresses $ADServerIP
              
              # Clear DNS cache after changing DNS server
              Clear-DnsClientCache
              
              # Wait for DNS resolution with retry (max 10 attempts, 10 seconds interval)
              $maxRetries = 10
              $retryCount = 0
              do {
                  Start-Sleep -Seconds 10
                  $result = Resolve-DnsName ${ADDomainName} -ErrorAction SilentlyContinue
                  $retryCount++
                  Write-Output "DNS resolution attempt $retryCount : $($result.IPAddress)"
              } while (-not $result -and $retryCount -lt $maxRetries)
              
              if (-not $result) {
                  Write-Error "DNS resolution for ${ADDomainName} failed after $maxRetries attempts"
                  exit 1
              }
              
              # Join domain with error handling
              $password = ConvertTo-SecureString "${ADAdminPassword}" -AsPlainText -Force
              $credential = New-Object System.Management.Automation.PSCredential("CORP\Administrator", $password)
              
              try {
                  Add-Computer -DomainName "${ADDomainName}" -Credential $credential -Force -ErrorAction Stop
                  Write-Output "Domain join successful"
              } catch {
                  Write-Error "Domain join failed: $_"
                  exit 1
              }
              
              Restart-Computer -Force
              
            - ADInstancePrivateIp: !GetAtt ADInstance.PrivateIp
  
  # Lambda execution role for NLB target registration
  NLBTargetRegistrationRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
      Policies:
        - PolicyName: 'ELBv2TargetRegistration'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'elasticloadbalancing:RegisterTargets'
                  - 'elasticloadbalancing:DeregisterTargets'
                Resource: '*'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-nlb-target-reg-role'
        - Key: Cost
          Value: 'cognitoadfs'

  # Lambda function for NLB target registration
  NLBTargetRegistrationFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-nlb-target-reg'
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt NLBTargetRegistrationRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import json
          import boto3
          import cfnresponse

          def handler(event, context):
              try:
                  target_group_arn = event['ResourceProperties']['TargetGroupArn']
                  instance_id = event['ResourceProperties']['InstanceId']
                  client = boto3.client('elbv2')

                  if event['RequestType'] in ['Create', 'Update']:
                      client.register_targets(
                          TargetGroupArn=target_group_arn,
                          Targets=[{'Id': instance_id}]
                      )
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {'InstanceId': instance_id})
                  elif event['RequestType'] == 'Delete':
                      try:
                          client.deregister_targets(
                              TargetGroupArn=target_group_arn,
                              Targets=[{'Id': instance_id}]
                          )
                      except Exception:
                          pass
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              except Exception as e:
                  print(f'Error: {e}')
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-nlb-target-reg-func'
        - Key: Cost
          Value: 'cognitoadfs'

  # Custom resource to register ADFS instance to Internal NLB target group
  NLBTargetRegistration:
    Type: Custom::NLBTargetRegistration
    DependsOn: ADFSInstance
    Properties:
      ServiceToken: !GetAtt NLBTargetRegistrationFunction.Arn
      TargetGroupArn:
        Fn::ImportValue: !Sub '${PseudoOnPremVpcStackName}-InternalNlbTargetGroupArn'
      InstanceId: !Ref ADFSInstance

Outputs:
  ADInstanceId:
    Description: 'AD Server Instance ID'
    Value: !Ref ADInstance
    Export:
      Name: !Sub '${AWS::StackName}-ADInstanceId'
  
  ADFSInstanceId:
    Description: 'ADFS Server Instance ID'
    Value: !Ref ADFSInstance
    Export:
      Name: !Sub '${AWS::StackName}-ADFSInstanceId'
  
  ADInstancePrivateIp:
    Description: 'AD Server Private IP Address'
    Value: !GetAtt ADInstance.PrivateIp
    Export:
      Name: !Sub '${AWS::StackName}-ADInstancePrivateIp'
  
  ADFSInstancePrivateIp:
    Description: 'ADFS Server Private IP Address'
    Value: !GetAtt ADFSInstance.PrivateIp
    Export:
      Name: !Sub '${AWS::StackName}-ADFSInstancePrivateIp'

cfn-ad-adfs_adfs-ad.json

[
  {
    "ParameterKey": "PseudoOnPremVpcStackName",
    "ParameterValue": "adfs-pseudo-vpc"
  },
  {
    "ParameterKey": "ADInstanceType",
    "ParameterValue": "t3.medium"
  },
  {
    "ParameterKey": "ADFSInstanceType",
    "ParameterValue": "t3.medium"
  },
  {
    "ParameterKey": "ADAdminPassword",
    "ParameterValue": "ChangeMe123!"
  },
  {
    "ParameterKey": "ADDomainName",
    "ParameterValue": "corp.local"
  },
  {
    "ParameterKey": "ADFSDomainName",
    "ParameterValue": "adfs.example.com"
  },
  {
    "ParameterKey": "WindowsAMI",
    "ParameterValue": "/aws/service/ami-windows-latest/Windows_Server-2022-English-Full-Base"
  }
]

スタック2のデプロイ完了後、UserDataスクリプトの実行完了まで約25-30分の待機が必要です。UserDataではADドメインの作成とADFSサーバーのドメイン参加が自動実行されます。

Step 3: スタック3(WAP)のデプロイ

コマンド

aws cloudformation create-stack \
--stack-name adfs-wap \
--template-body file://cfn-wap.yaml \
--parameters file://cfn-wap_adfs-wap.json \
--capabilities CAPABILITY_IAM \
--region ap-northeast-1

cfn-wap.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Stack 3: WAP Server for ADFS Proxy Security Enhancement'

Parameters:
  VpcStackName:
    Type: String
    Description: 'Name of the Pseudo On-Premises VPC stack'
    Default: 'adfs-pseudo-vpc'
  
  InstanceType:
    Type: String
    Description: 'Instance type for WAP server'
    Default: 't3.medium'
    AllowedValues:
      - t3.medium
      - t3.large
      - t3.xlarge
  
  LatestWindowsAmiId:
    Type: AWS::SSM::Parameter::Value
    Description: 'Latest Windows Server 2019 Japanese AMI from SSM Parameter Store'
    Default: '/aws/service/ami-windows-latest/Windows_Server-2019-Japanese-Full-Base'
  
  KeyPairName:
    Type: String
    Description: 'EC2 Key Pair name for WAP server (optional)'
    Default: ''

Conditions:
  HasKeyPair: !Not [!Equals [!Ref KeyPairName, '']]

Resources:
  # IAM Role for SSM (Fleet Manager)
  WapSSMRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-ssm-role'
        - Key: Cost
          Value: 'cognitoadfs'

  # Instance Profile for SSM
  WapSSMInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Roles:
        - !Ref WapSSMRole

  # WAP Server Instance
  WapInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref LatestWindowsAmiId
      InstanceType: !Ref InstanceType
      KeyName: !If [HasKeyPair, !Ref KeyPairName, !Ref 'AWS::NoValue']
      IamInstanceProfile: !Ref WapSSMInstanceProfile
      SubnetId:
        Fn::ImportValue: !Sub '${VpcStackName}-WapSubnet1aId'
      SecurityGroupIds:
        - Fn::ImportValue: !Sub '${VpcStackName}-WapSecurityGroupId'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-wap-server'
        - Key: Cost
          Value: 'cognitoadfs'
      PropagateTagsToVolumeOnCreation: true
      UserData:
        Fn::Base64: |
          
          # Install WAP role
          Install-WindowsFeature Web-Application-Proxy -IncludeManagementTools
          
          # Disable Network Level Authentication for RDP (for Fleet Manager access)
          Set-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp' -Name 'UserAuthentication' -Value 0
          

  # Lambda execution role for NLB target registration
  WapNLBTargetRegistrationRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: 'sts:AssumeRole'
      ManagedPolicyArns:
        - 'arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole'
      Policies:
        - PolicyName: 'ELBv2TargetRegistration'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - 'elasticloadbalancing:RegisterTargets'
                  - 'elasticloadbalancing:DeregisterTargets'
                Resource: '*'
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-nlb-target-reg-role'
        - Key: Cost
          Value: 'cognitoadfs'

  # Lambda function for NLB target registration
  WapNLBTargetRegistrationFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub '${AWS::StackName}-nlb-target-reg'
      Runtime: python3.12
      Handler: index.handler
      Role: !GetAtt WapNLBTargetRegistrationRole.Arn
      Timeout: 60
      Code:
        ZipFile: |
          import json
          import boto3
          import cfnresponse

          def handler(event, context):
              try:
                  target_group_arn = event['ResourceProperties']['TargetGroupArn']
                  instance_id = event['ResourceProperties']['InstanceId']
                  client = boto3.client('elbv2')

                  if event['RequestType'] in ['Create', 'Update']:
                      client.register_targets(
                          TargetGroupArn=target_group_arn,
                          Targets=[{'Id': instance_id}]
                      )
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {'InstanceId': instance_id})
                  elif event['RequestType'] == 'Delete':
                      try:
                          client.deregister_targets(
                              TargetGroupArn=target_group_arn,
                              Targets=[{'Id': instance_id}]
                          )
                      except Exception:
                          pass
                      cfnresponse.send(event, context, cfnresponse.SUCCESS, {})
              except Exception as e:
                  print(f'Error: {e}')
                  cfnresponse.send(event, context, cfnresponse.FAILED, {'Error': str(e)})
      Tags:
        - Key: Name
          Value: !Sub '${AWS::StackName}-nlb-target-reg-func'
        - Key: Cost
          Value: 'cognitoadfs'

  # Custom resource to register WAP instance to External NLB target group
  WapNLBTargetRegistration:
    Type: Custom::NLBTargetRegistration
    DependsOn: WapInstance
    Properties:
      ServiceToken: !GetAtt WapNLBTargetRegistrationFunction.Arn
      TargetGroupArn:
        Fn::ImportValue: !Sub '${VpcStackName}-ExternalNlbTargetGroupArn'
      InstanceId: !Ref WapInstance

Outputs:
  WapInstanceId:
    Description: 'WAP Server Instance ID'
    Value: !Ref WapInstance
    Export:
      Name: !Sub '${AWS::StackName}-WapInstanceId'
  
  WapInstancePrivateIp:
    Description: 'WAP Server Private IP Address'
    Value: !GetAtt WapInstance.PrivateIp
    Export:
      Name: !Sub '${AWS::StackName}-WapInstancePrivateIp'

cfn-wap_adfs-wap.json

[
  {
    "ParameterKey": "VpcStackName",
    "ParameterValue": "adfs-pseudo-vpc"
  },
  {
    "ParameterKey": "InstanceType",
    "ParameterValue": "t3.medium"
  },
  {
    "ParameterKey": "LatestWindowsAmiId",
    "ParameterValue": "/aws/service/ami-windows-latest/Windows_Server-2019-Japanese-Full-Base"
  },
  {
    "ParameterKey": "KeyPairName",
    "ParameterValue": ""
  }
]

Step 4: ADテストユーザーの作成

SSM Run Commandを使用して、ADサーバー上にテストユーザーを作成します。

ユーザ名 パスワード
testuser01@corp TestPass123!
testuser02@corp TestPass123!
testuser03@corp TestPass123!
aws ssm send-command \
--instance-ids "<ADインスタンスID>" \
--document-name "AWS-RunPowerShellScript" \
--parameters file://ad-create-test-users.json \
--region ap-northeast-1

ad-create-test-users.json

{
  "commands": [
    "Import-Module ActiveDirectory",
    "$password = ConvertTo-SecureString 'TestPass123!' -AsPlainText -Force",
    "New-ADUser -Name 'testuser01' -SamAccountName 'testuser01' -UserPrincipalName 'testuser01@corp.local' -AccountPassword $password -Enabled $true -PasswordNeverExpires $true -Path 'CN=Users,DC=corp,DC=local'",
    "New-ADUser -Name 'testuser02' -SamAccountName 'testuser02' -UserPrincipalName 'testuser02@corp.local' -AccountPassword $password -Enabled $true -PasswordNeverExpires $true -Path 'CN=Users,DC=corp,DC=local'",
    "New-ADUser -Name 'testuser03' -SamAccountName 'testuser03' -UserPrincipalName 'testuser03@corp.local' -AccountPassword $password -Enabled $true -PasswordNeverExpires $true -Path 'CN=Users,DC=corp,DC=local'",
    "Write-Output 'Test users created. Verifying...'",
    "Get-ADUser -Filter * | Select-Object Name,SamAccountName,Enabled | Format-Table -AutoSize"
  ]
}

Step 5: ADFSファームの作成

Fleet Manager でADFSサーバーに接続し、PowerShellでADFSファームを作成します。adfs.example.comのところ(2か所)はADFSを外部公開するFQDNに置き換えてください。

ユーザ名 パスワード
CORP\Administrator ChangeMe123!
# 証明書サムプリント取得
$cert = Get-ChildItem -Path Cert:\LocalMachine\My |
Where-Object {$_.Subject -like "*adfs.example.com*"}

# ADFSファーム作成
$password = ConvertTo-SecureString "ChangeMe123!" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential(
"CORP\Administrator", $password)
Install-AdfsFarm `
-CertificateThumbprint $cert.Thumbprint `
-FederationServiceName "adfs.example.com" `
-ServiceAccountCredential $credential `
-OverwriteConfiguration

Step 6: ADFSメタデータXMLのS3アップロード

自己署名証明書環境では、CognitoのMetadataURL方式が使えないため、メタデータXMLをS3にアップロードします。

# メタデータXML取得。ファイル名 adfs-metadata.xml として保存
curl -sk https://<ADFSのFQDN>/FederationMetadata/2007-06/FederationMetadata.xml \
-o /tmp/adfs-metadata.xml

# S3バケット作成
aws s3 mb "s3://<バケット名>" --region ap-northeast-1

# S3にアップロード
aws s3 cp tmp/adfs-metadata.xml "s3://<バケット名>/adfs-metadata.xml" \
--region ap-northeast-1

Step 7: スタック4(本番VPC)のデプロイ

デプロイには、ウェブサーバの前に配置するALBにインストールするACMサーバ証明書のARNが必要になります。

コマンド

aws cloudformation create-stack \
--stack-name adfs-prod-vpc \
--template-body file://cfn-production-vpc.yaml \
--parameters file://cfn-production-vpc_adfs-prod-vpc.json \
--region ap-northeast-1

cfn-production-vpc.yaml

AWSTemplateFormatVersion: '2010-09-09'
Description: 'Stack 4: Production VPC with ALB for Web Application'

Parameters:
  VpcCidr:
    Type: String
    Description: 'CIDR block for the VPC'
    Default: '10.1.0.0/16'
  
  PublicSubnet1Cidr:
    Type: String
    Description: 'CIDR block for the first public subnet'
    Default: '10.1.1.0/24'
  
  PublicSubnet2Cidr:
    Type: String
    Description: 'CIDR block for the second public subnet'
    Default: '10.1.2.0/24'
  
  PrivateSubnet1Cidr:
    Type: String
    Description: 'CIDR block for the first private subnet'
    Default: '10.1.3.0/24'
  
  PrivateSubnet2Cidr:
    Type: String
    Description: 'CIDR block for the second private subnet'
    Default: '10.1.4.0/24'
  
  AvailabilityZone1:
    Type: String
    Description: 'First Availability Zone'
    Default: 'ap-northeast-1a'
  
  AvailabilityZone2:
    Type: String
    Description: 'Second Availability Zone'
    Default: 'ap-northeast-1c'
  
  ALBCertificateArn:
    Type: String
    Description: 'ARN of the ACM certificate for ALB HTTPS listener'

Resources:
  # VPC
  ProductionVpc:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VpcCidr
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-vpc'
  
  # Internet Gateway
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-igw'
  
  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref ProductionVpc
      InternetGatewayId: !Ref InternetGateway
  
  # Public Subnet 1
  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVpc
      CidrBlock: !Ref PublicSubnet1Cidr
      AvailabilityZone: !Ref AvailabilityZone1
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-subnet-1'
  
  # Public Subnet 2
  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVpc
      CidrBlock: !Ref PublicSubnet2Cidr
      AvailabilityZone: !Ref AvailabilityZone2
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-subnet-2'
  
  # Private Subnet 1
  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVpc
      CidrBlock: !Ref PrivateSubnet1Cidr
      AvailabilityZone: !Ref AvailabilityZone1
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-subnet-1'
  
  # Private Subnet 2
  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVpc
      CidrBlock: !Ref PrivateSubnet2Cidr
      AvailabilityZone: !Ref AvailabilityZone2
      MapPublicIpOnLaunch: false
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-subnet-2'
  
  # Elastic IP for NAT Gateway
  NatGatewayEIP:
    Type: AWS::EC2::EIP
    DependsOn: InternetGatewayAttachment
    Properties:
      Domain: vpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-nat-eip'
  
  # NAT Gateway (in Public Subnet 1)
  NatGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NatGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-nat-gateway'
  
  # Public Route Table
  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref ProductionVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-public-rt'
  
  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: InternetGatewayAttachment
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      GatewayId: !Ref InternetGateway
  
  PublicSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet1
      RouteTableId: !Ref PublicRouteTable
  
  PublicSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet2
      RouteTableId: !Ref PublicRouteTable
  
  # Private Route Table
  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref ProductionVpc
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-private-rt'
  
  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: '0.0.0.0/0'
      NatGatewayId: !Ref NatGateway
  
  PrivateSubnet1RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet1
      RouteTableId: !Ref PrivateRouteTable
  
  PrivateSubnet2RouteTableAssociation:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet2
      RouteTableId: !Ref PrivateRouteTable

  # Security Group for ALB
  ALBSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security group for Application Load Balancer'
      VpcId: !Ref ProductionVpc
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 443
          ToPort: 443
          CidrIp: '0.0.0.0/0'
          Description: 'Allow HTTPS from Internet'
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-alb-sg'
  
  # Security Group for Web Application
  WebAppSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: 'Security group for Web Application server'
      VpcId: !Ref ProductionVpc
      SecurityGroupEgress:
        - IpProtocol: -1
          CidrIp: '0.0.0.0/0'
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-webapp-sg'
  
  # Security Group Ingress Rule for WebApp (from ALB)
  WebAppSecurityGroupIngressFromALB:
    Type: AWS::EC2::SecurityGroupIngress
    Properties:
      GroupId: !Ref WebAppSecurityGroup
      IpProtocol: tcp
      FromPort: 80
      ToPort: 80
      SourceSecurityGroupId: !Ref ALBSecurityGroup
      Description: 'Allow HTTP from ALB'
  
  # Application Load Balancer
  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub '${AWS::StackName}-alb'
      Type: application
      Scheme: internet-facing
      IpAddressType: ipv4
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-alb'
  
  # ALB Target Group
  ALBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub '${AWS::StackName}-alb-tg'
      VpcId: !Ref ProductionVpc
      Port: 80
      Protocol: HTTP
      TargetType: instance
      HealthCheckEnabled: true
      HealthCheckPath: /health
      HealthCheckProtocol: HTTP
      HealthCheckPort: traffic-port
      HealthyThresholdCount: 5
      UnhealthyThresholdCount: 2
      HealthCheckIntervalSeconds: 30
      HealthCheckTimeoutSeconds: 6
      Tags:
        - Key: Cost
          Value: cognitoadfs
        - Key: Name
          Value: !Sub '${AWS::StackName}-alb-tg'
  
  # ALB HTTPS Listener
  ALBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      LoadBalancerArn: !Ref ApplicationLoadBalancer
      Port: 443
      Protocol: HTTPS
      SslPolicy: ELBSecurityPolicy-TLS13-1-2-2021-06
      Certificates:
        - CertificateArn: !Ref ALBCertificateArn
      DefaultActions:
        - Type: forward
          TargetGroupArn: !Ref ALBTargetGroup

Outputs:
  VpcId:
    Description: 'VPC ID'
    Value: !Ref ProductionVpc
    Export:
      Name: !Sub '${AWS::StackName}-VpcId'
  
  PrivateSubnet1Id:
    Description: 'Private Subnet 1 ID'
    Value: !Ref PrivateSubnet1
    Export:
      Name: !Sub '${AWS::StackName}-PrivateSubnet1Id'
  
  PrivateSubnet2Id:
    Description: 'Private Subnet 2 ID'
    Value: !Ref PrivateSubnet2
    Export:
      Name: !Sub '${AWS::StackName}-PrivateSubnet2Id'
  
  PublicSubnet1Id:
    Description: 'Public Subnet 1 ID'
    Value: !Ref PublicSubnet1
    Export:
      Name: !Sub '${AWS::StackName}-PublicSubnet1Id'
  
  PublicSubnet2Id:
    Description: 'Public Subnet 2 ID'
    Value: !Ref PublicSubnet2
    Export:
      Name: !Sub '${AWS::StackName}-PublicSubnet2Id'
  
  ALBTargetGroupArn:
    Description: 'ALB Target Group ARN'
    Value: !Ref ALBTargetGroup
    Export:
      Name: !Sub '${AWS::StackName}-ALBTargetGroupArn'
  
  ALBListenerArn:
    Description: 'ALB Listener ARN'
    Value: !Ref ALBListener
    Export:
      Name: !Sub '${AWS::StackName}-ALBListenerArn'
  
  WebAppSecurityGroupId:
    Description: 'Web Application Security Group ID'
    Value: !Ref WebAppSecurityGroup
    Export:
      Name: !Sub '${AWS::StackName}-WebAppSecurityGroupId'
  
  ALBDnsName:
    Description: 'ALB DNS Name'
    Value: !GetAtt ApplicationLoadBalancer.DNSName
    Export:
      Name: !Sub '${AWS::StackName}-ALBDnsName'

cfn-production-vpc_adfs-prod-vpc.json

[
  {
    "ParameterKey": "VpcCidr",
    "ParameterValue": "10.1.0.0/16"
  },
  {
    "ParameterKey": "PublicSubnet1Cidr",
    "ParameterValue": "10.1.1.0/24"
  },
  {
    "ParameterKey": "PublicSubnet2Cidr",
    "ParameterValue": "10.1.2.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet1Cidr",
    "ParameterValue": "10.1.3.0/24"
  },
  {
    "ParameterKey": "PrivateSubnet2Cidr",
    "ParameterValue": "10.1.4.0/24"
  },
  {
    "ParameterKey": "AvailabilityZone1",
    "ParameterValue": "ap-northeast-1a"
  },
  {
    "ParameterKey": "AvailabilityZone2",
    "ParameterValue": "ap-northeast-1c"
  },
  {
    "ParameterKey": "ALBCertificateArn",
    "ParameterValue": "<ACMで作成したウェブアプリ用サーバ証明書のARN>"
  }
]

Step 8: スタック5(Cognito/WebApp)のデプロイ

  • Step 6で作成したADFSメタデータXMLファイルを格納したS3バケット名をパラメータファイルで指定する必要があります。
  • ウェブサイトのDNS名を決定し、Route 53で名前解決設定(CNAMEまたはALIASをStep 7で作成したALBに向ける)をしてから実行してください。またそのDNS名をパラメータファイルで指定する必要があります。
  • パラメータファイルで指定するCognitoのドメインプレフィックスはAWSアカウント横断でグローバルに一意である必要があります。”AlreadyExists”エラーが発生した場合は、プレフィックスを変更してください。

コマンド

aws cloudformation create-stack \
--stack-name adfs-webapp \
--template-body file://cfn-cognito-webapp.yaml \
--parameters file://cfn-cognito-webapp_adfs-webapp.json \
--capabilities CAPABILITY_NAMED_IAM \
--region ap-northeast-1

cfn-cognito-webapp.yaml

表示崩れるため別掲します。

cfn-cognito-webapp_adfs-webapp.json

[
  {
    "ParameterKey": "ProductionVpcStackName",
    "ParameterValue": "adfs-prod-vpc"
  },
  {
    "ParameterKey": "PseudoOnPremVpcStackName",
    "ParameterValue": "adfs-pseudo-vpc"
  },
  {
    "ParameterKey": "ADFSMetadataS3Bucket",
    "ParameterValue": "your-adfs-metadata-xml-bucket-name"
  },
  {
    "ParameterKey": "ADFSMetadataS3Key",
    "ParameterValue": "adfs-metadata.xml"
  },
  {
    "ParameterKey": "CognitoDomainPrefix",
    "ParameterValue": "your-cognito-domain-prefix"
  },
  {
    "ParameterKey": "ALBDnsName",
    "ParameterValue": "<ウェブサーバのDNS名>"
  },
  {
    "ParameterKey": "WebAppInstanceType",
    "ParameterValue": "t3.micro"
  },
  {
    "ParameterKey": "WebAppAmiId",
    "ParameterValue": "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
  }
]

Step 9: WAP構成

Fleet Manager でWAPサーバーに接続し、以下の手順でWAPを構成します。

9-1. DNS解決設定

WAPサーバーのhostsファイルに、ADFS FQDNとInternal NLBのIPアドレスのマッピングを追加します。WAPはADFS FQDNに対してHTTPS通信を行いますが、通常のDNS解決ではExternal NLBに解決されてしまうため、hostsファイルでInternal NLB経由に向ける必要があります。

$internalNlbDns = "<Internal NLB DNS名>"
$adfsIps = [System.Net.Dns]::GetHostAddresses($internalNlbDns) |
Select-Object -ExpandProperty IPAddressToString
$hostsEntry = "$($adfsIps[0]) adfs.example.com"
Add-Content -Path "C:\Windows\System32\drivers\etc\hosts" -Value $hostsEntry

9-2. ADFS証明書のインポート

ADFSサーバーから証明書をPFXファイルとしてエクスポートし、S3経由でWAPサーバーに転送してインポートします。

# WAPサーバーで証明書をインポート
$password = ConvertTo-SecureString -String "<エクスポートパスワード>" `
-Force -AsPlainText
Import-PfxCertificate -FilePath "C:\adfs-cert.pfx" `
-CertStoreLocation Cert:\LocalMachine\My -Password $password

9-3. Proxy Trust確立

WAPとADFS間の信頼関係を確立します。

$cred = Get-Credential # CORP\Administrator
Install-WebApplicationProxy `
-FederationServiceName "adfs.example.com" `
-FederationServiceTrustCredential $cred `
-CertificateThumbprint "<証明書Thumbprint>"

9-4. ADFSアプリケーション公開

ADFSをWAP経由で外部に公開します。

Add-WebApplicationProxyApplication `
-Name "ADFS" `
-ExternalUrl "https://adfs.example.com/adfs/ls/" `
-ExternalCertificateThumbprint "<証明書Thumbprint>" `
-BackendServerUrl "https://adfs.example.com/adfs/ls/" `
-ExternalPreauthentication PassThrough

以上

著者について

SCSKにて、AWSの技術支援を提供するAWSテクニカルエスコートサービスの業務に従事しています。
自称ネットワークエンジニア。
CloudFormationは触っていても面白みが感じられないけれどCDKは好き
…だったのですが、最近、生成AIで作りやすいのでCloudFormationをよく使うようになりました。生成AI任せなのでスキルは向上していません。

2024-2025 Japan AWS Top Engineer (Networking)
2024-2025 Japan All AWS Certifications Engineer

その他所持資格:
IPA 情報処理安全確保支援士

好きなすみっコはとかげ。

貝塚広行をフォローする

クラウドに強いによるエンジニアブログです。

SCSKクラウドサービス(AWS)は、企業価値の向上につながるAWS 導入を全面支援するオールインワンサービスです。AWS最上位パートナーとして、多種多様な業界のシステム構築実績を持つSCSKが、お客様のDX推進を強力にサポートします。

AWSクラウドセキュリティ
シェアする
タイトルとURLをコピーしました