AWS Cognitoでワンタイムパスワード（MFA認証）を設定していても自動テスト(Cypress)をする方法

## Cypressとは

cypressはブラウザをクライアントとして、WebアプリケーションのE2Eテスト(End to End Test)を実現するためのテストツールです。テストコードをjavascriptのコードで記述することでテストを自動化することができます。

https://www.cypress.io/

### Cypressと認証

cypressでE2Eテストを行う際の対象Webシステムが認証を必要とする場合、cypress側が認証を通過する必要があります。IDとパスワードのみの認証であれば、テストコード側に認証情報を渡してキーボード入力で認証を通過することが可能です。ただ、最近のアプリケーションではSNS認証や多要素認証など様々な認証方法があります。
cypress公式でもAuth0やAWS Cognitoなど最近よく使われる認証サービスに関するガイドがあり、実装の参考になります。

https://docs.cypress.io/guides/end-to-end-testing/amazon-cognito-authentication

私がやりたかったAWS Cognitoの多要素認証(ワンタイムパスワード)に関しては公式のガイドにも情報がなかったんですが、最近Githubのissueに投稿があり参考に試してみました。

https://github.com/aws-amplify/amplify-js/discussions/6646

## 事前準備

### ワンタイムパスワード(TOTP)で認証するサンプルアプリ

