Gestione delle sessioni con i service worker

Firebase Auth offre la possibilità di utilizzare i service worker per rilevare e passare i token ID Firebase per la gestione delle sessioni. Questo offre i seguenti vantaggi:

  • Possibilità di trasmettere un token ID a ogni richiesta HTTP dal server senza alcun lavoro aggiuntivo.
  • Possibilità di aggiornare il token ID senza round trip o latenze aggiuntive.
  • Sessioni sincronizzate di backend e frontend. Le applicazioni che devono accedere a servizi Firebase come Realtime Database, Firestore e così via e ad alcune risorse lato server esterne (database SQL e così via) possono utilizzare questa soluzione. Inoltre, è possibile accedere alla stessa sessione anche dal service worker, dal web worker o dallo shared worker.
  • Elimina la necessità di includere il codice sorgente di Firebase Auth in ogni pagina (riduce la latenza). Il service worker, caricato e inizializzato una volta, gestisce la gestione delle sessioni per tutti i client in background.

Panoramica

Firebase Auth è ottimizzato per l'esecuzione sul lato client. I token vengono salvati nella memoria web. In questo modo è facile integrarsi anche con altri servizi Firebase come Realtime Database, Cloud Firestore, Cloud Storage e così via. Per gestire le sessioni dal punto di vista del server, i token ID devono essere recuperati e passati al server.

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

Tuttavia, ciò significa che è necessario eseguire uno script dal client per ottenere l'ultimo token ID e passarlo al server tramite l'intestazione della richiesta, il corpo POST e così via.

Questa operazione potrebbe non essere scalabile e potrebbero essere necessari cookie di sessione lato server. I token ID possono essere impostati come cookie di sessione, ma hanno una durata breve e devono essere aggiornati dal client e poi impostati come nuovi cookie alla scadenza, il che potrebbe richiedere un ulteriore round trip se l'utente non ha visitato il sito da un po' di tempo.

Sebbene Firebase Auth fornisca una soluzione di gestione delle sessioni più tradizionale basata sui cookie, questa soluzione funziona meglio per le applicazioni basate sui cookie lato server ed è più difficile da gestire, in quanto i token lato client e lato server potrebbero non essere sincronizzati, soprattutto se devi utilizzare anche altri servizi Firebase basati su client.httpOnly

I service worker possono invece essere utilizzati per gestire le sessioni utente per il consumo lato server. Questo funziona perché:

  • I service worker hanno accesso allo stato attuale di Firebase Auth. Il token ID utente corrente può essere recuperato dal service worker. Se il token è scaduto, l'SDK client lo aggiornerà e ne restituirà uno nuovo.
  • I service worker possono intercettare le richieste di recupero e modificarle.

Modifiche al service worker

Il service worker dovrà includere la libreria Auth e la possibilità di ottenere il token ID corrente se un utente ha eseguito l'accesso.

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

Tutte le richieste di recupero all'origine dell'app verranno intercettate e, se è disponibile un token ID, verrà aggiunto alla richiesta tramite l'intestazione. Lato server, le intestazioni della richiesta verranno controllate per il token ID, verificate ed elaborate. Nello script del service worker, la richiesta di recupero verrebbe intercettata e modificata.

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

Di conseguenza, tutte le richieste autenticate avranno sempre un token ID passato nell'intestazione senza ulteriori elaborazioni.

Affinché il service worker rilevi le modifiche dello stato di autenticazione, deve essere installato nella pagina di accesso/registrazione. Assicurati che il service worker sia incluso nel bundle in modo che continui a funzionare dopo la chiusura del browser.

Dopo l'installazione, il service worker deve chiamare clients.claim() all'attivazione in modo che possa essere configurato come controller per la pagina corrente.

Web

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

Web

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

Modifiche lato client

Il service worker, se supportato, deve essere installato nella pagina di accesso/registrazione lato client.

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

Quando l'utente ha eseguito l'accesso e viene reindirizzato a un'altra pagina, il service worker potrà inserire il token ID nell'intestazione prima del completamento del reindirizzamento.

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

Modifiche lato server

Il codice lato server sarà in grado di rilevare il token ID in ogni richiesta. Questo comportamento è supportato dall'SDK Admin per Node.js o dall'SDK web utilizzando FirebaseServerApp.

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

API modulare web

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

    // ...
}

Conclusione

Inoltre, poiché i token ID verranno impostati tramite i service worker e i service worker possono essere eseguiti solo dalla stessa origine, non esiste alcun rischio di CSRF, poiché un sito web di origine diversa che tenta di chiamare i tuoi endpoint non riuscirà a richiamare il service worker, facendo apparire la richiesta non autenticata dal punto di vista del server.

Sebbene i service worker siano ora supportati in tutti i principali browser moderni, alcuni browser meno recenti non li supportano. Di conseguenza, potrebbe essere necessario un fallback per trasmettere il token ID al server quando i service worker non sono disponibili o un'app può essere limitata all'esecuzione solo su browser che supportano i service worker.

Tieni presente che i service worker sono di una sola origine e verranno installati solo su siti web pubblicati tramite connessione HTTPS o localhost.

Scopri di più sul supporto dei browser per i service worker su caniuse.com.