Gestione delle sessioni con i service worker

Firebase Authentication offre la possibilità di utilizzare i worker di servizio per rilevare e trasmettere i token ID Firebase per la gestione delle sessioni. Ciò offre i seguenti vantaggi:

  • Possibilità di passare un token ID in ogni richiesta HTTP dal server senza alcun lavoro aggiuntivo.
  • Possibilità di aggiornare il token ID senza ulteriori round trip o latenze.
  • Sessioni sincronizzate di backend e frontend. Questa soluzione può essere utilizzata dalle applicazioni che devono accedere ai servizi Firebase come Realtime Database, Firestore e così via e ad alcune risorse lato server esterne (database SQL e così via). Inoltre, è possibile accedere alla stessa sessione anche dal service worker, dal web worker o dal worker condiviso.
  • 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, gestiva la gestione delle sessioni per tutti i client in background.

Panoramica

Firebase Auth è ottimizzato per funzionare lato client. I token vengono salvati nello spazio di archiviazione 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 lato server, gli ID token devono essere recuperati e trasmessi 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'ID token più recente e poi 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. Gli ID token possono essere impostati come cookie della sessione, ma questi sono di breve durata e dovranno essere aggiornati dal client e impostati come nuovi cookie alla scadenza, il che potrebbe richiedere un'ulteriore transazione se l'utente non ha visitato il sito da un po' di tempo.

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

I worker di servizio possono invece essere utilizzati per gestire le sessioni utente per il consumo lato server. Questo funziona per i seguenti motivi:

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

Modifiche ai 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. Sul lato server, le intestazioni della richiesta verranno controllate per verificare la presenza del token ID, che verrà poi verificato ed elaborato. Nello script del worker del servizio, 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 ulteriore elaborazione.

Affinché il service worker possa rilevare le modifiche dello stato di autenticazione, deve essere installato nella pagina di accesso/registrazione. Assicurati che il worker del servizio sia aggregato in modo che funzioni ancora dopo la chiusura del browser.

Dopo l'installazione, il worker del servizio deve chiamare clients.claim() all'attivazione in modo da poter 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 lato client nella pagina di accesso/registrazione.

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à iniettare 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 che utilizza 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é gli ID token verranno impostati tramite i service worker e questi ultimi possono essere eseguiti solo dalla stessa origine, non esiste il rischio di CSRF, poiché un sito web di origine diversa che tenta di chiamare i tuoi endpoint non riuscirà a invocare il service worker, facendo apparire la richiesta come non autenticata dal punto di vista del server.

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

Tieni presente che i service worker sono solo di origine singola e verranno installati solo sui siti web pubblicati tramite connessione https o localhost.

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