Let AI write emails and messages for you 🔥

Authenticate user in Chrome extension and web app with single auth flow using Firebase

Gourav Goyal

Gourav Goyal

Mar 18, 2023

There are multiple approaches to implement Firebase Auth but this one allows users to log in/signup into the web app and chrome extension with the single auth flow given that Firebase Auth is used in the web app as well. This provides a better UX as the user need not repeat the auth flow for the web app. However, if the user first signs in using the web app he/she still needs to go through the auth flow for the chrome extension.

Prefer Supabase instead? checkout my other guide: Authenticate user in Chrome extension and web app with single auth flow using Supabase (gourav.io)

User flow

  1. User clicks on the signup/login button from the Chrome extension popup or content script.
  2. User is redirected to the web app’s auth page (Firebase auth is implemented in the web app).
  3. User completes the auth flow.. user is now logged in to the web app.
  4. User also gets logged in to the chrome extension.

How does it internally work?

When the user completes the auth flow from the web app, the web app makes a server call to create a custom JWT token (createCustomToken) and add it to the query string parameter i.e. https://myapp.com/login-success?custom_token=xxxxx. Chrome extension then parses this custom_token from the query string and saves it into the chrome storage. This custom_token is then used to authenticate the user in the Chrome extension using signInWithCustomToken method.

Implementation

Chrome Extension code

Setup Firebase in extension

import { initializeApp } from "firebase/app";
import { getAuth } from "@firebase/auth";

const firebaseConfig = {
  projectId: PLASMO_PUBLIC_FIREBASE_PROJECT_ID,
  apiKey: PLASMO_PUBLIC_FIREBASE_PUBLIC_API_KEY,
  authDomain: PLASMO_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: PLASMO_PUBLIC_FIREBASE_DATABASE_URL
};

const app = initializeApp(firebaseConfig);

export const authClient = getAuth(app);

Add tabs permission to manifest in order to parse custom token from URL query string:

"permissions": [
      "tabs"
    ]

Redirect user to auth page on signup/login action from popup / content script:

// content script / popup page

// this function is called when the user clicks on the signup/login button from the extension
function authHandler() {
  // tell background service worker to create a new tab and start listening to tab URL changes to parse query string param
  chrome.runtime.sendMessage({
    action: "handleAuth"
  });
}

Store custom token in extension

In the background page, trigger auth flow and save custom_token from the URL query string parameter into chrome storage necessary to set the user session in the Chrome extension later.

// background service worker

chrome.runtime.onMessage.addListener(function (request, sender, sendResponse) {
  const action = request.action;
  switch (action) {
    case "handleAuth":
      {
        // remove old listener if it exists
        chrome.tabs.onUpdated.removeListener(parseCustomToken);
        // create a new auth tab
        chrome.tabs
          .create({
            url: `${domain}/signup?via_extension`,
            active: true,
          })
          .then((tab) => {
            // add listener to that url and watch for custom_token query string param
            chrome.tabs.onUpdated.addListener(parseCustomToken);
            sendResponse(request.action + " executed");
          });

        break;
      }
  }
});


const parseCustomToken = async (
  tabId: number,
  changeInfo: chrome.tabs.TabChangeInfo,
  tab: chrome.tabs.Tab
) => {
  // once the tab is loaded
  if (tab.status === "complete") {
    if (!tab.url) return;
    const url = new URL(tab.url);

    // at this point user is logged-in to the web app
    // parse customToken from query string param
    if (url.origin === mySiteDomain) {

      const params = new URL(url.href).searchParams;
      const customToken = params.get("custom_token");

      if (customToken) {
        console.log("custom token found", customToken);

        // we can close that tab now if we want
        // if (tab.id) {
        //   await chrome.tabs.remove(tab.id);
        // }

        // store customToken in storage as these will be used to authenticate user in chrome extension
        const key: chromeStorageKey = "authCustomToken";
        await chrome.storage.sync.set({
          [key]: customToken
        });

        // remove tab listener as token is set
        chrome.tabs.onUpdated.removeListener(parseCustomToken);

        // TODO: send message to content scripts to login user
        //await sendMessageToAllContentScripts({ action: "triggerLoginAction" });
      }
    }
  }
};

Authenticate user using custom token in extension

You can access isUser variable to check the user auth state:

// content script / popup page

import { onAuthStateChanged, signInWithCustomToken } from "@firebase/auth";
import { authClient } from "./initFirebaseClient";

// check user status: logged in or logged out
export let isUser = false
onAuthStateChanged(auth, async (fbuser) => {
  if (fbuser) {
    isUser = true
    console.log("user is logged in", fbuser);
  } else {
    isUser = false
    console.log("user is logged out");
  }
});

export async function signout() {
  await removeItemFromStorage("authCustomToken");
  await authClient.signOut();
}

export async function triggerLoginAction() {
  initAuth();
}

