Angular × CognitoでMFAサインイン機能をすぐ実装

## はじめに

今日は**Amazon Cognito**を使ってサインイン機能（とユーザ管理機能）を作り、**Angular**アプリケーションに組み込んでみましょう。

<ruby>巷<rt>ちまた</rt></ruby>の解説記事は、フロントエンドにReactを採用したものが多く、AngularとCognitoの統合については残念ながら殆ど見当たりません。

そのため、必要に迫られて自分のAngularアプリをCognitoと連携させることになったとき、どこから手を付けたらよいのか分からず困ってしまう方も多いかと思います。

そこで本記事では、できるだけ丁寧に実装手順をご紹介していきます。

### 想定読者

- サンプルアプリを作っていたら、ついうっかりメインとなる機能の開発に集中しすぎてしまい、サインイン・サインアウト・ユーザ管理機能を作る時間がなくなってしまった人
- Cognitoというサービスの名前を聞いたことがあるけれど、実際の使い方がよくわからず、一回くらいは触ってみたい人

### やりたいこと

- Cognitoの多要素認証 (MFA) をAngularアプリにサクっと組み込んでみたい
- ただし、ログインフォームのUIは独自に作りたい[^1]

[^1]: アプリの世界観を統一したいとか、手抜きだと思われたくないとか、さまざまな事情によります。

### やらないこと

ここでは話を簡単にするため、いくつかの親切機能の実装を割愛しています。

- 各種救済機能
  - ユーザがアカウントロックされた際の救済機能
  - ユーザがパスワードを忘れた際の　　〃
  - ユーザがMFAデバイスを紛失した際の　　〃
- いわゆるSMS認証[^2]

また、後述のとおりCognitoへのアクセスのためにAmplifyのライブラリを用いますが、Amplify CLIは利用しません。

[^2]: 代わりに、TOTP (Time-based One-Time Password) を用いた多要素認証を実装します。

### 開発環境
各種ツールのバージョンは以下を想定しています。

- **Node.js**: 20.10.0 (LTS)
- **npm**: 10.2.4
- **Angular CLI**: 17.0.5
- **OS**: Windows 11

各種ライブラリのバージョンは以下の通りです（万一、サンプルが動かない場合は、バージョンを指定してライブラリを導入してください）。

