code-server を nginx で HTTPS 化して Amazon Cognito で MFA 認証をさせる – 実装編

こんにちは、広野です。

AWS Cloud9 に代わる、使い勝手の良い IDE on AWS を作れないものか、、、日々模索しています。

今回は、code-server を HTTPS 公開して MFA 認証を付ける構成の実装編として AWS CloudFormation テンプレートを紹介します。

前回の記事

背景やアーキテクチャ説明については、前回記事をご覧ください。

 

 

アーキテクチャ

前回記事からの再掲で、図を載せておきます。

 

AWS CloudFormation テンプレート

テンプレートは大きく 2 つに分かれています。

  1. ユーザー共通で使用する Amazon Cognito ユーザープール 【既存ユーザープールも条件付きで使用可】
  2. ユーザー単位で作成する Amazon EC2 インスタンス、他

以下は本テンプレートを使用する際に前提として必要となるリソースです。

  • Amazon Route 53 パブリックホストゾーン: 証明書作成やレコード登録に必要
  • Amazon VPC: 既存のものを使用する想定、パブリックサブネットが必要
  • Amazon SES ID および 設定セット: Amazon SES で Amazon Cognito からの通知メールを送るために必要

本記事のテンプレートを加工すれば 既存の Amazon Cognito ユーザープールを使用可能ですが、メールアドレスを属性として持っていること、Amazon Cognito マネージドログインのドメイン設定ができていることが前提となります。Amazon Cognito ID プールは不要です。

Amazon SES に関する説明は以下の記事をご覧ください。

 

 

AWS CloudFormation テンプレートの記述に関する補足は # コメントでインラインで追加します。

【ユーザー共通】Amazon Cognito ユーザープール

他の記事でも紹介していますが、より簡略化した設定になっています。セルフサインアップ可能なメールアドレスをドメイン名で制限するための AWS Lambda 関数を組み込んでいます。

AWSTemplateFormatVersion: 2010-09-09
Description: The CloudFormation template that creates a Cognito user pool with Managed Login, self-signup by email, and forced MFA.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1

  SubName:
    Type: String
    Description: System sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1

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

  SesConfigurationSet:
    Type: String
    Description: Amazon SES configuration set for sending emails.
    Default: xxxxxxxxxxxxxxxx
    MaxLength: 100
    MinLength: 5

  CognitoReplyTo:
    Type: String
    Description: Cognito Reply-to email address. (e.g. xxx@xxx.xxx)
    Default: xxxxx@xxx.xxx
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  CognitoEmailFrom:
    Type: String
    Description: Cognito e-mail from address. (e.g. xxx@xxx.xxx)
    Default: xxxxx@xxx.xxx
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  AllowedUserEmailDomains:
    Description: Domain list to allow user sign up. Each domains must be comma delimited and double quoted.
    Type: String
    Default: '"xxx.xxx"'

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

Resources:
# ------------------------------------------------------------#
# Lambda (triggered from Cognito) Role (IAM)
# ------------------------------------------------------------#
  LambdaTriggeredFromCognitoRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub ${SystemName}-LambdaTriggeredFromCognitoRole-${SubName}
      Description: This role grants Lambda functions triggered from Cognito basic priviledges.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
              - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
        - arn:aws:iam::aws:policy/AWSXRayDaemonWriteAccess
        - arn:aws:iam::aws:policy/AmazonCognitoPowerUser

