[amplify]Cognito認証付きReactWebページのCICD

Cognitoを使った認証付きのWebページをreactで作る場合、amplify使うと認証まわりをいい感じにしてくれるのでとても便利だが、Cognitoだけ他のAWSリソースと切り離してamplifyで管理としたくないのでCDKで構成し、importさせる。
その上でCICDパイプラインにのせた以下の構成を考える。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/406130/d9da7681-3f48-a844-9e16-45815b53c3a7.png)

# 0. 環境構築

必要なリソースはCDKで構築。
以下参照。CodeCommitは誤って消えたりすると困るので別で手動作成。

<details><summary>Cognito-Stack CDKコード</summary><div>

```ts:cognito-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as cognito from 'aws-cdk-lib/aws-cognito';

export class CognitoStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const userPoolName = "hogehogeuserpool";
    const userPoolAppClientName = "hogehogeappclient";

    // == Cognito ==
    const userpool = new cognito.UserPool(this, userPoolName, {
      userPoolName: userPoolName,
      standardAttributes: {
        email: {required: true, mutable: true},
      },
      selfSignUpEnabled: true,
      signInCaseSensitive: false,
      autoVerify: {email: true},
      accountRecovery: cognito.AccountRecovery.EMAIL_ONLY,
      removalPolicy: RemovalPolicy.DESTROY,
    })
    userpool.addClient(userPoolAppClientName, {
      oAuth: {
        scopes: [
          cognito.OAuthScope.EMAIL,
          cognito.OAuthScope.OPENID,
          cognito.OAuthScope.PROFILE,
        ],
        flows: {
          authorizationCodeGrant: true,
        },
      },
      authFlows: {
        adminUserPassword: true, // ALLOW_ADMIN_USER_PASSWORD_AUTH
        custom: true, // ALLOW_CUSTOM_AUTH
        userPassword: false, // ALLOW_USER_PASSWORD_AUTH
        userSrp: true, // ALLOW_USER_SRP_AUTH
      }
    });
  }
}
```

</div></details>

<details><summary>Hosting-Stack CDKコード</summary><div>

