Firebase Authentication + Vue.js を SSR/SPA 両方に対応する方法


Firebase Authentication は手軽に認証の仕組みが使えてとても良いです。今回は Vue.js を使って SSR (ServerSideRendering) と SPA (SinglePageApplication) 両方に対応した使い方をまとめておきます。

というのも普通の SPA であれば特になんの問題もなく使えるのが SSR にも対応した形にしようとすると、とても苦労したというのがあり自分でまとめておかないと忘れてしまいそうになるからです。

## 使うもの

- Vue.js
- Vue Router
- Vuex
- Firebase

## SPA で普通に認証をしてみる

Vue.js + Vue Router を使って認証が必要なページを用意し、Firebase Authentication で認証が通っている場合は閲覧できるようにしてみます。ここではサンプルとして Google ログインを使います。ここでは特に難しいことはありません。

### router/routes.js

```javascript
const routes = [
  {
    path: '/',
    component: () => import('layouts/MyLayout.vue'),
    children: [
      {
        path: '',
        name: 'Home',
        component: () => import('pages/Index.vue'),
        meta: { requiresAuth: false }
      },
      {
        path: 'mypage',
        name: 'MyPage',
        component: () => import('pages/MyPage.vue'),
        meta: { requiresAuth: true }
      }
    ]
  }
]
```

`requiresAuth: false` というオブジェクトを用意して認証が必要なページかそうでないかを明記しておきます。

### router/index.js

次に Vue Router の `Router.beforeEach` を使ってページ遷移前に認証が必要なページかどうかを見て、アクセスしてきているクライアントの認証状況を確認します。すでに認証されている場合は `firebase.auth().currentUser` でユーザ情報が確認できます。されていない場合は `null` が返ってきます。

認証されていない場合は、`onAuthStateChanged` を使って認証結果をコールバックで待ちます。動作としては非同期でクライアントから Web SDK を通じて Firebase へ認証リクエストが飛んでいる形になります。コールバックで受け取れるものは `currentUser` を同じです。以降はブラウザのリロードをしない限りは `currentUser` にデータが入っている状態になります。

```javascript
Router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // すでに認証済みの場合は遷移
    if (firebase.auth().currentUser) {
      next()
      return
    }
    // まだ認証されていない場合は Firebase SDK からのコールバックを待つ
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        next()
      } else {
        next({ name: 'Home' })
      }
      return
    })
  }
  next()
})
```

### pages/Home.vue

続いて Google ログインするページを用意します。認証プロバイダのオブジェクトを用意して `signInWithRedirect` で認証ページで飛ばします。ここで重要なのがもう一つ用意されている `signInWithPopup` は使わないことです。Firebase のドキュメントでは `signInWithPopup` で書かれているので、こちらのほうがデフォルトっぽい感じですが `signInWithRedirect` を使ったほうが絶対に良いです。

なぜなら `signInWithPopup` の場合、スマホアプリのアプリ内ブラウザ (WebView) だとポップアップのタブが正常に処理されずログインができない状況があるからです。Firebase のドキュメントにも `モバイル端末ではリダイレクトすることをおすすめします。` と書かれていますが、今どきならモバイル端末を意識しないアプリケーションはないと思うので素直に `signInWithRedirect` を使っていきましょう。

その代わり？ `signInWithRedirect` の方が認証の流れが分断されて面倒くさいですが…

```vue
<template>
  <div>
    <button @click="authGoogle">Google login</button>
  </div>
</template>

<script>
import * as firebase from 'firebase/app'
export default {
  name: 'Home',
  methods: {
    authGoogle () {
      const provider = new firebase.auth.GoogleAuthProvider()
      firebase.auth().signInWithRedirect(provider)
    }
  }
}
</script>
```

これで SPA での認証の仕組みは実装完了です。

## SSR で認証

次は SSR での認証です。SSR で認証するということは一般的な Web アプリケーションと同じように Cookie などを使って認証する必要があります。しかし、Firebase Authentication の Web SDK ではクライアントから直接 JavaScript を用いて直接 Firebase の認証サーバへ情報を送信することになります。つまり SSR をしている Web サーバにも認証情報を送信する必要があります。

