AWS Amplify での Cognito アクセスは React Context.Provider を使って認証処理を Hooks 化しよう

![Amplify × React Hooks.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163591/933eeb20-489f-ce8c-d6e6-3214050a49ff.png)

AWS Cognito は認証・認可を提供している AWS のサービスです。Amplify と統合することで、超高速に構築できます。Cognito を使用することで、API Gateway や S3 など他の AWS サービスとの統合がより簡単にできるようになります。

本記事では、Cognito を使用した React アプリケーションの実装例を紹介します。Cognito へのアクセスには [amplify-js](https://github.com/aws-amplify/amplify-js) というライブラリを使用します。さらに React の Context.Provider という機能を使うことで認証に関連する処理をカスタムフックに集約する方法を考察します。

本記事で実装されたアプリケーションは以下のような動作をします。ログイン、ログアウト、サインアップ、確認メールなど。

![完成するアプリケーション](https://camo.qiitausercontent.com/99ef26191e6cdb5b1e1f79002e3ee52fd7f49973/68747470733a2f2f71696974612d696d6167652d73746f72652e73332e61702d6e6f727468656173742d312e616d617a6f6e6177732e636f6d2f302f3136333539312f33316234393066322d313762312d366566392d383535392d3965343964656339653734392e676966)

本アプリケーションは Vercel にデプロイされています。
https://task-app.geeawa.vercel.app/login

また、以下の GitHub リポジトリにホストしています。
https://github.com/daisuke-awaji/task-app

## amplify-js でも React Hooks を使いたい

先週は [React アプリに Auth0 でシュッと認証を組み込んで Vercel に爆速デプロイする](https://qiita.com/G-awa/items/fa34fd7065ef1e14d3d9) という記事を書きました。Auth0 のクライアントライブラリは非常に使い勝手がよく、`<Auth0Provider>` という Provider で包むだけで `useAuth0` フックを使用できるようになります。

```tsx:index.tsx
import React from "react";
import ReactDOM from "react-dom";
import { Auth0Provider } from "@auth0/auth0-react";
import "bootstrap/dist/css/bootstrap.min.css";
import { App } from "./App";

ReactDOM.render(
  <Auth0Provider
    domain={process.env.REACT_APP_AUTH0_DOMAIN!}
    clientId={process.env.REACT_APP_AUTH0_CLIENT_ID!}
    redirectUri={window.location.origin}
  >
    <App />
  </Auth0Provider>,
  document.querySelector("#root")
);
```

一方で amplify-js にはこのような機能はありません。認証系処理のメソッドは `Auth` モジュールから取り出して使う必要があります。以下はサインアップするメソッドです。参考: [公式 Sign up, Sign in & Sign out](https://docs.amplify.aws/lib/auth/emailpassword/q/platform/js#sign-up)

```jsx
import { Auth } from "aws-amplify";

async function signUp() {
  try {
    const user = await Auth.signUp({
      username,
      password,
      attributes: {
        email,
        phone_number,
      },
    });
    console.log({ user });
  } catch (error) {
    console.log("error signing up:", error);
  }
}
```

メソッドしか用意されておらず、ログインユーザの情報などを React アプリでグローバルに保持する仕組みは自分で用意する必要があります。amplify-js でも Auth0 のような使いやすい DX（開発者体験）にしたい！　ということが本記事のモチベーションです。つまり、以下のように使用したいわけです。

```tsx:index.tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import "./index.css";
import CognitoAuthProvider from "./cognito/CognitoAuthProvider";

ReactDOM.render(
  <CognitoAuthProvider>
    <App />
  </CognitoAuthProvider>,
  document.getElementById("root")
);
```

`<App/>` コンポーネントを `<CognitoAuthProvider>` でラップするだけで、認証系の処理やログインユーザのステートを取り出す `useAuth` フックが使えるようにしていきます。

```tsx:LogoutButton.tsx
import React from "react";
import { useAuth } from "../../cognito/CognitoAuthProvider";

export default function LogoutButton(props: any) {
  const { isAuthenticated, signOut } = useAuth();

  if (!isAuthenticated) return null;

  return <Button onClick={() => signOut()} {...props} />;
}
```

## React.Context とは

React の [Context](https://ja.reactjs.org/docs/context.html) は配下の子コンポーネントにデータを渡すための便利な方法です。従来は `props` を使用することで、子コンポーネントにデータを渡していましたが、コンポーネントのネストが深くなると非常に面倒で複雑になります。 Context を使用することで **認証** や **UI テーマ** など多くのコンポーネントが使用する情報を共有して保持・取得できます。

![context.provider.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163591/15e73c38-65a0-2cd3-a00a-5b0381d48b72.png)

#### React.createContext

Context オブジェクトを作成します。React がこの Context オブジェクトが登録されているコンポーネントをレンダーする場合、ツリー内の最も近い上位の一致する Provider から現在の Context の値を読み取ります。

```jsx
const MyContext = React.createContext(defaultValue);
```

#### Context.Provider

全ての Context オジェクトには Context.Provider コンポーネントが付属しています。これにより Context.Consumer コンポーネントは Context の変更を購読できます。実際のユースケースでは Consumer ではなく、useContext フックを使用することが多いでしょう。

```jsx
<MyContext.Provider value={/* 何らかの値 */}>
```

#### useContext

Context オブジェクトを受け取り、その Context の value を返します。`<MyContext.Provider/>` が更新されると、このフックは `MyContext.Provider` に渡された value を使用してコンポーネントを再レンダーします。

```jsx
const value = useContext(MyContext);
```

## 認証情報を Context に集約する

さて、認証情報として以下のようなメソッドとステートを保持する Context を作っていきます。これらの値があればログイン、ログアウト、サインアップ、確認コード入力の一連の流れが実装できます。

| 項目            | 概要                                                     |
| --------------- | -------------------------------------------------------- |
| isAuthenticated | ログインしているか                                       |
| isLoading       | ローディング中か（画面制御で使用）                       |
| user            | ログインしているユーザの情報                             |
| error           | ログイン処理、サインアップ処理などでエラーがあれば詰める |
| signIn          | サインインする。                                         |
| signUp          | サインアップする。                                       |
| confirmSignUp   | サインアップ確認コードを入力する                         |
| signOut         | サインアウトする。                                       |

#### State

Context が保持するステートの定義（インタフェース）を作成します。

```tsx
import { CognitoUser } from "amazon-cognito-identity-js";
export interface AuthState {
  isAuthenticated: boolean;
  isLoading: boolean;
  user?: CognitoUser;
  error?: any;
}
const initialState: AuthState = {
  isAuthenticated: false,
  isLoading: false,
};
const stub = (): never => {
  throw new Error(
    "You forgot to wrap your component in <CognitoAuthProvider>."
  );
};
export const initialContext = {
  ...initialState,
  signIn: stub,
  signUp: stub,
  confirmSignUp: stub,
  signOut: stub,
};
```

#### Context

Context オブジェクトを作成します。各コンポーネントから取り出すためのカスタムフック `useAuth()` を合わせて作成しておきます。

```tsx
import React, { useContext } from "react";
import { SignUpParams } from "@aws-amplify/auth/lib-esm/types";
import { CognitoUser } from "amazon-cognito-identity-js";
import { AuthState, initialContext } from "./AuthState";
import { LoginOption } from "./CognitoAuthProvider";
interface IAuthContext extends AuthState {
  signIn: (signInOption: LoginOption) => Promise<void>;
  signUp: (params: SignUpParams) => Promise<CognitoUser | undefined>;
  confirmSignUp: (params: any) => Promise<void>;
  signOut: () => void;
}
export const AuthContext = React.createContext<IAuthContext>(initialContext);
export const useAuth = () => useContext(AuthContext);
```

#### Provider

最後に Provider には Cognito とやりとりする処理と、認証情報を保持する処理を実装します。

```tsx
import React from "react";

import { useState, useEffect } from "react";
import { SignUpParams } from "@aws-amplify/auth/lib-esm/types";
import { CognitoUser } from "amazon-cognito-identity-js";

import { Auth } from "aws-amplify";
import Amplify from "aws-amplify";
import { AuthContext } from "./AuthContext";

export type LoginOption = {
  username: string;
  password: string;
};
interface ICognitoAuthProviderParams {
  amplifyConfig: {
    aws_project_region: string;
    aws_cognito_identity_pool_id: string;
    aws_cognito_region: string;
    aws_user_pools_id: string;
    aws_user_pools_web_client_id: string;
    oauth: {
      domain: string;
      scope: string[];
      redirectSignIn: string;
      redirectSignOut: string;
      responseType: string;
    };
    federationTarget: string;
  };
  children: any;
}

export default function CognitoAuthProvider(props: ICognitoAuthProviderParams) {
  Amplify.configure(props.amplifyConfig);
  const [isAuthenticated, setIsAuthenticated] = useState(false);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);
  const [user, setUser] = useState<CognitoUser>();

  useEffect(() => {
    checkAuthenticated();
    currentAuthenticatedUser();
  }, []);

  const checkAuthenticated = () => {
    setIsLoading(true);
    Auth.currentSession()
      .then((data) => {
        if (data) setIsAuthenticated(true);
      })
      .catch((err) => console.log("current session error", err))
      .finally(() => {
        setIsLoading(false);
      });
  };

  const currentAuthenticatedUser = async (): Promise<void> => {
    const user: CognitoUser = await Auth.currentAuthenticatedUser();

    setUser(user);
  };

  const signIn = async ({ username, password }: LoginOption): Promise<void> => {
    setIsLoading(true);
    try {
      await Auth.signIn(username, password);
      setIsAuthenticated(true);
    } catch (error) {
      console.log("error signing in", error);
      setError(error);
      setIsAuthenticated(false);
    }
    setIsLoading(false);
  };

  const signUp = async (
    param: SignUpParams
  ): Promise<CognitoUser | undefined> => {
    setIsLoading(true);
    let result;
    try {
      result = await Auth.signUp(param);
      setUser(result.user);
    } catch (error) {
      console.log("error signing up", error);
      setError(error);
    }
    setIsLoading(false);
    return result?.user;
  };

  const confirmSignUp = async ({ username, code }: any): Promise<void> => {
    setIsLoading(true);
    try {
      await Auth.confirmSignUp(username, code);
      setIsAuthenticated(true);
    } catch (error) {
      console.log("error confirming sign up", error);
      setError(error);
    }
    setIsLoading(false);
  };

  const signOut = () => {
    setIsLoading(true);
    Auth.signOut()
      .then(() => {
        setIsAuthenticated(false);
      })
      .catch((err) => console.log("error signing out: ", err))
      .finally(() => {
        setIsLoading(false);
      });
  };

  return (
    <AuthContext.Provider
      value={{
        isAuthenticated,
        isLoading,
        signIn,
        signUp,
        confirmSignUp,
        signOut,
        user,
        error,
      }}
    >
      {props.children}
    </AuthContext.Provider>
  );
}
```

## 使用方法

ここまで準備ができれば使用する側はこの `CognitoAuthProvider` でコンポーネントをラップすることで `useAuth()` フック経由で各種ステートの値またはメソッドを使用できます。

amplifyConfig として設定値は外部ファイルで保持しています。

```tsx
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./index.css";
import CognitoAuthProvider from "./cognito/CognitoAuthProvider";
import awsconfig from "./aws-exports";

ReactDOM.render(
  <CognitoAuthProvider amplifyConfig={awsconfig}>
    <App />
  </CognitoAuthProvider>,
  document.getElementById("root")
);
```

amplifyConfig は以下のようなファイルになります。

```jsx
const amplifyConfig = {
  aws_project_region: "ap-northeast-1",
  aws_cognito_identity_pool_id: "ap-northeast-1:12345678909876543234567890",
  aws_cognito_region: "ap-northeast-1",
  aws_user_pools_id: "ap-northeast-1_xxxxxxxx",
  aws_user_pools_web_client_id: "xxxxxxxxxxxxxxx",
  oauth: {
    domain: "mydomain.auth.ap-northeast-1.amazoncognito.com",
    scope: [
      "phone",
      "email",
      "openid",
      "profile",
      "aws.cognito.signin.user.admin",
    ],
    redirectSignIn: "http://localhost:3000/",
    redirectSignOut: "http://localhost:3000/logout/",
    responseType: "code",
  },
  federationTarget: "COGNITO_USER_POOLS",
};

export default amplifyConfig;
```

ログアウトボタンのコンポーネントです。コードベースをシンプルにできました。

```tsx:LogoutButton.tsx
import React from "react";
import { useAuth } from "../../cognito/CognitoAuthProvider";

export default function LogoutButton(props: any) {
  const { isAuthenticated, signOut } = useAuth();

  if (!isAuthenticated) return null;

  return <Button onClick={() => signOut()} {...props} />;
}
```

## さいごに

React の Context を使用することで、認証情報などのグローバルな値を一元的に管理できるようになります。
ただ、 Context は多くのコンポーネントからアクセスされる場合に使用することとしましょう。
Context はコンポーネントの再利用をより難しくする為、慎重に利用してください。

本記事で紹介した React.Context を使用したカスタムフックを使用するという発想はそのうち [amplify-js](https://github.com/aws-amplify/amplify-js) に PullRequest しようと思います。Cognito ユーザ（または Amplify ユーザ）が個別にこのような実装をしなくとも、ライブラリとして提供し、すぐに簡単なインタフェースで認証処理を実現できるようにしていきたいですね。