Vite + React + TypeScriptで構築したアプリにCognitoの認証機能を追加する(React 18対応も)
(2022年11月25日追記)
私の本が株式会社インプレス R&Dさんより出版されました。この記事の内容も含まれています。イラストは鍋料理さんの作品です。猫のモデルはなんとうちのコです!
感想を書いていただけるととても嬉しいです!
(2022年8月3日追記)この記事の内容はこちらの本でも読めます。
はじめに
開発中のReactアプリにGoogleアカウントによるサインイン機能を追加したので、手順をご紹介します。個人的にはFirebaseが好きなのですが、今回はバックエンドをAWSにしている関係上、Cognitoを使ってみます。
こちらの記事の続きです。
今回実装した内容(ログインからログアウトまで)はこんな感じです。
ランディングページでログインボタンをクリックします。
Google認証を促すダイアログが表示されます。デザインはこれからカスタマイズする予定です。
Googleアカウントでログイン(キャプチャ省略)するとマイページに遷移します。右上のユーザーアイコンをクリックしてプルダウンメニューからログアウトをクリックします。ログインしていることがわかるように、暫定的にユーザー名を表示しています。
ログアウトするとランディングページに遷移します。
Cognitoユーザープールの作成
こちらの記事を参考にしました。
Cognitoのコンソール画面は最近新しくなったようで、キャプチャとぜんぜん違って苦労しましたが、設定する内容は基本的に同じです。注意するところや変えたところは以下の通りです。
- アプリクライアントを作成する際に「クライアントシークレットを生成」チェックボックスを外す
- コールバックURLはhttp:/localhost:3000/mypageにする(httpsではなくhttp)
- サインアウトURLはhttp:/localhost:3000/にする(httpsではなくhttp)
最終的に「ホストされたUI」が下図のようになっていればOKです。
React側の実装
今回は「とりあえず動いた」レベルなので、いろいろと暫定的です。
まずAmplifyをインストールします。
npm install aws-amplify
ちなみにAmplify CLIを使えばバックエンドも全部自動で用意してくれますが、余計なものまで大量に作られるので今回は使用しません。ライブラリのみ使用します。
Amplify設定の読み込み
Amplifyの接続設定はとりあえず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
で行います。次のように書き換えました。
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
というエラーが出ています。
これはViteで環境構築したときに発生する現象で、create-react-app
で環境構築した場合には起きません。こちらのドキュメントを参考に、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
に実装しました。画面上ではこの部分ですね。
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
に実装しました。画面上ではこの部分です。
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が正式発表されました。
こちらの動画で詳しいアップデート手順が紹介されていたので、アップデートしてみました。
まず、npm installします。
npm install react@18 react-dom@18
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など、選択肢が多くて迷ってしまいます。今回のアプリはシンプルなので状態管理ツールを使わないという選択肢もありますが、勉強も兼ねているので何らかのツールは使ってみる予定です。
Discussion