🎩

Next.js(App Router)でFirebase Authenticationを使う

2024/06/24に公開

はじめに

今回は 2 部構成です。というのも、現在、次の章ので載せているようなアーキテクチャで開発を行っており、フロント・バック、どちらも認証周りについて書きたいことがあるからです。とうことで今回は、フロントエンドの側から、表題の件についてツラツラ書いていきます ✏️

技術構成

ちょっとした図を書いてみました。

フレームワークは、Next.js の 14 系で App Router を使っています。これを Vercel にデプロイしています。認証には、慣れている Firebase Authentication を使っていて、(今回の主題でもありますが)NextAuth.js を使って認証のセッション管理を行なっています。

バックエンドは、一部 Route Handlers(API Routes の後身のようなもの)でも実装していますが、基本、別のリポジトリで API を作っていてそれと通信しています。フレームワークには Hono を採用し、Cloudflare Workers にデプロイをしています。データベースは、Cloudflare が最近出した D1 を使っています。

比較的新しい技術群で構成している次第です。

App Router で Firebase Authentication を使いたい

以前まで(Pages Router)であれば、Firebase JavaScript SDKに用意されている超便利な API を使えば簡単に認証とそのセッション管理ができました。しかし、この SDK は Client Component(以下 CC)でのみ使える API であり、Server Component(以下 SC)では使用することはできません。つまり、App Router で引き続き Firebasen Authentication を使いたいのであれば、抜本的にその方法を再考する必要がある、というわけです。

ではどうするのか?

結論、JS SDK の利用は最小にして、SC(あるいは Server Actions)で使えるFirebase Admin SDKと NextAuth.js を使っていきます。

サインインフォームの用意

さて、まずはとりあえずサインインフォーム(FormSignIn)と、押下時に実行されるsignInメソッドを実装します。以下の記事で書いたようなフォームの実装をします。スタイリングは無視してください。

https://zenn.dev/psi/articles/app-router-form-240606

ルーティング

app/(no-header)/sign-in/page.tsx
import { SignIn } from '@/features/sign-in'

export default SignIn

ルーティングの責務とレンダリング内容の責務を分離するためにこのようにしていますが、ご自身の環境に合わせてお読みください。

本体

features/sign-in/index.tsx
'use client'

import { signIn } from './_actions'
import { FormEmail } from './form-email'
import { FormPassword } from './form-password'

export const SignIn = async () => {
  return (
    <form action={signIn}>
      <FormEmail />
      <FormPassword />
      <ButtonSignIn />
    </form>
  )
}

FormEmailFormPassword の記述は省略しますが、メールアドレス、パスワードを入力するためのコンポーネントです。上記記事と違う点は、このページは CC にしている点です。CC にしている理由としては、signIn メソッドで JS SDK を使いたいからです。次で説明をします。

action

features/sign-in/_actions/index.ts
import { formDataToJSON } from '@/helpers/form'
import { SIGN_IN_SCHEMA } from '../_helpers'
import { signInWithEmailAndPassword } from 'firebase/auth'
import { auth } from '@/libs/firebase/client'
import { signIn as signInByNextAuth } from 'next-auth/react'

export const signIn = async (formData: FormData) => {
  const formJson = formDataToJSON(formData)
  const result = SIGN_IN_SCHEMA.safeParse(formJson)

  if (!result.success) {
    const { errors } = result.error
    // バリデーションエラー時の処理を記述
  } else {
    const {
      data: { email, password },
    } = result

    signInWithEmailAndPassword(auth, email, password)
      .then(async ({ user }) => {
        if (user) {
          const refreshToken = user.refreshToken
          const idToken = await user.getIdToken()
          await signInByNextAuth('credentials', {
            idToken,
            refreshToken,
            callbackUrl: '/',
          })
        }
      })
      .catch((error) => {
        // エラー処理
      })
  }
}

Zod でバリデーションしてますが、本題は else の中です。入力された emailpassword を使って Firebase Authentication にサインインします。この signInWithEmailAndPassword が Firebase JS SDK で提供されている API なので、クライアントサイドで実行する必要があるので CC に記述した、というわけです。

