こんにちは、広野です。
以前、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 テンプレートにしました。まだ下準備段階です。
本記事が皆様のお役に立てれば幸いです。