- **[qrcode](https://github.com/soldair/node-qrcode)**: 1.5.3
- **[aws-amplify](https://docs.amplify.aws/)**: 6.0.6

### 免責事項
本記事の内容は情報提供のみを目的としております。

内容に基づく実施・運用において発生したいかなる損害も、著者は一切の責任を負わないものとします。

## サンプルの概要

今回作成するサンプルの振る舞いを少し詳しくみていきましょう。

従来のID/パスワードだけの認証とは異なり、MFAではワークフローが少し煩雑になりますので、一つひとつ場合分けをして考えます。

### 初回サインイン時の振る舞い

今回のサンプルの中で最も手数のかかる初回サインイン時の流れは、おおよそ以下の通りです。

1. ユーザ名とパスワードの照合
2. パスワードの更新
3. QRコードの表示
4. MFAコードの照合

これらの一連の動きをアニメーションで確認してみましょう。

![mfa.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/73666f08-07fa-5444-19e1-d280a10c45ac.gif)

上記の **項番3.** で表示されるQRコードをスマホの認証アプリでスキャンすることで、スマホにアカウント情報が関連づけられ、MFAデバイスとしてサインインに使用できるようになります。

このQRコードには秘密鍵が含まれているため、MFAデバイスとアカウントの関連付けが完了したら、不正利用を防ぐため当該QRコードはすみやかに破棄するのが望ましいと言われています。

ですから、（このあとすぐに確認しますが）2回目以降のサインイン時にはQRコードが画面上に表示されなくなります。

### 2回目以降のサインイン時の振る舞い

2回目以降のサインインでは、以下の条件を既に満たしていますので、初回サインインよりも手続きが簡潔になります。

- 初回サインイン時に求められるパスワードの更新が済んでいる
- MFAデバイスの登録も完了している

ユーザ名とパスワードを照合したあとは、スマホなどのMFAデバイスに表示されているコードを送信すればよいことになります。

![mfa2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/30787f8b-1295-1f8e-25de-3d8c043da0e0.gif)

先程とは異なり、MFAデバイスの設定は初回ログインで既に完了しているはずですから、UI上にQRコードが表示されない点に注意しましょう。

### 初回サインイン時の振る舞い（特殊ケース）

少し例外的な動きとして、初回サインイン時にパスワード変更までは行ったものの、諸事情でMFAデバイスの設定を完了せずに（= サインインが完了しないまま）離脱してしまったパターンも考えられます。

この場合は、ユーザ名とパスワードの照会後、以下の振る舞いになる点に注意しましょう。

- パスワード更新が求められない点
- 改めてQRコードが表示される点

![mfa3.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/965e3a8a-41c5-3895-be9c-43ccb1ae1117.gif)

### 場合分けのまとめ

以上をまとめると、下図のような状態遷移図が描けます[^3]。 この図は、本記事の全編に<ruby>亘<rt>わた</rt></ruby>って出てきます。 ここで全体像を把握しておくと、それぞれのセクションでどの部分の話をしているのかが分かりやすくなります。

[^3]: 状態名が英名になっているのは、サンプルで用いるライブラリの定義に合わせているためです。詳しくはamplify-jsのドキュメントの[AuthNextSignInStepの項](https://aws-amplify.github.io/amplify-js/api/globals.html#authnextsigninstep)を参照。

![20231218_001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/8e762f65-7267-9be3-e9b5-916c64d7643b.png)

## サンプルの作成

### Cognitoユーザープールの作成

早速、Cognitoを使ってみましょう。 Cognitoには「ユーザープール」と「IDプール」という2つの機能がありますが、今回のユースケースに合致するのは「ユーザープール」の方です。

AWSのマネジメントコンソールにサインインし、Cognitoのサービスを検索します。

以下のような画面が表示されたら、「ユーザープールを作成」というオレンジ色のボタンをクリックしましょう。

![20231218_100.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/e7edf689-6b90-e1e0-4293-3566c847df01.png)

既にユーザープールが存在する場合などは、上記の画面が表示されない場合があります。 その場合は、左側のメニューで「ユーザープール」のリンクをクリックし、「ユーザープールの作成」をクリックしましょう（下図）。

![20231218_101.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/4edd4d0d-598c-b40e-95cd-2d48ec00b81a.png)

#### サインインエクスペリエンスを設定

続いて、「サインインエクスペリエンスを設定」ページで「認証プロバイダー」の設定を行います。

ここでは、「Cognitoユーザープールのサインインオプション」という項目の「ユーザー名」にチェックを入れて、「次へ」ボタンをクリックしましょう。

![20231218_102.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/fc1e9270-8a4d-e25a-731c-fc8111038a8c.png)

#### セキュリティ要件を設定

さらにここで、MFAの方法を設定します。

今回は「Authenticatorアプリケーション」にチェックを入れ、「次へ」ボタンをクリックしましょう。

![20231218_103.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/572682a5-8257-2340-8551-8bd10672fb32.png)

ちなみにこの方式は、Google Authenticatorなどのスマホアプリを使い、初回サインイン時に表示される2次元QRコードを読み取ると、こんな感じ（↓）でアプリに6桁の数字が表示されます。

<!--
![20231218_104.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/f9a66811-aa52-74eb-15a0-5f9539e406a8.png)
-->

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/f9a66811-aa52-74eb-15a0-5f9539e406a8.png" width="200px"/>

この数字は一定時間おきに刻々と更新されるため、**T**ime-based **O**ne-**T**ime **P**assword（通称: TOTP）と呼ばれます。

ただし、多くの一般利用者にとってTOTPという言葉には耳馴染みがなく、多くのサービスにおいて「**MFAコード**」と表記されることが多いようです。 そこで本記事でもそれに<ruby>倣<rt>なら</rt></ruby>い、MFAコードという言葉で統一することにします。

#### サインアップエクスペリエンスを設定

今回は、<ruby>予<rt>あらかじ</rt></ruby>め許可されたユーザ以外はサインインできないようにしたいので、「自己登録を有効化」のチェックを外します。

![20231218_106.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/7999e548-d5ce-7b66-f7dd-72ee54e5857d.png)

#### メッセージ配信を設定

Eメールの項目では、「CognitoでEメールを送信」を選択します。

「Amazon SESでEメールを送信」がデフォルトで選択状態になっていますが、こちらを選ぶと設定が少々ややこしく、手軽にCognitoを試す目的から逸脱してしまうので、今回は簡単のため選択を変えます。

![20231218_107.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/329bb1f1-bffa-bc33-6dd9-7ad06ccfddbd.png)

#### アプリケーション統合

「ユーザープール名」と「アプリケーションクライアント名」を適当に設定し、「次へ」ボタンをクリックします。

![20231218_108.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/6c95aeaf-25db-dc23-aa37-b598423261fa.png)

#### 確認および作成

これまでの設定内容が表示されるので、問題なければページの一番下にある「ユーザープールを作成」ボタンをクリックします。

![20231218_109.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/6ade240a-0ce4-7bc9-2f67-b13deb988cdf.png)

### ユーザー登録

ここまでの手順でユーザープールができあがりましたが、まだサインインできるユーザが一人もいません。

そこで、実際にサインインできるユーザをユーザープールに登録してみましょう。

AWSのマネジメントコンソールから、ユーザープールのリンクをクリックします。

![20231218_110.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/286d5ab5-db5d-2ac9-fcde-be705a94176e.png)

#### ユーザーを作成

ページ中ほどにある「ユーザー」タブから、「ユーザーを作成」ボタンをクリックします。

![20231218_111_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/090b51a6-537a-dd5e-5f92-9c6d84e79b37.png)

作成したいユーザー情報を適当に入力して、「ユーザーを作成」ボタンをクリックしましょう。

![20231218_112.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/fe305e07-161e-2565-ee23-8ac1b549f5c8.png)

:::note alert
**注意**
今回は実験なので、パスワードに「`P@ssw0rd`」と指定していますが、実際の運用でこのような推測が容易なパスワードは決して設定しないようにしましょう。

MFAデバイスの設定が完了する前に悪意の第三者によってパスワードが推測・突破されてしまうと、結果的にアカウントが乗っ取られてしまうおそれがあるからです。
:::

ユーザーが正常に作成されたことを確認します。

![20231218_113_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/9ee7d17b-e744-24b7-fc95-5582158f0837.png)

おめでとうございます。

これでCognitoの設定は一旦完了です。 簡単でしたね。

ここからはいよいよフロントエンドを構築していきましょう。

### Angularプロジェクトの作成

`ng new`コマンドでAngularプロジェクト（ワークスペース）を作成します。

```bash
> ng new sample --routing --standalone --style scss --ssr false --skip-tests
> cd sample
```

細かいオプションの意味などは、[前回の記事](https://qiita.com/shida_h/items/e58872bd4501260c07e2)を参照してください。

https://qiita.com/shida_h/items/e58872bd4501260c07e2

### ライブラリのインストール

続いて、`aws-amplify`と`qrcode`をインストールします。

CognitoはAPIベースの認証（・認可）サービスですが、`aws-amplify`にはそれをさらに使いやすくラップした便利なライブラリが用意されているため、今回はこれを活用します。

```bash
> npm install aws-amplify qrcode
> npm install -D @types/qrcode
```

### ページの大まかなレイアウト

本来は、状態に応じてページが動的に遷移する作りにしたいところですが、ここでは話を簡単にするため、1つのコンポーネント (`app.component`) にすべての実装を集約させることにします。

本来は3ページに分かれるフローを1ページに集約するので、ページ全体を3等分して以下のようなレイアウトを作りましょう。

![20231218_002.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/63067593-06a8-2bc9-df3e-7ed66c48ad20.png)

```scss:app.compoenet.scss
.container {
  display: grid;
  grid-template-rows: 1fr;
  grid-template-columns: repeat(3, 1fr);

  & > * + * {
    border-left: 1px solid black;
    padding-left: 1rem;
  }
}
```

```html:app.component.html
<div class="container">
  <div>
    <h2>初期状態</h2>
  </div>
  <div>
    <h2>パスワード更新</h2>
  </div>
  <div>
    <h2>MFAコード入力</h2>
  </div>
</div>
```

:::note
**フクロウセレクタ**

今回の本質ではありませんが、`app.component.scss`の中にある、`* + *`というセレクタは、通称 **<ruby>フクロウ<rt>owl</rt></ruby>セレクタ**と呼ばれており、ここでは3分割の境界線をイイ感じに引くためのテクニックとして利用しています。

通常、すべての兄弟要素に対してスタイルを適用すると、境界線が1本余計に描画されてしまいますが、フクロウセレクタは兄弟要素のうち最初の要素には適用されないため、この特徴を上手く使うと兄弟要素間の境界線や間隔指定などを簡単に設定できます。
:::

### 入力部品の配置

それぞれの区画に、入力部品（`<input>`やら`<button>`やら）を配置していきます。

このまま無造作にマークアップすると<ruby>見<rt>み</rt></ruby><ruby>栄<rt>ば</rt></ruby>えがガタガタになりますので、CSS Gridを使って縦と横の位置を揃えておきましょう。

![20231218_004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/da93d0d7-1f97-3ee5-9530-3a0132bbc5c5.png)


```scss:app.component.scss
.container {
  display: grid;
  grid-template-rows: 1fr;
  grid-template-columns: repeat(3, 1fr);

  & > * + * {
    border-left: 1px solid black;
    padding-left: 1rem;
  }
}

.active {
  background: linear-gradient(lightgreen, lightblue);
}

.form-container {
  display: grid;

  grid-template-columns: repeat(2, max-content);
  grid-auto-rows: max-content;

  column-gap: .5rem;
  row-gap: 1rem;
}

.form-button {
  grid-column: 1 / -1;
}
```

```html:app.component.html
<div class="container">
  <div>
    <h2>初期状態</h2>
    <div class="form-container">
      <label for="username">ユーザ名:</label>
      <input id="username" type="text" [(ngModel)]="username">
      
      <label for="password">パスワード:</label>
      <input id="password" type="password" [(ngModel)]="password">
      
      <button class="form-button" (click)="signIn()">ログイン</button>
    </div>
  </div>

  <div>
    <h2>パスワード更新</h2>
    <div class="form-container">
      <label for="newPassword">新しいパスワード:</label>
      <input id="newPassword" type="password" [(ngModel)]="newPassword">
      
      <button class="form-button" (click)="updatePassword()">パスワード更新</button>
    </div>
  </div>

  <div>
    <h2>MFAコード入力</h2>
    @if(!!qrCodeUri) { 
      <img [src]="qrCodeUri" alt="QR Code for TOTP Setup">
    }

    <div class="form-container">
      <label for="mfaCode">MFAコード:</label>
      <input id="mfaCode" type="number" [(ngModel)]="mfaCode">

      <button class="form-button" (click)="sendMfaCode()">MFAコード送信</button>
    </div>
  </div>
</div>
```

```typescript:app.component.ts
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterOutlet } from '@angular/router';
import { FormsModule } from '@angular/forms';
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule, RouterOutlet, FormsModule],
  templateUrl: './app.component.html',
  styleUrl: './app.component.scss'
})
export class AppComponent {
  username = '';
  password = '';

  newPassword = '';

  qrCodeUri = '';
  mfaCode:number | undefined;

  signIn() { }
  updatePassword() { }
  sendMfaCode() { }
}
```

### コンポーネントに関する補足説明

一気にコード量が増えたので、ここで少しコンポーネントの構造とバインドされている変数の対応関係を補足しておきましょう。

下図左は、テンプレートのHTMLの構造を視覚的に表した図であり、下図右が対応するコンポーネントのTypeScriptコードです。 図中の ① ～ ⑧ が、バインドの対応関係にあります。

![20231218_006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/a53ea301-a84c-3fd3-aac5-85cc68766b00.png)

各`<input>`タグでは、[双方向バインディング](https://angular.jp/guide/two-way-binding)を用いて、入力値が即座にコンポーネントの変数に反映されるようにしています。 なお、`ngModel`を使うためには`FormsModule`を追加でインポートする必要があるという点に注意してください。

また、`<button>`タグでは、クリック時に処理が実行されるよう、[イベントバインディング](https://angular.jp/guide/event-binding)を用いてコンポーネントのメソッドと紐づけています。

この段階では、各イベント処理は<ruby>空<rt>から</rt></ruby>実装になっていますが、基本的なレイアウトはこれで完成しました。

## AngularアプリケーションをCognitoと統合する

### Step1: 初期化コード

はじめに、Angularアプリケーションが先ほどのCognitoユーザープールにアクセスできるよう、アプリケーションのコンストラクタに初期化コードを書きます。

AWSマネジメントコンソールから、先ほど作成したユーザープールを開きましょう。

ページ中ほどにある「アプリケーションの統合」タブ（下図赤枠）をクリックし、「ユーザープールID」「クライアントID」を確認します（下図青枠）。

![20231218_114_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/38d2b104-e9ed-ff49-bc0b-e050a4fd3753.png)


続いて、`app.component.ts`のコンストラクタを以下のように実装します。 

ただし、`userPoolId`と`userPoolClientId`の値は、先ほど確認した値を設定してください。

```diff_typescript:app.component.ts
  import { Component } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
  import { FormsModule } from '@angular/forms';
+ import { Amplify } from 'aws-amplify';

  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [CommonModule, RouterOutlet, FormsModule],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
  export class AppComponent {
    username = '';
    password = '';
  
    newPassword = '';
  
    qrCodeUri = '';
    mfaCode:number | undefined;
  
+   constructor() {
+     Amplify.configure({
+       Auth: {
+         Cognito: {
+           userPoolId: 'ap-northeast-1_yr5A7urP3',         // ユーザープールID
+           userPoolClientId: '1p62c1c0ik1jr4h896h4a2ld3i'  // クライアントID
+         }
+       }
+     });
+   }
  
    signIn() { }
    updatePassword() { }
    sendMfaCode() { }
  }
```

:::note alert
**注意**
ここではあくまで実験のためにユーザープールIDとクライアントIDをハードコーディングしていますが、実際の開発ではこのような方法はとらず、環境変数やセキュアなパラメータストアで安全に管理し、プログラムからはそちらを参照するようにします。

もしもユーザープールIDとクライアントIDがハードコーディングされたプログラムをGitHub等で不特定多数に向けて公開してしまった場合、悪意のある人がユーザープールに対して攻撃を試みる可能性があります。
:::

### Step2: ユーザ名とパスワードの照合

それではいよいよAngularからCognitoのAPIを叩いてみましょう。 

手始めに、下図赤枠の部分の機能 ―― すなわち、ユーザ名とパスワード欄の入力値を、Cognitoユーザープールの登録内容と照合する処理をコーディングしていきます。

![20231218_201.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/ecd6f310-b96e-72aa-aab8-6556a2dd98a5.png)

`app.component.ts`を以下のように修正しましょう。

```diff_typescript:app.component.ts
  import { Component } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
  import { FormsModule } from '@angular/forms';
  import { Amplify } from 'aws-amplify';
+ import { signIn } from 'aws-amplify/auth';
  
  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [CommonModule, RouterOutlet, FormsModule],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
  export class AppComponent {
    username = '';
    password = '';
  
    newPassword = '';
  
    qrCodeUri = '';
    mfaCode:number | undefined;
  
    constructor() {
      Amplify.configure({
        Auth: {
          Cognito: {
            userPoolId: 'ap-northeast-1_yr5A7urP3',         // ユーザープールID
            userPoolClientId: '1p62c1c0ik1jr4h896h4a2ld3i'  // クライアントID
          }
        }
      });
    }
  
-   signIn() { }
+   async signIn() {
+     const [username, password] = [this.username, this.password];
+     try {
+       const result = await signIn({ username, password });
+       console.log(result.nextStep.signInStep);
+     } catch(e: any) {
+       console.error(e.name);
+     }  
+   }

    updatePassword() { }
    sendMfaCode() { }
  }
```

この状態で、試しにAngularアプリケーションを実行してみましょう。

```bash
> ng serve --open
```

先ほど登録したユーザ名とパスワードを入力し、「ログイン」ボタンを押します。

![20231218_202.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/664ed1e8-82fd-7979-a1c3-c7334dd1f866.png)

この状態で開発者ツールを開いてみると、コンソールに「`CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED`」という文字列が表示されていることが分かります。

![20231218_204.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/8b62000e-ae15-8006-ae47-e9a3296ac02e.png)

これは、ユーザ名とパスワードが照合されて、認証の手続きが次のステップに遷移したことを意味します。

状態遷移図でいうと下図の赤色のパスを通ったことになり、パスワードの変更を待ち受けている状態へと変わりました。

![20231218_205.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/45138b61-a192-9aee-595a-3f33b163ef5d.png)

ちなみに、万一ユーザ名やパスワードを空のまま「ログイン」ボタンを押したり、どちらか間違えたりした場合は例外が発生しますので、以下のように場合分けをして対処するとよいでしょう（今回のサンプルではこうしたユーザーフレンドリーな演出は割愛しています）。

```typescript:app.component.ts
  async signIn() {
    const [username, password] = [this.username, this.password];
    try {
      const result = await signIn({ username, password });
      console.log(result.nextStep.signInStep);
    } catch(e: any) {
      if(!e.name) {
        /* 予期せぬエラー */
        return;
      }
      switch(e.name) {
        case 'EmptySignInUsername':
          /* ユーザ名が空である */
          break;
        case 'EmptySignInPassword':
          /* パスワードが空である */
          break;
        case 'NotAuthorizedException':
          /* ユーザ名またはパスワードを間違えている */
          break;
      }
    }
  }
```

#### 補足: `result`の構造について

ここで少しだけ寄り道をして、`signIn()`メソッドの戻り値の構造を、デバッガで確認してみましょう（下図赤枠）。

すると、`isSignIn`と`nextStep`という2つの属性を持ったオブジェクトであることが分かります。 `isSignIn`はブール値の変数で、中身は`false`になっています。これは、MFAを有効化しているため、ユーザ名とパスワードを照合しただけではサインイン状態にならないからです。

さらに`nextStep`属性のツリーを展開すると、下位の階層に`signInStep`という属性が現れます。 基本的には、この`signInStep`を評価して、どの状態に遷移したのかを知ることができるのです。


![20231218_203_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/6945f0d5-beca-e307-e445-64fda0c4d765.png)

### Step3: パスワードの変更

初回のサインイン時（すなわち`result.nextStep.signInStep`の値が`CONFIRM_SIGN_IN_WITH_NEW_PASSWORD_REQUIRED`のとき）は、パスワードの変更が強制されます。

そこで、下図赤枠の部分を実装していきましょう。

![20231218_300.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/c52c1667-3f25-765a-1c6f-6d2f87b93f1c.png)

再び、`app.component.ts`を以下のように修正します。

```diff_typescript:app.component.ts
  import { Component } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
  import { FormsModule } from '@angular/forms';
  import { Amplify } from 'aws-amplify';
- import { signIn } from 'aws-amplify/auth';
+ import { signIn, SignInOutput, confirmSignIn } from 'aws-amplify/auth';
+ import QRCode from 'qrcode'

  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [CommonModule, RouterOutlet, FormsModule],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
  export class AppComponent {
    username = '';
    password = '';
  
    newPassword = '';
  
    qrCodeUri = '';
    mfaCode:number | undefined;
  
+   readonly app_name = 'my_app_name';  // 適当なアプリケーション名（QRコード作るときに使う）
  
    constructor() {
      Amplify.configure({
        Auth: {
          Cognito: {
            userPoolId: 'ap-northeast-1_yr5A7urP3',         // ユーザープールID
            userPoolClientId: '1p62c1c0ik1jr4h896h4a2ld3i'  // クライアントID
          }
        }
      });
    }
  
    async signIn() {
      const [username, password] = [this.username, this.password];
      try {
        const result = await signIn({ username, password });
+       this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e: any) {
        console.error(e.name);
      }  
    }
  
+   async generateTotpSetupQrCode(appName: string, result: SignInOutput): Promise<string> {
+     if (result.nextStep.signInStep === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP') {
+       const totpSetupDetails = result.nextStep.totpSetupDetails;
+       const setupUri = totpSetupDetails.getSetupUri(appName);
+       return await QRCode.toDataURL(setupUri.toString());
+     }
+     return Promise.resolve('');
+   }
  
-   updatePassword() { }
+   async updatePassword() {
+     try {
+       const result = await confirmSignIn({challengeResponse: this.newPassword});
+       this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
+       console.log(result.nextStep.signInStep);
+     } catch(e) {
+       console.error(e);
+     }
+   } 
  
    sendMfaCode() { }
  }
```

パスワードが変更されたときの状態は、下図のようになるはずです。

![20231218_301.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/6da0f6a9-1716-91d6-8d5a-808b5ac3f233.png)

さて、状態が`CONTINUE_SIGN_IN_WITH_TOTP_SETUP`に遷移したとき、認証アプリでスキャンするためのQRコードを画面に表示しないと、ユーザーは詰みになってしまいます。

そこで、QRコードを生成するために`generateTotpSetupQrCode()`メソッドを以下のように実装しました。 これは、`CONTINUE_SIGN_IN_WITH_TOTP_SETUP`状態以外の場合は<ruby>空<rt>から</rt></ruby>文字を返し、それ以外はQRコードのURIを返すメソッドです。

```typescript:app.component.ts（抜粋）
  async generateTotpSetupQrCode(appName: string, result: SignInOutput): Promise<string> {
    if (result.nextStep.signInStep === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP') {
      const totpSetupDetails = result.nextStep.totpSetupDetails;
      const setupUri = totpSetupDetails.getSetupUri(appName);
      return await QRCode.toDataURL(setupUri.toString());
    }
    return Promise.resolve('');
  }
```

この結果をテンプレートの`<img>`タグの`src`属性にバインドすることで、動的にQRコードの生成・表示を実現しています。

ちなみに、先ほどの「特殊ケース」でご紹介したように、QRコードの生成が必要になる分岐パターンがもう一つあります。 それは、ユーザがパスワードを一度変更したあと、何らかの理由でMFAデバイスを登録しないまま離脱し、時間を空けて再チャレンジした場合です。

![20231218_302.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/d84b0129-d15f-5f97-9a3c-87bad8ded949.png)

このように、条件さえ揃えば初期状態からいきなり`CONTINUE_SIGN_IN_WITH_TOTP_SETUP`に遷移するケースもあるため、`signIn()`メソッドも以下のように書き換えておきます。

```diff_typescript:app.component.ts（抜粋）
    async signIn() {
      const [username, password] = [this.username, this.password];
      try {
        const result = await signIn({ username, password });
+       this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e: any) {
        console.error(e.name);
      }  
    }
```

これでよいでしょう。

適当にパスワードを更新してみます。

![20231218_303.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/2c03c766-7368-4dcf-c689-cac4588678fb.png)

パスワード変更に成功すると、QRコードが表示されるようになります。

![20231218_304.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/63126c65-f39e-f27e-be00-faddd5b0b099.png)

### Step4: MFAコードの送信

最後に、MFAコードを送信する処理を作りこみましょう。

![20231218_306.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/fb8c3d39-746c-8c48-d92f-bd6722cad35a.png)

状態遷移図でいうと、下図の赤丸の2つの状態（`CONTINUE_SIGN_IN_WITH_TOTP_SETUP`と`CONFIRM_SIGN_IN_WITH_TOTP_CODE`）をまとめて作ります。

![20231218_400.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/14d2d2fc-9eb6-0d28-4719-907e654d4568.png)

この2つは、QRコードを表示するかどうかだけの違いであり、呼び出すAPI自体に変わりはないため、プログラム自体は共通化してしまって問題ありません。

```diff_typescript:app.component.ts
  import { Component } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
  import { FormsModule } from '@angular/forms';
  import { Amplify } from 'aws-amplify';
- import { signIn, SignInOutput, confirmSignIn } from 'aws-amplify/auth';
+ import { signIn, SignInOutput, confirmSignIn, fetchAuthSession, getCurrentUser} from 'aws-amplify/auth';
  import QRCode from 'qrcode'
  
  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [CommonModule, RouterOutlet, FormsModule],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
  export class AppComponent {
    username = '';
    password = '';
  
    newPassword = '';
  
    qrCodeUri = '';
    mfaCode:number | undefined;
  
    readonly app_name = 'my_app_name';  // 適当なアプリケーション名
  
    constructor() {
      Amplify.configure({
        Auth: {
          Cognito: {
            userPoolId: 'ap-northeast-1_yr5A7urP3',         // ユーザープールID
            userPoolClientId: '1p62c1c0ik1jr4h896h4a2ld3i'  // クライアントID
          }
        }
      });
    }
  
    async signIn() {
      const [username, password] = [this.username, this.password];
      try {
        const result = await signIn({ username, password });
        this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e: any) {
        console.error(e.name);
      }  
    }
  
    async generateTotpSetupQrCode(appName: string, result: SignInOutput): Promise<string> {
      if (result.nextStep.signInStep === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP') {
        const totpSetupDetails = result.nextStep.totpSetupDetails;
        const setupUri = totpSetupDetails.getSetupUri(appName);
        return await QRCode.toDataURL(setupUri.toString());
      }
      return Promise.resolve('');
    }
  
    async updatePassword() {
      try {
        const result = await confirmSignIn({challengeResponse: this.newPassword});
        this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e) {
        console.error(e);
      }
    } 
  
-   sendMfaCode() { }
+   async sendMfaCode() {
+     try {
+       const result = await confirmSignIn({ challengeResponse: `${this.mfaCode}` });
+       console.log(result.nextStep.signInStep);
+
+       // アクセストークンを取得
+       const session = await fetchAuthSession();
+       const tokens = session.tokens;
+       const accessToken = tokens?.accessToken;
+       console.log(accessToken);
+
+       // ログインユーザを取得
+       const user = await getCurrentUser();
+       console.log(user.username);
+     } catch(e) {
+       console.error(e);
+     }
+   }
  }
```

![20231218_305.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/c70836e3-e0d1-ac56-e091-c9e565f2f902.png)

デバッガで確認すると、状態が`DONE`に遷移していることが分かります。

![20231218_401_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/babf6208-25a6-4d9c-d28b-7006daf3757c.png)


状態遷移図では一番最後の状態です。

![20231218_408.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/a1861ad4-e519-7f1c-5672-cfee16d0d0d0.png)

今回は、最終的にサインインに成功した際に、アクセストークンとユーザ名を取得し、コンソールに吐いています。 アクセストークンは、（今回は触れませんが）HTTPリクエスト時の`Authorization`ヘッダなどに設定する形で用います。

![20231218_403_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/2e4c0e36-c6b8-cddc-1d57-157c5aa8ef3c.png)

ユーザ名は、フロントエンドなどにサインイン中のユーザ名を表示するといった演出などに使えます。

![20231218_402_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/321759c1-55c1-47c7-0916-f9d85a7807b8.png)

なお、サインインに成功すると、LocalStorageに各種トークンが格納されます。

![20231218_404.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/b616d39d-b7a9-5071-759b-182037e3f5cb.png)

これで無事にサインインの実装が完了しました。 お疲れ様でした。

### Step5: サインアウト

ところで、無事にサインインできた後、LocalStorageに値が残った状態で、ブラウザをリロードするなりして再度サインインを試みるとどうなるでしょう。

![20231218_405.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/5e713b23-fd28-c4f6-c0c6-2336d06ea05e.png)

なんと、`signIn()`メソッドで`UserAlreadyAuthenticatedException`という例外が投げられてしまいます。

![20231218_406_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/a281b3c0-5382-954d-083c-c6ea52bfa9c4.png)

上図赤枠の`recoverySuggestion`を見ると、`Call signOut before calling signIn again.`というメッセージが表示されていることが分かります。 つまり、`signIn()`メソッドを呼ぶ前に一度サインアウトせよ、という意味です。

せっかくですから、コンポーネントの`ngOnInit`ライフサイクルフックにサインアウト処理を実装してみましょう。

```diff_typescript:app.component.ts
- import { Component } from '@angular/core';
+ import { Component, OnInit } from '@angular/core';
  import { CommonModule } from '@angular/common';
  import { RouterOutlet } from '@angular/router';
  import { FormsModule } from '@angular/forms';
  import { Amplify } from 'aws-amplify';
- import { signIn, SignInOutput, confirmSignIn, fetchAuthSession, getCurrentUser} from 'aws-amplify/auth';
+ import { signIn, SignInOutput, confirmSignIn, fetchAuthSession, getCurrentUser, signOut} from 'aws-amplify/auth';
  import QRCode from 'qrcode'
  
  @Component({
    selector: 'app-root',
    standalone: true,
    imports: [CommonModule, RouterOutlet, FormsModule],
    templateUrl: './app.component.html',
    styleUrl: './app.component.scss'
  })
- export class AppComponent {
+ export class AppComponent implements OnInit {
    username = '';
    password = '';
  
    newPassword = '';
  
    qrCodeUri = '';
    mfaCode:number | undefined;
  
    readonly app_name = 'my_app_name';  // 適当なアプリケーション名
  
    constructor() {
      Amplify.configure({
        Auth: {
          Cognito: {
            userPoolId: 'ap-northeast-1_yr5A7urP3',         // ユーザープールID
            userPoolClientId: '1p62c1c0ik1jr4h896h4a2ld3i'  // クライアントID
          }
        }
      });
    }
  
+   async ngOnInit() {
+     await signOut();
+   }
  
    async signIn() {
      const [username, password] = [this.username, this.password];
      try {
        const result = await signIn({ username, password });
        this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e: any) {
        console.error(e.name);
      }  
    }
  
    async generateTotpSetupQrCode(appName: string, result: SignInOutput): Promise<string> {
      if (result.nextStep.signInStep === 'CONTINUE_SIGN_IN_WITH_TOTP_SETUP') {
        const totpSetupDetails = result.nextStep.totpSetupDetails;
        const setupUri = totpSetupDetails.getSetupUri(appName);
        return await QRCode.toDataURL(setupUri.toString());
      }
      return Promise.resolve('');
    }
  
    async updatePassword() {
      try {
        const result = await confirmSignIn({challengeResponse: this.newPassword});
        this.qrCodeUri = await this.generateTotpSetupQrCode(this.app_name, result);
        console.log(result.nextStep.signInStep);
      } catch(e) {
        console.error(e);
      }
    } 
  
    async sendMfaCode() {
      try {
        const result = await confirmSignIn({ challengeResponse: `${this.mfaCode}` });
        console.log(result.nextStep.signInStep);
  
  
        // アクセストークンを取得
        const session = await fetchAuthSession();
        const tokens = session.tokens;
        const accessToken = tokens?.accessToken;
        console.log(accessToken);
  
        // ログインユーザを取得
        const user = await getCurrentUser();
        console.log(user.username);
  
      } catch(e) {
        console.error(e);
      }
    }
  }
```

サインアウト処理が実行されると、LocalStorageに格納されていた各種トークンも綺麗に消え去ることが確認できます。

![20231218_407.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635606/65c098ff-31d9-5c00-cb68-3a6f4caba801.png)

これで、ひととおりの流れを実装できたことになります。 お疲れ様でした。

## まとめ

今回は、AngularアプリケーションとCognitoユーザープールを連携し、サインイン / サインアウトを実装してみました。

初めてCognitoに触れる方でも、意外と簡単だなと感じられたのではないでしょうか。

サインインに限らず、こうした「誰が作ってもだいたい同じ出来上がりになるもの」はできるだけラクをして、差別化に繋がるオリジナルの機能開発に注力できれば、より高い価値を生みやすくなります。

末筆ながら、本記事が一人でも多くの方のお役に立てれば幸いです。

最後までお読みいただきありがとうございました。