code-server に Kiro CLI と Kiro Crew を同居させ Amazon Cognito で MFA 認証をさせる – Cognito 実装編

こんにちは、広野です。
以前、code-server を nginx で HTTPS 化して Amazon Cognito で MFA 認証をさせる構成を紹介しました。本記事では、同じ Amazon EC2 インスタンス上で AI エージェントの Kiro CLI と Kiro Crew を立ち上げる構成を紹介します。Amazon Cognito 実装編です。
実装は 3つの AWS CloudFormation テンプレートに分けてデプロイします。本記事はその 1つ目で、Amazon Cognito のユーザープールを作るテンプレートを紹介します。
  • Cognito 編 (本記事)
  • EC2 インスタンス編
  • SSM ドキュメント編

作成物

このテンプレートをデプロイすると、次のような Cognito ユーザープールができます。
  • メールアドレスでサインアップとログインができます。サインアップ時には、メールで確認コードが届きます。
  • ログインには、認証アプリのワンタイムパスワード (MFA) が必須です。
  • セルフサインアップできるのは、あらかじめ許可したドメインのメールアドレスだけです。
  • ログイン画面は、Cognito がホストするマネージドログインです。アーキテクチャ編の「画面紹介」を参照してください。
  • このテンプレートは、AWS アカウントごとに 1回だけデプロイします。EC2 インスタンスは何台あっても、このユーザープールを共有します。
  • 想定外のエラーが起きたときは、SNS トピックにメールを通知します。SNS トピックにはサブスクライバーを登録していないため、管理者が手動でサブスクライブします。
  • Amazon Cognito アプリケーションクライアントは、このテンプレートでは作りません。アーキテクチャ編のとおり、EC2 インスタンス 1台につき 1つ作る設計のため、EC2 インスタンス用のテンプレートで作ります。

前提事項

以下は構築済みであることを前提にします。構築方法は、この記事では扱いません。これらリソースの情報を AWS CloudFormation テンプレートに入力することになります。
事前に用意するもの 用途 テンプレートのパラメータ
Amazon SES の検証済み ID (ドメインまたはメールアドレス) Cognito が確認コードのメールを送る SesId
Amazon SES の構成セット メールの送信状況を記録する SesConfigurationSet
メールの送信元アドレス (検証済み ID に含まれるもの) メールの差出人にする CognitoEmailFrom
メールの返信先アドレス 利用者からの返信を受け取る CognitoReplyTo
Amazon Route 53 のパブリックホストゾーン (example.com) EC2 インスタンスの DNS レコードを登録する (なし。EC2 インスタンス編で使用)
上の表のパラメータ以外に、デプロイ時に自分で決める値が 4つあります。
パラメータ 内容
SystemName リソース名の共通部分になる、システムの名前。小文字の英数字だけ使えます
SubName リソース名の共通部分になる、システムのサブ名。prod や dev など
AllowedUserEmailDomains セルフサインアップを許可するメールドメイン。複数ある場合はカンマで区切ります
LogRetentionInDays Lambda 関数のログの保持期間 (日)
SystemName と SubName は、ログイン画面のドメインにも使われます。このドメインはリージョン内で一意でなければならないため、サンプル値の example と dev のままでは、他のアカウントと衝突する可能性があります。必ず自分の値に変更してください。

Amazon SES の準備

SES はリージョンサービスです。Amazon Cognito と同じリージョンで送信元と送信先を「検証済み ID」にしておきます。
SES の新しいアカウントは、サンドボックスという制限下に置かれます。サンドボックスのままでは、検証済みのアドレスとドメインにしかメールを送れません。次のドキュメントを参照して、サンドボックスの解除を申請してください。
構成セットも、同じリージョンに作っておきます。Cognito が SES を使うための設定は、次のドキュメントにまとまっています。

AWS CloudFormation テンプレート

このテンプレートで作成されるリソースは次のとおりです。
  • Amazon Cognito ユーザープール、ユーザープールのドメイン
  • AWS Lambda 関数 (Pre sign-up)
  • Lambda 関数用の Amazon CloudWatch Logs ロググループ
  • Lambda 関数の実行ロール
  • Cognito から Lambda 関数を呼び出すための許可
  • 管理者通知用の Amazon SNS トピック
AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template to create a Cognito user pool with Managed Login, self-signup by email, and forced MFA.
# ------------------------------------------------------------#
# General Information
#
# Last update: 2026-10-30
# Author: Hirono
# ------------------------------------------------------------#

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  SubName:
    Type: String
    Description: System sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1
    AllowedPattern: "^[a-z0-9]+$"

  SesId:
    Type: String
    Description: Amazon SES ID for sending emails. (email addreess or domain)
    Default: example.com
    MaxLength: 100
    MinLength: 5

  SesConfigurationSet:
    Type: String
    Description: Amazon SES configuration set for sending emails.
    Default: ses-logs-123456789012-ap-northeast-1
    MaxLength: 100
    MinLength: 5

  CognitoReplyTo:
    Type: String
    Description: Cognito Reply-to email address. (e.g. xxx@xxx.xx)
    Default: admin@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  CognitoEmailFrom:
    Type: String
    Description: Cognito e-mail from address. (e.g. xxx@xxx.xx)
    Default: no-reply@example.com
    MaxLength: 100
    MinLength: 5
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  AllowedUserEmailDomains:
    Description: Domain list to allow user sign up. (e.g. example.com, example.org)
    Type: CommaDelimitedList
    Default: example.com

  LogRetentionInDays:
    Type: Number
    Description: Retention period in days for the CloudWatch Logs log groups of the Lambda functions triggered from Cognito.
    Default: 400
    AllowedValues:
      - 1
      - 3
      - 5
      - 7
      - 14
      - 30
      - 60
      - 90
      - 120
      - 150
      - 180
      - 365
      - 400
      - 545
      - 731
      - 1096
      - 1827
      - 2192
      - 2557
      - 2922
      - 3288
      - 3653

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SystemName
          - SubName
      - Label:
          default: "Email Configuration"
        Parameters:
          - SesId
          - SesConfigurationSet
          - CognitoReplyTo
          - CognitoEmailFrom
      - Label:
          default: "Security Configuration"
        Parameters:
          - AllowedUserEmailDomains
      - Label:
          default: "Log Configuration"
        Parameters:
          - LogRetentionInDays
  cfn-lint:
    config:
      ignore_checks:
        - "W3005"

Resources:
# ------------------------------------------------------------#
# CloudWatch Logs (Lambda functions triggered from Cognito)
# ------------------------------------------------------------#
  LogGroupLambdaCognitoPresignup:
    Type: AWS::Logs::LogGroup
    Properties:
      LogGroupName: !Sub "/${SystemName}-${SubName}/cognito-presignup"
      RetentionInDays: !Ref LogRetentionInDays
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}

# ------------------------------------------------------------#
# Lambda (triggered from Cognito) Role (IAM)
# ------------------------------------------------------------#
  LambdaTriggeredFromCognitoRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SystemName}-LambdaTriggeredFromCognitoRole-${SubName}
      Description: This role grants the Lambda function triggered from Cognito minimum privileges.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      Policies:
        - PolicyName: !Sub ${SystemName}-LambdaTriggeredFromCognitoPolicy-${SubName}
          PolicyDocument:
            Version: "2012-10-17"
            Statement:
              - Effect: Allow
                Action:
                  - "sns:Publish"
                Resource: !Ref AdminNotificationTopic
              - Effect: Allow
                Action:
                  - "logs:CreateLogStream"
                  - "logs:PutLogEvents"
                Resource: !GetAtt LogGroupLambdaCognitoPresignup.Arn
    DependsOn:
      - AdminNotificationTopic
      - LogGroupLambdaCognitoPresignup

# ------------------------------------------------------------#
# Cognito Lambda Invocation Permission
# ------------------------------------------------------------#
  CognitoLambdaInvocationPermissionPresignup:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt LambdaCognitoPresignup.Arn
      Action: lambda:InvokeFunction
      Principal: cognito-idp.amazonaws.com
      SourceAccount: !Sub ${AWS::AccountId}
      SourceArn: !GetAtt UserPool.Arn
    DependsOn:
      - LambdaCognitoPresignup
      - UserPool

