Sitzungsverwaltung mit Service Workern

Mit Firebase Auth können Sie Dienst-Worker verwenden, um Firebase-ID-Tokens für die Sitzungsverwaltung zu erkennen und weiterzuleiten. Das bietet folgende Vorteile:

  • Möglichkeit, ohne zusätzliche Arbeit ein ID-Token für jede HTTP-Anfrage vom Server zu übergeben.
  • Das ID-Token kann ohne zusätzlichen Rückgabevorgang oder Latenz aktualisiert werden.
  • Synchronisierte Sitzungen zwischen Backend und Frontend. Diese Lösung eignet sich für Anwendungen, die auf Firebase-Dienste wie Realtime Database, Firestore usw. und einige externe serverseitige Ressourcen (z. B. SQL-Datenbanken) zugreifen müssen. Außerdem kann auf dieselbe Sitzung auch über den Service-, Web- oder Shared Worker zugegriffen werden.
  • Es ist nicht mehr erforderlich, den Firebase Auth-Quellcode auf jeder Seite einzufügen. Dadurch wird die Latenz verringert. Der Service Worker, der einmal geladen und initialisiert wird, übernimmt die Sitzungsverwaltung für alle Clients im Hintergrund.

Übersicht

Firebase Auth ist für die Ausführung auf der Clientseite optimiert. Tokens werden im Webspeicher gespeichert. So lässt sich die Integration auch in andere Firebase-Dienste wie Realtime Database, Cloud Firestore und Cloud Storage ganz einfach realisieren. Um Sitzungen serverseitig zu verwalten, müssen ID-Tokens abgerufen und an den Server übergeben werden.

Web

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

Web

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

Das bedeutet jedoch, dass ein Script vom Client ausgeführt werden muss, um das aktuelle ID-Token abzurufen und es dann über den Anfrageheader, den POST-Body usw. an den Server weiterzuleiten.

Dies ist möglicherweise nicht skalierbar und stattdessen sind serverseitige Sitzungscookies erforderlich. ID-Tokens können als Sitzungs-Cookies festgelegt werden. Diese sind jedoch kurzlebig und müssen vom Client aktualisiert und dann nach Ablauf als neue Cookies festgelegt werden. Dies kann einen zusätzlichen Rückgabevorgang erfordern, wenn der Nutzer die Website schon länger nicht besucht hat.

Firebase Auth bietet zwar eine traditionellere cookiebasierte Lösung zur Sitzungsverwaltung, diese Lösung eignet sich jedoch am besten für serverseitige httpOnly cookiebasierte Anwendungen und ist schwieriger zu verwalten, da die clientseitigen und serverseitigen Tokens ins Stocken geraten können, insbesondere wenn Sie auch andere clientbasierte Firebase-Dienste verwenden müssen.

Stattdessen können Sie Dienstprogramme verwenden, um Nutzersitzungen für die serverseitige Nutzung zu verwalten. Das funktioniert aus folgenden Gründen:

  • Dienst-Worker haben Zugriff auf den aktuellen Firebase Auth-Status. Das aktuelle Nutzer-ID-Token kann vom Service Worker abgerufen werden. Wenn das Token abgelaufen ist, wird es vom Client SDK aktualisiert und ein neues zurückgegeben.
  • Service Worker können Abrufanfragen abfangen und ändern.

Änderungen an Dienst-Workern

Der Dienst-Worker muss die Auth-Bibliothek und die Möglichkeit enthalten, das aktuelle ID-Token abzurufen, wenn ein Nutzer angemeldet ist.

Web

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

Web

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

Alle Abrufanfragen an den Ursprung der App werden abgefangen und, falls ein ID-Token verfügbar ist, über den Header an die Anfrage angehängt. Serverseitig werden die Anfrageheader auf das ID-Token geprüft, bestätigt und verarbeitet. Im Service Worker-Script wird die Abrufanfrage abgefangen und geändert.

Web

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

Web

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

Daher wird bei allen authentifizierten Anfragen immer ein ID-Token im Header übergeben, ohne dass es zusätzlich verarbeitet werden muss.

Damit der Dienst-Worker Änderungen des Authentifizierungsstatus erkennen kann, muss er auf der Anmelde-/Registrierungsseite installiert sein. Achten Sie darauf, dass der Dienst-Worker gebundelt ist, damit er auch nach dem Schließen des Browsers funktioniert.

Nach der Installation muss der Dienstarbeiter bei der Aktivierung clients.claim() aufrufen, damit er als Controller für die aktuelle Seite eingerichtet werden kann.

Web

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

Web

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

Clientseitige Änderungen

Der Service Worker muss, sofern unterstützt, auf der clientseitigen Anmeldeseite installiert werden.

Web

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

Web

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

Wenn der Nutzer angemeldet ist und zu einer anderen Seite weitergeleitet wird, kann der Dienst-Worker das ID-Token in den Header einschleusen, bevor die Weiterleitung abgeschlossen ist.

Web

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

Web

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

Serverseitige Änderungen

Der serverseitige Code kann das ID-Token bei jeder Anfrage erkennen. Dieses Verhalten wird vom Admin SDK für Node.js oder vom Web SDK mit FirebaseServerApp unterstützt.

Node.js

  // Server side code.
  const admin = require('firebase-admin');

  // The Firebase Admin SDK is used here to verify the ID token.
  admin.initializeApp();

  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('/'));

Web modular API

import { initializeServerApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';

export default function MyServerComponent() {

    // Get relevant request headers (in Next.JS)
    const authIdToken = headers().get('Authorization')?.split('Bearer ')[1];

    // Initialize the FirebaseServerApp instance.
    const serverApp = initializeServerApp(firebaseConfig, { authIdToken });

    // Initialize Firebase Authentication using the FirebaseServerApp instance.
    const auth = getAuth(serverApp);

    if (auth.currentUser) {
        redirect('/profile');
    }

    // ...
}

Fazit

Da ID-Tokens über die Service Worker festgelegt werden und Service Worker nur vom selben Ursprung ausgeführt werden dürfen, besteht außerdem kein CSRF-Risiko. Wenn eine Website mit einem anderen Ursprung versucht, Ihre Endpunkte aufzurufen, kann der Service Worker nicht aufgerufen werden. Daher wird die Anfrage vom Server als nicht authentifiziert angezeigt.

Service Worker werden jetzt in allen modernen Browsern unterstützt, einige ältere Browser unterstützen sie jedoch nicht. Daher ist möglicherweise ein Fallback erforderlich, um das ID-Token an Ihren Server weiterzuleiten, wenn Dienstworker nicht verfügbar sind. Eine App kann auch so eingeschränkt werden, dass sie nur in Browsern ausgeführt wird, die Dienstworker unterstützen.

Hinweis: Dienst-Worker sind nur für einen einzelnen Ursprung bestimmt und werden nur auf Websites installiert, die über eine https-Verbindung oder localhost ausgeliefert werden.

Weitere Informationen zur Browserunterstützung für Service Worker finden Sie unter caniuse.com.