```ts:hosting-stack.ts
import { Stack, StackProps, RemovalPolicy } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as s3deploy from 'aws-cdk-lib/aws-s3-deployment';
import * as cloudfront from 'aws-cdk-lib/aws-cloudfront';
import * as cloudfront_origins from 'aws-cdk-lib/aws-cloudfront-origins';

export class HostingStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const hostingBucketName = "hogehogehostingbucket";
    const oaiName = "hogehogeoai";
    const cloudfrontDistributionName = "hogehogeDistribution";

    // == S3 ==
    // Hosting Bucket
    const hostingBucket = new s3.Bucket(this, hostingBucketName, {
      bucketName: hostingBucketName,
      blockPublicAccess: new s3.BlockPublicAccess({
        blockPublicAcls: false,
        blockPublicPolicy: false,
        ignorePublicAcls: false,
        restrictPublicBuckets: false
      }),
      versioned: false,
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
    });

    // Bucket Contents Upload
    new s3deploy.BucketDeployment(this, "deployToS3", {
      destinationBucket: hostingBucket,
      sources: [s3deploy.Source.asset(`src/testpage/index.html`)], // 任意のhtmlをテスト用に用意すること！！
    })

    // == CloudFront ==
    const oai = new cloudfront.OriginAccessIdentity(this, oaiName, {
      comment: `s3-bucket-${hostingBucketName}`
    });

    const cloudfrontDistribution = new cloudfront.Distribution(this, cloudfrontDistributionName, {
      enabled: true,
      defaultRootObject: "index.html",
      priceClass: cloudfront.PriceClass.PRICE_CLASS_ALL,
      defaultBehavior: {
        allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD,
        cachedMethods: cloudfront.CachedMethods.CACHE_GET_HEAD,
        cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED,
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.HTTPS_ONLY,
        origin: new cloudfront_origins.S3Origin(
          hostingBucket, {
            originAccessIdentity: oai
          }
        )
      }
    });

    hostingBucket.addToResourcePolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions: ["s3:GetObject"],
      principals: [
        new iam.CanonicalUserPrincipal(oai.cloudFrontOriginAccessIdentityS3CanonicalUserId)
      ],
      resources: [
        `${hostingBucket.bucketArn}/*`
      ]
    }));
  }
}
```

</div></details>

<details><summary>Pipeline-Stack CDKコード</summary><div>

```ts:pipeline-stack.ts
import { Stack, StackProps } from 'aws-cdk-lib';
import { Construct } from 'constructs';
import * as s3 from 'aws-cdk-lib/aws-s3';
import * as codecommit from 'aws-cdk-lib/aws-codecommit';
import * as codebuild from 'aws-cdk-lib/aws-codebuild';
import * as codepipeline from 'aws-cdk-lib/aws-codepipeline';
import * as codepipeline_actions from 'aws-cdk-lib/aws-codepipeline-actions';
import * as sns from 'aws-cdk-lib/aws-sns';
import * as subscriptions from 'aws-cdk-lib/aws-sns-subscriptions';
import * as iam from 'aws-cdk-lib/aws-iam';

export class PipelineStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    // == const ==
    const codecommitRepName = "hogehoge";
    const hostingBucketName = "hogehogehostingbucket";
    const snsTopicName = "hogehogeTopicForApproval";
    const approvalEmailAddress = "************@gmail.com";
    const buildProjectName = "hogehogeBuildProj";
    const buildProjectRoleName = "hogehogeBuildProjRole";
    const pipelineName = "hogehogePipeline";

    // == S3 ==
    const dist_bucket = s3.Bucket.fromBucketAttributes(this, hostingBucketName, {
      bucketName: hostingBucketName,
    });
  
    // == Artifact ==
    const sourceOutput = new codepipeline.Artifact('sourceOutput');
    const buildOutput = new codepipeline.Artifact("buildOutput");
  
    // == SNS ==
    const snsTopic = new sns.Topic(this, snsTopicName, {
      topicName: snsTopicName,
    })

    snsTopic.addSubscription(new subscriptions.EmailSubscription(approvalEmailAddress))

    // == CodeCommit ==
    const repository = codecommit.Repository.fromRepositoryName(this, codecommitRepName, codecommitRepName);

    // == CodeBuild ==
    const buildProjectRole = new iam.Role(this, buildProjectRoleName, {
      roleName: buildProjectRoleName,
      assumedBy: new iam.ServicePrincipal("codebuild.amazonaws.com"),
    });
    buildProjectRole.addToPolicy(
      new iam.PolicyStatement({
        resources: ["*"],
        actions: [
          "ssm:GetParameters",
        ],
      })
    );
    const buildProject = new codebuild.PipelineProject(this, buildProjectName, {
        projectName: buildProjectName,
        environment: {
          buildImage: codebuild.LinuxBuildImage.STANDARD_5_0,
        },
        buildSpec: codebuild.BuildSpec.fromSourceFilename("buildspec.yml"),
        role: buildProjectRole,
        environmentVariables: {
          ENV: { value: "dev", type: codebuild.BuildEnvironmentVariableType.PLAINTEXT},
        },
    });

    // == CodePipeline ==
    const pipeline = new codepipeline.Pipeline(this, pipelineName, {
        pipelineName: pipelineName,
    });

    // == CodePipeline Actions == 
    const sourceAction = new codepipeline_actions.CodeCommitSourceAction({
      actionName: "Suorce",
      repository: repository,
      branch: "master",
      trigger: codepipeline_actions.CodeCommitTrigger.EVENTS,
      output: sourceOutput,
    });
    const buildAction = new codepipeline_actions.CodeBuildAction({
      actionName: "Build",
      project: buildProject,
      input: sourceOutput,
      outputs: [buildOutput],
    });
    const approvalAction = new codepipeline_actions.ManualApprovalAction({
      actionName: "Approval",
      notificationTopic: snsTopic,
    })
    const deployAction = new codepipeline_actions.S3DeployAction({
      actionName: "Deploy",
      bucket: dist_bucket,
      input: buildOutput,
      extract: true,
    })

    pipeline.addStage({
      stageName: "Source",
      actions: [sourceAction]
    });
    pipeline.addStage({
      stageName: "Build",
      actions: [buildAction]
    });
    pipeline.addStage({
      stageName: "Approval",
      actions: [approvalAction]
    })
    pipeline.addStage({
      stageName: "Deploy",
      actions: [deployAction]
    });
  }
}
```

</div></details>

```ts:main.ts
#!/usr/bin/env node
import * as cdk from 'aws-cdk-lib';
import { CognitoStack } from 'cognito-stack';
import { HostingStack } from 'hosting-stack';
import { PipelineStack } from 'pipeline-stack';

const app = new cdk.App();
new HostingStack(app, 'HostingStack');
new CognitoStack(app, 'CognitoStack');
new PipelineStack(app, 'PipelineStack');
```

```
$ npx cdk deploy --all
```

CloudFrontコンソールからURLを確認しアクセスしてtest用ページが見れることを確認。

# 1. reactアプリ作成

CDKのディレクトリとは別に任意のディレクトリを作成し、以下reactアプリの雛形を作成。(これが環境構築で作成したCodeCommitで管理する対象。)

```
$ npx create-react-app react-frontui-cognito --template typescript 
```

# 2. amplify用のユーザを作成

途中マネジメントコンソールに飛ぶ。
実行前にAWSコンソールにログインしていたケースでしたやったことがない(未ログインだとログイン求められるのでは？)
デフォルトでAdmin権限が付与されるが、大きいのでAdministratorAccess-Amplifyを選び直して作成。

```
$ amplify configure
Follow these steps to set up access to your AWS account:

Sign in to your AWS administrator account:
https://console.aws.amazon.com/
Press Enter to continue
Specify the AWS Region
? region:  ap-northeast-1
Specify the username of the new IAM user:
? user name:  ****
Complete the user creation using the AWS console
https://console.aws.amazon.com/iam/home?region=ap-northeast-1#/users$new?step=final&accessKey&userNames=****&permissionType=policies&policies=arn:aws:iam::aws:policy%2FAdministratorAccess
Press Enter to continue

Enter the access key of the newly created user:
? accessKeyId:  ****
? secretAccessKey:  ****
This would update/create the AWS Profile in your local machine
? Profile Name:  ****

Successfully set up the new user.
```

# 3. amplifyの設定・初期化

```
$ cd react-frontui-cognito

$ amplify init
Note: It is recommended to run this command from the root of your app directory
? Enter a name for the project reactfrontuicognito
The following configuration will be applied:

Project information
| Name: ****
| Environment: dev
| Default editor: Visual Studio Code
| App type: javascript
| Javascript framework: react
| Source Directory Path: src
| Distribution Directory Path: build
| Build Command: npm run-script build
| Start Command: npm run-script start

? Initialize the project with the above configuration? Yes
Using default provider  awscloudformation
? Select the authentication method you want to use: AWS profile

For more information on AWS Profiles, see:
https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html

? Please choose the profile you want to use ****
```

# 4. 既存Cognitoの取り込み

```
$ amplify import auth
Using service: Cognito, provided by: awscloudformation
✔ What type of auth resource do you want to import? · Cognito User Pool only
✔ Select the User Pool you want to import: · ap-northeast-1_*********
```

# 5. 設定の反映・`aws-exports.js`の作成

```
$ amplify push
```

# 6. packageの追加

```
$ npm install aws-amplify @aws-amplify/ui-react
```

# 7. コード編集

`src/App.tsx`をまるごと編集。

```tsx
import { Amplify } from 'aws-amplify';
import { Authenticator } from '@aws-amplify/ui-react';
import '@aws-amplify/ui-react/styles.css';

import awsExports from './aws-exports';
Amplify.configure(awsExports);

export default function App() {
  return (
    <Authenticator variation="modal">
      {({ signOut, user }) => (
        <main>
          <h1>Hello {user.username}</h1>
          <button onClick={signOut}>Sign out</button>
        </main>
      )}
    </Authenticator>
  );
}
```

参考
https://ui.docs.amplify.aws/components/authenticator

# 8. ローカルで画面の確認

```
$ npm start
```

# 9. テストユーザの作成

## 9-1) 画面から作成する。

→ "Create Account"をクリックして作成。

## 9-2) CLIから作成する。 (主に"Create Account"を用意しない環境での確認用)

```
$ aws cognito-idp admin-create-user --user-pool-id <USER_POOL_ID> --username <USER_NAME> --user-attributes Name=email,Value=<E_MAIL_ADDRESS> Name=email_verified,Value=true --temporary-password <PASSWORD>

# https://dev.classmethod.jp/articles/cognito-admin-set-user-password/
$ aws cognito-idp admin-set-user-password --user-pool-id <USER_POOL_ID> --username <USER_NAME> --password <PASSWORD> --permanent 

# get IdToken (確認用)
$ aws cognito-idp admin-initiate-auth --user-pool-id <USER_POOL_ID> --client-id <CLIENT_ID> --auth-flow "ADMIN_USER_PASSWORD_AUTH" --auth-parameters USERNAME=<USERNAME>,PASSWORD=<PASSWORD>
```

参考
https://dev.classmethod.jp/articles/obtain-access-tokens-for-cognito-users-using-aws-cli/

# 10. build環境の設定ファイル作成 

## 10-1) `buildspec.yml`を作成。

```yml
version: 0.2

phases:
  install:
    runtime-versions:
      nodejs: 14
  pre_build:
    commands:
      - npm update -g npm
      - npm install -g @aws-amplify/cli
      - npm install --production
      - chmod u+x ./amplify_init.sh
      - ./amplify_init.sh

  build:
    commands:
      - npm run build
artifacts:
  files:
    - '**/*'
  base-directory: 'build'
