Sitzungsverwaltung mit Service Workern

Firebase Auth bietet die Möglichkeit, mithilfe von Service Workern Firebase-ID-Tokens für die Sitzungsverwaltung zu erkennen und weiterzugeben. Das bietet folgende Vorteile:

  • Möglichkeit, ein ID-Token bei jeder HTTP-Anfrage vom Server ohne zusätzlichen Aufwand weiterzugeben.
  • Die Möglichkeit, das ID-Token ohne zusätzlichen Umlauf oder zusätzliche Latenzen zu aktualisieren.
  • Mit Backend und Frontend synchronisierte Sitzungen Anwendungen, die auf Firebase-Dienste wie Realtime Database, Firestore usw. und einige externe serverseitige Ressourcen (SQL-Datenbank usw.) zugreifen müssen, können diese Lösung verwenden. Darüber hinaus kann dieselbe Sitzung auch über den Service Worker, Web Worker oder Shared Worker aufgerufen werden.
  • Es ist nicht mehr erforderlich, den Firebase Auth-Quellcode auf jeder Seite einzufügen (verringert die Latenz). Der einmal geladene und initialisierte Service Worker übernimmt die Sitzungsverwaltung für alle Clients im Hintergrund.

Überblick

Firebase Auth ist für die Ausführung auf Clientseite optimiert. Tokens werden im Web Storage gespeichert. Dies erleichtert auch die Einbindung in andere Firebase-Dienste wie Realtime Database, Cloud Firestore, Cloud Storage usw. Um Sitzungen serverseitig zu verwalten, müssen ID-Tokens abgerufen und an den Server übergeben werden.

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

Web-Namespace-API

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

Dies bedeutet jedoch, dass ein Skript auf dem Client ausgeführt werden muss, um das neueste ID-Token abzurufen und es dann über den Anfrageheader, den POST-Text usw. an den Server zu übergeben.

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

Firebase Auth stellt zwar eine traditionellere Cookie-basierte Sitzungsverwaltungslösung bereit, funktioniert jedoch am besten für serverseitige httpOnly-Cookie-basierte Anwendungen und ist schwieriger zu verwalten, da Client- und serverseitige Tokens nicht synchron sein können, insbesondere wenn Sie auch andere clientbasierte Firebase-Dienste verwenden müssen.

Stattdessen können Service Worker verwendet werden, um Nutzersitzungen für den serverseitigen Verbrauch zu verwalten. Dies funktioniert aus folgenden Gründen:

  • Service Worker haben Zugriff auf den aktuellen Firebase-Authentifizierungsstatus. 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.

Service Worker-Änderungen

Der Service Worker muss die Authentifizierungsbibliothek enthalten und das aktuelle ID-Token abrufen, wenn ein Nutzer angemeldet ist.

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

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

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

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

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

Dadurch wird allen authentifizierten Anfragen immer ein ID-Token im Header übergeben, ohne dass eine weitere Verarbeitung erforderlich ist.

Damit der Service Worker Änderungen des Auth-Status erkennen kann, muss er auf der Anmeldeseite oder Anmeldeseite installiert werden. Der Service Worker muss in einem Bundle zusammengefasst sein, damit er auch nach dem Schließen des Browsers weiterhin funktioniert.

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

Modulare Web-API

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

Web-Namespace-API

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

Clientseitige Änderungen

Der Service Worker muss, sofern unterstützt, auf der clientseitigen Anmelde-/Registrierungsseite installiert werden.

Modulare Web-API

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

Web-Namespace-API

// 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 auf eine andere Seite weitergeleitet wird, kann der Service Worker das ID-Token in den Header einschleusen, bevor die Weiterleitung abgeschlossen ist.

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

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

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

Modulare Web-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 darauf beschränkt sind, vom selben Ursprung aus ausgeführt zu werden, besteht außerdem kein CSRF-Risiko, da eine Website unterschiedlicher Herkunft versucht, die Endpunkte aufzurufen, den Service Worker nicht aufrufen kann, wodurch die Anfrage aus Serverperspektive als nicht authentifiziert angezeigt wird.

Service Worker werden jetzt in allen gängigen gängigen Browsern unterstützt, aber von einigen älteren Browsern nicht. Daher ist möglicherweise ein Fallback erforderlich, um das ID-Token an Ihren Server zu übergeben, wenn Service Worker nicht verfügbar sind, oder eine Anwendung kann so eingeschränkt werden, dass sie nur in Browsern ausgeführt wird, die Service Worker unterstützen.

Beachten Sie, dass Service-Worker nur einen einzelnen Ursprung haben und nur auf Websites installiert werden, die über eine HTTPS-Verbindung oder über localhost bereitgestellt werden.

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