Skip to content

Instantly share code, notes, and snippets.

@patrickkettner
Last active June 4, 2024 05:39
Show Gist options
  • Save patrickkettner/ed4faa346ae351aacc20da4083213f4d to your computer and use it in GitHub Desktop.
Save patrickkettner/ed4faa346ae351aacc20da4083213f4d to your computer and use it in GitHub Desktop.

Firebase Auth - signInWithPopup

This recipe shows how to authorize Firebase via signInWithPopup in a Chrome extension with manifest v3

Overview

There are a number of ways to authenticate using Firebase, some more complex than others. signInWithPopup requires a popup to be displayed to your user. Additionally, many Firebase authentication methods need to asyncronously load sub dependencies. Manifest v3 extensions are required to package code they need to run within their extension.

To get around these incompatibilities, our service worker connects to an offscreenDocument. That document creates an iframe that connects to the remote web service, which loads a compiled version of our signInWithPopup wrapper directly from our extension. This would normally be blocked by the browser, but we allow for this specific remote web service to connect to this specific file by specifying web_accessible_resources in our manifest.json. Once the compiled script has been loaded inside of the iframe, the firebase code is execute in the context of the remote web service. As a result, it is no longer blocked by the strict CORS rules used in Manifest v3.

As the firebase code executes, it will show the popup from signInWithPopup to your user. Once they complete their login flow the iframe sends the authentication results to the offscreenDocument via postMessage. The offscreenDocument then repeats the same data to the service worker.

Running this extension

  1. Clone this repository.
  2. Update firebaseConfig.js with your Firebase Config. This can be found on your Firebase dashboard.
  3. Run npm run compile:signInWithPopup. This will package the a specific version of the Firebase client into a single file controlled by our extension, rather than rely on an external service.
  4. Ensure that https://positive-fanatical-machine.glitch.me is an "Authorized domain" in your Firebase Authentication dashboard
  5. Load this directory in Chrome as an unpacked extension.
  6. Open the Extension menu and click the extension named "Firebase Auth - signInWithPopup".
  7. Open a console and run let {userCred} = await firebaseAuth()