```

## 10-2) `amplify_init.sh`を作成。
AUTHCONFIGにCognitoの情報を書き込む。(`tesm-provider-info.json`を参照するといい。)

以下を参考。amplifyをCodeBuildで使うにはscriptを書く必要があるみたい。わりと多くの人がそうしている。これらをもとにcognito部分を追加させてもらう。

* https://qiita.com/rocketmaso/items/4f5b7d61c512b97f637a#buildspecyml%E3%81%AE%E7%B7%A8%E9%9B%86
* https://stackoverflow.com/questions/56649214/amplify-error-auth-headless-init-is-missing-the-following-inputparams-facebooka
* https://github.com/aws-amplify/amplify-console/issues/1271

```sh
#!/bin/bash
set -eu

echo "[amplify_init.sh] start"
# SSMからクレデンシャル取得
access_key_id=$(aws ssm get-parameters --names '/AmplifyCICD/AccessKeyID' --with-decryption --query Parameters[].Value --output text)
secret_access_key=$(aws ssm get-parameters --names '/AmplifyCICD/SecretAccessKey' --with-decryption --query Parameters[].Value --output text)

# 環境変数取得
env=$1

# クレデンシャルの設定
aws configure set aws_access_key_id $access_key_id
aws configure set aws_secret_access_key $secret_access_key
aws configure set default.region ap-northeast-1

