🔥

【Next.js】Firebase Authenticationのカスタムクレームで管理者ログインを実装

2025/03/10に公開

はじめに

Firebase Authenticationを使用してFirebaseの管理者権限がないユーザーはログインできないようにする認証機能を実装しました。
実装方法だけでなく、設計や考え方について説明しているため、Firebase Authenticationを使用しない場合でも読むことができる内容になっています。

バックエンドはExpress、フロントエンドはNext.jsを使用しており、この記事ではNext.jsの部分のみに触れています。

Next.jsのバージョン
"next": "15.2.1",

設計

実現したいこと

アプリ上で新規ユーザーを登録する機能は不要で、あらかじめ存在している管理者アカウントのみがログインできるものとします。

やりたいことをざっくり洗い出しました。

  • メールアドレス、パスワードを使用してログインする
  • Firebaseの管理者権限があるユーザーのみログインできる
  • ログイン済み & 管理者権限がある場合のみ/dashboardにアクセスできる
  • 未ログインまたは権限がない場合は/signinにリダイレクトされる
  • 初回ログイン後にGoogleアカウントを紐付け、2回目以降はGoogleアカウントでサインインできるようにする

使用するもの

Firebase Admin SDKは、Firebaseプロジェクトにおいて管理者レベルの操作をプログラムから簡単に行うためのライブラリです。

今回、管理者権限有無の判断にはFirebase Authentication のカスタムクレームという属性を使用します。
https://firebase.google.com/docs/auth/admin/custom-claims?hl=ja

カスタムクレームとはユーザーアカウントのカスタム属性の定義のことで、ユーザーに特定の権限を付与できます。サーバー側(Firebase Admin SDK)から付与することができ、クライアント側からはユーザーの認証トークンを使用してアクセスできます。

前提としてあらかじめバックエンド(Express側)でFirebase Admin SDKを使用してカスタムクレームを付与したアカウントを作成しておきます。今回はその作成済みのアカウントでログインする方法の解説をします。※バックエンド(Express側)の処理は記載しません。

認証の流れ

【事前】 管理者アカウントの事前作成

  • Firebase Admin SDKを使用して、管理者権限(admin)付きのアカウントを事前に作成しておく。※今回は省略

① ユーザーの初回ログイン

  • ユーザーがメールアドレスとパスワードでログインする。
  • Firebase Authenticationを使用してメールアドレス、パスワードで認証。
  • メール認証が完了後、idTokenを取得し、その中のadmin属性(カスタムクレーム)を確認する。

② セッションクッキーの作成と管理

  • サーバー側でFirebase Admin SDKを使用し、idTokenを検証。
  • 有効期限付きのセッションクッキーを作成し、ブラウザのCookieに保存する。
  • 次回アクセス時は、セッションクッキーの有効性を検証し、適切なページにリダイレクト。
idTokenとセッションクッキーの違い。なぜわざわざセッションクッキーを作るのか

idToken とセッションクッキーはどちらもJWTベースのトークンであり、中身もほぼ同じです。
どうしてわざわざ idToken からセッショントークンを作成するのでしょうか。
最大の違いは有効期限です。

  • idToken は1時間で期限切れになり、定期的にリフレッシュが必要です。
  • セッションクッキーは5分~2週間までの有効期限を自由に設定可能 です。

https://stackoverflow.com/questions/79300479/why-use-firebases-createsessioncookie-instead-of-the-id-token

セッションクッキーはなぜ長期間の有効期限でも安全なのか?と思いましたが、

  • セッションクッキーはhttpOnly & Secure クッキーとして保存され、JavaScriptからアクセスできないため、XSSなどに強いらしい。
  • サーバー側(Firebase Admin SDK)で厳密に検証できるため、長期間の有効期限を設定しても比較的安全。
  • idToken はクライアント側(Firebase SDK)で管理されるため、盗まれるリスクがあるため慎重に扱う必要がある。

そのため、セッションクッキーで管理します。

③ Google アカウントとの紐付け

  • ログインした状態でGoogleアカウントと連携。
  • ユーザーはサインアウトし、以降はGoogle認証でログイン可能にする。
  • ログイン後、再びidTokenを取得し、admin属性を確認する。

Firebase の初期化の実装

Firebase SDKの初期化

インストール、firebaseプロジェクトの作成等は済ませておいてください。
こちらの以前の記事に記載しています。
https://zenn.dev/kiwichan101kg/articles/b38dd43d04622e

lib/firebase/client.ts
import { getApp, getApps, initializeApp } from 'firebase/app'
import { getAuth } from 'firebase/auth'

