Gestión de sesiones con trabajadores de servicios.

Firebase Auth brinda la capacidad de utilizar trabajadores de servicio para detectar y pasar tokens de ID de Firebase para la administración de sesiones. Esto proporciona los siguientes beneficios:

  • Capacidad de pasar un token de identificación en cada solicitud HTTP del servidor sin ningún trabajo adicional.
  • Capacidad de actualizar el token de identificación sin ningún viaje de ida y vuelta ni latencias adicionales.
  • Sesiones sincronizadas backend y frontend. Las aplicaciones que necesitan acceder a servicios de Firebase como Realtime Database, Firestore, etc. y algún recurso externo del servidor (base de datos SQL, etc.) pueden utilizar esta solución. Además, también se puede acceder a la misma sesión desde el trabajador de servicio, el trabajador web o el trabajador compartido.
  • Elimina la necesidad de incluir el código fuente de Firebase Auth en cada página (reduce la latencia). El trabajador del servicio, cargado e inicializado una vez, se encargaría de la gestión de sesiones para 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 el almacenamiento web. Esto facilita la integración también con otros servicios de Firebase, como Realtime Database, Cloud Firestore, Cloud Storage, etc. Para administrar las sesiones desde una perspectiva del lado del servidor, los tokens de identificación deben recuperarse y pasarse al servidor.

Web modular API

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 namespaced API

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

Sin embargo, esto significa que se debe ejecutar algún script desde el cliente para obtener el token de identificación más reciente y luego pasarlo al servidor a través del encabezado de la solicitud, el cuerpo POST, etc.

Es posible que esto no se escale y, en su lugar, es posible que se necesiten cookies de sesión del lado del servidor. Los tokens de identificación se pueden configurar como cookies de sesión, pero son de corta duración y el cliente deberá actualizarlos y luego configurarlos como cookies nuevas cuando caduquen, lo que puede requerir un viaje 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 basada en cookies más tradicional, esta solución funciona mejor para aplicaciones basadas en cookies httpOnly del lado del servidor y es más difícil de administrar ya que los tokens del cliente y los tokens del lado del servidor podrían desincronizarse, especialmente si también necesita usar otros servicios de Firebase basados ​​en el cliente.

En cambio, los trabajadores de servicios se pueden utilizar para administrar sesiones de usuario para el consumo del lado del servidor. Esto funciona debido a lo siguiente:

  • Los trabajadores del servicio tienen acceso al estado actual de Firebase Auth. El token de ID de usuario actual se puede recuperar del trabajador del servicio. Si el token ha caducado, el SDK del cliente lo actualizará y devolverá uno nuevo.
  • Los trabajadores del servicio pueden interceptar solicitudes de recuperación y modificarlas.

Cambios de trabajador de servicio

El trabajador del servicio deberá incluir la biblioteca de autenticación y la capacidad de obtener el token de identificación actual si un usuario inicia sesión.

Web modular API

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 namespaced API

// 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 del origen de la aplicación serán interceptadas y, si hay un token de identificación disponible, se agregarán a la solicitud a través del encabezado. Del lado del servidor, los encabezados de solicitud se verificarán en busca del token de identificación, se verificarán y se procesarán. En el script del trabajador del servicio, la solicitud de recuperación sería interceptada y modificada.

Web modular API

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 namespaced API

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

Como resultado, a todas las solicitudes autenticadas siempre se les pasará un token de identificación en el encabezado sin procesamiento adicional.

Para que el trabajador del servicio detecte cambios en el estado de autenticación, normalmente debe instalarse en la página de inicio de sesión/registro. Después de la instalación, el trabajador del servicio debe llamar clients.claim() durante la activación para que pueda configurarse como controlador de la página actual.

Web modular API

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

Web namespaced API

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

Cambios del lado del cliente

El trabajador de servicio, si es compatible, debe instalarse en la página de inicio de sesión/registro del lado del cliente.

Web modular API

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

Web namespaced API

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

Cuando el usuario inicia sesión y es redirigido a otra página, el trabajador del servicio podrá inyectar el token de identificación en el encabezado antes de que se complete la redirección.

Web modular API

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 namespaced API

// 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 del lado del servidor

El código del lado del servidor podrá detectar el token de identificación en cada solicitud. Esto se ilustra en el siguiente código de muestra de Node.js Express.

// 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, dado que los tokens de identificación se configurarán a través de los trabajadores del servicio, y los trabajadores del servicio están restringidos a ejecutarse desde el mismo origen, no hay riesgo de CSRF ya que un sitio web de diferente origen que intente llamar a sus puntos finales no podrá invocar al trabajador del servicio. , lo que hace que la solicitud parezca no autenticada desde la perspectiva del servidor.

Si bien los trabajadores de servicios ahora son compatibles con todos los principales navegadores modernos, algunos navegadores más antiguos no los admiten. Como resultado, es posible que se necesite algún respaldo para pasar el token de identificación a su servidor cuando los trabajadores del servicio no están disponibles o cuando una aplicación puede restringirse para ejecutarse solo en navegadores que admitan trabajadores del servicio.

Tenga en cuenta que los trabajadores de servicios son de origen único y solo se instalarán en sitios web atendidos a través de una conexión https o localhost.

Obtenga más información sobre la compatibilidad del navegador con el trabajador de servicio en caniuse.com .