WebTecNote

[Nuxt.js] Firebaseの覚書 ② ServiceWorkerによるセッション管理

NuxtでリファレンスにあるServiceWorkerによるセッション管理をやってみたのでここに記す…。

Firebase 認証 とかでググってみると、auth().onAuthStateChangedのイベントハンドラでstateを変更するのがよく引っかかるけど、
onAuthStateChangedは状態が反映されるまでの読み込みにかかるラグがどうしても発生するので、ログイン状態でも一瞬ログアウト状態が見えてしまう。
それを隠すためにローディングを入れるというのが合わせて出てくる手法なんだけれども、NuxtでSSRだとローディング表示はしたくないじゃないですかあ…。

リロードしてもログイン状態を即反映させるためには描画前にログインしているかどうかを判断しなければならないわけで、
それをやっつけるのがServiceWorkerによるセッション管理なのでした。
CookieではなくServiceWorkerなのは書いてある通りメリットが色々あるからですね。

NuxtにおけるServiceWorkerの有効化

自前でやってもいいけど大抵はPWAモジュール使うと思うのでPWAのWorkboxオプションでの書き方を載せておきます。

適当なディレクトリにfirebaseのserviceWorkerのソースをコピペして置いておく。

※仮に firebase/sw.js とする。

/* globals firebase, clients */
//https://firebase.google.com/docs/auth/web/service-worker-sessions
firebase.initializeApp({
 // TODO: 設定コピペする
});

/**
 * Returns a promise that resolves with an ID token if available.
 * @return {!Promise<?string>} The promise that resolves with an ID token if
 *     available. Otherwise, the promise resolves with null.
 */
const getIdToken = () => {
  return new Promise(resolve => {
    const unsubscribe = firebase.auth().onAuthStateChanged(user => {
      unsubscribe();
      if (user) {
        user.getIdToken().then(
          idToken => {
            resolve(idToken);
          },
          () => {
            resolve(null);
          }
        );
      } else {
        resolve(null);
      }
    });
  });
};

const getOriginFromUrl = url => {
  // https://stackoverflow.com/questions/1420881/how-to-extract-base-url-from-a-string-in-javascript
  const pathArray = url.split('/');
  const protocol = pathArray[0];
  const host = pathArray[2];
  return protocol + '//' + host;
};

self.addEventListener('fetch', event => {
  const requestProcessor = idToken => {
    let req = event.request;
    // For same origin https requests, append idToken to header.
    if (
      self.location.origin == getOriginFromUrl(event.request.url) &&
      self.location.protocol == 'https:' && // localhost-> .hostname == 'localhost'
      idToken
    ) {
      // Clone headers as request headers are immutable.
      const headers = new Headers();
      for (let entry of req.headers.entries()) {
        headers.append(entry[0], entry[1]);
      }
      // Add ID token to header.
      headers.append('authorization', 'Bearer ' + idToken);
      try {
        req = new Request(req.url, {
          method: req.method,
          headers: headers,
          mode: 'same-origin',
          credentials: req.credentials,
          cache: req.cache,
          redirect: req.redirect,
          referrer: req.referrer,
          body: req.body,
          bodyUsed: req.bodyUsed,
          context: req.context,
        });
      } catch (e) {
        // This will fail for CORS requests. We just continue with the
        // fetch caching logic below and do not pass the ID token.
      }
    }
    return fetch(req);
  };
  // Fetch the resource after checking for the ID token.
  // This can also be integrated with existing logic to serve cached files
  // in offline mode.
  event.respondWith(getIdToken().then(requestProcessor, requestProcessor));
});

self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

onAuthStateChangedでgetIdToken実行してリクエストヘッダにauthorizationを追加する、という内容。

PWAモジュールいれたらnuxt.config.jsの workboxオプションでfirebase関連を追加する

  // https://pwa.nuxtjs.org/modules/workbox.html
  workbox: {
    offline: false,
    importScripts: [
      'https://www.gstatic.com/firebasejs/7.10.0/firebase-app.js',
      'https://www.gstatic.com/firebasejs/7.10.0/firebase-auth.js',
    ],
    workboxExtensions: ['~/firebase/sw.js'],
  },

バンドルするよりimportScriptsする方が楽だったのでこのように。
workboxExtensionsはworkboxが生成するsw.jsに読み込んだファイルの内容を追記するオプションです。
これでFirebaseのserviceWorkerのソースが取り込まれる。
(バージョンはpackage.json見て合わせること)

ServerMiddlewareの用意

リクエストヘッダに追加されたIDトークンを検証しなければならないが、検証するメソッドのあるAdmin SDKはnode.js(サーバーサイド)でしか実行できないのです。
のでServerMiddlewareを使う。

serverMiddlewarevue-server-renderer前に サーバー側で実行され、API リクエストの処理やアセットの処理などのサーバー固有のタスクとして使用できます。

ServerMiddlewareならSSR前にJWT検証したいという要求もばっちり満たしてくれる。

firebase/serverMiddleware.js とする
※ プロジェクト設定→サービスアカウント→Firebase Admin SDKで秘密鍵を生成する
※ 秘密鍵を含む設定ファイルは firebase/serviceAccountKey.json とする
※ 絶対にserviceAccountKey.jsonをパブリックな場所にコミットしないこと
(実際やらかした人がいるけど驚きの速さで悪用される)

ソースはリファレンスを参考に…、

const admin = require('firebase-admin');
const serviceAccount = require('../firebase/serviceAccountKey.json');

// The Firebase Admin SDK is used here to verify the ID token.
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: 'https://your-database-url.firebaseio.com',
});

export default async function(req, res, next) {
  // /_nuxt/以下ファイルで実行しない
  if (req.url.indexOf('_nuxt') !== -1) return next();
  let idToken;
  if (
    req.headers.authorization &&
    req.headers.authorization.indexOf('Bearer ') !== -1
  ) {
    // Read the ID Token from the Authorization header.
    idToken = req.headers.authorization
      .split(',')
      .find(a => a.trim().startsWith('Bearer '))
      .split('Bearer ')[1];
  }

  if (idToken) {
    try {
      const decodedClaims = await admin.auth().verifyIdToken(idToken);
      req.authUser = decodedClaims;
    } catch (e) {
      delete req.authUser;
    }
  }

  next();
}

verifyIdTokenでトークンが確認されたらデコードされたユーザー情報をreq.authUserで追加するという処理してる。
nuxtが生成したファイル(_nuxt以下の)では不要なのでurlチェックして弾いている。

でこれをnuxt.config.jsに設定追加する。

serverMiddleware: ['~/firebase/serverMiddleware'],

Store/nuxtServerInit でユーザー情報受け取り

つつがなく動作すればstore/index.jsnuxtServerInitコンテキストでリクエストが受け取れるので、サーバーミドルウェアで追加したauthUserがあればストアにユーザーを追加する。

  async nuxtServerInit({ commit }, { app, error, route, redirect, req }) {
    try {
      const user = req.authUser || null;

      if (user) {
        commit('setUser', {
          name: user.name,
          email: user.email,
          avatar: user.picture,
          uid: user.user_id,
        });
      }

      // firestoreに用事があるならここでやる

    } catch (e) {
    }
  }

IDトークンがデコードされたオブジェクトのプロパティと、onAuthStateChangedが返すUserInfoとではキーがちょっと違うので使い回しに注意。

Basic認証下における注意点

もし開発環境などでBasic認証使ってるのであれば、authorizationはカンマ区切りの配列になる。
サーバー側がカンマ区切り未対応なら対応しておくこと。

verifyEmailが反映されない件

メールアドレス&パスワード認証を使っているとにメールアドレス確認状況がverfyEmailとして得られるが、
メールアドレス確認ではonAuthStateChangedが発火しないのでIDトークンが更新されない。
getUserを使って最新のユーザー情報を取得しなければならない。

try {
  // JWT確認 admin.auth.DecodedIdToken
  const decodedClaims = await admin.auth().verifyIdToken(idToken);
  // 最新のUserData取得
  const userData = await admin.auth().getUser(decodedClaims.uid);

  if (userData) {
    const data = userData.toJSON();
    delete data.passwordHash;
    delete data.passwordSalt;
    delete data.tokensValidAfterTime;
    req.authUser = data;
  }
} catch (e) {
  delete req.authUser;
}

Safariでエラーが出る件

Storageへのファイルアップロードが失敗する件

Storageへ画像保存しようとしたらエラーが出て死んだ。

[Error] FetchEvent.respondWith received an error: TypeError: Request header field Pragma is not allowed by Access-Control-Allow-Headers.
[Error] XMLHttpRequest cannot load https://firebasestorage.googleapis.com/v0/b/xxxxxxx.appspot.com/o?name=xxxxx.png.
[Error] Failed to load resource: FetchEvent.respondWith received an error: TypeError: Request header field Pragma is not allowed by Access-Control-Allow-Headers. (o, line 0)

なぜかSafariでエラーになり、Chromeでは発生しない。
gsutilでCORS設定しても解決しない。で、ググったら症例が見つかったので

Firebase storage upload fails on Safari on iOS 11 #1094

fetchの先頭にstorageのドメインなら弾く条件を追加したら治った。

self.addEventListener('fetch', event => {
  if (event.request.url.indexOf('firebasestorage.googleapis.com') !== -1) {
    return;
  }
//....

Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true

FetchEvent.respondWith received an error: TypeError: Cannot use wildcard in Access-Control-Allow-Origin when credentials flag is true.

authorizationヘッダのカンマ区切りによるエラーである。
上記、Basic認証管理下でauthorizationヘッダ追加していると発生する。
Basic認証はauthorization
firebaseはfirebase-authorizationみたいな名前変更をするか、
authorizationのカンマ区切りに対応する。

ReadableStream uploading is not supported

POSTリクエストした時に発生するエラー。

NotSupportedError: ReadableStream uploading is not supported

https://github.com/GoogleChrome/workbox/issues/1732

GETだけにヘッダー追加をすればいい。

回避策

これらのエラーまとめて解決するには、以下を追加する。

self.addEventListener('fetch', event => {
  if (self.location.origin === getOriginFromUrl(event.request.url) &&
    event.request.method === 'GET') {
   // ...
  }
});

自動ログアウト

2つタブ開いてどちらも同じアカウントでログインした状態にしてから、タブAをログアウトさせる。そうするとタブBも自動でログアウトする。
みたいな芸当はServiceWorkerだけではできないので、onAuthStateChangedの実装が必要。

//plugin/firebase.js
if (process.client) {
  auth.onAuthStateChanged(user => {
    if (user) {
      store.commit('setUser', user);
    } else {
      store.dispatch('logout');
    }
  });
}

認証ページで使ってるレイアウトに追加

//layout/auth.vue
computed: {
  ...mapState(['user']),
},
watch: {
  user(val) {
    if (!val || (val && !val.uid)) {
      this.$router.push('/');
    }
  }
}

※pluginやnuxtServerInitでonAuthStateChangedするとメモリリーク発生したので調査中。

auth.onAuthStateChangedによるメモリリーク

/plugin/firebase.jsをSSRでも実行するようにnuxt.config.jsで { ssr: true } 設定している場合、 auth.onAuthStateChanged はserverとclientで2回実行される。そしてメモリリークを起こす。

if (process.client) または if (!process.server) でクライアントだけ実行するように分岐をしておくこと。

注意点

モバイルバージョンを終了