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 o gerenciamento de sessões. Isso oferece os seguintes benefícios:

  • Capacidade de transmitir um token de ID a cada solicitação HTTP do servidor, sem trabalho a mais.
  • Capacidade de atualizar o token de ID sem viagens de ida e volta a mais ou latências.
  • Sessões sincronizadas de back-end e front-end. Os aplicativos que precisam acessar os serviços do Firebase, como o Realtime Database, o Firestore e outros, assim como alguns recursos externos do servidor (banco de dados SQL etc.), podem usar essa solução. Além disso, a mesma sessão também pode ser acessada a partir do service worker, worker da Web ou worker compartilhado.
  • 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, lidaria com o gerenciamento de sessões para todos os clientes em segundo plano.

Visão geral

O Firebase Auth é otimizado para ser executado no lado do cliente. Os tokens são salvos no armazenamento da Web. Isso facilita a integração com outros serviços do Firebase, como o Realtime Database, Cloud Firestore, Cloud Storage etc. Para gerenciar sessões de uma perspectiva do lado do servidor, os tokens de ID precisam ser recuperados e transmitidos para o servidor.

API modular da 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 com namespace da Web

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

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

Isso pode não ser escalonável, e pode ser preciso usar cookies de sessão no lado do servidor. Os tokens de ID podem ser definidos como cookies de sessão, mas eles são de curta duração e precisarão ser atualizados no cliente e definidos como novos cookies no vencimento, o que pode exigir uma viagem de ida e volta a mais caso o usuário não tenha acessado o site por um 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 apps httpOnly baseados em cookies no servidor. Além disso, ela é mais difícil de gerenciar, porque os tokens do cliente e do servidor podem ficar fora de sincronia, especialmente se você também precisar usar outros serviços do Firebase baseados em cliente.

Em vez disso, os service workers podem ser usados para gerenciar sessões de usuário para consumo no lado do servidor. Isso funciona pelos seguintes motivos:

  • 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 atualizará e retornará um novo.
  • Service workers podem interceptar solicitações de busca e modificá-las.

Mudanças no service worker

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

API modular da 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 com namespace da 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 as solicitações de busca para a origem do aplicativo serão interceptadas e, se um token de ID estiver disponível, ele será anexado à solicitação por meio do cabeçalho. No lado do servidor, os cabeçalhos de solicitação terão o token de ID marcado, serão verificados e processados. No script do service worker, a solicitação de busca seria interceptada e modificada.

API modular da 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 com namespace da 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));
});

Como resultado, todas as solicitações autenticadas sempre terão um token de ID transmitido no cabeçalho sem processamento extra.

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

API modular da Web

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

API com namespace da Web

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

Mudanças no lado do cliente

O service worker, se compatível, precisa ser instalado na página de login/inscrição no lado do cliente.

API modular da Web

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

API com namespace da Web

// 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 está 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.

API modular da 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 com namespace da 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.
  });

Mudanças no lado do servidor

O código no lado do servidor poderá detectar o token de ID em todas as solicitações. Isso é ilustrado no seguinte exemplo de código do 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('/'));

Conclusão

Além disso, como os tokens de ID serão definidos usando os service workers e os service workers estão restritos a uma execução a partir da mesma origem, não há risco de CSRF, porque um site de origem diferente que tenta chamar seus endpoints falhará ao chamar o service worker. Isso faz com que a solicitação pareça não autenticada da perspectiva do servidor.

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

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

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