# ------------------------------------------------------------#
# 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

  CognitoLambdaInvocationPermissionPostconfirm:
    Type: AWS::Lambda::Permission
    Properties:
      FunctionName: !GetAtt LambdaCognitoPostconfirm.Arn
      Action: lambda:InvokeFunction
      Principal: cognito-idp.amazonaws.com
      SourceAccount: !Sub ${AWS::AccountId}
      SourceArn: !GetAtt UserPool.Arn
    DependsOn:
      - LambdaCognitoPostconfirm
      - 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: 180
      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
        PostConfirmation: !GetAtt LambdaCognitoPostconfirm.Arn
      UsernameAttributes:
        - email
      UsernameConfiguration:
        CaseSensitive: false
      UserPoolAddOns:
        AdvancedSecurityMode: "OFF"
      UserPoolTags:
        Cost: !Sub ${SystemName}-${SubName}

  # この UserPoolDomain 設定は、Amazon Cognito マネージドログインを使用するために必要です。
  # Version 2 つまりマネージドログインを使用する設定にしています。
  # Version 1 だと旧型の hosted UI になります。
  UserPoolDomain:
    Type: AWS::Cognito::UserPoolDomain
    Properties:
      Domain: !Sub ${SystemName}-${SubName}
      ManagedLoginVersion: 2
      UserPoolId: !Ref UserPool

  UserPoolGroupBasic:
    Type: AWS::Cognito::UserPoolGroup
    Properties:
      Description: The User Group for standard users.
      GroupName: BASIC
      Precedence: 101
      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.13
      Timeout: 3
      MemorySize: 128
      Role: !GetAtt LambdaTriggeredFromCognitoRole.Arn
      Handler: index.lambda_handler
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
      Code:
        ZipFile: !Sub |
          import re
          def lambda_handler(event, context):
            try:
              print(event)
              triggersource = event['triggerSource']
              email = event['request']['userAttributes']['email']
              print(email)
              if triggersource == 'PreSignUp_SignUp':
                print('via self signup')
                domain = email.split('@')[1]
                allowedDomains = [ ${AllowedUserEmailDomains} ]
                if domain in allowedDomains:
                  print('allowed domain and email account')
                  return event
                else:
                  print('prohibited domain')
                  return None
              else:
                print('via admin console')
                return event
            except Exception as e:
              print(str(e))
    DependsOn: LambdaTriggeredFromCognitoRole

  LambdaCognitoPostconfirm:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: !Sub ${SystemName}-CognitoPostconfirm-${SubName}
      Description: !Sub Lambda Function triggered from Cognito after user confirmation to add the user in BASIC group for ${SystemName}-${SubName}
      Runtime: python3.13
      Timeout: 3
      MemorySize: 128
      Role: !GetAtt LambdaTriggeredFromCognitoRole.Arn
      Handler: index.lambda_handler
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
      Code:
        ZipFile: !Sub |
          import boto3
          client = boto3.client('cognito-idp', '${AWS::Region}')
          def lambda_handler(event, context):
            USERPOOLID = event['userPoolId']
            USERNAME = event['userName']
            try:
              print(event)
              res = client.admin_add_user_to_group(
                UserPoolId=USERPOOLID,
                Username=USERNAME,
                GroupName='BASIC'
              )
              return event
            except Exception as e:
              print(str(e))
    DependsOn: LambdaTriggeredFromCognitoRole

# ------------------------------------------------------------#
# 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}

 

【ユーザー単位】Amazon EC2 インスタンス、他付属リソース

ユーザー単位で Amazon EC2 インスタンスを作成するとともに、その EC2 インスタンス専用の Amazon Cognito ユーザープール「アプリケーションクライアント」を作成します。アプリケーションクライアントは上述のテンプレートで作成した Amazon Cognito ユーザープールに関連付けます。ここで作成したアプリケーションクライアントにはクライアントシークレットを作成しており、その Amazon EC2 インスタンスしか知りません。(まあ、マネジメントコンソールを見てしまえばわかるんですが、外部の人が簡単にわかるものではない) それを知ろうとするために、Amazon Cognito ユーザープールを参照可能な IAM ロール権限を付与しています。

Amazon EC2 インスタンスには Elastic IP アドレスを割り当て、それを指定した Amazon Route 53 パブリックホストゾーンに A レコード登録します。FQDN に合わせた SSL 証明書を Let’s Encrypt で作成します。Let’s Encrypt に証明書を作成依頼するときの認証方法は DNS-01 という方式を選定しています。これは Amazon Route 53 パブリックホストゾーンに登録した認証用レコードを Let’s Encrypt のシステム側が確認することでドメインの所有者確認をする方法です。そのため、この EC2 インスタンスの IAM ロールにレコード登録可能な権限を付与しています。

などなど、いろいろと複合技を仕掛けています。内容が盛り沢山で既に私もおなかいっぱいです。

テンプレート内にインラインの説明コメントが多くなると思いますが、ご容赦ください。

AWSTemplateFormatVersion: "2010-09-09"
Description: The CloudFormation template that creates an Amazon Linux 2023 x86_64 EC2 instance with code-server and Cognito authentication.

