Next.js + Firebase Authentication

Next.jsとTypeScriptに入門したバックエンドエンジニアです。 以前、Nuxt.jsのアプリケーションでFirebase Authenticationによる認証を試したのですが、似たようなことをNext.jsでもやってみたので、纏めておこうと思います。 (Nuxt.jsとFirebase Authenticationを試した際にこちらの記事を書きました。)

要件

次の要件を満たす簡単なサンプルプリケーションを作ってみました。

  • サインインのページを提供する
  • サインアップのページを提供する
  • サインイン、サインアップは、メールアドレスとパスワードを使用する
  • アクセスするのに認証が必要なページを2つ提供する
    • トップページ
    • プロフィールページ
  • トップページからログアウトできる
  • 認証していない状態で、認証が必要なページにアクセスした場合、サインインページにリダイレクトする
  • 認証している状態で、サインイン、サインアップの両ページにアクセスした場合、トップページにリダイレクトする

やったこと

「Authenticated server-side rendering with Next.js and Firebase」 という記事と、この記事を書かれた方がGithubに公開されている colinhacks/next-firebase-ssr を、参考に、というよりは写経に近い形で、内容を噛み砕きながら、同様のサンプルアプリケーションを作ってみました。

shimar/nextjs-firebase-auth-example

今回勉強になったなと思う部分を残しておこうと思います。

ContextとProvider

ReactにContextという機能があることを知りました。

コンテクスト – React

このContextを使うことで、親コンポーネントからその子孫に対して、一様に値を共有できるようです。 今回のアプリケーションでは、認証済みのユーザー情報をページ〜コンポーネント間で共有するために、次のようなContextを作ると良いようです。

import { createContext } from 'react';
import firebaseClient from '../lib/firebase-client';

const AuthContext = createContext<{ user: firebaseClient.User | null }>({
  user: null
});

export default AuthContext;

さらに、

全てのコンテクストオブジェクトにはプロバイダ (Provider) コンポーネントが付属しており、これによりコンシューマコンポーネントはコンテクストの変更を購読できます。

とあるように、ContextにはProviderというコンポーネントがくっついていて、このコンポーネントの子孫のコンポーネントに、Contextが管理する情報が共有されるという仕組みのようです。

参考にさせて頂いたサンプルでは、上述のAuthContextのプロバイダの中で、useEffectを用いて、次のコトを行っています。

  1. FirebaseアカウントのIdTokenの変更イベントをハンドリングする。
  2. FirebaseアカウントのIdTokenを定期的に再発行する。

コードを見ていただくとわかると思いますが、今回の例では、FirebaseアカウントのIdTokenをクッキーに格納し、この値の有無で、認証済みか否かを判定しています。FirebaseアカウントのIdTokenの有効期限が1時間であることから、コレをリフレッシュして、クッキーに再設定する、という処理が必要になる、ということのようです。

これは、こんなコードになります。

import { useState, useEffect } from 'react';
import nookies from 'nookies';
import firebaseClient from '../lib/firebase-client';
import AuthContext from '../contexts/auth';

const AuthProvider = ({ children }: any) => {
  const cookieKey = "token";
  const interval = 10 * 60 * 1000;
  const [user, setUser] = useState<firebaseClient.User | null>(null);

  useEffect(() => {
    return firebaseClient.auth().onIdTokenChanged(async (user) => {
      if (!user) {
        setUser(null);
        nookies.destroy(null, cookieKey);
        return;
      }

      const token = await user.getIdToken();
      setUser(user);
      nookies.destroy(null, cookieKey);
      nookies.set(undefined, cookieKey, token, {});
    });
  }, []);

  useEffect(() => {
    const handler = setInterval(async () => {
      const user = firebaseClient.auth().currentUser;
      if (user) {
        await user.getIdToken(true);
      }
    }, interval);
    return () => clearInterval(handler);
  }, []);

  return (
    <AuthContext.Provider value={{ user }}>{children}</AuthContext.Provider>
  )
}

export default AuthProvider;

このProvider、つまり上記のコードのAuthProviderは、基本的に全ページ共通の親となって良いと思われる(一般的に認証・認可が必要なアプリケーションでは認証が不要なページの方が少ないと考えています)ので、_app.tsxで、次のように使っています。

import { AppProps } from 'next/app';
import AuthProvider from '../providers/auth';
import '../styles/globals.scss';

function MyApp({ Component, pageProps }: AppProps) {
  return (
    <AuthProvider>
      <Component {...pageProps} />
    </AuthProvider>
  )
};

export default MyApp;

getServerSideProps

正直よくわかっていないのですが、Next.jsでSSR(Server Side Rendering)する際に、データの取得などをサーバーサイドで行う場合は、gerServerSideProps関数を対象ページで実装し、その中でサーバーサイドの処理を行う、と認識しています。

今回は、サーバーサイドで認証状態を判定し、冒頭で書いた、

  • 認証していない状態で、認証が必要なページにアクセスした場合、サインインページにリダイレクトする
  • 認証している状態で、サインイン、サインアップの両ページにアクセスした場合、トップページにリダイレクトする

を実現しています。

リクエストされたパスと認証状態に応じて、リダイレクトする、或いはしない、という処理を行う関数を↓のように書いてみました。

import { GetServerSidePropsContext } from 'next';
import nookies from 'nookies';
import firebaseAdmin from '../../lib/firebase-admin';

const redirectTop = {
  redirect: {
    destination: '/',
    permanent: false,
  },
  props: {} as never,
};

const redirectSignin = {
  redirect: {
    destination: '/signin',
    permanent: false,
  },
  props: {} as never,
};

const empty = {
  props: {},
};

const verifyAuthState = async (ctx: GetServerSidePropsContext) => {
  const unauthenticated = ['/signin', '/signup'];
  const cookies = nookies.get(ctx);
  const url = ctx.req.url || '';
  if (unauthenticated.includes(url)) {
    if (cookies.token) {
      return redirectTop;
    } else {
      return empty;
    }
  }

  try {
    await firebaseAdmin.auth().verifyIdToken(cookies.token);
    return empty;
  } catch (err) {
    return redirectSignin;
  }
};

export default verifyAuthState;

この関数を各ページのgetServerSideProps関数で実行することで、認証の要否とそれに応じたリダイレクトが実現できるのでは?と考えました。

import verifyAuthState from '../utils/functions/verify-auth-state';

export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
  return verifyAuthState(ctx);
}

おまけ

今回の実験では、ちょっと欲張って、tailwindcssを使ってみました。 (大した使い方はしていないです。)

おわりに

Next.js + Firebase Authenticationを参考サイトと参考リポジトリを写経に近い形で試してみました。 この方法以外のアプローチがたくさんあると思いますので、ご意見あれば、GithubのIssueやTwitterなどで、ご指摘頂けると幸いです。

ちなみに次は、Firebase Authenticationについて得た諸々を記事にできればと思います。

参考