async function initAuth() {
  // token as the source of truth for user auth state
  const token = await getItemFromStorage("authCustomToken");
  if (!token) {
    // token not present so remove any previously signed in user
    await authClient.signOut();
  }
  // token is present
  else {
    const user = authClient.currentUser;
    if (!user) {
      // token is present but user is not logged in, so login user via firebase auth
      try {
        const cred = await signInWithCustomToken(auth, token);
      } catch (e: any) {
        if (e && e.code === "auth/invalid-custom-token") {
          await removeItemFromStorage("authCustomToken");
        }
      }
    }
  }
}

async function removeItemFromStorage(key: string): Promise<void> {
  await chrome.storage.sync.remove(key);
}

async function getItemFromStorage(key: string): Promise<string | null> {
  const resp = await chrome.storage.sync.get(key);

  if (resp[key]) {
    return resp[key];
  }

  return null;
}

initAuth();

Server code

I’m using Vercel serverless functions but any server where you can initialize Firebase admin SDK would do.

Setup Firebase admin SDK on Node.JS server

import { credential } from "firebase-admin";
import { initializeApp, getApps, getApp } from "firebase-admin/app";
import { getAuth } from "firebase-admin/auth";

const adminCredentials = {
  credential: credential.cert({
    projectId: NEXT_PUBLIC_FIREBASE_PROJECT_ID,
    clientEmail: FIREBASE_CLIENT_EMAIL,
    privateKey: JSON.parse(FIREBASE_PRIVATE_KEY),
  }),
  databaseURL: NEXT_PUBLIC_FIREBASE_DATABASE_URL,
};

// avoid initializing twice
const firebaseAdminApp =
  getApps().length === 0 ? initializeApp(adminCredentials) : getApp();

export const authAdmin = getAuth(firebaseAdminApp);

Create custom JWT token on the server

Create custom JWT token API route:

// node api route page

import { NextApiRequest, NextApiResponse } from "next";
import { authAdmin } from "../../../server/initFirebaseAdmin";

export default async function createCustomToken(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const token = req.headers.authorization;
  const uid = (await authAdmin.verifyIdToken(token)).uid;
  if (!uid) return res;

  const customToken = await authAdmin.createCustomToken(uid);

  res.status(200).json({ data: { customToken: customToken } });
  return res;
}

Web app code

Setup Firebase in the web app

import { getAuth } from "@firebase/auth";
import { initializeApp, getApps, getApp } from "firebase/app";

const firebaseConfig = {
  projectId: NEXT_PUBLIC_FIREBASE_PROJECT_ID,
  apiKey: NEXT_PUBLIC_FIREBASE_PUBLIC_API_KEY,
  authDomain: NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
  databaseURL: NEXT_PUBLIC_FIREBASE_DATABASE_URL,
};

// avoid initializing twice
const firebaseClientApp =
  getApps().length === 0 ? initializeApp(firebaseConfig) : getApp();

export const authClient = getAuth(firebaseClientApp);

Note: I skipped the part to implement Firebase auth in the web app. Refer this or this guide instead.

Get custom JWT token from API after successful login

Once the user is logged in to the web app, redirect the user to some login success page like https://myapp.com/login-success. Next, call the API to get a custom JWT token and add it to the query string param. Remember that the extension’s tab listener is active so when it’s added to the query string, the extension will parse this token and store it to authenticate the user in the extension.

// login success page

const token = getCustomTokenFromQS();

// avoid infinite loop
if (!token) {
  // get custom jwt token from server
  const token = await getCustomTokenFromServer();
  // add it to the query string param
  setCustomTokenInQS(token);
}


// helper functions //

function setCustomTokenInQS(customToken: string) {
  const urlParams = new URLSearchParams(window.location.search);

  urlParams.set("custom_token", customToken);

  window.location.search = urlParams.toString();
}

async function getCustomTokenFromServer() {
  const customToken = (await callApi("/api/auth/createCustomToken", "GET")).data
    .customToken as string;
  return customToken;
}

export async function callApi(
  url: string,
  method: ApiMethod,
  payload?: any
): Promise<{ data?: any; error?: unknown }> {
  let res = { data: "", error: "" };
  try {
    const token = (await authClient.currentUser?.getIdToken(true)) as string;

    res = await fetcher(url, method, token, payload);
  } catch (e: any) {
    res.error = e.message;
    console.error("Error: ", e.message);
  }

  return res;
}

const fetcher = async (
  url: string,
  method: ApiMethod,
  token: string,
  payload?: string
): Promise<any> => {
  const requestHeaders = new Headers();
  requestHeaders.set("Content-Type", "application/json");
  requestHeaders.set("Authorization", token);

  const res = await fetch(url, {
    body: payload ? JSON.stringify(payload) : undefined,
    headers: requestHeaders,
    method,
  });

  let resobj = res;
  try {
    resobj = await res.json();
  } catch (e) {
    throw new Error("Unexpected issue. Please try again.");
    // }
  }
  return resobj;
};

export type ApiMethod = "GET" | "POST" | "PUT" | "DELETE";
That's all, folks!

Gourav Goyal

Gourav Goyal