# Amplifyの設定
AWSCLOUDFORMATIONCONFIG="{\
\"configLevel\":\"project\",\
\"useProfile\":true,\
\"profileName\":\"default\"\
}"
AMPLIFY="{\
\"projectName\":\"sample_app\",\
\"envName\":\"$env\",\
\"defaultEditor\":\"code\"\
}"
PROVIDERS="{\
\"awscloudformation\":$AWSCLOUDFORMATIONCONFIG\
}"
AUTHCONFIG="{\
\"userPoolId\":\"ap-northeast-1_*********\",\
\"webClientId\":\"**************************\",\
\"nativeClientId\":\"**************************\"\
}"
CATEGORIES="{\
\"auth\":$AUTHCONFIG\
}"                      

amplify init \
--amplify $AMPLIFY \
--providers $PROVIDERS \
--categories $CATEGORIES \
--yes

echo "[amplify_init.sh] finish"
```

CodeBuildの環境変数から直接SSMパラメータストアを指定することも可能だが、SecureStringを復号化してくれるわけではなさそうなので(要出典)、CLIで取得した。
なお、CodeBuildのbuildspecからSSMとかに取得に行く場合の注意点とかをまとめてくれてるページがあった。
https://dk521123.hatenablog.com/entry/2020/02/18/230358

# 11. amplify用のユーザのクレデンシャルをcodebuildから利用できるようにSSMに登録。

```
$ aws ssm put-parameter --name "/AmplifyCICD/AccessKeyID" --value "****" --type SecureString
$ aws ssm put-parameter --name "/AmplifyCICD/SecretAccessKey" --value "****" --type SecureString
```

# 12. パイプラインの実行。

```
$ git remote add origin https://git-codecommit.ap-northeast-1.amazonaws.com/v1/repos/******
$ git commit --allow-empty -m "init empty commit"
$ git add .  
$ git commit -m "add basic project code"
$ git push --set-upstream origin master
```

※ 前述のように`tesm-provider-info.json`にあまり外に出したくないID類が載ってるのでPublicなリポジトリにpushする場合は注意。
https://www.bioerrorlog.work/entry/public-amplify-project

# 13. 別環境への展開/移植

dev環境で構築したのちに同じ構成をstaging/production環境に構築する際、同じようにパイプラインを走らせてもCodeBuildでエラー終了する。
以下2つの問題を解消する必要がある。

## 1つ目の問題

> Could not initialize 'dev': Access Denied

ここの"dev"は`amplify init`時に`Environment`に対応するところで設定した環境名であり、`team-provider-info.json`に記述されている詳細情報との紐づけに利用されている。
ここに記載のリソースにアクセスできないとのことだが、それもそうで、開発環境で生成した`team-provider-info.json`には開発環境のAWSアカウントのARNやらリソースIDが記載されているので他環境からはアクセスできない。
→ 他環境ではその環境に対応する`team-provider-info.json`を用意しておいてあげる必要がある。

適切な値で記述するのもいいが、CFnで生成しないといけないリソースなどもあり正直面倒なので一度`amplify init`を実行して`team-provider-info.json`を出力してもらい、コピペする方が楽かもしれない。ここではその方法をとる。

`team-provider-info.json`を削除し、その上で`buildspec.yml`に`team-provider-info.json`をコンソールに出力するようにコードを差し込む。

```buildspec.yml
   (略)
  pre_build:
    commands:
      - npm update -g npm
      - npm install -g @aws-amplify/cli
      - npm install --production
      - chmod u+x ./amplify_init.sh
      - ./amplify_init.sh
      - cat ./amplify/team-provider-info.json  # <<<追加
   (略)
```

`git push`する。
こうすると、CodeBuild上でCFnが実行され、`team-provider-info.json`を生成し出力してくれる。
出力された`team-provider-info.json`を`amplify/`以下に配置する。

このファイルがある状態だと`amplify init`しても新しくCFnが実行されることはないので大丈夫。

## 2つ目の問題

上記のまま実行しても`aws-exports.js`に必要な項目が全然含まれていない(`aws-project_region`しかない)。
→ 一度`amplify push`する必要がある。

```amplify_init.sh
amplify init \
--amplify $AMPLIFY \
--providers $PROVIDERS \
--categories $CATEGORIES \
--yes

amplify push --yes  # <<<<追加

echo "[amplify_init.sh] finish"
```

この状態で再度`git push`すると以降`aws-exports.js`が正しく生成されるようになる。

以上の編集後、これらに追加した項目を削除し、再度pushしてビルドが通ることを確認する。