# ------------------------------------------------------------#
# Input Parameters
# ------------------------------------------------------------#
Parameters:
  SystemName:
    Type: String
    Description: System name. use lower case only. (e.g. example)
    Default: example
    MaxLength: 10
    MinLength: 1

  SubName:
    Type: String
    Description: System sub name. use lower case only. (e.g. prod or dev)
    Default: dev
    MaxLength: 10
    MinLength: 1

  DomainName:
    Type: String
    Description: Domain name for URL. xxxxx.xxx
    Default: example.com
    AllowedPattern: "^(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9])(?:\\.(?!-)(?:[a-zA-Z0-9-]{0,62}[a-zA-Z0-9]))*$"

  # VPC やパブリックサブネットは既存のものを選択してください。
  VpcId:
    Type: AWS::EC2::VPC::Id
    Description: Choose a existing VPC ID you deploy the EC2 instance in.

  InstanceSubnet:
    Type: AWS::EC2::Subnet::Id
    Description: Choose an existing Public Subnet ID you deploy the EC2 instance in.

  # EC2 インスタンスタイプは適宜変更してください。節約のため t3a にしているだけです。medium でもやや重いと思います。
  InstanceType:
    Type: String
    Description: EC2 instance type (AMD/t3a)
    Default: t3a.medium
    AllowedValues:
      - t3a.medium
      - t3a.large
      - t3a.xlarge

  # EBS のサイズも適宜変更してください。
  InstanceVolumeSize:
    Type: Number
    Description: The volume size in GB
    Default: 20

  # アクセスを許可するソースグローバル IP アドレスのサブネットを指定してください。
  AllowedSubnet:
    Type: String
    Description: Allowed source IPv4 subnet and subnet mask. (e.g. xxx.xxx.xxx.xxx/32)
    Default: 111.111.111.111/32
    AllowedPattern: "^((25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\\.){3}(25[0-5]|2[0-4][0-9]|1?[0-9]{1,2})\\/([0-9]|[1-2][0-9]|3[0-2])$"

  TimeZone:
    Type: String
    Description: The specified time zone.
    Default: Asia/Tokyo
    AllowedPattern: "^(Africa|America|Antarctica|Arctic|Asia|Atlantic|Australia|Europe|Indian|Pacific)/[A-Za-z0-9_/-]+$"

  Route53HostZoneId:
    Type: String
    Description: Route 53 public host zone ID for the SSL certificate.
    Default: XXXXXXXXXXXXXXXXXXXX
    MaxLength: 30
    MinLength: 1

  YourId:
    Type: String
    Description: The individual instance id (or number) specified from the administrator.
    Default: client01
    MaxLength: 20
    MinLength: 1

  # ここで指定したメールアドレスで Amazon Cognito ユーザーを作成する必要があります。
  YourEmail:
    Type: String
    Description: Your email address for the access control.
    Default: xxx@xxx.xxx
    AllowedPattern: "[^\\s@]+@[^\\s@]+\\.[^\\s@]+"

  # OAuth2 Proxy のバージョンを入れてください。7.12.0 で動作確認済みですが、
  # バージョンが変わるとコマンド仕様が変わる恐れがあります。
  Oauth2ProxyVersion:
    Type: String
    Description: oauth2-proxy version.
    Default: 7.12.0
    MaxLength: 10
    MinLength: 4

Metadata:
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label:
          default: "General Configuration"
        Parameters:
          - SystemName
          - SubName
          - YourId
          - YourEmail
      - Label:
          default: "Domain Configuration"
        Parameters:
          - DomainName
          - Route53HostZoneId
      - Label:
          default: "Network Configuration"
        Parameters:
          - VpcId
          - InstanceSubnet
          - AllowedSubnet
          - Oauth2ProxyVersion
      - Label:
          default: "EC2 Configuration"
        Parameters:
          - InstanceType
          - InstanceVolumeSize
      - Label:
          default: "OS Configuration"
        Parameters:
          - TimeZone