今回テストの対象システムとしてAWS Cognitoを利用して、多要素認証(TOTP）を実装した単純なログイン画面を実装します。amplifyでuiが提供されているため、今回はこれを使っていきます。AWSアカウント準備については省略します。

https://docs.amplify.aws/ui/q/framework/react/

Reactプロジェクトを作成します。

```bash
npx create-react-app cognito-totp-sample --template typescript
cd cognito-react-app
yarn add aws-amplify @aws-amplify/ui-react
```

`amplify init` で初期化し、`amplify add auth` で cognito user pool を作成します。このときの設定は適当で構いませんが、TOTPを有効化するように設定してください。

```bash
amplify init
amplify add auth
```

`amplify add auth` は対話的に設定していきます。この辺の設定がワンタイムパスワード関連です。

```bash
For user login, select the MFA types:
  Time-Based One-Time Password (TOTP)
```

設定ができたらpushします。

```bash
amplify push
```

App.tsxを以下のように修正します。

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

import awsconfig from "./aws-exports";
Amplify.configure(awsconfig);

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

public/index.html にフォントを追加します。

```html
    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:slnt,wght@-10..0,100..900&display=swap"
      rel="stylesheet"
    />
```

`yarn start` で起動し、以下のような画面が表示されたらOKです。

![cy-cognito1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/05161d4e-191f-36c9-389c-2d30167c83f2.png)


あとは以下の画面が表示されるようにテストユーザーの作成とパスワード再設定まで進めてください。（QRコードはダミー画像に置き換えてます）
通常であれば、このQRコードをGoogle Authenticatorなどで読み込んで設定しますが、今回はテストで自動的にワンタイムパスワードを取得したいため、ここで止めておきます。

![cy-cognito2_dummy.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/1cf1458a-c12c-6ba0-9d9f-50797bd86649.png)



## e2eテスト作成

### Cypressインストール

cypressをインストールします。(以下yarn前提で記載しますが、npmの方は適宜読み替えてください）

```bash
yarn add --dev cypress
```

インストール後にcypressを起動します。

```bash
yarn cypress open
```

起動すると設定を聞かれるのでE2E Testing を選びます。

![cypress1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/fb38de87-2f61-8e98-3338-27d1933be4db.png)


次の画面ではそのまま下にスクロールし、[Continue]をクリックします。

![cypress2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/6fb7278a-0a0f-74e5-5c3f-414d887b1fb0.png)


[Start E2E Testing in Chrome] をクリックします。

![cypress3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/99d00558-85a1-a1e0-8555-92d4e0e123f2.png)


新しくchromeが起動して、以下の画面になりますので[Create new empty spec]をクリックします。

![cypress4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/2cda911b-7d5d-6d14-6f81-15667a2a4981.png)


これでテストコード（cypress/e2e/spec.cy.ts）やその他ファイルが自動生成されますので以降はVSCode側で作業を行います。

### テストコード作成

typescriptでテストコードを書いていくため、cypress/tsconfig.jsonを以下の内容で作成します。

```json
{
    "extends": "../tsconfig.json",
    "include": ["./**/*.ts"],
    "exclude": [],
    "compilerOptions": {
      "types": ["cypress"],
      "lib": ["es2015", "dom"],
      "isolatedModules": false,
      "allowJs": true,
      "noEmit": true
    }
}
```

今回利用するライブラリをインストールします。

```bash
yarn add --dev otplib　dotenv path
```

cypress.config.tsを以下のように修正します。baseUrlはReactデフォルトのポートを入力していますが、テストを実行したいサイトに変更してください。

```tsx
import path from "path";
import { defineConfig } from "cypress";

// eslint-disable-next-line @typescript-eslint/no-var-requires
require("dotenv").config();
// eslint-disable-next-line @typescript-eslint/no-var-requires
const awsConfig = require(path.join(__dirname, "./src/aws-exports.js"));

export default defineConfig({
  e2e: {
    setupNodeEvents(on, config) {
      // implement node event listeners here
    },
    baseUrl: "http://localhost:3000",
    env: {
      cognito_username: process.env.CY_AWS_COGNITO_USERNAME,
      cognito_password: process.env.CY_AWS_COGNITO_PASSWORD,
      awsConfig: awsConfig.default,
    },
  },
});
```

.envファイルを作成して、CognitoユーザーのIDとパスワードを設定します。ここの情報は事前に作成したユーザー情報を入力してください。

```
CY_AWS_COGNITO_USERNAME="test"
CY_AWS_COGNITO_PASSWORD="test1234"
```

cypress/support/commands.ts にCognitoのログイン処理を実装します。42行目のchallengeSecretの部分はテスト初回時のconsoleから取得しますのでとりあえずこのままでOKです。

```tsx
import { authenticator } from "otplib";
import Amplify, { Auth } from "aws-amplify";

Amplify.configure(Cypress.env("awsConfig"));

export function generateOTP(secret: string, offset = 0) {
  if (offset === 0) {
    return authenticator.generate(secret);
  }
  const allOptions = authenticator.allOptions();
  const delta = allOptions.step * 1000;
  authenticator.options = { epoch: Date.now() + offset * delta };
  const offsetToken = authenticator.generate(secret);
  authenticator.resetOptions();
  return offsetToken;
}

Cypress.Commands.add("loginByCognitoApi", (email: string, password: string) => {
  const log = Cypress.log({
    displayName: "COGNITO LOGIN",
    message: [`🔐 Authenticating | ${email} `],
    autoEnd: false,
  });

  log.snapshot("before");

  const signIn = Auth.signIn({ username: email, password });

  cy.wrap(signIn, { log: false })
    .then(async (user) => {
      let offset = 0;
      while (offset < 10) {
        try {
          if (user.challengeName === "MFA_SETUP") {
            const secret = await Auth.setupTOTP(user);
            console.log(secret);
            const code = generateOTP(secret, offset++);
            await Auth.verifyTotpToken(user, code);
            await Auth.setPreferredMFA(user, "TOTP");
            return;
          } else {
            const challengeSecret = "初回テスト時に出力されたシークレットを入力";
            const code = generateOTP(challengeSecret, offset++);
            const result = await Auth.confirmSignIn(
              user,
              code,
              "SOFTWARE_TOKEN_MFA"
            );
            return result;
          }
          // eslint-disable-next-line no-empty
        } catch {}
      }
    })
    .then((cognitoResponse: any) => {
      const prefix = `${cognitoResponse.keyPrefix}.${cognitoResponse.username}`;
      const { idToken, accessToken, refreshToken, clockDrift } = cognitoResponse.signInUserSession;

      localStorage.setItem(`${prefix}.idToken`, idToken.jwtToken);
      localStorage.setItem(`${prefix}.accessToken`, accessToken.jwtToken);
      localStorage.setItem(`${prefix}.refreshToken`, refreshToken.token);
      localStorage.setItem(`${prefix}.clockDrift`, clockDrift);
      localStorage.setItem(`${cognitoResponse.keyPrefix}.LastAuthUser`, cognitoResponse.username);
      localStorage.setItem("amplify-signin-with-hostedUI", "false");
      log.snapshot("after");
      log.end();
    });
});
```

このままだと、型チェックのエラーが出力されるので cypress/global.d.ts を以下の内容で作成します。

```tsx
/// <reference types="cypress" />

declare namespace Cypress {
  interface Chainable {
    loginByCognitoApi(username: string, password: string): Chainable<any>;
  }
}
```

cypress/e2e/spec.cy.tsを以下のように修正します。

```tsx
describe("TOTP認証テスト", () => {
  before(() => {
    cy.loginByCognitoApi(
      Cypress.env("cognito_username"),
      Cypress.env("cognito_password")
    );
  });

  it("ログイン後のトップページが表示されること", () => {
    cy.visit("/");
    // 画面遷移と描画を待つ
    cy.get("h1", { timeout: 3000 }).should("be.visible");
    cy.get("h1").contains("Hello");
  });
});
```

## テスト実行

ここまで来たらcypressで作成したテストを実行します。spec.cy.tsをクリックします。

![cypress6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/186217da-3f63-0d9e-6d9d-6564adbcef25.png)


初めてテストを実行すると以下のようにcypressのchromeのコンソール上にシークレットが出力されます。

![cypress7.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/ccce7b54-9dfb-ec62-2e9e-6003fe2ce8d8.png)



2回目以降のテスト用にこのシークレットの文字列をcypress/support/commands.ts のchallengeSecret の部分に設定してください。このやり方改善できると思うんですが、cypress初心者の私には思いつきませんでした。。

```tsx
// const challengeSecret = "初回テスト時に出力されたシークレットを入力";
const challengeSecret = "上記キャプチャの VCJ7GPS〜 の部分";
```

再度テストを流します。認証を通過し、ログイン画面ではなく、認証後のトップページ（Hello ユーザー名とSign outボタン）が出力されれば成功です。

![cypress8.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2277523/70bd0681-8c0e-bd67-821a-28f73967ddce.png)


## 感想

いままで多要素認証があるWebシステムのE2Eテスト（テスト自動化）どうするんだろ、認証方式の違う環境を作成するしかないのかなと思っていましたが、今回の方法でより本番の環境に近いテストができそうです。今回の多要素認証はTOTP（ワンタイムパスワード）でしたが、SMSも試してみたいです。

## 参考

https://docs.cypress.io/guides/end-to-end-testing/amazon-cognito-authentication#Custom-Command-for-Amazon-Cognito-Authentication

https://github.com/aws-amplify/amplify-js/discussions/6646