AWS Cognito + Amplify + Not Hosted UI の実装例と注意点

# はじめに
半年くらい前、業務でCognitoを使って認証機能を実装した時に、ネット上の記事が古いライブラリを使っていて参考にできなかったり、Cognito自体に不具合があったりハマりポイントが多すぎて酷い目にあった。
この記事には実装の振り返りと~~恨み~~と備忘録を兼ねてCognitoとは、周辺ライブラリについて、直面した不具合など自分が把握している限りの情報を記載する。

# Cognitoとは
「認証にはCognitoをNot Hosted UIで使ってください」と言われたが正直聞いたこともなかったのでそこから調べることにした。調べたことを要約すると以下になる。

- サインアップ、サインイン、アカウント管理を一手に引き受けてくれるAWSが提供するサービスの一つである。
- 単純にユーザー名とパスワードを入力してサインアップ/インする以外に、GoogleやAppleなどの外部サービスのアカウントを使ったサインインもお手軽に実装することができる。
- Hosted UI(Cognitoが用意した認証画面、認証処理も全部やってくれる)とNot Hosted UI(画面は自前で認証処理だけライブラリに委託)がある。

これだけ見るととても便利そうな感じがする。

# ライブラリ多すぎ問題
次は実際にCognitoの機能を使うためのライブラリを調べてみる。言語にはVue.jsとTypeScriptを使うということだったので単純に「Vue Cognito」で検索した。すると記事によって使用しているライブラリが全然違うことが分かった。

- amazon-cognito-identity-jsを使用する
- aws-sdkを使用する
- awd-amplifyを使用する

どれ使えばええねんとなる。こういう時はリポジトリのREADMEなりUsageを読むのが手っ取り早い。調べたら[こういうの](https://github.com/amazon-archives/amazon-cognito-identity-js)が出てきた。長文のアメリカ語はわからないのでDeepLに翻訳してもらったのが以下。
>注：このライブラリは、このGitHubリポジトリの一部として開発を中止しました。今後は、AWS AmplifyのGitHubリポジトリの一部として開発を続けていきます。AWS AmplifyのGitHubリポジトリに課題を作成するか、Amazon Cognito Identityのフォーラムに投稿することで、私たちに連絡を取ることができます。バージョン1.xは引き続きNPM経由で利用可能です。今後の開発は2.xバージョンで継続されます。

開発中止してんじゃねーか！検索上位の記事で使ってたぞ！？てなる。この時点で逃げるべきだったのだ。
ともあれAmplifyを使えばいいのは分かった。

# Amplifyとは
使うべきライブラリが分かったのでAmplifyについて調べてみる。

- Amplify Cliがある
- Amplify Cliでコマンドを打つとGraph QLとかが使える
- Amplify Consoleがある。静的ホスティングが自動化できるよ

待ってほしい。認証だけしたいのになんでGraph QLやら自動化やらの話が出てくるんだ。

# Amplifyでやれること多すぎ問題
分からないながらに必死に調べた結果以下のことが分かった。

- amplifyはいろいろな既存のライブラリ・サービスを複合したものである。
- それぞれAmplify Consoleなどにカテゴリが分かれていてCognitoやS3とかを楽に操作できる。
- cliコマンドを利用して各サービスの環境構築を簡単に行えることが売りである。そのためドキュメントにはかならずcliコマンドから始めるようになっている。これが初心者が戸惑う原因だと思う。
- Amplifyのcliコマンドを使って環境構築しなくてもAmplifyライブラリは使える。
- Amplify Authの中にCognitoのAPIメソッドが入っている。

公式ドキュメントやネットの記事の多くはcliから始めているが、今回は既に運用されているCognitoを使うのでcliはスキップすることにした。

# Amplifyで既存のCognitoが使えたり使えなかったりする問題
あとはAmplifyと既存のCognitoの疎通ができれば実装に入れる。早速調べると記事によってAmplifyで既存のCognitoが使えるよという記事と使えないよという記事が出てくる。だからどっちやねん。
公式ドキュメントを見たら[既存のCognitoが使えるようになっていた。](https://aws.amazon.com/jp/about-aws/whats-new/2020/10/use-existing-cognito-user-pools-identity-pools-for-amplify-project/)

# プロジェクト作成
漸く実装に必要な情報が出揃ってきたのでプロジェクトを作成する。

```
$ vue create hoge
```

この時vue2.xとtypescriptとvue-routerを指定してインストールする。その他eslintなどついては割愛し、次にAmplifyを導入する。

```
$ yarn add aws-amplify
```

次にAmplifyを動かすために設定を記述する。.envに入れておくと吉。

```vue:.env
VUE_APP_AWS_REGION=ap-northeast-1
VUE_APP_AWS_USER_POOL_ID=ap-northeast-1_XXXXXXX
VUE_APP_AWS_USER_POOL_WEB_CLIENT_ID=XXXXXXXXXXXXXXX
```

環境変数を呼び出す。

```typescript:cognito/auth.ts
export const cognitoConstants = {
  region: process.env.VUE_APP_AWS_REGION,
  userPoolId: process.env.VUE_APP_AWS_USER_POOL_ID,
  userPoolWebClientId: process.env.VUE_APP_AWS_USER_POOL_WEB_CLIENT_ID,
};
```

多くの記事はidentityPoolIdなども用意しているが、GoogleやAppleなど外部認証を利用しない場合は無くて大丈夫。
用意した設定を適用する。

```typescript:main.ts
import Amplify from "aws-amplify";
import { cognitoConstants } from "@/cognito/auth";

Amplify.configure(cognitoConstants);
```

次はいよいよ認証UIとAPIの実装だがここで2つの方法がある。

1. Hosted UI(Cognitoが用意した認証画面)を使う。
1. Not Hosted UI(認証画面は自分で実装して裏のcognitoとの通信はAmplify Authに委託)でがんばる。

今回は2.で実装するので1.については触りだけ解説する。

# 1. Hosted UIを使う
こちらは用意されたUIを出すだけで認証処理もやってくれるので簡単らしい。
1.の場合はaws-amplifyのほかにUIライブラリを導入する。

```
$ yarn add @aws-amplify/ui-vue
```

※ これもまたややこしいことに、@aws-amplify/ui-vueとaws-amplify-vueという似た名前のライブラリがあるが@aws-amplify/ui-vueが新しい方。ネットでは古い方(aws-amplify-vue)を使ったサンプルを公開している記事も残っているので要注意。
[公式ドキュメント](https://docs.amplify.aws/ui/q/framework/vue/#installation)を確認しよう。

あとは[こちら](https://docs.amplify.aws/ui/auth/authenticator/q/framework/vue/)に沿って実装すれば良いかと。

# 2. Not Hosted UIでがんばる
@aws-amplify/ui-vueを使わずにAmplify Authでゴリゴリする方法。今回の本題。
こちらはroutingがやりやすいことと、サインイン時に独自の処理も持たせられることなどが利点。
ただしこちらを選択すると、参考になる記事が激減するので頑張ってほしい(切実)
### サインイン

```vue:SignIn.vue
<template>
  <div>
   <input v-model="state.email" placeholder="email" />
   <input v-model="state.password" placeholder="password" />
   <button @click="signIn">サインイン</button>
  </div>
</template>

<script lang="ts">
import { Auth } from "aws-amplify";
import { defineComponent } from 'vue'
import { useRouter } from "vue-router" 

type State {
  email: string;
  password: string;
};

export default defineComponent({
  setup () {
    const state = reactive<State>({
      email: "",
      password: "",
    });

    const router = useRouter()

    /**
     * サインインボタン押下時処理
     */
    const signIn = async () => {
      try {
        await Auth.signIn(state.email, state.password);
        await getAccessToken();
        router.push("/signed-in");
      } catch (error) {
        // お好みでエラーハンドリング
      }
    };

    /**
     * トークン取得処理
     * API Gatewayなどの認証に必要であれば
     */
    const getAccessToken = () => {
      const session = await Auth.currentSession();
      const accessToken = session.getAccessToken().getJwtToken();
      // セキュリティ警察の方はvuexなどで管理してもろて
      localStorage.setItem("cognitoUserAccessToken", accessToken);
    };

    return {
      state,
      signIn,
    }
  }
});
</script>
```

これでサインイン処理は完成。ここまで長々書いたが、前提知識のハードルが高いだけで実装難度自体は高くないと思う。基本的にリクエストパラメータをのせてAPIメソッドを叩けば結果が返ってくるので。
開発終了したライブラリが消されず散らばってるのはどうにかした方が良いと思う。

### サインアップ
AWS Consoleなどで事前にアカウントを用意せず、画面上からユーザー登録処理を行いたい場合

```vue:SignUp.vue
<template>
  <!-- お好みのサインアップ画面を作る -->
  <!-- 最低限メールアドレス、パスワード入力フォームとサインアップボタンがあれば良い -->
</template>

<script lang="ts">
import { Auth } from "aws-amplify";
import { defineComponent } from 'vue'
import { useRouter } from "vue-router" 

type State {
  email: string;
  password: string;
};

export default defineComponent({
  setup () {
    const state = reactive<State>({
      email: "",
      password: "",
    });

    const router = useRouter()

    /**
     * サインアップボタン押下時処理
     */
    const signUp = async () => {
      try {
        await Auth.signUp(state.email, state.password);
        // 検証にメアドを使うので引き渡す
        router.push({
          name: "verification",
          params: {
            email: state.email,
          },
        });
      } catch (error) {
        // お好みでエラーハンドリング
      }
    };

    return {
      state,
      signUp,
    }
  }
});
</script>
```

サインアップを実行すると入力したメアドに認証コードが送られてくるので、届いた認証コードとメアドで検証を行えばアカウントを有効化できる。

```vue:Verification.vue
<template>
  <!-- 認証コード入力フォームと認証ボタンを作る -->
</template>

<script lang="ts">
import { Auth } from "aws-amplify";
import { defineComponent } from 'vue'
import { useRoute, useRouter } from "vue-router" 

type State {
  email: string;
  verificationCode: string;
};

export default defineComponent({
  setup () {
    const router = useRouter()
    const route = useRoute()
    const state = reactive<State>({
      email: route.params.email,
      verificationCode: "",
    });

    /**
     * 認証ボタン押下時処理
     */
    const confirmSignUp = async () => {
      try {
        await Auth.confirmSignUp(state.mailAddress, state.verificationCode);
      } catch (error) {
        // お好みでエラーハンドリング
      }
    };

    return {
      state,
      confirmSignUp,
    }
  }
});

</script>
```

SignUp.vueからpasswordも渡せばconfirmSignUp成功時にそのままsignInを叩くことができる。

### アクセス制御
あとは画面遷移時に認証状態を確認して、期限が切れていたらサインアウトする処理を実装する。

```typescript:index.ts
import Vue from "vue";
import VueRouter, { RouteConfig } from "vue-router";

Vue.use(VueRouter);

/**
 * 認証情報を要求する画面に遷移した際に、認証情報がなければサインアウトしてリダイレクトさせる
 */
function checkAuthenticated() {
  return async (to: any, from: any, next: any) => {
    try {
      // ログイン中のユーザー情報が取得する
      await Auth.currentAuthenticatedUser();
      next();
    } catch (error) {
      Auth.signOut();
      localStorage.removeItem("cognitoUserAccessToken");
      next("/");
    }
  };
}

const routes: Array<RouteConfig> = [
  {
    path: "/",
    name: "sign-in",
    component: () => import("../views/SignIn.vue"),
  },
  {
    path: "/sign-up",
    name: "sign-up",
    component: () => import("../views/SignUp.vue"),
  },
  {
    path: "/verification",
    name: "verification",
    component: () => import("../views/Verification.vue"),
  },
  {
    path: "/signed-in",
    name: "signed-in",
    component: () => import("../views//SignedIn.vue"),
    // 画面遷移前に認証チェックをする
    beforeEnter: checkAuthenticated(),
  },
];

const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes,
});

export default router;
```

### その他のAPIメソッド
認証コードの再送信や、パスワードを忘れた際の再設定、メアド変更などは以下を参照すると良い。
[AWS Amplify フレームワークの使い方Part2〜Auth実践編〜](https://qiita.com/too/items/54992bb871fc1a2ab101)
ほとんどのAPIの解説をしてくださっているのでこちらを見るのが一番手取り早いと思う。
また、今回は説明のために毎回Amplify Authをimportしたが、実装では1つのファイルに認証APIをまとめて `Auth.〇〇` などで呼び出した方が何かと都合が良い。

# 実装時に直面した不具合
ここまでは問題なく実装できたのだが、認証情報を使ってAPI GateWayを叩こうとしたり、メアドの変更の際に不具合に見舞われ解決までにかなり苦戦したので遭遇した不具合の解説と、行った対処について記載する。

### 固定のscopeしか取得できない問題
認証成功時に送られてくるアクセストークンにはscope属性を内包することができる。こちらはaws consoleから以下の画像の箇所で設定することができる。
![スクリーンショット 2021-12-07 2.50.43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282374/746a2765-5e21-d25c-14eb-c00c2c02fd04.png)
しかし、上記のようにscope属性を設定してもAuth.signIn()で送られてくるアクセストークンには `aws.cognito.signin.user.admin` しか内包されていない。以下の画像は、上記のscope設定でAuth.signIn()した際に取得したアクセストークンを[jwt.io](https://jwt.io/)でデコードしたもの。
![スクリーンショット 2021-12-07 3.01.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2282374/c4fc4641-98be-7a72-2a60-ee266545e5b6.png)
この不具合によって例えばカスタムスコープでapi/readやapi/writeなどを設定し、アクセストークンを使ってAPI Gatewayを呼び出す際にLambda Validationでカスタムスコープの検証を行うことができないというような問題が発生してしまう。
こちらの[isuue](https://github.com/aws-amplify/amplify-ui/issues/237)から遡っていくと少なくとも3年前から何度か問題として取り上げられてはいるが修正されていないようである。

### メアド変更時に認証しなくても新しいメアドでサインインできてしまう問題
メアドの変更機能を実装していた時に直面した不具合。
本来のメアド変更フローは以下

1. ユーザーが新しいメアドを入力する
1. 新しいメアドに認証コード(6桁)が送信される
1. ユーザーが認証コードを入力することで、メアドの変更が完了となる

特段珍しいこともしていないフローだが以下なような不具合がある。

- 新しいメアドを申請すると、検証コードを入力する前に新しいメアドでログインできてしまう
- 逆に検証が成功していないのに古いメアドでログインできない
- つまり間違ったメアドをリクエストして、そうと気づかずログアウトした時点でログインできなくなってしまう

どうやらcognitoではメアド変更APIが呼ばれた時点でメアドを新しいものに変更しているらしい。ただし、consoleから確認するとEメール検証はfalseになっている。検証とは。
ちなみにこの問題も3年前から[isuue](https://github.com/aws-amplify/amplify-js/issues/987)に上がっていてまだ解決していない。
現状公式の回避策はなく、isuueで有志から提案された解決策を採用するか運用でカバーするしかない。
この問題の日本語で詳しく解説してくださっている記事を記載しておく。
[Cognitoでメールアドレス編集するとログインできなくなる問題](https://zenn.dev/dove/articles/78ecf08b51ee0c)

# まとめ
Not Hosted UIの場合、単純にコード量が膨らんでしまう点と、何よりクライアント側で直しようのない不具合と向き合うことになるため、現状、Cognitoを利用して認証機能を実装するならHosted UIを選択するのが無難だと思った。
記事を書いている半年の間にVue3に大きなアップデートがあり、ついに安定した環境で開発できるようになっていた。
この記事内のサンプルコードもそのうちscript setup形式で書き直したい。

# 参考記事
[AWS Cognito とは](https://qiita.com/miyuki_samitani/items/609096e4e7b0a1ee08da)
[AWS Amplify フレームワークの使い方Part2〜Auth実践編〜](https://qiita.com/too/items/54992bb871fc1a2ab101)
[AWS cognitoとReactでログインを実装する](https://zenn.dev/dove/articles/63494de652511c)
[Cognitoでメールアドレス編集するとログインできなくなる問題](https://zenn.dev/dove/articles/78ecf08b51ee0c)