Resources:
# ------------------------------------------------------------#
# EC2 Launch template
# ------------------------------------------------------------#
  EC2LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: !Sub codeserver-${SystemName}-${SubName}-${YourId}
      LaunchTemplateData:
        InstanceType: !Ref InstanceType
        # 使用する AMI は Amazon Linux 2023 x86_64 の最新版なのですが、スタックを変更するときに
        # 新しい AMI ID がリリースされていると EC2 インスタンスを再作成してしまうので注意が必要です。
        # 正直この指定は良くないと思っていますが楽さに負けて使ってしまいます。
        ImageId: >-
          {{resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64}}
        BlockDeviceMappings:
          - Ebs:
              VolumeSize: !Ref InstanceVolumeSize
              VolumeType: gp3
              DeleteOnTermination: true
              Encrypted: true
            DeviceName: /dev/xvda
        NetworkInterfaces:
          - SubnetId: !Ref InstanceSubnet
            Groups:
              - !Ref Ec2SecurityGroup
            DeviceIndex: 0
            AssociatePublicIpAddress: true
        MetadataOptions:
          HttpTokens: required
        Monitoring:
          Enabled: true
        TagSpecifications:
          - ResourceType: volume
            Tags:
              - Key: Cost
                Value: !Sub ${SystemName}-${SubName}
    DependsOn:
      - Ec2SecurityGroup

# ------------------------------------------------------------#
# EC2 Security Group
# ------------------------------------------------------------#
  Ec2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref VpcId
      GroupDescription: Allow HTTPS access from specified subnets
      SecurityGroupIngress:
        # セキュリティグループは 443 だけ開けていますが、必要に応じて変更してください。
        - Description: Allow HTTPS from the specified client
          FromPort: 443
          IpProtocol: tcp
          ToPort: 443
          CidrIp: !Ref AllowedSubnet
      SecurityGroupEgress:
        - Description: Allow all outbound traffic
          IpProtocol: -1
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub SG-codeserver-${SystemName}-${SubName}-${YourId}

# ------------------------------------------------------------#
# EC2 Role / Instance Profile (IAM)
# ------------------------------------------------------------#
  Ec2Role:
    Type: AWS::IAM::Role
    Properties:
      RoleName: !Sub Ec2Role-codeserver-${SystemName}-${SubName}-${YourId}
      Description: This role allows EC2 instance to invoke S3 and SSM.
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ec2.amazonaws.com
            Action:
              - sts:AssumeRole
      Path: /
      ManagedPolicyArns:
        # Session Manager で CUI ログインできるようにしています。トラブルシューティング時に。
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
        # AWS CodeCommit リポジトリにアクセスできるようにしています。
        - arn:aws:iam::aws:policy/AWSCodeCommitPowerUser
        - arn:aws:iam::aws:policy/CloudWatchAgentServerPolicy
      Policies:
        - PolicyName: !Sub Ec2Policy-codeserver-${SystemName}-${SubName}-${YourId}
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              # Amazon Q Developer の権限を付けています。
              - Effect: Allow
                Action:
                  - codewhisperer:GenerateRecommendations
                Resource: "*"
              # Amazon Route 53 の必要な権限です。Let's Encrypt の SSL 証明書作成に必要です。
              - Effect: Allow
                Action:
                  - route53:ListHostedZones
                  - route53:GetChange
                Resource:
                  - "*"
              - Effect: Allow
                Action:
                  - route53:ChangeResourceRecordSets
                Resource:
                  - !Sub arn:aws:route53:::hostedzone/${Route53HostZoneId}
              # Amazon Cognito ユーザープールアプリケーションクライアントのクライアントシークレット取得用です。
              - Effect: Allow
                Action:
                  - cognito-idp:DescribeUserPoolClient
                Resource:
                  - Fn::ImportValue:
                      !Sub CognitoUserPoolArn-${SystemName}-${SubName}

  Ec2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Ref Ec2Role
      Path: /
      Roles:
        - !Ref Ec2Role
    DependsOn:
      - Ec2Role

# ------------------------------------------------------------#
# Elastic IP address for EC2 instance
# ------------------------------------------------------------#
  EipEc2:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub eip-codeserver-${SystemName}-${SubName}-${YourId}

