こんにちは、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の間に直接の通信は発生しません。 通信フロー 具体的な通信フローは以下のとおりです。 Client → ALB(Webアプリ): Webアプリにアクセス ALB → Client: Cognitoの認証エンドポイントへリダイレクト(HTTP 302) Client → Cognito: 認証リクエスト Cognito → Client: ADFS(SAML IdP)へリダイレクト(HTTP 302、SAMLRequestを含む) Client → ADFS: SAMLRequestを送信(ADFSのログイン画面が表示される) ADFS ←→ AD: ADFSがADに対してユーザー認証を実行(サーバー間通信) ADFS → Client: SAMLResponseを返却(認証成功時) Client → Cognito: SAMLResponseをCognitoのACSエンドポイントにPOST Cognito → Client: 認証トークンを発行し、ALBのコールバックURLへリダイレクト 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 以上