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 トークンを取得してサーバーに渡す必要があります。

ウェブ向けのモジュラー API

import { getAuth, getIdToken } from "firebase/auth";

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

ウェブ向けの名前空間 API

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 ライブラリを組み込む必要があります。

ウェブ向けのモジュラー API

import { initializeApp } from "firebase/app";
import { getAuth, onAuthStateChanged, getIdToken } from "firebase/auth";

// Initialize the Firebase app in the service worker script.
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 auth = getAuth();
const getIdTokenPromise = () => {
  return new Promise((resolve, reject) => {
    const unsubscribe = onAuthStateChanged(auth, (user) => {
      unsubscribe();
      if (user) {
        getIdToken(user).then((idToken) => {
          resolve(idToken);
        }, (error) => {
          resolve(null);
        });
      } else {
        resolve(null);
      }
    });
  });
};

ウェブ向けの名前空間 API

// 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 スクリプトでフェッチ リクエストがインターセプトされ、変更されます。

ウェブ向けのモジュラー API

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

// Get underlying body if available. Works for text and json bodies.
const getBodyContent = (req) => {
  return Promise.resolve().then(() => {
    if (req.method !== 'GET') {
      if (req.headers.get('Content-Type').indexOf('json') !== -1) {
        return req.json()
          .then((json) => {
            return JSON.stringify(json);
          });
      } else {
        return req.text();
      }
    }
  }).catch((error) => {
    // Ignore error.
  });
};

self.addEventListener('fetch', (event) => {
  /** @type {FetchEvent} */
  const evt = event;

  const requestProcessor = (idToken) => {
    let req = evt.request;
    let processRequestPromise = Promise.resolve();
    // For same origin https requests, append idToken to header.
    if (self.location.origin == getOriginFromUrl(evt.request.url) &&
        (self.location.protocol == 'https:' ||
         self.location.hostname == 'localhost') &&
        idToken) {
      // Clone headers as request headers are immutable.
      const headers = new Headers();
      req.headers.forEach((val, key) => {
        headers.append(key, val);
      });
      // Add ID token to header.
      headers.append('Authorization', 'Bearer ' + idToken);
      processRequestPromise = getBodyContent(req).then((body) => {
        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,
            // 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 processRequestPromise.then(() => {
      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.
  evt.respondWith(getIdTokenPromise().then(requestProcessor, requestProcessor));
});

ウェブ向けの名前空間 API

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

// Get underlying body if available. Works for text and json bodies.
const getBodyContent = (req) => {
  return Promise.resolve().then(() => {
    if (req.method !== 'GET') {
      if (req.headers.get('Content-Type').indexOf('json') !== -1) {
        return req.json()
          .then((json) => {
            return JSON.stringify(json);
          });
      } else {
        return req.text();
      }
    }
  }).catch((error) => {
    // Ignore error.
  });
};

self.addEventListener('fetch', (event) => {
  /** @type {FetchEvent} */
  const evt = event;

  const requestProcessor = (idToken) => {
    let req = evt.request;
    let processRequestPromise = Promise.resolve();
    // For same origin https requests, append idToken to header.
    if (self.location.origin == getOriginFromUrl(evt.request.url) &&
        (self.location.protocol == 'https:' ||
         self.location.hostname == 'localhost') &&
        idToken) {
      // Clone headers as request headers are immutable.
      const headers = new Headers();
      req.headers.forEach((val, key) => {
        headers.append(key, val);
      });
      // Add ID token to header.
      headers.append('Authorization', 'Bearer ' + idToken);
      processRequestPromise = getBodyContent(req).then((body) => {
        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,
            // 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 processRequestPromise.then(() => {
      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.
  evt.respondWith(getIdToken().then(requestProcessor, requestProcessor));
});

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

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

ウェブ向けのモジュラー API

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

ウェブ向けの名前空間 API

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

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

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

ウェブ向けのモジュラー API

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

ウェブ向けの名前空間 API

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

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

ウェブ向けのモジュラー API

import { getAuth, signInWithEmailAndPassword } from "firebase/auth";

// Sign in screen.
const auth = getAuth();
signInWithEmailAndPassword(auth, 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.
  });

ウェブ向けの名前空間 API

// 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 をご覧ください。