サインインに成功すると、そのリフレッシュトークンと有効なトークンを取得し、NextAuth の signIn メソッドに渡します。このリフレッシュトークンは何に使うのかは、次の NextAuth のオプションで説明をします。サインインに成功すると、callbackUrl に渡した遷移先に遷移します。

NextAuth の設定

下準備

providers/next-auth.tsx
'use client'

import { SessionProvider } from 'next-auth/react'
import { ReactNode } from 'react'

export const NextAuthProvider = ({ children }: { children: ReactNode }) => (
  <SessionProvider>{children}</SessionProvider>
)

NextAuth でセッション管理をするために NextAuthProvider コンポーネント(CC)を作成して、Layout.tsx で React Node をラッピングします。

app/api/auth/[...nextauth]/route.ts
import { authOptions } from '@/libs/auth'
import NextAuth from 'next-auth'

const handler = NextAuth(authOptions)

export { handler as GET, handler as POST }

次に用意するオプションに基づいて NextAuth のハンドラを用意します。この辺りは公式ドキュメントを読んでもらえればそのまま使えます(Route Handler 用に加工はしています)。

NextAuth のオプション(コアな部分)

libs/auth.ts
import type { NextAuthOptions } from 'next-auth'
import CredentialsProvider from 'next-auth/providers/credentials'
import { auth } from '@/libs/firebase/admin'
import { fetchUserByUid, fetchUserForAuth } from '@/services/user'

const fetchNewIdToken = async (refreshToken: string) => {
  const res = await fetch(
    `https://securetoken.googleapis.com/v1/token?key=${process.env.FIREBASE_TOKEN_API_KEY}`,
    {
      method: 'POST',
      body: JSON.stringify({
        grant_type: 'refresh_token',
        refreshToken,
      }),
    },
  )

  const { id_token } = await res.json()

  return id_token
}

export const authOptions: NextAuthOptions = {
  providers: [
    CredentialsProvider({
      credentials: {},
      // 1
      authorize: async ({ idToken, refreshToken }: any, _req) => {
        if (idToken && refreshToken) {
          try {
            const decoded = await auth.verifyIdToken(idToken) // 2

            const user = {
              id: decoded.user_id,
              uid: decoded.uid,
              name: decoded.name || '',
              email: decoded.email || '',
              image: decoded.picture || '',
              emailVerified: decoded.email_verified || false,
              idToken,
              refreshToken,
              tokenExpiryTime: decoded.exp || 0,
            }

            return user
          } catch (err) {
            console.error(err)
          }
        }
        return null
      },
    }),
  ],
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.id = user.id
        token.uid = user.id
        token.name = user.name ?? ''
        token.emailVerified = !!user.emailVerified
        token.idToken = user.idToken
        token.refreshToken = user.refreshToken
        token.image = user.image ?? ''
        token.tokenExpiryTime = user.tokenExpiryTime
      }

      const currentTime = Math.floor(Date.now() / 1000)
      const tokenExpiryTime = token.tokenExpiryTime as number
      const isExpired = currentTime > tokenExpiryTime - 300 // 5分前には更新するようにする

      if (isExpired) {
        try {
          const newIdToken = await fetchNewIdToken(token.refreshToken as string)
          token.idToken = newIdToken
        } catch (error) {
          console.error('Error refreshing token:', error)
        }
      }

      // sessionにデータベースのユーザー情報を追加。
      const uid = token.uid
      const accessToken = token.idToken
      const res = await fetchUserForAuth(uid, accessToken)

      if (res.status === 'success') {
        const userData = res.data

        token.birth = userData.birth || ''
      }

      return token
    },
    async session({ session, token }) {
      // sessionにFirebase Authenticationで取得した情報を追加。
      session.user.emailVerified = token.emailVerified
      session.user.uid = token.uid
      session.user.name = token.name
      session.user.image = token.image || ''
      session.idToken = token.idToken || ''
      session.user.birth = userData.birth || '' // データベースから取得した値もsession情報に追加

      return session
    },
  },
  session: {
    strategy: 'jwt',
    maxAge: 90 * 24 * 60 * 60, // 90 days
  },
  pages: {
    signIn: '/sign-in',
  },
  secret: process.env.NEXTAUTH_SECRET,
}