// Firebaseの設定
const firebaseConfig = {
  apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY,
  authDomain: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  projectId: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  storageBucket: process.env.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET,
  messagingSenderId: process.env.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID,
  appId: process.env.NEXT_PUBLIC_FIREBASE_APP_ID,
}

// Firebase初期化
const app = !getApps().length ? initializeApp(firebaseConfig) : getApp()
const auth = getAuth(app)

export { auth }

Firebase Admin SDKの初期化

Firebase Admin SDKを初期化します。
https://firebase.google.com/docs/admin/setup?hl=ja

Firebase Console のプロジェクトの設定のサービスアカウントからJSON形式のシークレットキーをダウンロードするのですが、そのシークレットキーの情報を環境変数に設定し、Firebase Adminを初期化します。

こちらを参考にしました。
https://zenn.dev/milky/articles/firebase-admin-init
https://blog.ojisan.io/firebase-admin-init/

lib/firebase/admin.ts
import admin from 'firebase-admin'
import { getAuth } from 'firebase-admin/auth'
import 'server-only'

// Firebase Adminの設定
const adminConfig = {
  projectId: process.env.FIREBASE_PROJECT_ID,
  clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
  privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, '\n'),
}

// Firebase Adminの初期化
const app = !admin.apps.length
  ? admin.initializeApp({
      credential: admin.credential.cert(adminConfig),
    })
  : admin.app()

const adminAuth = getAuth(app)
export { adminAuth }

private_keyを環境変数に設定する時にハマりました。

envにFIREBASE_PRIVATE_KEYを設定する際に、値の-----BEGIN PRIVATE KEY-----の部分を除いてキーのみを設定してしまいましたが、この部分も含めて設定します。

.env
FIREBASE_PROJECT_ID='project-id'
FIREBASE_PRIVATE_KEY='-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n'
FIREBASE_CLIENT_EMAIL="firebase-adminsdk-aaa@example.example.com"

初期化の際にreplace(/\\n/g, '\n')のような形で変換するとうまくいきました。

import admin from "firebase-admin";

admin.initializeApp({
  credential: admin.credential.cert({
    projectId: process.env.FIREBASE_PROJECT_ID,
    clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
    privateKey: process.env.FIREBASE_PRIVATE_KEY?.replace(/\\n/g, "\n"),
  }),
});

クライアント側の実装

Firebase Admin SDKの処理はサーバー側で行う必要があるのでServer Actionsに切り出します。中身は後のサーバー側で記載しています。
※以降のコードは独自のエラーハンドリングなども行っているのであくまで参考程度に。

メールアドレス、パスワードサインイン

  • signInWithEmailAndPassword(メール、パスワード認証に使用)

getUserCredentialはサインイン後のユーザー情報を取得するために共通処理を独自で関数化したものなので後続で説明しています。

コード詳細
  const signInWithEmail = async (email: string, password: string): AsyncResult<{ isGoogleLinked: boolean }> => {
    try {
      const userCredential = await signInWithEmailAndPassword(auth, email, password)
      const response = await getUserCredential(userCredential) // ユーザー情報を取得

      if (!response.success) {
        return { success: false, error: response.error }
      }

      return {
        success: true,
        data: { isGoogleLinked: response.data?.isGoogleLinked || false },
      }
    } catch (error) {
      console.log('Email sign in error:', error)
      return {
        success: false,
        error: 'メールアドレスまたはパスワードが間違っています',
      }
    }
  }

Googleサインイン

  • signInWithPopup(Google認証に使用)
コード詳細
  const googleProvider = new GoogleAuthProvider()

  const signInWithGoogle = async (): AsyncResult => {
    try {
      const userCredential = await signInWithPopup(auth, googleProvider)
      const response = await getUserCredential(userCredential) // ユーザー情報を取得

      if (!response.success) {
        return { success: false, error: response.error }
      }
      return { success: true }
    } catch (error) {
      console.log('Google sign in error:', error)
      return { success: false, error: 'Googleサインインに失敗しました' }
    }
  }

サインイン後のユーザー情報を取得する共通処理

  • getIdToken(idTokenの取得に使用)
  • getIdTokenResult(カスタムクレームにアクセスするために使用)

以下のようなことを行うために独自で関数化しています。

  • idTokenを取得
  • カスタムクレームの中のadmin権限の確認
  • Google連携状態を確認
