Administración de sesiones con service workers

Firebase Auth permite usar service workers a fin de detectar y pasar tokens de ID de Firebase para administrar sesiones. Esto tiene los siguientes beneficios:

  • Se obtiene la capacidad de pasar un token de ID en cada solicitud HTTP desde el servidor sin trabajo adicional.
  • Se obtiene la capacidad de actualizar el token de ID sin ningún recorrido de ida y vuelta ni latencias adicionales.
  • Se llevan a cabo sesiones sincronizadas de backend y frontend. Las aplicaciones que necesitan acceder a servicios de Firebase, como Realtime Database, Firestore, etc., y algunos recursos externos del servidor (base de datos SQL, etc.) pueden usar esta solución. Además, también se puede acceder a la misma sesión desde el service worker, Web Worker o Shared Worker.
  • Se quita la necesidad de incluir el código fuente de Firebase Auth en cada página (reduce la latencia). Cuando se cargue y se inicialice, el service worker podría controlar la administración de las sesiones de todos los clientes en segundo plano.

Descripción general

Firebase Auth está optimizado para ejecutarse en el lado del cliente. Los tokens se guardan en un almacenamiento web, lo que facilita la integración en otros servicios de Firebase, como Realtime Database, Cloud Firestore, Cloud Storage, etc. Para administrar sesiones desde la perspectiva del servidor, los tokens de ID deben recuperarse y pasarse al servidor.

API modular 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 espacio de nombres web

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

Sin embargo, esto significa que algunas secuencias de comandos se deben ejecutar desde el cliente para obtener el token de ID más reciente y, luego, pasarlo al servidor a través del encabezado de la solicitud, el cuerpo de POST, etcétera.

Es posible que esto no escale y se necesiten cookies de sesión en el servidor. Los tokens de ID se pueden configurar como cookies de sesión, pero son de corta duración y deberán actualizarse desde el cliente y, luego, configurarse como cookies nuevas cuando estas caduquen, lo que en ocasiones requiere un recorrido de ida y vuelta adicional si el usuario no ha visitado el sitio por un tiempo.

Si bien Firebase Auth proporciona una solución de administración de sesiones con cookies más tradicional, esta funciona mejor en aplicaciones con cookies httpOnly en el servidor y es más difícil de administrar, ya que los tokens del cliente y los del servidor podrían perder la sincronización, en especial si también necesitas usar servicios de Firebase de otro cliente.

En cambio, los service workers se pueden utilizar a fin de administrar sesiones de usuario para el consumo en el servidor. Su funcionamiento se debe a lo siguiente:

  • Los service workers tienen acceso al estado actual de Firebase Auth. El token de ID de usuario actual se puede recuperar desde el service worker. Si el token caduca, el SDK del cliente lo actualizará y mostrará uno nuevo.
  • Los service workers pueden interceptar solicitudes de captura y modificarlas.

Cambios en el service worker

El service worker deberá incluir la biblioteca de Auth y la capacidad de obtener el token de ID actual si un usuario accedió.

API modular 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 espacio de nombres 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);
      }
    });
  });
};

Todas las solicitudes de recuperación emitidas al origen de la app se interceptarán y se adjuntarán a la solicitud a través del encabezado si hay un token de ID disponible. Los encabezados de solicitud en el servidor para el token de ID se revisarán, verificarán y procesarán. En la secuencia de comandos del service worker, se interceptaría y modificaría la solicitud de recuperación.

API modular 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 espacio de nombres 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));
});

Por lo tanto, en todas las solicitudes autenticadas siempre habrá un token de ID que se pasa en el encabezado sin necesidad de procesamiento adicional.

Para que el service worker detecte los cambios en el estado de Auth, por lo general, se debe instalar en la página de registro o de acceso. Después de la instalación, el service worker debe llamar a clients.claim() cuando se active para que pueda configurarse como controlador de la página actual.

API modular web

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

API con espacio de nombres web

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

Cambios en el cliente

El service worker, si es compatible, se debe instalar en la página de registro o de acceso del cliente.

API modular web

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

API con espacio de nombres web

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

Cuando el usuario accede y se lo redirecciona a otra página, el service worker podrá inyectar el token de ID en el encabezado antes de que finalice el redireccionamiento.

API modular 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 espacio de nombres 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.
  });

Cambios en el servidor

El código en el servidor podrá detectar el token de ID en cada solicitud, como se ilustra en el siguiente código de muestra de Express de Node.js:

// Server side code.
const admin = require('firebase-admin');
const serviceAccount = require('path/to/serviceAccountKey.json');

// The Firebase Admin SDK is used here to verify the ID token.
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount)
});

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('/'));

Conclusión

Además, debido a que los tokens de ID se configurarán a través de los service workers y estos no pueden ejecutarse desde el mismo origen, no hay riesgo de que ocurra un CSRF, ya que un sitio web de origen distinto que intente llamar a los extremos no podrá invocar al service worker y, de esta manera, la solicitud no aparecerá autenticada desde la perspectiva del servidor.

Si bien ahora los service workers son compatibles con todos los navegadores principales modernos, hay otros más antiguos que no los admiten. Por lo tanto, es posible que se necesite algún resguardo para pasar el token de ID a tu servidor cuando los service workers no estén disponibles o cuando una app solo pueda ejecutarse en navegadores que admiten service workers.

Ten en cuenta que los service workers tienen un solo origen y solo se instalarán en sitios web que se administran mediante una conexión HTTPS o localhost.

Si deseas obtener más información sobre la compatibilidad del navegador para service worker, visita caniuse.com.