Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
80
Help us understand the problem. What is going on with this article?
@G-awa

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

Amplify × React Hooks.png

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

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

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

完成するアプリケーション

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

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

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

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

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

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(開発者体験)にしたい! ということが本記事のモチベーションです。つまり、以下のように使用したいわけです。

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 フックが使えるようにしていきます。

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 は配下の子コンポーネントにデータを渡すための便利な方法です。従来は props を使用することで、子コンポーネントにデータを渡していましたが、コンポーネントのネストが深くなると非常に面倒で複雑になります。 Context を使用することで 認証UI テーマ など多くのコンポーネントが使用する情報を共有して保持・取得できます。

context.provider.png

React.createContext

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

const MyContext = React.createContext(defaultValue);

Context.Provider

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

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

useContext

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

const value = useContext(MyContext);

認証情報を Context に集約する

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

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

State

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

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() を合わせて作成しておきます。

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 とやりとりする処理と、認証情報を保持する処理を実装します。

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 として設定値は外部ファイルで保持しています。

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 は以下のようなファイルになります。

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;

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

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 に PullRequest しようと思います。Cognito ユーザ(または Amplify ユーザ)が個別にこのような実装をしなくとも、ライブラリとして提供し、すぐに簡単なインタフェースで認証処理を実現できるようにしていきたいですね。

80
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
G-awa
物性物理学、分子動力学の研究をやってました。サイエンス出身、スパコンが好きです。 java, spring, ruby, rails, python, nodejs, react, react native, aws, devopsあたりが得意です。NoOpsを目指して葛藤している4年目エンジニアです。
intec
未来を「ひらく」、技術で「つなぐ」、世界を「変える」、豊かなデジタル社会の一翼を担う会社です。※各記事の内容は個人の見解であり、所属する会社の公式見解ではありません。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
80
Help us understand the problem. What is going on with this article?