コード詳細
  const getUserCredential = async (
    userCredential: UserCredential,
  ): AsyncResult<{ user: User; isAdmin: boolean; isGoogleLinked: boolean }> => {
    try {
      const idToken = await userCredential.user.getIdToken()
      const idTokenResult = await userCredential.user.getIdTokenResult()
      const isAdmin = !!idTokenResult.claims.admin || false
      // Google連携状態を確認
      const isGoogleLinked = userCredential.user.providerData.some(
        provider => provider.providerId === GOOGLE_PROVIDER_ID,
      )

      if (!isAdmin) {
        return {
          success: false,
          error: '管理者権限がありません',
        }
      }

      const response = await actionsCreateSessionCookie(idToken) // Server Actionsで行う
      if (!response.success) {
        return { success: false, error: response.error }
      }

      return { success: true, data: { user: userCredential.user, isAdmin, isGoogleLinked } }
    } catch (error) {
      console.log('Authentication error:', error)
      return { success: false, error: '認証処理に失敗しました' }
    }
  }

Googleアカウント紐付け

  • linkWithPopup(Googleアカウント紐付けに使用)
コード詳細
  const GOOGLE_PROVIDER_ID = 'google.com'

  const linkGoogle = async (): AsyncResult => {
    try {
      const user = auth.currentUser
      if (!user) {
        return {
          success: false,
          error: 'ユーザーがログインしていません。Googleアカウントと紐付ける前にログインしてください。',
        }
      }
      await linkWithPopup(user, googleProvider)
      return { success: true }
    } catch (error) {
      console.log('Google link error:', error)
      return {
        success: false,
        error: 'Googleアカウントの紐付けに失敗しました',
      }
    }
  }

サインアウト

  • signOut(Firebase 関連の認証のサインアウトに使用)
コード詳細
  const firebaseSignOut = async (): AsyncResult => {
    try {
      await signOut(auth)
      await deleteSessionCookie() // Server Actionsで行う
      return { success: true }
    } catch (error) {
      console.log('Sign out error:', error)
      return { success: false, error: 'サインアウトに失敗しました' }
    }
  }

認証状態や認証メソッドはコンテキスト化して管理

認証状態や上記で紹介した認証メソッドはグローバルに管理したいためコンテキスト化してカスタムフックとしてアプリ全体のどこからでも使用できるようにします。

  • ログイン中のユーザー情報
  • ログイン中かどうか
  • メールアドレスサインイン関数
  • Googleサインイン関数
  • Googleアカウント紐付け関数
  • サインアウト関数

など、アプリ全体で共有したい認証系の状態や関数

ログイン画面

UIはなんでもいいのでコードは省略します。

メールアドレスでログイン後、Google紐付けを案内

コンテキスト化してカスタムフック化したものをこんな感じで呼び出して使います。

  • メールアドレス、パスワードサインイン
  • Googleサインイン
  • Googleアカウント紐付け
const { signInWithGoogle, signInWithEmail, linkGoogle } = useAuth()

サーバー側の実装

Firebase Admin SDKの処理はサーバー側で行う必要があるのでServer Actionsに切り出します。
Server Actions
https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations

クッキーの読み書きを行う非同期関数cookiesを使用していきます。
https://nextjs.org/docs/app/api-reference/functions/cookies

セッションクッキーの作成(Server Actions)

  • createSessionCookie (セッションクッキーの作成に使用)

idTokenから有効期限つきのセッションクッキーを作成します。
Firebase Admin SDKのAPIはNode.js環境でしか動作しません。そのため'use server'の中で行います。

app/actions/create-session-cookie.ts
'use server'
import { cookies } from 'next/headers'
import { adminAuth } from '../../lib/firebase/admin'
import { AsyncResult } from '../../types/result'

const setSessionCookie = async (sessionCookie: string, maxAge: number) => {
  const cookieStore = await cookies()
  cookieStore.set('session', sessionCookie, {
    maxAge,
    httpOnly: true,
    secure: true,
  })
}

// サーバー側でのみ実行されるセッションCookie作成処理
export async function actionsCreateSessionCookie(idToken: string): AsyncResult {
  try {
    const expiresIn = 60 * 60 * 24 * 5 * 1000 // 5日間有効
    const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn })

    await setSessionCookie(sessionCookie, expiresIn)
    return { success: true }
  } catch (error) {
    // eslint-disable-next-line no-console -- エラーログ
    console.log('Session cookie creation failed:', error)
    return { success: false, error: 'セッションの作成に失敗しました' }
  }
}

作成したセッションクッキーをCookieに保存します。

セッションクッキーの削除(Server Actions)

こちらはサインアウト時にCookieの中のセッションクッキーを削除する時に使用します。

app/actions/delete-session-cookie.ts
'use server'

import { cookies } from 'next/headers'
import { AsyncResult } from '../../types/result'

