Gestion des sessions avec les service workers

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

  • Possibilité de transmettre un jeton d'ID à chaque requête HTTP du serveur sans effort supplémentaire.
  • Possibilité d'actualiser le jeton d'ID sans aller-retour supplémentaire les latences.
  • Sessions synchronisées entre le backend et le frontend. Les applications qui ont besoin d'accéder Des services Firebase tels que Realtime Database, Firestore, etc., et certains une ressource côté serveur (base de données SQL, etc.) peut utiliser cette solution. De plus, la même session peut également être accessible depuis le service worker, le web worker ou le worker partagé.
  • Il n'est plus nécessaire d'inclure du code source Firebase Auth sur chaque page. (réduit la latence). Une fois chargé et initialisé, le service worker 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 stockage Web. Cela facilite également l'intégration à d'autres services Firebase comme 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ées et transmises 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 être exécuté à partir du client pour obtenir le dernier jeton d'identification, puis le transmettre au serveur via l'en-tête de requête, POST corps, etc.

Cette approche peut ne pas être évolutive 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 ont une durée de vie courte et doivent être actualisés à partir 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 visité le site depuis un certain temps.

Même si Firebase Auth fournit une interface solution de gestion de sessions basée sur les cookies, cette solution fonctionne mieux pour les applications httpOnly basées sur des cookies côté serveur et est plus difficile à gérer, car les jetons client et les jetons côté serveur pourraient n'est pas synchronisée, surtout si vous devez également utiliser d'autres applications services.

À la place, vous pouvez utiliser des service workers pour gérer les sessions utilisateur côté serveur. leur consommation. Cela fonctionne pour les raisons suivantes:

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

Modifications apportées au service worker

Le service worker doit inclure la bibliothèque Auth et pouvoir récupérer 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, et est ajouté à la requête via l'en-tête. Côté serveur, les en-têtes de requête sont vérifiés pour le jeton d'ID, validés et traités. Dans le script du service worker, la requête de récupération 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 sans traitement supplémentaire.

Pour que le service worker détecte les changements d'état d'authentification, il doit être installées 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 Le nœud de calcul doit appeler clients.claim() lors de l'activation pour pouvoir le configurer en tant que 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é côté client sur la page de connexion/inscription.

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 pourra 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 à chaque requête. Ce est compatible avec le SDK Admin pour Node.js ou avec l'API SDK utilisant 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, les jetons d'ID étant définis via les service workers, les nœuds de calcul ne peuvent s'exécuter qu'à partir de la même origine, il n'y a aucun risque de CSRF car un site Web d'origine différente qui tente d'appeler vos points de terminaison ne parvient pas à appeler le service worker, entraînant l'apparition de la requête non authentifiés du point de vue du serveur.

Même si les service workers sont désormais compatibles avec tous les principaux navigateurs modernes, certains les navigateurs plus anciens ne les prennent pas en charge. Par conséquent, certaines créations de remplacement peuvent être pour transmettre le jeton d'ID à votre serveur lorsque les service workers ne sont pas ou limiter l'exécution d'une application aux navigateurs qui prennent en charge de service workers.

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

Pour en savoir plus sur la compatibilité des navigateurs avec Service Worker, consultez la page caniuse.com.