Firebase Authentication の認証を signInWithRedirect にする

Firebase Authentication の認証を signInWithRedirect にする実装について。

開発環境

  • firebase: 9.9.1

概要

Firebase Authentication による認証について、『JavaScript による Google を使用した認証』のページに

ユーザーに Google アカウントでログインするよう促すために、ポップアップ ウィンドウを表示するか、ログインページにリダイレクトします。モバイル デバイスではリダイレクトすることをおすすめします。

と書いてあるので signInWithPopup を使用した認証から signInWithRedirect に変更した。

実装の方針

既に決めている仕様として

  • /login にログイン済のユーザーがアクセスした場合、/home にリダイレクトしたい
  • /login でログイン操作を実行した場合、サーバーサイドで customClaim を付与するので、/home にリダイレクトする前に customClaim を読み込み直したい

というのがあった。

signInWithRedirect を使用して /login 画面内だけで実装しようとするとどうしても前述の仕様達成が難しかったので、下記のように実装することにした。

  1. /login で「ログイン」ボタンを押下すると、/login/loading に画面遷移する
  2. /login/loading ではユーザーが signInWithRedirect で返ってきた状態かどうかを判別する。
  3. signInWithRedirect から返ってきた状態ならサーバーサイドへのログイン処理を実行する。
  4. signInWithRedirect から返ってきた状態でなく、未認証状態の場合は Google のログインページへリダイレクトする。

なおブラウザの履歴等から直接 /login/loading に飛んできた認証済ユーザーがいる場合、/login/loading では何も起こらないようにした。

実装

関係ない部分なるべく削った実装例。

useCurrentUser

ユーザーの認証状態を取得するカスタムフック。
ログイン画面とローディング画面に出てくるので実装を掲載する。

import { getAuth, onAuthStateChanged } from 'firebase/auth'
import type { User } from 'firebase/auth'
import { useState, useEffect } from 'react'
import { app } from '@/config'

export type UserType = User | null

export const useCurrentUser = () => {
  const auth = getAuth(app)
  const [user, setUser] = useState<UserType>(null)
  const [isAuthChecking, setIsAuthChecking] = useState(true)
  const [role, setRole] = useState<string | undefined>(undefined)

  useEffect(() => {
    const authStateChanged = onAuthStateChanged(auth, async (user) => {
      setUser(user)
      if (user) {
        const claims = (await user.getIdTokenResult())?.claims
        const role = claims?.['role']
        if (role) {
          setRole(role as string)
        }
      } else {
        setRole(undefined)
      }
      setIsAuthChecking(false)
    })
    return () => {
      authStateChanged()
    }
  }, [auth])

  return {
    user,
    isAuthChecking,
    role,
  }
}

ログイン画面(/login)

既にログイン済だったら /home にリダイレクトする。
まだログインしていない場合はログインボタンを押下されたら /login/loading にリダイレクトする。

import { Heading, Spinner } from '@chakra-ui/react'
import type { NextPageWithLayout } from 'next'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect } from 'react'
import LoginButton from '@/components/pages/login/LoginButton'
import { useCurrentUser } from '@/hooks/useCurrentUser'
import { Layout } from '@/layout/Layout'

const Login: NextPageWithLayout = () => {
  const { isAuthChecking, user: currentUser } = useCurrentUser()
  const router = useRouter()

  const login = () => {
    router.push('/login/loading')
  }

  useEffect(() => {
    // ログイン済だったらホームに飛ぶ
    if (currentUser) {
      router.push('/home')
    }
  }, [currentUser, router])

  if (isAuthChecking || currentUser) {
    return <Spinner size='xl' />
  }

  return (
    <div>
      <Head>
        <title>ログイン</title>
      </Head>

      <main>
        <Heading as='h1' size='4xl' mt={2} mb={2}>
          ログイン
        </Heading>
        <LoginButton onClick={login} />
      </main>
    </div>
  )
}

Login.getLayout = (page) => <Layout>{page}</Layout>

export default Login

ローディング画面(/login/loading)

Google 認証画面にリダイレクトし、戻ってきたらサーバーサイドの認証処理を実行して /home へリダイレクトする。

import { Spinner, useToast } from '@chakra-ui/react'
import {
  getAuth,
  onAuthStateChanged,
  signInWithRedirect,
  getRedirectResult,
  signOut,
} from 'firebase/auth'
import Head from 'next/head'
import { useRouter } from 'next/router'
import { useEffect, useState, useCallback } from 'react'
import { provider } from '@/config'
import { CONSTANT } from '@/constants'
import { useLoginMutation } from '@/graph/guest/login.generated'
import { LoginInput } from '@/graph/types.generated'
import { useCurrentUser } from '@/hooks/useCurrentUser'

const LoginLoading = () => {
  const { isAuthChecking, user: currentUser } = useCurrentUser()
  const [isError, setIsError] = useState(false)
  const auth = getAuth()
  const router = useRouter()
  const toast = useToast()

  const { mutateAsync } = useLoginMutation({
    onError: async (e: any) => {
      console.error(e)
      setIsError(true)
      toast({
        title: 'ログインに失敗しました',
        status: 'error',
        isClosable: true,
      })
      await signOut(auth).catch((error) => {
        alert(`ログアウトに失敗しました。エラーコード:${error.code}`)
      })
      router.push('/login')
    },
    onSuccess: () => {
      const unsubscribe = onAuthStateChanged(auth, async (user) => {
        if (user) {
          // カスタムクレームを更新するためにユーザーを読み込みなおしてからトークンリフレッシュ
          await user.reload()
          await user.getIdTokenResult(true)
          router.push('/home')
        } else {
          setIsError(true)
          alert('ログインに失敗しました')
          router.push('/login')
        }
      })
      unsubscribe()
    },
  })

  const mutateLogin = useCallback(async () => {
    const input: LoginInput = {
      token: (await currentUser?.getIdToken()) ?? '',
    }
    await mutateAsync({
      input: input,
    })
  }, [mutateAsync, currentUser])

  useEffect(() => {
    if (isAuthChecking) {
      return
    }
    const login = async () => {
      const result = await getRedirectResult(auth)
      if (!result?.user) {
        if (!currentUser && !isError) {
          await signInWithRedirect(auth, provider)
        }
      } else {
        if (!isError) {
          mutateLogin()
        }
      }
    }
    login()
  }, [isAuthChecking, currentUser, isError, auth, mutateLogin])

  return (
    <div>
      <Head>
        <title>ログイン</title>
      </Head>

      <main>
        <Spinner size='xl' />
      </main>
    </div>
  )
}

export default LoginLoading

以上