[Nuxt] Firebase Authentication を使ってみての覚書② 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:' ||
        self.location.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を追加する、という内容。

もし開発環境などでBasic認証使ってるのであれば、それも合わせて追加しておく

headers.append('authorization', 'Basic ここにbase64エンコードしたやつ');

これでauthorizationがカンマ区切りになる。サーバー側がカンマ区切り未対応なら対応しておくこと。

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

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

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

ServerMiddlewareの用意

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

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

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

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

firebase/serverMiddleware.js とする
※ サービスアカウント設定ファイルは firebase/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;
      next();
    } catch (e) {
      delete req.authUser;
      next();
    }
  } else {
    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とではキーがちょっと違うので使い回しに注意。

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;
  }
//....

注意点

  • PWAはプロダクションモードでしかsw.js生成しない
  • ハードリロードした時はSeviceWorkerが動かない

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください