Gestion des sessions avec les service workers

Firebase Auth permet d'utiliser des service workers pour détecter et transmettre des jetons d'ID Firebase pour la gestion des sessions. Cela présente les avantages suivants :

  • Possibilité de transmettre un jeton d'ID sur chaque requête HTTP depuis le serveur sans travail supplémentaire.
  • Possibilité d'actualiser le jeton d'ID sans aller-retour ni latence supplémentaires.
  • Sessions synchronisées entre le backend et le frontend. Les applications qui doivent accéder à des services Firebase tels que Realtime Database, Firestore, etc., et à certaines ressources côté serveur externes (base de données SQL, etc.) peuvent utiliser cette solution. De plus, la même session est également accessible depuis le service worker, le web worker ou le shared worker.
  • Élimine le besoin d'inclure le code source de Firebase Auth sur chaque page (réduit la latence). Le service worker, chargé et initialisé une seule fois, gère la gestion des sessions pour tous les clients en arrière-plan.

Présentation

Firebase Auth est optimisé pour s'exécuter côté client. Les jetons sont enregistrés dans le stockage Web. Cela facilite également l'intégration à d'autres services Firebase tels que Realtime Database, Cloud Firestore, Cloud Storage, etc. Pour gérer les sessions du point de vue du serveur, les jetons d'ID doivent être récupérés et transmis au serveur.

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

Toutefois, cela signifie qu'un script doit s'exécuter à partir du client pour obtenir le dernier jeton d'ID, puis le transmettre au serveur via l'en-tête de requête, le corps POST, etc.

Cela peut ne pas être évolutif et des cookies de session côté serveur peuvent être nécessaires. Les jetons d'ID peuvent être définis comme des cookies de session, mais ils sont de courte durée et doivent être actualisés à partir du client, puis définis comme de nouveaux cookies à l'expiration, ce qui peut nécessiter un aller-retour supplémentaire si l'utilisateur n'a pas visité le site depuis un certain temps.

Bien que Firebase Auth fournisse une solution de gestion des sessions plus traditionnelle basée sur les cookies, cette solution fonctionne mieux pour les applications basées sur les cookies httpOnly côté serveur et est plus difficile à gérer, car les jetons client et les jetons côté serveur peuvent être désynchronisés, en particulier si vous devez également utiliser d'autres services Firebase basés sur le client.

Au lieu de cela, les service workers peuvent être utilisés pour gérer les sessions utilisateur pour la consommation côté serveur. Cela fonctionne pour les raisons suivantes :

  • Les service workers ont accès à l'état actuel de Firebase Auth. Le jeton d'ID de l'utilisateur actuel peut être récupéré à partir du service worker. Si le jeton a expiré, le SDK client l'actualise et en renvoie un nouveau.
  • Les service workers peuvent intercepter les requêtes d'extraction et les modifier.

Modifications apportées au service worker

Le service worker doit inclure la bibliothèque Auth et la possibilité d'obtenir le jeton d'ID actuel si un utilisateur est connecté.

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

Toutes les requêtes d'extraction vers l'origine de l'application seront interceptées et, si un jeton d'ID est disponible, ajouté à la requête via l'en-tête. Côté serveur, les en-têtes de requête seront vérifiés pour le jeton d'ID, validés et traités. Dans le script du service worker, la requête d'extraction est interceptée et modifiée.

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

Par conséquent, toutes les requêtes authentifiées auront toujours un jeton d'ID transmis dans l'en-tête sans traitement supplémentaire.

Pour que le service worker détecte les modifications d'état d'Auth, il doit être installé sur la page de connexion/d'inscription. Assurez-vous que le service worker est regroupé afin qu'il continue de fonctionner après la fermeture du navigateur.

Après l'installation, le service worker doit appeler clients.claim() lors de l'activation afin de pouvoir être configuré en tant que contrôleur pour la page actuelle.

Web

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

Web

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

Modifications côté client

Le service worker, s'il est compatible, doit être installé sur la page de connexion/d'inscription côté 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: '/'});
}

Lorsque l'utilisateur est connecté et redirigé vers une autre page, le service worker peut injecter le jeton d'ID dans l'en-tête avant la fin de la redirection.

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

Modifications côté serveur

Le code côté serveur peut détecter le jeton d'ID sur chaque requête. Ce comportement est compatible avec le SDK Admin pour Node.js ou avec le SDK Web à l'aide de 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 modulaire 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');
    }

    // ...
}

Conclusion

De plus, étant donné que les jetons d'ID seront définis via les service workers et que les service workers sont limités à l'exécution à partir de la même origine, il n'y a aucun risque de CSRF, car un site Web d'une origine différente qui tente d'appeler vos points de terminaison ne parviendra pas à appeler le service worker, ce qui fera apparaître la requête comme non authentifiée du point de vue du serveur.

Bien que les service workers soient désormais compatibles avec tous les principaux navigateurs modernes, certains navigateurs plus anciens ne les prennent pas en charge. Par conséquent, une solution de secours peut être nécessaire pour transmettre le jeton d'ID à votre serveur lorsque les service workers ne sont pas disponibles, ou une application peut être limitée à l'exécution uniquement sur les navigateurs compatibles avec les service workers.

Notez que les service workers sont à origine unique et ne seront installés que sur les sites Web diffusés via une connexion https ou localhost.

En savoir plus sur la compatibilité des navigateurs avec les service workers sur caniuse.com.