Gerenciamento de sessão com service workers

O Firebase Auth oferece a capacidade de usar service workers para detectar e transmitir tokens de ID do Firebase para gerenciamento de sessões. Isso fornece os seguintes benefícios:

  • Capacidade de passar um token de ID em cada solicitação HTTP do servidor sem nenhum trabalho adicional.
  • Capacidade de atualizar o token de ID sem qualquer viagem de ida e volta ou latências adicionais.
  • Sessões sincronizadas de backend e frontend. Aplicativos que precisam acessar serviços do Firebase, como Realtime Database, Firestore, etc. e alguns recursos externos do servidor (banco de dados SQL, etc.) podem usar esta solução. Além disso, a mesma sessão também pode ser acessada a partir do Service Worker, Web Worker ou Shared Worker.
  • Elimina a necessidade de incluir o código-fonte do Firebase Auth em cada página (reduz a latência). O service worker, carregado e inicializado uma vez, cuidaria do gerenciamento de sessões para todos os clientes em segundo plano.

Visão geral

O Firebase Auth é otimizado para execução no lado do cliente. Os tokens são salvos no armazenamento na web. Isso facilita a integração também com outros serviços do Firebase, como Realtime Database, Cloud Firestore, Cloud Storage, etc. Para gerenciar sessões da perspectiva do servidor, os tokens de ID devem ser recuperados e passados ​​para o 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.
  });

No entanto, isso significa que algum script deve ser executado no cliente para obter o token de ID mais recente e, em seguida, passá-lo ao servidor por meio do cabeçalho da solicitação, do corpo do POST, etc.

Isso pode não ser escalonável e, em vez disso, podem ser necessários cookies de sessão do lado do servidor. Os tokens de ID podem ser definidos como cookies de sessão, mas têm vida curta e precisarão ser atualizados no cliente e, em seguida, definidos como novos cookies na expiração, o que pode exigir uma viagem de ida e volta adicional se o usuário não visitar o site há algum tempo.

Embora o Firebase Auth forneça uma solução de gerenciamento de sessão baseada em cookies mais tradicional, essa solução funciona melhor para aplicativos baseados em cookies httpOnly do lado do servidor e é mais difícil de gerenciar, pois os tokens do cliente e do lado do servidor podem ficar fora de sincronia, especialmente se você também precisar usar outros serviços Firebase baseados em cliente.

Em vez disso, os service workers podem ser usados ​​para gerenciar sessões de usuário para consumo no servidor. Isso funciona devido ao seguinte:

  • Os service workers têm acesso ao estado atual do Firebase Auth. O token de ID do usuário atual pode ser recuperado do service worker. Se o token expirar, o SDK do cliente irá atualizá-lo e retornar um novo.
  • Os service workers podem interceptar solicitações de busca e modificá-las.

Mudanças no trabalhador de serviço

O service worker precisará incluir a biblioteca Auth e a capacidade de obter o token de ID atual se um usuário estiver conectado.

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 as solicitações de busca para a origem do aplicativo serão interceptadas e, se um token de ID estiver disponível, anexado à solicitação por meio do cabeçalho. No lado do servidor, os cabeçalhos de solicitação serão verificados quanto ao token de ID, verificados e processados. No script do service worker, a solicitação de busca seria interceptada e 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, todas as solicitações autenticadas sempre terão um token de ID passado no cabeçalho sem processamento adicional.

Para que o service worker detecte alterações no estado do Auth, ele deve ser instalado normalmente na página de login/inscrição. Após a instalação, o service worker deve chamar clients.claim() na ativação para que possa ser configurado como controlador da página atual.

Web modular API

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

Web namespaced API

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

Mudanças no lado do cliente

O service worker, se suportado, precisa ser instalado na página de entrada/inscrição do lado do 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: '/'});
}

Quando o usuário estiver conectado e redirecionado para outra página, o service worker poderá injetar o token de ID no cabeçalho antes que o redirecionamento seja concluído.

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

Mudanças no lado do servidor

O código do lado do servidor será capaz de detectar o token de ID em cada solicitação. Isso é ilustrado no código de exemplo do Node.js Express a seguir.

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

Conclusão

Além disso, como os tokens de ID serão definidos por meio dos service workers, e os service workers estão restritos a executar a partir da mesma origem, não há risco de CSRF, pois um site de origem diferente que tentar chamar seus endpoints não conseguirá invocar o service worker , fazendo com que a solicitação pareça não autenticada da perspectiva do servidor.

Embora os service workers agora sejam suportados em todos os principais navegadores modernos, alguns navegadores mais antigos não os suportam. Como resultado, pode ser necessário algum substituto para passar o token de ID para o seu servidor quando os service workers não estão disponíveis ou um aplicativo pode ser restrito para ser executado apenas em navegadores que suportam service workers.

Observe que os service workers são de origem única e só serão instalados em sites atendidos por meio de conexão https ou localhost.

Saiba mais sobre o suporte do navegador para service workers em caniuse.com .