Gestione delle sessioni con i service worker

Firebase Auth consente di utilizzare i service worker per rilevare e passare i token ID Firebase per la gestione delle sessioni. Questa opzione offre i seguenti vantaggi:

  • Possibilità di passare un token ID su ogni richiesta HTTP del server senza alcuna operazione aggiuntiva.
  • Possibilità di aggiornare il token ID senza ulteriori round trip o latenze.
  • 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, alla stessa sessione è possibile accedere anche da service worker, web worker o worker condivisi.
  • 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, gestirà la gestione delle sessioni per tutti i client in background.

Panoramica

Firebase Auth è ottimizzato per l'esecuzione sul lato client. I token vengono salvati nello spazio di archiviazione web. Ciò semplifica l'integrazione 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.

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

API con spazio dei nomi web

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

Tuttavia, questo significa che alcuni script devono essere eseguiti dal client per ottenere il token ID più recente e poi passarlo al server tramite l'intestazione della richiesta, il corpo POST e così via.

Questa opzione potrebbe non essere scalabile e, al contrario, 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 quindi impostati come nuovi cookie alla scadenza, il che potrebbe richiedere un ulteriore round trip se l'utente non visita il sito da un po' di tempo.

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

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

  • I service worker hanno accesso allo stato attuale di Firebase Auth. L'attuale token ID utente può essere recuperato dal service worker. Se il token è scaduto, l'SDK del 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 di autenticazione e la possibilità di ottenere il token ID corrente se un utente ha eseguito l'accesso.

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

API con spazio dei nomi 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, verranno aggiunte alla richiesta tramite l'intestazione. Lato server, le intestazioni delle richieste verranno controllate per trovare il token ID, verificate ed elaborate. Nello script del service worker, la richiesta di recupero viene intercettata e modificata.

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

API con spazio dei nomi 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 trasmesso nell'intestazione senza ulteriori elaborazioni.

Affinché il service worker possa rilevare le modifiche dello stato dell'autenticazione, deve essere installato nella pagina di accesso/registrazione. Assicurati che il service worker sia associato in modo da continuare a funzionare dopo la chiusura del browser.

Dopo l'installazione, il worker del servizio deve chiamare clients.claim() al momento dell'attivazione in modo che possa essere configurato come controller della pagina corrente.

API modulare web

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

API con spazio dei nomi 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.

API modulare web

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

API con spazio dei nomi 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 esegue l'accesso e viene reindirizzato a un'altra pagina, il service worker potrà inserire il token ID nell'intestazione prima del completamento del reindirizzamento.

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

API con spazio dei nomi 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 su ogni richiesta. Questo comportamento è supportato dall'SDK Admin per Node.js o con l'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 sono limitati per l'esecuzione dalla stessa origine, non vi è 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 passare il token ID al server quando i service worker non sono disponibili o quando un'app può essere limitata all'esecuzione solo su browser che supportano i service worker.

Tieni presente che i service worker hanno un'unica origine e verranno installati solo su siti web gestiti tramite connessione https o localhost.

Scopri di più sul supporto dei browser per i service worker all'indirizzo caniuse.com.