Firebase Auth bietet die Möglichkeit, Service Worker zu verwenden, um Firebase-ID-Tokens für die Sitzungsverwaltung zu erkennen und zu übergeben. Das bietet folgende Vorteile:
- Möglichkeit, bei jeder HTTP-Anfrage vom Server ein ID-Token zu übergeben, ohne zusätzlichen Aufwand.
- Möglichkeit, das ID-Token ohne zusätzliche Roundtrips oder Latenzen zu aktualisieren.
- Synchronisierte Back-End- und Front-End-Sitzungen. Anwendungen, die auf Firebase-Dienste wie Realtime Database, Firestore usw. und auf eine externe serverseitige Ressource (SQL-Datenbank usw.) zugreifen müssen, können diese Lösung verwenden. Außerdem kann auf dieselbe Sitzung auch über den Service Worker, Web Worker oder Shared Worker zugegriffen werden.
- Der Firebase Auth-Quellcode muss nicht auf jeder Seite enthalten sein (reduziert die Latenz). Der Service Worker wird nur einmal geladen und initialisiert und verwaltet dann die Sitzungen für alle Clients im Hintergrund.
Übersicht
Firebase Auth ist für die Ausführung auf dem Client optimiert. Tokens werden im Webspeicher gespeichert. Dadurch lässt sich der Dienst auch problemlos in andere Firebase-Dienste wie Realtime Database, Cloud Firestore, Cloud Storage usw. einbinden. 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 Skript 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 zu übergeben.
Das ist möglicherweise nicht skalierbar und stattdessen sind serverseitige Sitzungscookies erforderlich. ID-Tokens können als Sitzungscookies festgelegt werden. Diese sind jedoch nur kurz gültig und müssen vom Client aktualisiert und dann nach Ablauf als neue Cookies festgelegt werden. Das kann einen zusätzlichen Roundtrip erfordern, wenn der Nutzer die Website schon länger nicht mehr besucht hat.
Firebase Auth bietet zwar eine traditionellere
cookiebasierte Sitzungsverwaltungslösung,
diese Lösung funktioniert jedoch am besten für serverseitige httpOnly cookiebasierte Anwendungen
und ist schwieriger zu verwalten, da die Client-Tokens und serverseitigen Tokens nicht mehr 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 die serverseitige Nutzung zu verwalten. Das funktioniert aus folgenden Gründen:
- Service Worker haben Zugriff auf den aktuellen Firebase Auth-Status. Das aktuelle ID-Token des Nutzers kann vom Service Worker abgerufen werden. Wenn das Token abgelaufen ist, aktualisiert das Client-SDK es und gibt ein neues zurück.
- Service Worker können Fetch-Anfragen abfangen und ändern.
Änderungen am Service Worker
Der Service 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 Fetch-Anfragen an den Ursprung der App werden abgefangen und wenn 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-Skript wird die Fetch-Anfrage 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)); });
Dadurch wird bei allen authentifizierten Anfragen immer ein ID-Token im Header übergeben, ohne dass eine zusätzliche Verarbeitung erforderlich ist.
Damit der Service Worker Änderungen am Auth-Status erkennen kann, muss er auf der Anmelde-/Registrierungsseite installiert sein. Achten Sie darauf, dass der Service Worker gebündelt ist, damit er auch nach dem Schließen des Browsers 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.
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 Anmelde-/Registrierungsseite installiert sein.
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 Service 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 mit dem 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 nur vom selben Ursprung ausgeführt werden können, besteht außerdem kein Risiko von CSRF. Wenn eine Website mit einem anderen Ursprung versucht, Ihre Endpunkte aufzurufen, kann der Service Worker nicht aufgerufen werden. Die Anfrage wird aus Serversicht als nicht authentifiziert angezeigt.
Service Worker werden jetzt in allen modernen gängigen Browsern unterstützt, einige ältere Browser jedoch nicht. Daher ist möglicherweise ein Fallback erforderlich, um das ID-Token an Ihren Server zu übergeben, wenn keine Service Worker verfügbar sind. Alternativ kann eine App so eingeschränkt werden, dass sie nur in Browsern ausgeführt werden kann, die Service Worker unterstützen.
Service Worker sind nur für einen Ursprung verfügbar und werden nur auf Websites installiert, die über eine HTTPS-Verbindung oder localhost bereitgestellt werden.
Weitere Informationen zur Browserunterstützung für Service Worker finden Sie unter caniuse.com.
Nützliche Links
- Weitere Informationen zur Verwendung von Service Workern für die Sitzungsverwaltung finden Sie im Quellcode der Beispiel-App auf GitHub.
- Eine bereitgestellte Beispiel-App finden Sie unter https://auth-service-worker.appspot.com.