Vite + React + TypeScriptで構築したアプリにCognitoの認証機能を追加する(React 18対応も)


はじめに

開発中のReactアプリにGoogleアカウントによるサインイン機能を追加したので、手順をご紹介します。個人的にはFirebaseが好きなのですが、今回はバックエンドをAWSにしている関係上、Cognitoを使ってみます。

こちらの記事の続きです。

https://zenn.dev/sikkim/articles/d976bd7fd4adfc

今回実装した内容(ログインからログアウトまで)はこんな感じです。

ログイン

ランディングページでログインボタンをクリックします。

Google認証

Google認証を促すダイアログが表示されます。デザインはこれからカスタマイズする予定です。

マイページ

Googleアカウントでログイン(キャプチャ省略)するとマイページに遷移します。右上のユーザーアイコンをクリックしてプルダウンメニューからログアウトをクリックします。ログインしていることがわかるように、暫定的にユーザー名を表示しています。

ログアウト

ログアウトするとランディングページに遷移します。

Cognitoユーザープールの作成

こちらの記事を参考にしました。

https://dev.classmethod.jp/articles/react-cognito-signin/

https://dev.classmethod.jp/articles/cognito-google-oauth-singin/

Cognitoのコンソール画面は最近新しくなったようで、キャプチャとぜんぜん違って苦労しましたが、設定する内容は基本的に同じです。注意するところや変えたところは以下の通りです。

  • アプリクライアントを作成する際に「クライアントシークレットを生成」チェックボックスを外す
  • コールバックURLはhttp:/localhost:3000/mypageにする(httpsではなくhttp)
  • サインアウトURLはhttp:/localhost:3000/にする(httpsではなくhttp)

最終的に「ホストされたUI」が下図のようになっていればOKです。

ホストされたUI

React側の実装

今回は「とりあえず動いた」レベルなので、いろいろと暫定的です。

まずAmplifyをインストールします。

npm install aws-amplify

ちなみにAmplify CLIを使えばバックエンドも全部自動で用意してくれますが、余計なものまで大量に作られるので今回は使用しません。ライブラリのみ使用します。

Amplify設定の読み込み

Amplifyの接続設定はとりあえずsrc/awsExports.tsxに格納してみました。最終的にどうするかは検討中です。やはり環境変数で設定すべきでしょうか?

src/awsExports.tsx
const awsExports = {
  Auth: {
    region: 'ap-northeast-1',
    userPoolId: 'ap-northeast-1_xxxxxxxxx',
    userPoolWebClientId: 'xxxxxxxxxxxxxxxxxxxxxxxxx',
    oauth: {
      domain: 'xxxxxxxxxx.auth.ap-northeast-1.amazoncognito.com',
      scope: ['openid'],
      redirectSignIn: 'http://localhost:3000/mypage',
      redirectSignOut: 'http://localhost:3000/',
      responseType: 'code',
    },
  },
};

export default awsExports;

設定の読み込みはsrc/main.tsxで行います。次のように書き換えました。

src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Amplify from 'aws-amplify';
import App from './App';
import Doc from './routes/Doc';
import Login from './routes/Login';
import MyPage from './routes/MyPage';
import NoMatch from './routes/NoMatch';
import PrivacyPolicy from './routes/PrivacyPolicy';
import Signup from './routes/Signup';
import Thanks from './routes/Thanks';
import Terms from './routes/Terms';
import Tokusyouhou from './routes/Tokusyouhou';
import awsExports from './awsExports';

Amplify.configure(awsExports);

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="doc" element={<Doc />} />
        <Route path="login" element={<Login />} />
        <Route path="mypage" element={<MyPage />} />
        <Route path="privacy_policy" element={<PrivacyPolicy />} />
        <Route path="signup" element={<Signup />} />
        <Route path="terms" element={<Terms />} />
        <Route path="thanks" element={<Thanks />} />
        <Route path="tokusyouhou" element={<Tokusyouhou />} />
        <Route path="*" element={<NoMatch />} />
      </Routes>
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root'),
);