export default {
apiKey: "...",
authDomain: "...",
projectId: "...",
storageBucket: "...",
messagingSenderId: "...",
appId: "..."
};
{
"manifest_version": 3,
"name": "Firebase Auth - signInWithPopup",
"description": "This sample shows how to authorize Firebase via signInWithPopup in a Chrome extension with manifest v3",
"version": "0.1",
"background": {
"service_worker": "service_worker.js"
},
"permissions": [
"offscreen"
],
"web_accessible_resources": [
{
"resources": [ "signInWithPopup_background.js" ],
"matches": [ "https://positive-fanatical-machine.glitch.me/*" ]
}
]
}
<!DOCTYPE html>
<script src="./offscreen.js"></script>
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// Registering this listener when the script is first executed ensures that the
// offscreen document will be able to receive messages when the promise returned
// by `offscreen.createDocument()` resolves.
const _URL = "https://positive-fanatical-machine.glitch.me/signInWithPopup.html"
const iframe = document.createElement('iframe');
iframe.src=_URL
document.documentElement.appendChild(iframe)
chrome.runtime.onMessage.addListener(handleChromeMessages);
function handleChromeMessages(message, sender, sendResponse) {
// Return early if this message isn't meant for the offscreen document.
if (message.target !== 'offscreen') {
return false;
}
function handleIframeMessage({data}) {
try {
data = JSON.parse(data);
self.removeEventListener("message", handleIframeMessage)
sendResponse(data)
} catch (e) {
console.log(`json parse failed (probably fine) - ${e.message}`)
}
}
self.addEventListener("message", handleIframeMessage, false);
iframe.contentWindow.postMessage('load firebase auth', new URL(_URL).origin)
return true;
}
{
"name": "firebase-crx-demo",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"node-fetch": "^3.3.1"
}
}
import { existsSync } from 'fs';
import fetch from 'node-fetch';
async function getLatestPackageVersions(packageName) {
const response = await fetch(`https://registry.npmjs.org/${packageName}`);
if (!response.ok) {
throw new Error(`Failed to retrieve package versions. Status code: ${response.status}`);
}
const data = await response.json();
const versions = Object.keys(data.versions).filter(v => v.match(/\d*\.\d*\.\d*$/));
return versions?.pop();
}
const LATEST_VERSION = await getLatestPackageVersions('firebase');
const REMOTE_FIREBASE_URL = `https://www.gstatic.com/firebasejs/${LATEST_VERSION}`;
export default {
plugins: [{
transform: function transform(code, id) {
// find references that are being imported locally (i.e. `require("./firebase/foo")`,
// or as an installed module (`import {foo} from firebase/bar` ) and rewrite them to
// be full remote URLs
return code.replace(/(\.\/)?(?:firebase\/)([a-zA-Z]+)/g, `${REMOTE_FIREBASE_URL}/firebase-$2.js`)
},
resolveDynamicImport: function(importee) {
if (!existsSync(importee)) {
return importee
}
},
load: async function transform(id, options, outputOptions) {
// this code runs over all of out javascript, so we check every import
// to see if it resolves as a local file, if that fails, we grab it from
// the network via fetch, and return the contents of that file directly inline
if (!existsSync(id)) {
const response = await fetch(id);
const code = await response.text();
return code
}
return null
}
},
{
resolveId: function(importee, importer, options) {
if (!importer) {
return null
}
return importee
}
}
],
output: {
inlineDynamicImports: true
}
};
// Copyright 2023 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
const OFFSCREEN_DOCUMENT_PATH = '/offscreen.html';
// A global promise to avoid concurrency issues
let creating;
let locating;
// There can only be one offscreenDocument. So we create a helper function
// that returns a boolean indicating if a document is already active.
async function hasDocument() {
// Check all windows controlled by the service worker to see if one
// of them is the offscreen document with the given path
const matchedClients = await clients.matchAll();
return matchedClients.some(
(c) => c.url === chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH)
);
}
async function setupOffscreenDocument(path) {
//if we do not have a document, we are already setup and can skip
if (!(await hasDocument())) {
// create offscreen document
if (creating) {
await creating;
} else {
creating = chrome.offscreen.createDocument({
url: path,
reasons: [
chrome.offscreen.Reason.DOM_SCRAPING
],
justification: 'authentication'
});
await creating;
creating = null;
}
}
}
async function firebaseAuth() {
await setupOffscreenDocument(OFFSCREEN_DOCUMENT_PATH);
const auth = await chrome.runtime.sendMessage({
type: 'firebase-auth',
target: 'offscreen'
});
await closeOffscreenDocument();
return auth;
}
async function closeOffscreenDocument() {
if (!(await hasDocument())) {
return;
}
await chrome.offscreen.closeDocument();
}
<!DOCTYPE html>
<html>
<head>
<!--
NOTE: THIS FILE IS NOT USED HERE
it is just included as a reference for what is hosted at https://positive-fanatical-machine.glitch.me, which is used in our offscreen document
-->
<title>signInWithPopup generator</title>
<script>
async function displayMessage ({origin}) {
// TODO: this should be checking a specific chrome-extension URL
if (origin.startsWith('chrome-extension')) {
const script = document.createElement('script');
// we set the `src` to a URL of a file that is bundled with our extension.
script.src = `${origin}/signInWithPopup_background.js`
document.head.appendChild(script);
}
}
// when iframe receives a message, this runs and adds the background script to the page to request all of the firebase shit
window.addEventListener("message", displayMessage, false);
</script>
</head>
<body>
<h1>Hello, World!</h1>
</body>
</html>
import { signInWithPopup, GoogleAuthProvider, getAuth } from "firebase/auth";
import { initializeApp } from "firebase/app";
import firebaseConfig from './firebaseConfig.js'
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const PARENT_FRAME = document.location.ancestorOrigins[0]
// This demo is using Google auth provider, but any supported provider should work
const PROVIDER = new GoogleAuthProvider()
// here is where we can configure any scopes or options for the authentication
signInWithPopup(getAuth(), PROVIDER).then(userCred => {
globalThis.parent.self.postMessage(JSON.stringify({userCred}), PARENT_FRAME)
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment