AWS Amplify + React で認証を実装する中でハマったアレコレ

Amplify/AppSyncとReactを利用したプロダクトで、Amplifyの認証(Cognito)を利用してログイン、登録フォーム、PrivateRouteを作成した際にハマった内容をご紹介します。
2020.02.07

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ども、もこ@札幌オフィスです。

昨年の年末からAWSJ様によるプログラム「APN Next Generation Engineer Leaders Dojo」(以下ANGEL Dojoと表記)に参加しています。

Amplify/AppSyncとReactを利用したプロダクトで、Amplifyの認証(Cognito)を利用してログイン、登録フォーム、PrivateRouteを作成した際にハマった内容をご紹介します。

ANGEL Dojoとは?

ANGEL Dojoでは次世代を担うAPNの若手のエンジニアの方々に、擬似プロジェクトを通じてアジャイル、DevOps、モダンなアプリケーション開発などのクラウドネイティブな手法と、様々なInnovationを作ってきたAmazonの文化と考え方を体験いただくことで、お客様にクラウドの価値を100%届けるための基礎的なスキルを実践を通して身につけていただきます。参加者の皆様はここで培ったスキルと、各パートナーの皆様がお持ちのそれぞれの強みを活かすことでお客様のビジネスを成功に導き、日本のITや経済をさらに成長させる主役、すなわち「APN Next Generation Engineer Leader」になっていただきます。

「日本を元気にする」APN Next Generation Engineer Leaders(ANGEL) Dojo のご紹介

ざっくりいうと、APNパートナーの若手(IT経験 1-3年)エンジニア育成のため、「日本を元気にする何か」をAmazon的な方法でチーム開発しようぜ!というプログラムです。

弊社チームでは「知識のインプットとアウトプットをいい感じに効率よくやれるサービス」を企画していまして、アウトプットに関連するものなんだし自分たちもアウトプットしないとね、ということで、企画をしてみたエントリーなどを皮切りに、ANGEL Dojo – シリーズ –として関連するエントリーをまとめるようにしました。

では早速本編に入りましょう。

作りたいもの

React Router(v5.1)を利用して、/login でAmplifyのビルドインコンポーネントのHOCである withAuthenticator を利用して、自分たちでUIを組む工数を削減しつつ、Cognitoによる認証を済ませた後に /dashboard にリダイレクトさせるようなものを作ろうとしていました。

また、非ログイン状態で /dashboard にアクセスすると認証画面をだす、俗にいうPrivateRouteも作ろうと計画していました。

ハマりポイント

今回初めてAmplify/Reactをまともに使った開発をしたため、何点かのハマりポイントに時間が吸われて行きました。

各ハマりポイントごとに解消方法をご紹介していきます。

Module not found: Can't resolve '@aws-amplify/analytics' in 'xxxxxxxxxxxx'

こちらはwithAuthenticatorなどを提供するパッケージ、 aws-amplify-react を入れた際にエラーが出ました。

一部のStackOverflowなどでは「エラーが出たパッケージを一個ずつinstallしてく」みたいな回答がちらほら見れましたが、 aws-amplify をインストールすることで解消します。

AmplifyのGetting Startedでは npm install @aws-amplify/api @aws-amplify/pubsubのインストール直後にUIフレームワークとして npm install aws-amplify-react するように書かれていますが、 実は aws-amplify も入れる必要があるため、少々ハマりました。

withAuthenticatorの第二引数にconfigを入れられない

AWSのドキュメントなどでは、下記のように、第二引数で usernameAttributes を入れていましたが、TypeScriptを利用していた関係で、エラーになってしまっていました。

withAuthenticator(Login, {
  usernameAttributes: "email",
  signUpConfig
});

エラー

Argument of type '{ usernameAttributes: string; signUpConfig: { hideAllDefaults: boolean; signUpFields: { label: string; key: string; required: boolean; displayOrder: number; type: string; }[]; }; includeGreetings: boolean; }' is not assignable to parameter of type 'boolean | undefined'.

下記のような型になっていて、苦肉の策ですが、//@ts-ignoreしました。

withAuthenticator(Comp: any, includeGreetings?: boolean, authenticatorComponents?: any[], federated?: any, theme?: any, signUpConfig?: {}):
//@ts-ignore
withAuthenticator(Login, {
  usernameAttributes: "email",
  signUpConfig
});

ログインしたかどうかを子コンポーネントで取得、変更したい

reduxを入れていない環境で子コンポーネント(ヘッダー等)でもログイン済みかを判定したかったため、Contextを使って解決しました。

コードはこんな感じで、Contextの中にログインしているかどうかのbooleanを持つようにしています。

  const state = useContext(LoginContext);
  useEffect(() => {
    async function getCurrentAuthenticatedUser() {
      const userInfo = await Auth.currentUserInfo();
      userInfo ? state.setLogin(true) : state.setLogin(false);
    }
    getCurrentAuthenticatedUser();
  }, [state]);

Providerは LoginContextProviderを利用して子コンポーネントを囲ってあげる形にします。

import React, { useState } from "react";

export const LoginContext = React.createContext({
  login: false,
  setLogin: login => {}
});

export const LoginContextProvider = props => {
  const setLogin = login => {
    setState({ ...state, login });
  };

  const initState = {
    login: false,
    setLogin: setLogin
  };

  const [state, setState] = useState(initState);
  return (
    <LoginContext.Provider value={state}>
      {props.children}
    </LoginContext.Provider>
  );
};

子コンポーネントから使う場合はこんな感じ。

setLogin を使うことで、子コンポーネントからも書き換えれるようにしています。

const state = useContext(LoginContext);
useEffect(() => {
  state.setLogin(true);
}, [state]);

ログインした後の状態変更どこでやろう...

ログインした後にContextを書き換えてあげる必要がありますが、どこで書き換えてあげようか少し迷いました。

結局、 withAuthenticator でログイン後に第一引数のコンポーネントが呼び出されるので、ここのuseEffectでContextを書き換えてあげています。

const Login: React.FC = () => {
  const state = useContext(LoginContext);
  useEffect(() => {
    state.setLogin(true);
  }, []);
  return <Redirect to="/dashboard" />;
};

//@ts-ignore
export default withAuthenticator(Login, {
  usernameAttributes: "email",
  signUpConfig
});

Private Routeでリダイレクトするようにしたら全パスがリダイレクトされる

「えいや!」の気持ちでそれっぽいリダイレクトしないPrivate Routerを作成しました。

const PrivateRoute = ({ component: ComponentParams, ...options }) => {
  const state = useContext(LoginContext);
  // ログインしてなかったらLogin Componentにする
  const Component = state.login ? ComponentParams : Login;
  return <Route {...options} component={Component} />;
};

export default PrivateRoute;

Router部分はこんな感じ

<LoginContextProvider>
  <Router>
    <Route exact path="/" component={Top} />
    <Route exact path="/posts" component={Posts} />
    <Route exact path="/login" component={Login} />
    <PrivateRoute
      exact
      path="/dashboard"
       component={Dashboard}
    ></PrivateRoute>
  </Router>
</LoginContextProvider>

この例ですと、componentをstate.loginの結果に合わせて出し分けるというもので、画面はLoginコンポーネントが出力されますが、パスが/loginにリダイレクトされません。

Redirect をreturnすればいけるな?」って話だと思って、雑に Redirect を挟んだんですが、下記のコードだと全てのパスでリダイレクトされるようになってしまいました。

const PrivateRoute = ({ component: Component, ...options }) => {
  const state = useContext(LoginContext);
  // ログインしてなかったらLogin Componentにする
  if (state.login) return <Route {...options} component={Component} />;
  return <Redirect to="/login" />;
};

export default PrivateRoute;

何故全てのパスでリダイレクトされてしまうのか少々悩みましたが、 PrivateRoute が実行されるタイミングは Route のように「指定したパスに来た時に実行される」訳でなないため、全てのパスで実行されてしまい、強制的にリダイレクトが走ってしまいます。

例えば、下記のようなコードの場合、 /posts などにアクセスした場合でも /login にリダイレクトされてしまいます。

      <LoginContextProvider>
        <Router>
          <Route exact path="/" component={Top} />
          <Route exact path="/posts" component={Posts} />
          <Route exact path="/login" component={Login} />
          <Redirect to="/login" />
        </Router>
      </LoginContextProvider>

はてさて、どうしたものかと大変悩みましたが、 render={} を使うことで回避できるそうなので、render内で出し分けをするようにしました。

import React, { useContext, Component } from "react";
import { LoginContext } from "../context/LoginContext";
import { Route, Redirect } from "react-router-dom";
import Login from "./Login";

const PrivateRoute = ({ component: Component, ...options }) => {
  const state = useContext(LoginContext);
  return (
    <Route
      {...options}
      render={props =>
        state.login ? <Component {...props} /> : <Redirect to="/login" />
      }
    />
  );
};

export default PrivateRoute;

AWS環境が...消えた!?

当初はAmplifyの環境が production しかなく、Gitのマスターブランチから切ったブランチで amplify add auth, push をしてAuthリソースを作っていましたが、

同じチームの方がAppSyncのSchemaを定義するアップデートをprudction環境にPushしたため、Cognitoなどの環境を吹き飛ばすアップデートが実行された形になりました。(あるあるですね。)

こちらについてのエントリーも公開されていますので、合わせてご確認ください。Amplify Multi Environment でチーム開発を整備する

まとめ

Amplifyを使うと簡単に認証を実装することができました。

今回ハマったのはAmplifyの仕様の話などではなく、単にReactを完全に理解していないだけの話ですので、もっと出来る方だと30分程度で実装することができると思います。

※Reactを本格的に触り始めて数ヶ月のまだまだな初心者なので、まさかりをお待ちしています!が、優しめでお願いします。

以上、もこ@札幌オフィスでした。