Authenticate user in Chrome extension and web app with single auth flow using Firebase
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
- User clicks on the signup/login button from the Chrome extension popup or content script.
- User is redirected to the web app’s auth page (Firebase auth is implemented in the web app).
- User completes the auth flow.. user is now logged in to the web app.
- 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";