# ------------------------------------------------------------#
# EIP Association
# ------------------------------------------------------------#
  EIPAssociation:
    Type: AWS::EC2::EIPAssociation
    Properties:
      AllocationId: !GetAtt EipEc2.AllocationId
      InstanceId: !Ref Ec2Instance
    DependsOn:
      - Ec2Instance
      - EipEc2

# ------------------------------------------------------------#
# EC2
# ------------------------------------------------------------#
  Ec2Instance:
    Type: AWS::EC2::Instance
    Properties:
      IamInstanceProfile: !Ref Ec2InstanceProfile
      LaunchTemplate:
        LaunchTemplateId: !Ref EC2LaunchTemplate
        Version: !GetAtt EC2LaunchTemplate.LatestVersionNumber
      UserData:
        Fn::Base64: !Sub
          - |
            #!/bin/bash
            set -euxo pipefail
            # Locale & timezone
            timedatectl set-timezone ${TimeZone}
            # Install packages
            dnf update -y
            # とりあえず Git, Node.js, Python をインストールしています。
            dnf install -y git nodejs gcc make zlib-devel bzip2-devel xz-devel curl-devel libffi-devel python3.13 python3-pip python3-boto3 python3-pytest
            # Install code-server
            export HOME=/home/ec2-user
            curl -fsSL https://code-server.dev/install.sh | bash
            mkdir -p /home/ec2-user/.config/code-server
            mkdir -p /home/ec2-user/.local/share/code-server/User
            mkdir -p /home/ec2-user/environment
            chown -R ec2-user:ec2-user /home/ec2-user/.config /home/ec2-user/.local /home/ec2-user/environment
            # Update code-server config
            # code-server は 8008 ポートで起動しています。よく使う 80 や 8080 と重複しないようにするため。
            # code-server ネイティブの認証は無し(none)にしています。Amazon Cognito を使用するため。
            tee /home/ec2-user/.config/code-server/config.yaml << EOF
            bind-addr: 127.0.0.1:8008
            auth: none
            cert: false
            EOF
            chown ec2-user:ec2-user /home/ec2-user/.config/code-server/config.yaml
            tee /home/ec2-user/.local/share/code-server/User/settings.json << EOF
            {
              "extensions.autoUpdate": false,
              "extensions.autoCheckUpdates": false,
              "terminal.integrated.cwd": "/home/ec2-user/environment",
              "telemetry.telemetryLevel": "off",
              "security.workspace.trust.startupPrompt": "never",
              "security.workspace.trust.enabled": false,
              "security.workspace.trust.banner": "never",
              "security.workspace.trust.emptyWindow": false,
              "editor.indentSize": "tabSize",
              "editor.tabSize": 2,
              "python.testing.pytestEnabled": true,
              "auto-run-command.rules": [
                {
                  "command": "workbench.action.terminal.new"
                }
              ]
            }
            EOF
            chown ec2-user:ec2-user /home/ec2-user/.local/share/code-server/User/settings.json
            # Service start
            systemctl enable --now code-server@ec2-user
            # Create SSL cert
            # Let's Encrypt で SSL 証明書を作成するためのモジュール、Amazon Route 53 対応のものをインストールしています。
            dnf install -y certbot python3-certbot-dns-route53
            # 証明書を DNS-01 という認証方式で作成するコマンドです。
            certbot certonly --dns-route53 -d codeserver-${SystemName}-${SubName}-${YourId}.${DomainName} --agree-tos -m ${YourEmail} --non-interactive --preferred-challenges dns-01
            # Install oauth2-proxy
            curl -L -o /tmp/oauth2-proxy.tar.gz https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v${Oauth2ProxyVersion}/oauth2-proxy-v${Oauth2ProxyVersion}.linux-amd64.tar.gz
            tar -xzf /tmp/oauth2-proxy.tar.gz -C /tmp
            cp /tmp/oauth2-proxy-v${Oauth2ProxyVersion}.linux-amd64/oauth2-proxy /usr/local/bin/
            chmod +x /usr/local/bin/oauth2-proxy
            # ここで、Amazon Cognito ユーザープールアプリケーションクライアントのクライアントシークレットを取得します。
            CLIENT_SECRET=$(aws cognito-idp describe-user-pool-client \
            --user-pool-id ${UserPoolId} \
            --client-id ${UserPoolAppClient} \
            --region ${AWS::Region} \
            --query 'UserPoolClient.ClientSecret' \
            --output text)
            # クッキーシークレットを生成します。
            COOKIE_SECRET=$(python3.13 -c 'import os,base64; print(base64.urlsafe_b64encode(os.urandom(32)).decode())')
            mkdir -p /etc/oauth2-proxy
            chown ec2-user:ec2-user /etc/oauth2-proxy
            chmod 700 /etc/oauth2-proxy
            # 以下は自分のメールアドレス以外ではログインを許可しない設定です。
            echo ${YourEmail} > /etc/oauth2-proxy/allowed_emails.txt
            chmod 600 /etc/oauth2-proxy/allowed_emails.txt
            chown ec2-user:ec2-user /etc/oauth2-proxy/allowed_emails.txt
            # 以下は OAuth2 Proxy をサービスとして起動するための設定です。
            # Amazon Cognito とやり取りするために、この通り記述する必要があります。
            cat <<EOF > /etc/systemd/system/oauth2-proxy.service
            [Unit]
            Description=oauth2-proxy
            After=network.target
            [Service]
            Type=simple
            Environment=CLIENT_SECRET=${!CLIENT_SECRET}
            Environment=COOKIE_SECRET=${!COOKIE_SECRET}
            ExecStart=/usr/local/bin/oauth2-proxy \
            --provider=oidc \
            --oidc-issuer-url=${UserPoolProviderUrl} \
            --client-id=${UserPoolAppClient} \
            --client-secret=$CLIENT_SECRET \
            --redirect-url=https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback \
            --login-url=https://${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com/oauth2/authorize \
            --profile-url=https://${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com/oauth2/userInfo \
            --redeem-url=https://${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com/oauth2/token \
            --scope=openid \
            --cookie-secure=true \
            --cookie-expire=72h \
            --cookie-refresh=1h \
            --cookie-secret=$COOKIE_SECRET \
            --authenticated-emails-file=/etc/oauth2-proxy/allowed_emails.txt \
            --http-address=127.0.0.1:4180
            Restart=on-failure
            User=ec2-user
            Group=ec2-user
            [Install]
            WantedBy=multi-user.target
            EOF
            systemctl daemon-reload
            systemctl enable --now oauth2-proxy
            # Install nginx
            # nginx は code-server を HTTPS 化するためにインストールします。SSL 証明書はここで使われます。            
            dnf install -y nginx
            systemctl enable --now nginx
            mkdir -p /etc/nginx/ssl
            chmod 700 /etc/nginx/ssl
            cp /etc/letsencrypt/live/codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/fullchain.pem /etc/nginx/ssl/nginx-chain.crt
            cp /etc/letsencrypt/live/codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/privkey.pem /etc/nginx/ssl/nginx.key
            chmod 644 /etc/nginx/ssl/nginx-chain.crt
            chmod 600 /etc/nginx/ssl/nginx.key
            # nginx が OAuth2 Proxy とやり取りするためにこの通りパスの設定をする必要があります。
            # proxy_buffer 系の設定が重要で、これが大きくないとエラーになります。
            tee /etc/nginx/conf.d/codeserver.conf << EOF
            server {
              listen 443 ssl;
              server_name codeserver-${SystemName}-${SubName}-${YourId}.${DomainName};
              large_client_header_buffers 4 16k;
              ssl_certificate_key /etc/nginx/ssl/nginx.key;
              ssl_certificate /etc/nginx/ssl/nginx-chain.crt;
              location / {
                auth_request /oauth2/auth;
                error_page 401 = /oauth2/sign_in;
                proxy_pass http://127.0.0.1:8008;
                proxy_http_version 1.1;
                proxy_set_header Upgrade \$http_upgrade;
                proxy_set_header Connection "upgrade";
                proxy_set_header Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_read_timeout 3600s;
                proxy_send_timeout 3600s;
              }
              location /oauth2/ {
                proxy_pass http://127.0.0.1:4180;
                proxy_set_header Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto \$scheme;
                proxy_set_header X-Auth-Request-Redirect \$request_uri;
                proxy_buffers 16 16k;
                proxy_buffer_size 16k;
              }
              location = /oauth2/auth {
                proxy_pass http://127.0.0.1:4180;
                proxy_set_header Host \$host;
                proxy_set_header X-Real-IP \$remote_addr;
                proxy_set_header X-Scheme \$scheme;
                proxy_set_header Content-Length "";
                proxy_pass_request_body off;
                proxy_buffers 16 16k;
                proxy_buffer_size 16k;
              }
            }
            EOF
            systemctl restart nginx
            # Create cert renew script
            # 以下は Let's Encrypt の SSL 証明書を更新するためのスクリプトです。ec2-user のホームディレクトリに配置します。
            cat << 'EOF' > /home/ec2-user/renew-cert.sh
            #!/bin/bash
            set -e
            sudo certbot renew
            sudo cp -f /etc/letsencrypt/live/codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/fullchain.pem /etc/nginx/ssl/nginx-chain.crt
            sudo cp -f /etc/letsencrypt/live/codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/privkey.pem /etc/nginx/ssl/nginx.key
            sudo chmod 644 /etc/nginx/ssl/nginx-chain.crt
            sudo chmod 600 /etc/nginx/ssl/nginx.key
            sudo systemctl restart nginx
            echo "Certificate renewed."
            EOF
            chmod +x /home/ec2-user/renew-cert.sh
            chown ec2-user:ec2-user /home/ec2-user/renew-cert.sh
            # Create sign out file
            # 以下はサインアウト用の URL を md ファイルにして code-server のホームディレクトリに配置します。
            tee /home/ec2-user/environment/signout.md > /dev/null << EOF
            [Sign Out](https://${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com/logout?client_id=${UserPoolAppClient}&logout_uri=https%3A%2F%2Fcodeserver-${SystemName}-${SubName}-${YourId}.${DomainName}%2Foauth2%2Fsign_out)
            EOF
            chown ec2-user:ec2-user /home/ec2-user/environment/signout.md
            chmod 644 /home/ec2-user/environment/signout.md
            # Download Amazon Q Developer CLI
            # Amazon Q Developer CLI のインストーラをダウンロードしています。
            # インストールはサインインが必要なため、ダウンロードにとどめています。
            sudo -i -u ec2-user bash << 'EOF'
            curl --proto '=https' --tlsv1.2 -sSf "https://desktop-release.q.us-east-1.amazonaws.com/latest/q-x86_64-linux.zip" -o "q.zip"
            unzip q.zip
            EOF
            # Install nvm
            # Node.js のバージョン管理ツール nvm をインストールします。
            # インストールは OS 再起動が必要なので最後に入れています。
            # 必要なバージョンを nvm install コマンドで手動で入れましょう。
            sudo -u ec2-user bash -c "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash"
            # Finally, reboot
            reboot
          - UserPoolProviderUrl:
              Fn::ImportValue:
                !Sub CognitoUserPoolProviderUrl-${SystemName}-${SubName}
            UserPoolId:
              Fn::ImportValue:
                !Sub CognitoUserPoolId-${SystemName}-${SubName}
      Tags:
        - Key: Cost
          Value: !Sub ${SystemName}-${SubName}
        - Key: Name
          Value: !Sub codeserver-${SystemName}-${SubName}-${YourId}
    DependsOn:
      - Ec2InstanceProfile
      # Route 53 に Elastic IP アドレスの A レコードが登録されないと Let's Encrypt の SSL 証明書作成が
      # 失敗するので、明示的に DependsOn 設定を入れています。
      - Route53RecordA
      - UserPoolAppClient

# ------------------------------------------------------------#
# Route 53
# ------------------------------------------------------------#
  Route53RecordA:
    Type: AWS::Route53::RecordSet
    Properties:
      HostedZoneName: !Sub ${DomainName}.
      Name: !Sub codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}.
      Type: A
      TTL: 300
      ResourceRecords:
        - !GetAtt EipEc2.PublicIp
    DependsOn: EipEc2

# ------------------------------------------------------------#
# Cognito User Pool App Client
# ------------------------------------------------------------#
  UserPoolAppClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      UserPoolId:
        Fn::ImportValue:
          !Sub CognitoUserPoolId-${SystemName}-${SubName}
      ClientName: !Sub ${SystemName}-${SubName}-${YourId}
      GenerateSecret: true
      RefreshTokenValidity: 3
      AccessTokenValidity: 6
      IdTokenValidity: 6
      ExplicitAuthFlows:
        - ALLOW_USER_SRP_AUTH
        - ALLOW_REFRESH_TOKEN_AUTH
      PreventUserExistenceErrors: ENABLED
      SupportedIdentityProviders: 
        - COGNITO
      # Amazon Cognito マネージドログインを使用するには、以下の URL 系の設定が必須です。
      # ここの設定は繊細で、EC2 内でコマンド実行している nginx や OAuth2 Proxy の設定ときっちり
      # 合わせる必要があります。
      # 特に LogoutURLs に指定した URL を get パラメータの1つとして完全一致させないと
      # ログアウト時に使用しないとログアウトに失敗します。
      # ここでは意味不明かもしれませんがやってみるとわかります。
      # その他、AuthFlow 系の設定も繊細だと思うので、ここに書いてある通りに書いておかないと
      # 動かなそうな気がします。
      CallbackURLs:
        - !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback
      LogoutURLs:
        - !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/sign_out
      DefaultRedirectURI: !Sub https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/oauth2/callback
      AllowedOAuthFlows:
        - code
      AllowedOAuthFlowsUserPoolClient: true
      AllowedOAuthScopes:
        - openid
        - email
        - profile

  # Amazon Cognito マネージドログインを使用する場合、以下の設定が必要になります。
  # デザインを細かくカスタマイズできそうなのですが、大変なので完全デフォルトにしました。
  ManagedLoginBranding:
    Type: AWS::Cognito::ManagedLoginBranding
    Properties:
      ClientId: !Ref UserPoolAppClient
      ReturnMergedResources: true
      UseCognitoProvidedValues: true
      UserPoolId:
        Fn::ImportValue:
          !Sub CognitoUserPoolId-${SystemName}-${SubName}

