AWS CDKでCognitoユーザープールを作るとメールアドレスが変更できない問題とその解決策

## はじめに

[AWS CDK](https://github.com/aws/aws-cdk) は TypeScript や Python などのプログラミング言語を使って AWS CloudFormation のテンプレートを定義できるツールです。 YAML や JSON で定義するのに比べると可読性が高く、 TypeScript を使って定義がすることで**補完が強力に効いたり、間違った値を入れるとエディタ上でエラーが確認できる**というメリットがあります。

AWS CDK で Cognito ユーザープールを作成するための `@aws-cdk/aws-cognito` は現時点では  EXPERIMENTAL となっており注意が必要ですが、実際に使用してみたところ**初めは問題なさそうに見えたのですがメールアドレスが変更できないという問題が後から発覚しました。**
 

## 前提

以下がメールアドレスによるログインが可能な Cognito ユーザープールを AWS CDK で作成する際の基本的なコードになります。

```typescript
import * as cdk from "@aws-cdk/core";
import * as cognito from "@aws-cdk/aws-cognito";

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

    const userPool = new cognito.UserPool(this, "UserPool", {
      selfSignUpEnabled: true,
      signInAliases: {
        email: true,
      },
      requiredAttributes: {
        email: true,
      },
      autoVerify: {
        email: true,
      },
    });
  }
}
```

このコードで作成したユーザープールをコンソール上で確認すると以下ような設定になります。

<img width="1115" alt="ユーザープール_-_Amazon_Cognito.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27764/77a9b862-2b4b-3881-2eca-cd180e259c26.png">

`signInAliases: { email: true }` を設定したことにより、サインイン方法が `Eメールアドレスおよび電話番号` および `Eメールアドレスを許可` になっています。
サインイン方法が `ユーザー名` の場合ユーザー名はサインアップ時にユーザーが指定したものとなりますが、今回の設定ではサインアップ時に `sub` （ユーザー作成時に作られる一意な UUID）がユーザー名としてセットされ、登録したメールアドレスに確認メールが届き、確認が完了するとユーザーはメールアドレスを使ってログインできます。

<img width="1078" alt="ユーザープール_-_Amazon_Cognito.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/27764/de7d85ba-a904-f6dc-8683-77f5f8dcbccf.png">

メールアドレスを使ってユーザーにログインさせたい場合、デフォルトの設定よりもこの設定が望ましいのではないでしょうか。ただしコンソール上の画面で選択肢が灰色になっていることからわかるように、**サインイン方法はユーザープール作成時にのみ設定でき後から変更することが出来ない**ため、この設定は慎重に行う必要があります。


## 何が問題なのか

上記のコードで `cdk synth` を実行すると、 `cdk.out` ディレクトリに以下のような CloudFormation テンプレートが確認できます。

```typescript
    "UserPool6BA7E5F2": {
      "Type": "AWS::Cognito::UserPool",
      "Properties": {
        "AdminCreateUserConfig": {
          "AllowAdminCreateUserOnly": false
        },
        "AutoVerifiedAttributes": [
          "email"
        ],
        "EmailVerificationMessage": "Hello {username}, Your verification code is {####}",
        "EmailVerificationSubject": "Verify your new account",
        "Schema": [
          {
            "Name": "email",
            "Required": true
          }
        ],
        // 長いので省略
      },
      "Metadata": {
        "aws:cdk:path": "auth/CustomMessage/ServiceRole/Resource"
      }
    },
```

ここで注目するべきは `Schema` です。 **CloudFormation でユーザープールのスキーマを定義するとき、デフォルトでは後から変更できない状態で作られてしまいます。変更可能にするには以下のように `"Mutable": true` が指定されていなければなりません。**

```typescript
"Schema": [
  {
    "Name": "email",
    "Required": true,
    "Mutable": true
  }
],
```

参考: https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-properties-cognito-userpool-schemaattribute.html

**この設定もサインイン方法と同様、ユーザープールの作成時にのみ設定でき、後から変更することはできません。そして、標準属性が Mutable かどうかはコンソール上では確認できません。**


## 解決方法

### 高レベル API を使いたい場合（その1）

CDKでは `cognito.UserPool` クラスなどの抽象的な API を 高レベル API と呼びますが、高レベル API とは別に `Cfn` プレフィックスのついた CloudFormation レイヤーの操作ができる API が必ず提供されています。

今回のように高レベル API がまだ対応していないプロパティをどうしても変更したいときは、高レベルインスタンスから `Cfn` インスタンスを取得した上でプロパティのオーバーライドが可能です。

https://docs.aws.amazon.com/cdk/latest/guide/cfn_layer.html#cfn_layer_raw

今回のケースでは以下のようなコードを追加することで対応可能です。

```typescript
const cfnUserPool = userPool.node.defaultChild as cognito.CfnUserPool;
cfnUserPool.addPropertyOverride("Schema.0.Mutable", true);
```

この方法は `Schema.0` を指定していることからわかるように、 email 属性が Schema の1番目に定義されているのを前提としています。CDK の仕様変更やカスタム属性を追加したときに Schema の順番が変わらないことは保証されていませんので、あまりお勧めはできません。
（とはいえ、仮に途中で仕様が変わってもデプロイに失敗するだけですので、ユーザープールの作成時にのみしっかり確認すれば悪くはなさそうです）

### 高レベル API を使いたい場合（その2）

上記の方法と方針は同じですが `Schema.0` で指定してしまっている問題を解消できないか試してみたところ、より手続き的な書き方にはなってしまいますが一応こんな感じで対応可能でした。

```typescript
const cfnUserPool = userPool.node.defaultChild as cognito.CfnUserPool;
const emailAttribute = (cfnUserPool.schema as cognito.CfnUserPool.SchemaAttributeProperty[]).find(
      (attribute) => attribute.name === "email"
);
if (emailAttribute) {
  // @ts-ignore mutableの型がreadonlyで定義されてしまっているため
  emailAttribute.mutable = true;
}
```

### 高レベル API を諦める

上記の高レベル API を使う場合の方法はどうしても無理やり感があり、最初から `cognito.CfnUserPool` を使うか CDK 以外の方法で CloudFormation を管理したほうが正攻法な気がします。
（この際 `"Mutable": true` を忘れてしまっては意味がないので気をつけてください！）


### CloudFormation での管理を諦める

結局私はこの方法をとり、 Cognito ユーザープールについてはコンソール上で手作業で作成するようにしました。
今回の例のように **Cognito ユーザープールは作成時の設定を間違えると詰む項目がいくつかある**ため、自分から `"Mutable": true` を指定しなければいけないという CloudFormation の仕様が危険な気がしたためです。
CDK 管理下にないユーザープールを CDK 内で参照したくなった場合は `fromUserPoolArn` や `fromUserPoolId` で参照することが可能です。

## Tips: CDK 管理下からの外し方

メールアドレスが変更できない状態でユーザープールを作ってしまい既にユーザーが登録されてしまった場合、ユーザープールは CDK の管理化から外して上に挙げたような対策を取った上で作った新しいユーザープールにユーザーを移行する必要があります。
（シームレスなユーザー移行には UserMigration トリガーの Lambda を実装する必要がありますがここでは割愛します）
この場合 `"DeletionPolicy": "Retain"` を設定してから CloudFormation 上からリソースを外すのですが、 `cognito.UserPool` のオプションには現状 `deletionPolicy` がないので、これも `Cfn` インスタンスを一度取得してから設定をします。

```typescript
const cfnUserPool = userPool.node.defaultChild as cognito.CfnUserPool;
cfnUserPool.cfnOptions.deletionPolicy = cdk.CfnDeletionPolicy.RETAIN;
```

## まとめ

AWS CDK はとても便利ですが、今回のような問題に直面したときには背後にある CloudFormation や AWS のサービスそのものについての理解が必要になります。
とはいえ今回の根本的な問題は Cognito ユーザープールの設定が作成後に変更できないものがあるというところにあるように思えます。認証という必要不可欠かつ重要な役割を担うサービスですので、 Cognito ユーザープールの今後の改善に期待したいです。