"オプション"と言いつつ、NextAuth の処理のコアな部分です。処理の流れとしては以下です。

  1. フロントで signIn されたら、authorize の関数が実行される。引数はフロントで指定した、idTokenrefreshToken。この authorize は、サインイン時に一度だけ呼ばれる。
  2. idToken が有効かどうか、オプション内でも auth.verifyIdToken メソッドを使って verify してユーザー情報を取得する。auth は Firebase Admin SDK を参照している。
  3. 取得したユーザー情報に基づいてオブジェクトを作成し、return する。ここで return された値が callbacksjwt メソッドの引数で取得できる。ここで token を作成する。
  4. 作成された token に基づいて、session メソッド内で session オブジェクトを作成する。上の例のように、時に外部からデータを取得して session に加えることができる。この session オブジェクトが各コンポーネントで取得できる。

上記オプション内のコードについて、いくつかコメントをします。

リフレッシュトークン

const currentTime = Math.floor(Date.now() / 1000)
const tokenExpiryTime = token.tokenExpiryTime as number
const isExpired = currentTime > tokenExpiryTime - 300

if (isExpired) {
  try {
    const newIdToken = await fetchNewIdToken(token.refreshToken as string)
    token.idToken = newIdToken
  } catch (error) {
    console.error('Error refreshing token:', error)
  }
}

上記の箇所ですが、Firebase の token は、1 時間で有効期限が切れてしまうのでリフレッシュしています。詳しくは別の記事で書いたのでそちらご覧ください。

https://zenn.dev/psi/articles/firebase-token-refresh

別リソースによる拡張

// tokenにデータベースのユーザー情報を追加。
const uid = token.uid
const accessToken = token.idToken
const res = await fetchUserForAuth(uid, accessToken)

if (res.status === 'success') {
  const userData = res.data

  token.birth = userData.birth || ''
}

別のリソースから情報を取得してユーザー情報を拡張することができます。callbacks.jwt はリクエスト毎に呼ばれるので、fetchUserForAuth も毎回呼ばれそうですが、そこは Next の fetch の force-cache でキャッシュを利用します。force-cache については、以下の公式サイトをご参照ください。

https://nextjs.org/docs/app/api-reference/functions/fetch

session に追加

 async session({ session, token }) {
  // sessionにFirebase Authenticationで取得した情報を追加。
  session.user.emailVerified = token.emailVerified
  session.user.uid = token.uid
  session.user.name = token.name
  session.user.image = token.image || ''
  session.idToken = token.idToken || ''
  session.user.birth = userData.birth || '' // データベースから取得した値もsession情報に追加

  return session
},

session 関数で session に情報を追加し、次項で説明するように、各コンポーネントからユーザー情報にアクセスできるようにします。

コンポーネントにおける session の取得方法

上記のように NextAuth によるセッション管理を用意すれば、SC でも CC でもユーザーのセッション情報が以下のように取得できます。

SC

import { getServerSession } from 'next-auth'
import { authOptions } from '@/libs/auth'

export const Root = async ({ searchParams }: Props) => {
  const session = await getServerSession(authOptions)

  return <SomeComponents />
}

CC

'use client'

import { useSession } from 'next-auth/react'

export const TestComponent = () => {
  const { data: session, status } = useSession()

  return <SomeComponents />
}

おわりに

以上のように、NextAuth を使うことで、Firebase による認証でサーバー・クライアント問わずユーザーのセッションの管理ができます。第二部は、このフロント側からたたく API の構成(冒頭の図で言うところの右側、Hono で実装されている箇所)について話したいと思います!

プラスクラス・スポーツ・インキュベーション株式会社

Discussion