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 su ogni richiesta HTTP dal server senza lavoro aggiuntivo.
  • Possibilità di aggiornare il token ID senza ulteriori viaggi di andata e ritorno o e la latenza minima.
  • 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, il service worker può accedere alla stessa sessione, worker web 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, gestire la gestione delle sessioni per tutti i client in background.

Panoramica

Firebase Auth è ottimizzato per l'esecuzione sul lato client. I token vengono salvati in archiviazione sul web. In questo modo è facile anche l'integrazione con altri servizi Firebase. come Realtime Database, Cloud Firestore, Cloud Storage e così via. Per gestire le sessioni da un punto di vista lato server, i token ID devono essere recuperate e trasmesse 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.

Potrebbe non essere scalabile e potrebbero essere necessari cookie di sessione lato server. Gli ID token possono essere impostati come cookie della sessione, ma hanno una durata breve 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.

Anche se Firebase Auth fornisce 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 Service worker possono essere utilizzati per gestire le sessioni utente lato server il consumo eccessivo. Questo funziona 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 la aggiornerà e ne restituirà uno nuovo.
  • I Service worker possono intercettare le richieste di recupero e modificarle.

Modifiche ai service worker

Il service worker dovrà includere la libreria Auth e la possibilità di recuperare 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);
      }
    });
  });
};

Verranno intercettate tutte le richieste di recupero all'origine dell'app e se un token ID è disponibile, aggiunto alla richiesta tramite l'intestazione. Sul lato server, gli intestazioni della richiesta verranno controllate per verificare la presenza del token ID, che verrà poi verificato ed elaborato. Nello script del service worker, la richiesta di recupero viene intercettata e modificato.

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 disporranno sempre di un token ID dell'intestazione senza ulteriori elaborazioni.

Affinché il service worker possa rilevare le modifiche allo stato di autorizzazione, deve essere è stata installata nella pagina di accesso/registrazione. Assicurati che il service worker è in bundle in modo che possa funzionare anche dopo che il browser chiuso.

Dopo l'installazione, il servizio il lavoratore deve chiamare clients.claim() all'attivazione per poterlo configurare come il 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 sul lato client 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 su ogni richiesta. Questo è supportato da SDK Admin per Node.js o con SDK 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 web modulare

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, I worker sono limitati per l'esecuzione dalla stessa origine, non c'è alcun rischio di CSRF poiché un sito web di origine diversa che tenta di chiamare i tuoi endpoint non riescono a richiamare il service worker, causando la visualizzazione della richiesta non autenticati 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 del browser per il service worker all'indirizzo caniuse.com.