Firebase ドキュメントに[セッション Cookie を使った管理方法](https://firebase.google.com/docs/auth/admin/manage-cookies?hl=ja)が紹介されています。これを見ると Web SDK で認証後に `getIdToken()` を使って認証トークンを取得、SSR サーバに送信してセッション Cookie を発行するようにしています。これによって SSR サーバだけでクライアントが認証済みかそうでないかを判断することができます。

本来であれば上記のセッション Cookie を使って認証するのが王道なのですが、今回は SPA にも SSR にも対応可能な形で実装をしたかったので SPA で行ったようなクライアントだけで完結する方法で行います。

### router/index.js

先程 SPA で Vue Router の `Router.beforeEach` を使って遷移前に認証状況を確認していましたが、 SSR をすると Vue Router はサーバ側の処理になるため `onAuthStateChanged` はここでは利用できません。正確に言うと初回アクセス時にはサーバ側の処理になって認証情報は利用できません。以降のページ遷移は SPA と同じくクライアントでの処理になるので利用できます。

```javascript
Router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    // すでに認証済みの場合は遷移
    if (firebase.auth().currentUser) {
      next()
      return
    }
    // まだ認証されていない場合は Firebase SDK からのコールバックを待つ…
    // けどサーバ側の処理なので何も手に入らない
    firebase.auth().onAuthStateChanged(user => {
      if (user) {
        next()
      } else {
        next({ name: 'Home' })
      }
      return
    })
  }
  next()
})
```

初回アクセス時に認証情報が利用できなくて、なぜ困るのかというと認証が必要なページへ直接アクセスされたときに制御する方法がないからです。認証が必要なページの `beforeCreate` のタイミングで認証確認をしたとしても、認証は非同期処理なため見えてはいけないページが見えてしまう可能性があります。

そこで Vue Router 側では Vuex の State を使って認証済みかどうかだけを判断させるようにします。そして認証されていない場合は認証専用ページへ飛ばします。

```javascript
Router.beforeEach((to, from, next) => {
  if (to.matched.some(record => record.meta.requiresAuth)) {
    if (store.getters['auth/alreadyLoggedIn'] === true) {
      next()
      return
    }
    // Auth コンポーネントで Firebase 認証を行ってから目的先へリダイレクトさせる
    next({ name: 'Auth', query: { redirect: to.fullPath } })
  } else {
    next()
  }
})
```

認証用の Vuex ストアを用意します。今回は単純に Firebase Authentication の uid を保持するだけのものにします。実際には Firestore などのデータベースとの連携もあると思います。それらはここで合わせて実装するのが良いでしょう。

### store/auth/state.js

```javascript
export default {
  uid: null
}
```

### store/auth/getters.js

```javascript
export const alreadyLoggedIn = state => {
  return state.uid !== null && firebase.auth().currentUser !== null
}
```

### store/auth/actions.js

```javascript
export const login = async ({ commit }, uid) => {
  // Firestore などの処理があればここで
  commit('LOGIN', { uid: uid })
}
```

### store/auth/mutations.js

```javascript
export const LOGIN = (state, uid) => {
  state.uid = uid
}
```

### pages/Auth.vue

認証するだけの Auth ページです。見た目は真っ白なので処理中というのをユーザに伝えるためのローディングのインジケータなどがあるよ良いと思います。Firebase Authentication の認証コールバックで認証が確認されたら目的のページへリダイレクトしてあげます。

```vue
<template>
  <div></div>
</template>

<style>
</style>

<script>
import { mapActions } from 'vuex'
export default {
  name: 'Auth',
  methods: {
    ...mapActions('auth', [
      'login'
    ])
  },
  beforeCreate () {
    // ここでローディングのインジケータアニメーションを表示すると良い
    firebase.auth().onAuthStateChanged(async user => {
      if (user) {
        await this.login(user.uid)
        this.$router.push(this.$route.query.redirect)
      } else {
        this.$router.push({ name: 'Home' })
      }
    })
  }
}
</script>
```

これで SSR しながらも SPA との共存が可能な認証の流れができました。サーバサイド側で認証の処理をしない分、Auth ページというワンクッションが挟まっていますが、どちらでも使えるというのが活きてくるシーンもあると思います。まぁ…王道的にはあまりないと思いますが…。

実際にはユーザ情報登録画面や Firestore との連携もあるのでもっと複雑な分岐にはなると思いますが、基本はこの考え方で行けると思います。実際のコードは GitHub にアップしているので参考にしてみてください。

- https://github.com/zaru/quasar_ssr_firebase_sample/tree/auth