Next.js(App Router)でFirebase Authenticationを使う
はじめに
今回は 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
メソッドを実装します。以下の記事で書いたようなフォームの実装をします。スタイリングは無視してください。
ルーティング
import { SignIn } from '@/features/sign-in'
export default SignIn
ルーティングの責務とレンダリング内容の責務を分離するためにこのようにしていますが、ご自身の環境に合わせてお読みください。
本体
'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>
)
}
FormEmail
と FormPassword
の記述は省略しますが、メールアドレス、パスワードを入力するためのコンポーネントです。上記記事と違う点は、このページは CC にしている点です。CC にしている理由としては、signIn
メソッドで JS SDK を使いたいからです。次で説明をします。
action
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
の中です。入力された email
と password
を使って Firebase Authentication にサインインします。この signInWithEmailAndPassword
が Firebase JS SDK で提供されている API なので、クライアントサイドで実行する必要があるので CC に記述した、というわけです。
サインインに成功すると、そのリフレッシュトークンと有効なトークンを取得し、NextAuth の signIn
メソッドに渡します。このリフレッシュトークンは何に使うのかは、次の NextAuth のオプションで説明をします。サインインに成功すると、callbackUrl
に渡した遷移先に遷移します。
NextAuth の設定
下準備
'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 をラッピングします。
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 のオプション(コアな部分)
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 の処理のコアな部分です。処理の流れとしては以下です。
- フロントで
signIn
されたら、authorize
の関数が実行される。引数はフロントで指定した、idToken
とrefreshToken
。このauthorize
は、サインイン時に一度だけ呼ばれる。 -
idToken
が有効かどうか、オプション内でもauth.verifyIdToken
メソッドを使って verify してユーザー情報を取得する。auth
は Firebase Admin SDK を参照している。 - 取得したユーザー情報に基づいてオブジェクトを作成し、return する。ここで return された値が
callbacks
のjwt
メソッドの引数で取得できる。ここでtoken
を作成する。 - 作成された
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 時間で有効期限が切れてしまうのでリフレッシュしています。詳しくは別の記事で書いたのでそちらご覧ください。
別リソースによる拡張
// 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
については、以下の公式サイトをご参照ください。
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