Service Worker によるセッション管理

Firebase Auth では、Service Worker を使用して Firebase ID トークンを処理し、セッション管理を行うことができます。この方法には、次のような利点があります。

  • 追加の作業を行わずに、サーバーからの HTTP リクエストに ID トークンを渡すことができます。
  • 追加のラウンド トリップや遅延を発生させずに、ID トークンを更新できます。
  • バックエンドとフロントエンドのセッションを同期できます。この方法は、Realtime Database、Firestore などの Firebase サービスや、外部サーバー側のリソース(SQL データベースなど)にアクセスする必要のあるアプリケーションで利用できます。また、Service Worker、Web Worker または Shared Worker から同じセッションにアクセスできます。
  • 各ページに Firebase Auth ソースコードを含める必要がありません(遅延が短くなります)。Service Worker は、読み込まれて初期化されると、すべてのクライアントのセッション管理をバックグラウンドで処理します。

概要

Firebase Auth は、クライアント側で実行するように最適化されています。トークンはウェブのストレージに保存されます。これにより、Realtime Database、Cloud Firestore、Cloud Storage などの他の Firebase サービスとの統合も容易になります。サーバー側からセッションを管理するには、ID トークンを取得してサーバーに渡す必要があります。

firebase.auth().currentUser.getIdToken()
  .then((idToken) => {
    // idToken can be passed back to server.
  })
  .catch((error) => {
    // Error occurred.
  });

ただし、クライアント側でスクリプトを実行して最新の ID トークンを取得し、リクエスト、ヘッダー、POST 本文などによってサーバーに渡す必要があります。

これはスケーリングできないため、サーバー側のセッション Cookie が必要になる場合があります。ID トークンはセッション Cookie として設定できますが、これらは有効期間が短く、期限切れになったときにクライアントから更新して、新しい Cookie して設定する必要があります。ユーザーがサイトにしばらくアクセスしなかった場合は、追加のラウンド トリップが必要になることもあります。

Firebase Auth は従来の Cookie ベースのセッション管理を行います。これは、サーバー側の httpOnly Cookie をベースにするアプリケーションに最適な方法ですが、クライアントとサーバーのトークンが非同期状態になる可能性があるため、他のクライアント ベースの Firebase サービスを使用する場合は管理が難しくなります。

Service Worker を使用すると、サーバー側で処理されるユーザー セッションを管理できます。その理由は次のとおりです。

  • Service Worker は現在の Firebase Auth 状態にアクセスできます。現在のユーザー ID トークンを Service Worker から取得できます。トークンが期限切れになっている場合、クライアントの SDK はトークンを更新し、新しいトークンを返します。
  • Service Worker は、フェッチ リクエストをインターセプトして変更できます。

Service Worker に対する変更

ユーザーがログインしたときに現在の ID トークンを取得できるように、Service Worker に Auth ライブラリを組み込む必要があります。

// Initialize the Firebase app in the service worker script.
firebase.initializeApp(config);

/**
 * 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, reject) => {
    const unsubscribe = firebase.auth().onAuthStateChanged((user) => {
      unsubscribe();
      if (user) {
        user.getIdToken().then((idToken) => {
          resolve(idToken);
        }, (error) => {
          resolve(null);
        });
      } else {
        resolve(null);
      }
    });
  });
};

アプリに対するフェッチ リクエストはすべてインターセプトされ、使用可能であれば ID トークンがヘッダーを介してリクエストに追加されます。サーバー側では、リクエスト ヘッダーに ID トークンがあるかどうか確認され、検証後に処理されます。Service Worker スクリプトで、フェッチ リクエストがインターセプトされ、変更されます。

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));
});

その結果、追加の処理を行うことなく、すべての認証済みリクエストのヘッダーに ID トークンが渡されます。

認証状態の変更を検出できるように、Service Worker をログインページ / 登録ページにインストールする必要があります。インストール後、現在のページのコントローラとして設定されるように、Service Worker はアクティベーション時に clients.claim() を呼び出します。

// In service worker script.
self.addEventListener('activate', event => {
  event.waitUntil(clients.claim());
});

クライアント側に対する変更

Service Worker がサポートされている場合は、Service Worker をクライアント側のログインページ /登録ページにインストールする必要があります。

// Install servicerWorker if supported on sign-in/sign-up page.
if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('/service-worker.js', {scope: '/'});
}

ユーザーがログインして別のページにリダイレクトされると、リダイレクトの完了前に、Service Worker が ID トークンをヘッダーに挿入します。

// Sign in screen.
firebase.auth().signInWithEmailAndPassword(email, password)
  .then((result) => {
    // Redirect to profile page after sign-in. The service worker will detect
    // this and append the ID token to the header.
    window.location.assign('/profile');
  })
  .catch((error) => {
    // Error occurred.
  });

サーバー側に対する変更

サーバー側のコードは、リクエストごとに ID トークンを検出します。次に Node.js Express サンプルコードを示します。

// Server side code.
const admin = require('firebase-admin');
const serviceAccount = require('path/to/serviceAccountKey.json');

// The Firebase Admin SDK is used here to verify the ID token.
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

function getIdToken(req) {
  // Parse the injected ID token from the request header.
  const authorizationHeader = req.headers.authorization || '';
  const components = authorizationHeader.split(' ');
  return components.length > 1 ? components[1] : '';
}

function checkIfSignedIn(url) {
  return (req, res, next) => {
    if (req.url == url) {
      const idToken = getIdToken(req);
      // Verify the ID token using the Firebase Admin SDK.
      // User already logged in. Redirect to profile page.
      admin.auth().verifyIdToken(idToken).then((decodedClaims) => {
        // User is authenticated, user claims can be retrieved from
        // decodedClaims.
        // In this sample code, authenticated users are always redirected to
        // the profile page.
        res.redirect('/profile');
      }).catch((error) => {
        next();
      });
    } else {
      next();
    }
  };
}

// If a user is signed in, redirect to profile page.
app.use(checkIfSignedIn('/'));

まとめ

ID トークンは Service Worker を介して設定されますが、Service Worker は同じ起点から実行されるように制限されています。異なる起点のウェブサイトがエンドポイントを呼び出そうとすると、Service Worker の呼び出しに失敗し、リクエストはサーバーから認証されません。このため、CSRF のリスクはありません。

Service Worker は現在の主要なブラウザでサポートされていますが、一部の古いブラウザではサポートされていません。Service Worker が利用できない場合や、Service Worker をサポートするブラウザでのみアプリケーションを実行するように制限できる場合は、サーバーに ID トークンを渡すためにフォールバックが必要になることがあります。

Service Worker の起点は 1 つだけで、https 接続または localhost 経由でサービスを提供するウェブサイトにのみインストールされます。

Service Worker のブラウザ サポートについては、caniuse.com をご覧ください。

フィードバックを送信...

ご不明な点がありましたら、Google のサポートページをご覧ください。