まだ認証処理を入れていませんが、いったん動かしてみましょう。npm run devしたあとlocalhot:3000にアクセスすると、、、

Uncaught ReferenceError: global is not defined

画面が真っ白になってしまいました!コンソールを見るとUncaught ReferenceError: global is not definedというエラーが出ています。

これはViteで環境構築したときに発生する現象で、create-react-appで環境構築した場合には起きません。こちらのドキュメントを参考に、index.htmlを修正します。

index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/src/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>FM Mail</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
    <script>
      window.global = window;
      window.process = {
        env: { DEBUG: undefined },
      };
      var exports = {};
    </script>
  </body>
</html>

下記が追加した部分です。

    <script>
      window.global = window;
      window.process = {
        env: { DEBUG: undefined },
      };
      var exports = {};
    </script>

ちなみに実験したら下記の記述だけでも正常に動きました。今回、Amplify UIは使用していないので、余計な記述があるかもしれません。一応、公式ドキュメントの記載通りにしておきます。

    <script>
      window.global = window;
    </script>

あっさりと書いていますが、この問題の解消には2日ほどかかりました。

サインイン処理の実装

サインイン処理はsrc/components/Header1.tsxに実装しました。画面上ではこの部分ですね。

サインイン処理

src/components/Header1.tsxの一部
import { VFC } from 'react';
import { Link } from 'react-router-dom';
import { HashLink } from 'react-router-hash-link';
import { Auth } from 'aws-amplify';
import Logo from '../svg/FM_Mail_logo.svg';

const Header1: VFC = () => (

// 中略

          <div className="mt-4 flex items-center md:mt-0">
            <div className="-ml-8 hidden flex-col gap-2.5 sm:flex-row sm:justify-center lg:flex lg:justify-start">
              <button
                type="button"
                className="inline-block rounded-lg px-4 py-3 text-center text-sm font-semibold text-gray-500 outline-none ring-indigo-300 transition duration-100 hover:text-indigo-500 focus-visible:ring active:text-indigo-600 md:text-base"
                onClick={() => Auth.federatedSignIn()}
              >
                ログイン
              </button>

              <button
                type="button"
                className="inline-block rounded-lg bg-blue-500 px-8 py-3 text-center text-sm  text-white outline-none hover:bg-blue-600 active:bg-blue-700 md:text-base"
                onClick={() => Auth.federatedSignIn()}
              >
                新規登録
              </button>
            </div>
          </div>

// 後略

onClick={() => Auth.federatedSignIn()}がGoogle認証を呼び出している箇所です。今回はGoogle認証のみなので、新規登録とログインはまったく同じ処理になっています。ユーザーが存在しない状態でログインすると新規ユーザーが作られます。

サインアウト処理の実装

サインアウト処理はsrc/components/Header2.tsxに実装しました。画面上ではこの部分です。

サインアウト処理

src/components/Header2.tsxの一部
import { Link } from 'react-router-dom';
import { VFC, useState, useEffect } from 'react';
import { Auth, Hub } from 'aws-amplify';
import Logo from '../svg/FM_Mail_logo.svg';

const Header2: VFC = () => {

// 中略

  const [user, setUser] = useState<any | null>(null);

  const getUser = async () => {
    try {
      const userData = await Auth.currentAuthenticatedUser();
      // デバッグ用
      Auth.currentSession().then((data) => {
        console.log(`token: ${data.getIdToken().getJwtToken()}`);
      });
      console.log(userData);

      return userData;
    } catch (e) {
      return console.log('Not signed in');
    }
  };

  const listener = ({ payload: { event, data } }) => {
    switch (event) {
      case 'signIn':
      case 'cognitoHostedUI':
        void getUser().then((userData) => setUser(userData));
        break;
      case 'signOut':
        setUser(null);
        break;
      case 'signIn_failure':
      case 'cognitoHostedUI_failure':
      default:
        console.log('Sign in failure', data);
        break;
    }
  };

  useEffect(() => {
    Hub.listen('auth', listener);
    void getUser().then((userData) => setUser(userData));
  }, []);

// 中略

                  <ul className="py-1" aria-labelledby="dropdownButton">
                    <li>
                      <Link
                        to="/settings"
                        className="block w-full py-2 px-4 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white"
                      >
                        ユーザー設定{user ? user.username : null}
                      </Link>
                    </li>
                    <li>
                      <button
                        type="button"
                        className="block w-full py-2 px-4 text-left text-sm text-gray-700 hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-gray-600 dark:hover:text-white"
                        onClick={() => Auth.signOut()}
                      >
                        ログアウト
                      </button>
                    </li>
                  </ul>

// 後略

今回は全般的に暫定的な実装になっていますが、ここがもっとも暫定的です。そもそもヘッダーコンポーネントに書くべき処理ではないかもしれません。ベストプラクティスが知りたいです。

一応、サインインされていたらユーザー名を表示することと、サインアウトすることはできています。

(おまけ)React 18対応

一昨日、React 18が正式発表されました。

https://reactjs.org/blog/2022/03/29/react-v18.html

で詳しいアップデート手順が紹介されていたので、アップデートしてみました。

まず、npm installします。

npm install react@18 react-dom@18

src/main.tsxを次のように書き換えます。

src/main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Amplify from 'aws-amplify';
import awsExports from './awsExports';
import App from './App';
import Doc from './routes/Doc';
import Login from './routes/Login';
import MyPage from './routes/MyPage';
import NoMatch from './routes/NoMatch';
import PrivacyPolicy from './routes/PrivacyPolicy';
import Signup from './routes/Signup';
import Thanks from './routes/Thanks';
import Terms from './routes/Terms';
import Tokusyouhou from './routes/Tokusyouhou';

Amplify.configure(awsExports);

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<App />} />
        <Route path="doc" element={<Doc />} />
        <Route path="login" element={<Login />} />
        <Route path="mypage" element={<MyPage />} />
        <Route path="privacy_policy" element={<PrivacyPolicy />} />
        <Route path="signup" element={<Signup />} />
        <Route path="terms" element={<Terms />} />
        <Route path="thanks" element={<Thanks />} />
        <Route path="tokusyouhou" element={<Tokusyouhou />} />
        <Route path="*" element={<NoMatch />} />
      </Routes>
    </BrowserRouter>
  </React.StrictMode>,
);

importがimport ReactDOM from 'react-dom';からimport ReactDOM from 'react-dom/client';に変わりました。

また、ReactDOM.renderの代わりに、ReactDOM.createRootで生成したroot変数を用いてrenderを行うようになりました。

基本的にはたったこれだけでReact 18にアップデートすることができました。

TypeScriptのエラーが出ましたが、サジェストにしたがって以下を実行したら解消しました。

npm install -D @types/react-dom

新規開発で過去のしがらみもないので、このまま18で開発を進めようと思います。

まとめ

Cognitoを用いてVite + React + TypeScriptという環境に認証処理を暫定実装してみました。まだいろいろ課題が残っています。

  • LinterやTypeScriptのエラーが解消していない
  • サインインしなくても認証が必要な画面に直接アクセスできてしまう
  • サインインの状態を保持していないので不要なサインイン処理が発生してしまう

認証周りはやはり難しいですね。そして、状態管理をどうするか決める必要が出てきました。Reduxは論外としても、Meta謹製のRecoilや、ダウンロード数ではRecoilの倍近い人気のあるZustand、日本で作られたJotaiなど、選択肢が多くて迷ってしまいます。今回のアプリはシンプルなので状態管理ツールを使わないという選択肢もありますが、勉強も兼ねているので何らかのツールは使ってみる予定です。