export async function deleteSessionCookie(): AsyncResult {
  const cookieStore = await cookies()
  cookieStore.delete('session')
  return { success: true }
}

セッションクッキーの検証(Route Handlers)

  • verifySessionCookie(セッションクッキーの検証に使用)

Route Handlerの中でCookieの値を取得して、有効なセッションクッキーかを検証します。
Route Handlers
https://nextjs.org/docs/app/building-your-application/routing/route-handlers

これだけなぜServer ActionsではなくRoute Handlersなのかというと、Server Actionsの中で検証処理を行いmiddleware.tsで呼び出すと、Firebase Admin SDK verifySessionCookieがなぜか使用できなかったからです。(ここ不明。。)
Route Handlersの中で検証を行いmiddleware.tsで呼び出すことはできました。

middleware内でfirebase-admin SDKは使用できない

middleware内でfirebase-admin SDKは使用できません。

https://logical-space.com/2023/11/07/next-js-13-middleware内でfirebase-admin-sdkは使えない。/

middlewareはEdgeランタイムで動作するため、Node.jsのAPIなどサポートされていないAPIがあります。
https://nextjs.org/docs/app/api-reference/edge#unsupported-apis

app/api/auth/verify/route.ts
import { adminAuth } from '@/lib/firebase/admin'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  try {
    const session = request.headers.get('Cookie')?.split('session=')[1]?.split(';')[0]

    if (!session) {
      return NextResponse.json({ isValid: false }, { status: 401 })
    }

    // 第二引数には必ずtrueを渡す。 そうしないと、無効なセッション ID でも認証情報を取得できてしまう。
    const decodedToken = await adminAuth.verifySessionCookie(session, true)
    const isAdmin = !!decodedToken.admin || false

    if (!isAdmin) {
      return NextResponse.json({ isValid: false }, { status: 403 })
    }

    return NextResponse.json({ isValid: true, user: decodedToken }, { status: 200 })
  } catch (error) {
    // eslint-disable-next-line no-console -- エラーログ
    console.log('Session verification failed:', error)
    return NextResponse.json({ isValid: false }, { status: 401 })
  }
}

verifySessionCookieは有効なトークンだった場合はデコードされたものが返却されます。
第二引数には必ずtrueを設定します。falseを設定すると、無効なトークンでもデコードした値が返却されるので注意が必要です。

Middlewareの実装

Middleware
https://nextjs.org/docs/app/building-your-application/routing/middleware

Middlewareはユーザーがアクセスした時にまずはじめに通る処理です。
そのため、認証情報を検証し、適切なリダイレクトなどの処理をするのに適しています。
今回の実装で配下の処理を行います。

  • Cookieの値を取得し、セッションクッキーを取り出す
  • セッションクッキーが有効かどうかを検証
  • 認証結果に応じて適切なリダイレクトを実施
middleware.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { env } from './utils/env'

export async function middleware(request: NextRequest) {
  const session = request.cookies.get('session')?.value
  const isPublicPage = request.nextUrl.pathname === '/signin'
  const isPrivatePage = !isPublicPage
  const isLoggedIn = session

  // 未ログインの場合、認証が必要なページなら `/signin` にリダイレクト
  if (!isLoggedIn && isPrivatePage) {
    return NextResponse.redirect(new URL('/signin', request.url))
  }

  // ログイン済みの場合、セッションクッキーの検証を行う
  if (isLoggedIn) {
    // セッションの検証
    const response = await fetch(`${env.NEXT_PUBLIC_APP_URL}/api/auth/verify`, {
      headers: {
        Cookie: `session=${session}`,
      },
    })

    // セッションクッキーが無効なら `/signin` にリダイレクト
    if (!response.ok) {
      const redirectResponse = NextResponse.redirect(new URL('/signin', request.url))
      redirectResponse.cookies.delete('session') // セッション Cookie を削除
      return redirectResponse
    }

    // `/signin` にアクセスしているが、セッションクッキーが有効なら `/dashboard` にリダイレクト
    if (isPublicPage) {
      return NextResponse.redirect(new URL('dashboard', request.url))
    }
  }

  return NextResponse.next()
}

// ミドルウェアを適用するパスを設定
export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}

まとめ

本記事ではFirebase Authenticationのカスタムクレームで管理者専用のログインを実装する方法を紹介しました。
他のサービスを使用した管理者権限制御の方法も知りたいと思いました。もっといいやり方があればぜひ教えてください。
認証機能はどのアプリでも必要となる重要な機能なのでさらに習得していきたいと思います!

参考:https://zenn.dev/nbstsh/scraps/7acd43e3e8116e

Discussion