Nuxt3 + Pinia + firebase v9で認証処理を作ってみる


# はじめに
[Nuxt3](https://v3.nuxtjs.org/) と firebase v9 を使って認証フローを作成してみました。
認証データの状態管理には [Pinia](https://pinia.vuejs.org/) を使用します。

# firebaseの接続情報
接続情報は publicRuntimeConfig にまとめておきます。
``` typescript:nuxt.config.ts
export default defineNuxtConfig({
  ...
  modules: [
    ['@pinia/nuxt', { autoImports: ['defineStore'] }],
  ],
  publicRuntimeConfig: {
    firebase: {
      apiKey: 'xxxxxxxxxxxxxxxxxxxx',
      authDomain: 'xxxxxxxxxxxxxxxxxxxx',
      projectId: 'xxxxxxxxxxxxxxxxxxxx',
      storageBucket: 'xxxxxxxxxxxxxxxxxxxx',
      messagingSenderId: 'xxxxxxxxxxxxxxxxxxxx',
      appId: 'xxxxxxxxxxxxxxxxxxxx',
    },
  },
})
```

# Storeに認証処理を記述します
signIn / signOut 処理自体はごくシンプルに firebase/auth を呼び出すだけに留めておき、state更新はonAuthStateChangedハンドラで行うことにします。

```typescript:store/auth.ts
import { navigateTo } from '#app'
import { defineStore } from 'pinia'
import {
  getAuth,
  onAuthStateChanged,
  signInWithEmailAndPassword,
  signOut,
} from 'firebase/auth'

export const useAuthStore = defineStore('auth', {
  state: () => ({
    displayName: '',
    email: '',
    idToken: '',
  }),
  getters: {
    isLoggedIn: (state): boolean => !!state.idToken,
  },
  actions: {
    initAuthStateChangedHandler() {
      onAuthStateChanged(getAuth(), (user) => {
        if (!user) {
          this.$reset()
          navigateTo('/login', { replace: true })
          return
        }

        user.getIdToken().then((idToken) => {
          const { displayName, email } = user
          this.$patch({
            idToken,
            displayName: displayName || '',
            email: email || '',
          })

          const redirectTo = useRoute().redirectedFrom?.path || '/'
          navigateTo(redirectTo, { replace: true })
        })
      })
    },
    signOut(): Promise<void> {
      return signOut(getAuth())
    },
    async signIn(email: string, password: string): Promise<void> {
      await signInWithEmailAndPassword(getAuth(), email, password)
    },
  },
})
```

# Firebaseの初期化
firebase v9 は @nuxtjs/firebase が対応していないので、Firebase 初期化はpluginで行います。

onAuthStateChanged の登録処理は、plugin内でFirebaseAppオブジェクトの作成した後で、続けて呼び出しておく事にします。
Firebase の初期化前にハンドラーが呼び出されることを避けるためにこのようにしています。

```typescript: plugins/firebase.client.js
import { useAuthStore } from '~/stores/auth'

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()

  initializeApp({
    apiKey: config.public.firebase.apiKey,
    authDomain: config.public.firebase.authDomain,
    projectId: config.public.firebase.projectId,
    storageBucket: config.public.firebase.storageBucket,
    messagingSenderId: config.public.firebase.messagingSenderId,
    appId: config.public.firebase.appId,
  })

  useAuthStore().initAuthStateChangedHandler()
})
```

# ページ自動遷移処理
ログイン状態による自動遷移は global middleware で定義しておきます。
isLoggedIn は onAuthStateChanged によって更新されるので、 backend で更新トークンが無効化された際に自動でログインページに遷移すると期待します。（まだ試していませんが）

```typescript: middleware/auth.global.ts
import { useAuthStore } from '~/stores/auth'
import { navigateTo } from '#app'

export default defineNuxtRouteMiddleware(async (to, _from) => {
  const { isLoggedIn } = useAuthStore()

  if (isLoggedIn && to.path === '/login') {
    return navigateTo('/')
  }

  if (!isLoggedIn && to.path !== '/login') {
    return navigateTo('/login', { replace: true })
  }
})
```

ここまでが 画面遷移時に session の自動確認を行う手法です。
更に認証が必要な画面が表示されっぱなしにならないようにする場合は plugin でイベントを追加して対応します。
今回はタブ表示切り替えと定時実行に対応します。

```typescript: plugins/session.client.ts
import { defineNuxtPlugin } from '#app'
import { useAuthStore } from '~/stores/auth'

export default defineNuxtPlugin(() => {
  const { isLoggedIn, signOut } = useAuthStore()
  const signOutWhenSessionIsOver = async (): Promise<void> => {
    if (document.visibilityState === 'visible' && isLoggedIn) {
      await signOut()
    }
  }

  document.addEventListener('visibilitychange', signOutWhenSessionIsOver)

  window.setInterval(signOutWhenSessionIsOver, 60000)
})
```


# おわりに
Nuxt3は自動importが効くので、実際はもっとソース量を圧縮できるのですが、明示的にimportするとideaの型推測サポートが効いたので、今回はあえて書くことにしました。
それでもNuxt2に比べると書く量がだいぶ減った印象を持ちました。TypeSafeなPiniaが使用できる恩恵も大きかったです。