Firebase Authenticationを使用してFirebaseの管理者権限がないユーザーはログインできないようにする認証機能を実装しました。
実装方法だけでなく、設計や考え方について説明しているため、Firebase Authenticationを使用しない場合でも読むことができる内容になっています。
バックエンドはExpress、フロントエンドはNext.jsを使用しており、この記事ではNext.jsの部分のみに触れています。
"next": "15.2.1",
アプリ上で新規ユーザーを登録する機能は不要で、あらかじめ存在している管理者アカウントのみがログインできるものとします。
やりたいことをざっくり洗い出しました。
/dashboard
にアクセスできる/signin
にリダイレクトされるFirebase Admin SDKは、Firebaseプロジェクトにおいて管理者レベルの操作をプログラムから簡単に行うためのライブラリです。
!Firebase SDK はクライアント側(ブラウザなど)で動作し、ユーザーの認証処理を行います。
Firebase Admin SDK はサーバー側(Node.js 環境)でのみ 動作し、カスタムクレームの設定やユーザー管理を担当します。
今回、管理者権限有無の判断にはFirebase Authentication のカスタムクレームという属性を使用します。
https://firebase.google.com/docs/auth/admin/custom-claims?hl=ja
カスタムクレームとはユーザーアカウントのカスタム属性の定義のことで、ユーザーに特定の権限を付与できます。サーバー側(Firebase Admin SDK)から付与することができ、クライアント側からはユーザーの認証トークンを使用してアクセスできます。
前提としてあらかじめバックエンド(Express側)でFirebase Admin SDKを使用してカスタムクレームを付与したアカウントを作成しておきます。今回はその作成済みのアカウントでログインする方法の解説をします。※バックエンド(Express側)の処理は記載しません。
idToken は Firebase Authentication によって発行される短期間有効なJWTベースの認証トークンです。セッションクッキーも同様にJWTベースのトークンですが、長期間のセッション管理を目的として提供されています。
idToken とセッションクッキーはどちらもJWTベースのトークンであり、中身もほぼ同じです。
どうしてわざわざ idToken からセッショントークンを作成するのでしょうか。
最大の違いは有効期限です。
セッションクッキーはなぜ長期間の有効期限でも安全なのか?と思いましたが、
そのため、セッションクッキーで管理します。
インストール、firebaseプロジェクトの作成等は済ませておいてください。
こちらの以前の記事に記載しています。
https://zenn.dev/kiwichan101kg/articles/b38dd43d04622e
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を初期化します。
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/
import adminfrom'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}
envにFIREBASE_PRIVATE_KEY
を設定する際に、値の-----BEGIN PRIVATE KEY-----
の部分を除いてキーのみを設定してしまいましたが、この部分も含めて設定します。
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 adminfrom"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=awaitsignInWithEmailAndPassword(auth, email, password)const response=awaitgetUserCredential(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:'メールアドレスまたはパスワードが間違っています',}}}
signInWithPopup
(Google認証に使用)const googleProvider=newGoogleAuthProvider()const signInWithGoogle=async(): AsyncResult=>{try{const userCredential=awaitsignInWithPopup(auth, googleProvider)const response=awaitgetUserCredential(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
(カスタムクレームにアクセスするために使用)以下のようなことを行うために独自で関数化しています。
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=awaitactionsCreateSessionCookie(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:'認証処理に失敗しました'}}}
linkWithPopup
(Googleアカウント紐付けに使用)constGOOGLE_PROVIDER_ID='google.com'const linkGoogle=async(): AsyncResult=>{try{const user= auth.currentUserif(!user){return{ success:false, error:'ユーザーがログインしていません。Googleアカウントと紐付ける前にログインしてください。',}}awaitlinkWithPopup(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{awaitsignOut(auth)awaitdeleteSessionCookie()// Server Actionsで行うreturn{ success:true}}catch(error){console.log('Sign out error:', error)return{ success:false, error:'サインアウトに失敗しました'}}}
認証状態や上記で紹介した認証メソッドはグローバルに管理したいためコンテキスト化してカスタムフックとしてアプリ全体のどこからでも使用できるようにします。
など、アプリ全体で共有したい認証系の状態や関数
UIはなんでもいいのでコードは省略します。
メールアドレスでログイン後、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
createSessionCookie
(セッションクッキーの作成に使用)idTokenから有効期限つきのセッションクッキーを作成します。
Firebase Admin SDKのAPIはNode.js環境でしか動作しません。そのため'use server'の中で行います。
'use server'import{ cookies}from'next/headers'import{ adminAuth}from'../../lib/firebase/admin'import{ AsyncResult}from'../../types/result'constsetSessionCookie=async(sessionCookie:string, maxAge:number)=>{const cookieStore=awaitcookies() cookieStore.set('session', sessionCookie,{ maxAge, httpOnly:true, secure:true,})}// サーバー側でのみ実行されるセッションCookie作成処理exportasyncfunctionactionsCreateSessionCookie(idToken:string): AsyncResult{try{const expiresIn=60*60*24*5*1000// 5日間有効const sessionCookie=await adminAuth.createSessionCookie(idToken,{ expiresIn})awaitsetSessionCookie(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に保存します。
こちらはサインアウト時にCookieの中のセッションクッキーを削除する時に使用します。
'use server'import{ cookies}from'next/headers'import{ AsyncResult}from'../../types/result'exportasyncfunctiondeleteSessionCookie(): AsyncResult{const cookieStore=awaitcookies() cookieStore.delete('session')return{ success:true}}
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 SDKverifySessionCookie
がなぜか使用できなかったからです。(ここ不明。。)
Route Handlersの中で検証を行いmiddleware.tsで呼び出すことはできました。
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
import{ adminAuth}from'@/lib/firebase/admin'import{ NextResponse}from'next/server'exportasyncfunctionGET(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||falseif(!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
https://nextjs.org/docs/app/building-your-application/routing/middleware
Middlewareはユーザーがアクセスした時にまずはじめに通る処理です。
そのため、認証情報を検証し、適切なリダイレクトなどの処理をするのに適しています。
今回の実装で配下の処理を行います。
importtype{ NextRequest}from'next/server'import{ NextResponse}from'next/server'import{ env}from'./utils/env'exportasyncfunctionmiddleware(request: NextRequest){const session= request.cookies.get('session')?.valueconst isPublicPage= request.nextUrl.pathname==='/signin'const isPrivatePage=!isPublicPageconst isLoggedIn= session// 未ログインの場合、認証が必要なページなら `/signin` にリダイレクトif(!isLoggedIn&& isPrivatePage){return NextResponse.redirect(newURL('/signin', request.url))}// ログイン済みの場合、セッションクッキーの検証を行うif(isLoggedIn){// セッションの検証const response=awaitfetch(`${env.NEXT_PUBLIC_APP_URL}/api/auth/verify`,{ headers:{ Cookie:`session=${session}`,},})// セッションクッキーが無効なら `/signin` にリダイレクトif(!response.ok){const redirectResponse= NextResponse.redirect(newURL('/signin', request.url)) redirectResponse.cookies.delete('session')// セッション Cookie を削除return redirectResponse}// `/signin` にアクセスしているが、セッションクッキーが有効なら `/dashboard` にリダイレクトif(isPublicPage){return NextResponse.redirect(newURL('dashboard', request.url))}}return NextResponse.next()}// ミドルウェアを適用するパスを設定exportconst config={ matcher:['/((?!api|_next/static|_next/image|favicon.ico).*)'],}
本記事ではFirebase Authenticationのカスタムクレームで管理者専用のログインを実装する方法を紹介しました。
他のサービスを使用した管理者権限制御の方法も知りたいと思いました。もっといいやり方があればぜひ教えてください。
認証機能はどのアプリでも必要となる重要な機能なのでさらに習得していきたいと思います!
バッジを受け取った著者にはZennから現金やAmazonギフトカードが還元されます。