Gestion des sessions avec les service workers

Firebase Auth permet d'utiliser des service workers pour détecter et transmettre des jetons d'identification Firebase pour la gestion de session. Cela offre les avantages suivants:

  • Possibilité de transmettre un jeton d'ID à chaque requête HTTP à partir du 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 l'interface. Les applications qui doivent accéder aux services Firebase telles que Realtime Database et Firestore, par exemple, et à certaines ressources externes côté serveur (base de données SQL, etc.) peuvent utiliser cette solution. En outre, la même session est également accessible à partir du service worker, du nœud de calcul Web ou du nœud de calcul partagé.
  • Élimine la nécessité d'inclure le code source de l'authentification Firebase sur chaque page (réduit la latence). Le service worker, chargé et initialisé une fois, gère les sessions de 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 côté serveur, les jetons d'ID doivent être récupérés et transmis au serveur.

API Web modulaire

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 d'espace de noms 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 être exécuté à partir du client pour obtenir le jeton d'ID le plus récent, puis le transmettre au serveur via l'en-tête de requête, le corps POST, etc.

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

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

À la place, les service workers peuvent être utilisés pour gérer les sessions utilisateur pour une utilisation côté serveur. Cela fonctionne pour les raisons suivantes:

  • Les service workers ont accès à l'état actuel de l'authentification Firebase. Le jeton d'ID utilisateur actuel peut être récupéré auprès 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 aux service workers

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

API Web modulaire

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 d'espace de noms 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 de récupération envoyées à l'origine de l'application seront interceptées et, si un jeton d'ID est disponible, ajoutées à la requête via l'en-tête. Côté serveur, les en-têtes de requête sont vérifiés et traités pour trouver le jeton d'ID. Dans le script du service worker, la requête de récupération serait interceptée et modifiée.

API Web modulaire

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 d'espace de noms 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 comportent toujours un jeton d'ID transmis dans l'en-tête sans traitement supplémentaire.

Pour que le service worker détecte les changements d'état d'authentification, il doit être installé sur la page de connexion/inscription. Assurez-vous que le service worker est groupé 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 pour pouvoir le configurer en tant que contrôleur pour la page actuelle.

API Web modulaire

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

API d'espace de noms 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/inscription côté client.

API Web modulaire

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

API d'espace de noms 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.

API Web modulaire

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 d'espace de noms 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 pourra détecter le jeton d'ID à 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 Web modulaire

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'identification seront définis via les service workers et que leur exécution est limitée à partir de la même origine, il n'y a aucun risque de CSRF puisqu'un site Web d'origine différente tentant d'appeler vos points de terminaison échouera à appeler le service worker, rendant la requête 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 le sont pas. 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 lorsqu'une application peut être limitée à l'exécution sur des navigateurs compatibles avec les service workers.

Notez que les services workers ont une origine unique et ne seront installés que sur les sites Web accessibles via une connexion HTTPS ou localhost.

Pour en savoir plus sur les navigateurs compatibles avec Service Worker, consultez la page caniuse.com.