# ------------------------------------------------------------#
# Cognito
# ------------------------------------------------------------#
  UserPool:
    Type: AWS::Cognito::UserPool
    Properties:
      UserPoolName: !Sub ${SystemName}-${SubName}
      MfaConfiguration: "ON"
      EnabledMfas:
        - SOFTWARE_TOKEN_MFA
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireUppercase: true
          RequireLowercase: true
          RequireNumbers: true
          RequireSymbols: false
          TemporaryPasswordValidityDays: 7
      AccountRecoverySetting:
        RecoveryMechanisms:
          - Name: verified_email
            Priority: 1
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
      AutoVerifiedAttributes:
        - email
      DeviceConfiguration:
        ChallengeRequiredOnNewDevice: false
        DeviceOnlyRememberedOnUserPrompt: false
      EmailConfiguration:
        ConfigurationSet: !Ref SesConfigurationSet
        EmailSendingAccount: DEVELOPER
        From: !Sub "${SystemName}-${SubName} admin <${CognitoEmailFrom}>"
        ReplyToEmailAddress: !Ref CognitoReplyTo
        SourceArn: !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/${SesId}
      EmailVerificationMessage: !Sub "${SystemName}-${SubName} Verification code: {####}"
      EmailVerificationSubject: !Sub "${SystemName}-${SubName} Verification code"
      LambdaConfig:
        PreSignUp: !GetAtt LambdaCognitoPresignup.Arn
      UsernameAttributes:
        - email
      UsernameConfiguration:
        CaseSensitive: false
      UserPoolAddOns:
        AdvancedSecurityMode: "OFF"
      UserPoolTags:
        Cost: !Sub ${SystemName}-${SubName}
      UserPoolTier: ESSENTIALS
    DependsOn:
      - LambdaCognitoPresignup

  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Sub ${SystemName}-${SubName}
      ManagedLoginVersion: 2
      UserPoolId: !Ref UserPool

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------#
  LambdaCognitoPresignup:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${SystemName}-CognitoPresignup-${SubName}
      Description: !Sub Lambda Function triggered from Cognito before user self signup to check the user's email domain for ${SystemName}-${SubName}
      Runtime: python3.14
      Timeout: 5
      MemorySize: 128
      Role: !GetAtt LambdaTriggeredFromCognitoRole.Arn
      Handler: index.lambda_handler
      LoggingConfig:
        LogGroup: !Ref LogGroupLambdaCognitoPresignup
      Environment:
        Variables:
          ALLOWED_DOMAINS: !Join
            - ","
            - !Ref AllowedUserEmailDomains
          ADMIN_NOTIFICATION_TOPIC_ARN: !Ref AdminNotificationTopic
          SYSTEM_NAME: !Ref SystemName
          SUB_NAME: !Ref SubName
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
      Code:
        ZipFile: |
          import os
          import logging
          import boto3
          from botocore.config import Config
          logger = logging.getLogger()
          logger.setLevel(logging.INFO)
          # SNS notification is published only on error. Keep timeouts/retries short so a slow SNS call does not exceed the Lambda timeout.
          _sns_config = Config(connect_timeout=1, read_timeout=2, retries={'max_attempts': 1})
          sns = boto3.client('sns', config=_sns_config)
          # Messages returned to the user. Prefix with "- " for readability and drop the trailing period. Cognito wraps them as "PreSignUp failed with error ."
          _MSG_REJECTED = "- Sign-up with this email address is not allowed"
          _MSG_UNEXPECTED = "- Sign-up failed due to an unexpected error"
          def _sanitize_for_log(value):
            # CWE-117/93: remove CR/LF to prevent log/notification forgery from user-supplied input.
            return str(value).replace('\r', '').replace('\n', '')
          class SignUpRejected(Exception):
            # Intentional rejection. Not notified to admin; the rejection message is returned to the user.
            pass
          def notify_admin(trigger_source, email, error):
            subject = f"[{os.environ['SYSTEM_NAME']}-{os.environ['SUB_NAME']}] Cognito PreSignUp ERROR"
            message = (
              f"Trigger: PreSignUp\n"
              f"triggerSource: {_sanitize_for_log(trigger_source)}\n"
              f"Email: {_sanitize_for_log(email) if email else '(unknown)'}\n"
              f"Error: {_sanitize_for_log(error)}\n"
            )
            try:
              sns.publish(
                TopicArn=os.environ['ADMIN_NOTIFICATION_TOPIC_ARN'],
                Subject=subject,
                Message=message
              )
            except Exception:
              logger.exception("failed to publish SNS notification (PreSignUp)")
          def lambda_handler(event, context):
            trigger_source = None
            email = None
            try:
              trigger_source = event['triggerSource']
              email = event['request']['userAttributes']['email']
              # Log identifiers only. Never log the email address or the whole event (personal information).
              logger.info(_sanitize_for_log(
                f"triggerSource={trigger_source} sub={event['request']['userAttributes'].get('sub', '(none)')}"
              ))
              if trigger_source == 'PreSignUp_SignUp':
                # Self sign-up: validate domain
                domain = email.split('@')[1]
                allowed_domains = [d.strip() for d in os.environ['ALLOWED_DOMAINS'].split(',')]
                if domain not in allowed_domains:
                  # The domain is logged so that a rejection can be investigated (the email address is not).
                  logger.info(_sanitize_for_log(f"prohibited domain: {domain}"))
                  raise SignUpRejected('prohibited domain')
                logger.info('allowed domain')
                return event
              else:
                # Admin console (PreSignUp_AdminCreateUser etc.): pass through without validation
                logger.info('via admin console')
                return event
            except SignUpRejected:
              # Intentional rejection: no SNS notification, return the common rejection message to the user
              raise Exception(_MSG_REJECTED)
            except Exception as e:
              # Unexpected error: log + SNS notify, then return a generic message
              logger.exception('unexpected error in PreSignUp')
              notify_admin(trigger_source, email, str(e))
              raise Exception(_MSG_UNEXPECTED)
    DependsOn:
      - LambdaTriggeredFromCognitoRole
      - LogGroupLambdaCognitoPresignup