# ------------------------------------------------------------#
# Output Parameters
# ------------------------------------------------------------#
Outputs:
# EC2
  # 完成した code-server の URL です。
  codeserverUrl:
    Value: !Sub "https://codeserver-${SystemName}-${SubName}-${YourId}.${DomainName}/?folder=/home/ec2-user/environment"
  # ログアウト時にアクセスする URL です。get パラメータがかなり繊細です。
  signoutUrl:
    Value: !Sub "https://${SystemName}-${SubName}.auth.${AWS::Region}.amazoncognito.com/logout?client_id=${UserPoolAppClient}&logout_uri=https%3A%2F%2Fcodeserver-${SystemName}-${SubName}-${YourId}.${DomainName}%2Foauth2%2Fsign_out"

 

まとめ

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

今回の題材は code-server でしたが、他の認証機能のない (または弱い) Web アプリケーションによりセキュアな認証機能を付けたいニーズにぴったりはまると思います。

本件は CloudFormation 化するのに苦労しました。しかし、ユーザーデータスクリプトが dnf install でしばしば失敗するのは勘弁して欲しいです。その度に /var/log/cloud-init-output.log を見るはめになります。とりあえず動くものを作りましたが、今後はもっと信頼性の高い仕組みを作りたいです。AMI 化すればいい、では済まない処理もあるので。

さすがにここまでの内容を独力で作れるわけもなく、生成 AI にかなり助けていただきました。例によって AI の回答のままでは動かないことが多かったので各種ツールのリファレンスと見比べながらの作業を強いられましたが、総合的に見て開発、勉強の両面で非常に効率が良かったことに間違いはないでしょう。

一連の動くコマンドや設定はここにありますので、要件に合わせて部分的に活用いただければと思っております。

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

雑談

Let’s Encrypt はいいですね。AWS Certificate Manager にも証明書をエクスポートできる機能が新たにできましたが、金額が結構高いので Amazon EC2 インスタンスに証明書を組み込むなら Let’s Encrypt 一択かなと思います。更新もスクリプト用意しておけば思ったより楽でしたし。

nginx も便利すぎます。とりあえず HTTPS 化、なら Let’s Encrypt と nginx だけでいいですね。

OAuth2 Proxy は神です。Amazon Cognito マネージドログインとの組み合わせ構成は私の中で標準化されました。w

著者について
広野 祐司

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

広野 祐司をフォローする

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

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

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