# ------------------------------------------------------------#
# SNS Topic (Admin Notification)
# ------------------------------------------------------------#
  AdminNotificationTopic:
    Type: AWS::SNS::Topic
    Properties:
      DisplayName: !Sub "${SystemName}-${SubName}-AdminNotification"
      Tags:
        - Key: "Cost"
          Value: !Sub "${SystemName}-${SubName}"

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# Cognito
  CognitoUserPoolId:
    Value: !Ref UserPool
    Export:
      Name: !Sub CognitoUserPoolId-${SystemName}-${SubName}
  CognitoUserPoolArn:
    Value: !GetAtt UserPool.Arn
    Export:
      Name: !Sub CognitoUserPoolArn-${SystemName}-${SubName}
  CognitoUserPoolProviderName:
    Value: !GetAtt UserPool.ProviderName
    Export:
      Name: !Sub CognitoUserPoolProviderName-${SystemName}-${SubName}
  CognitoUserPoolProviderUrl:
    Value: !GetAtt UserPool.ProviderURL
    Export:
      Name: !Sub CognitoUserPoolProviderUrl-${SystemName}-${SubName}
サインアップとログインの実際の動作は、EC2 インスタンスおよびソフトウェアのインストールが出来上がってから確認します。

続編記事

続編記事が出来次第、こちらにリンクを貼ります。

まとめ

いかがでしたでしょうか。

Cognito のユーザープールを、MFA 必須、許可ドメインだけのセルフサインアップという条件で、AWS CloudFormation テンプレートにしました。まだ下準備段階です。

本記事が皆様のお役に立てれば幸いです。

著者について
広野 祐司

AWS サーバーレスアーキテクチャと React を使用して社内向け e-Learning アプリ開発とコンテンツ作成に勤しんでいます。React でアプリを書き始めたら、快適すぎて他の言語には戻れなくなりました。近年は社内外への AWS 技術支援にも従事しています。AWS サービスには AWS が考える IT 設計思想が詰め込まれているので、いつも AWS を通して勉強させて頂いてまます。
取得資格:AWS 認定は13資格、IT サービスマネージャ、ITIL v3 Expert 等
2020 - 2026 Japan AWS Top Engineer 受賞
2022 - 2026 AWS Ambassador 受賞
2023 当社初代フルスタックエンジニア認定
好きなAWSサービス:AWS AppSync Events / AWS Step Functions / AWS CloudFormation

広野 祐司をフォローする

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

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

AWSAI・MLアプリケーション開発クラウドソリューション
シェアする
×
タイトルとURLをコピーしました