DEV Community

Cover image for How to Emulate Firebase Auth
Harvtronix
Harvtronix

Posted on • Originally published at harvtronix.com

How to Emulate Firebase Auth

I was recently building an app in which I was trying to integrate Firebase Authentication and Firebase Realtime Database. But I ran into a problem pretty quickly while I was testing things locally.

Even though Firebase has a super amazing Emulator Suite for local testing, authentication is not included. To me, this meant that the lovely auth-based Realtime DB rules I'd crafted were impossible to test locally unless I modified my DB rules beforehand. But that doesn't make for a very good permissions test, does it? There is an open issue on GitHub for addressing this, but at the time of writing, no solution has yet been included in the emulator suite.

Update 2020-10-27: Firebase Auth is now part of the Emulator Suite! Upgrade the Firebase CLI to version 8.14.0 or greater to use it. If that's all you were looking for, the rest of this post might not be useful to you, but feel free to keep reading for a more detailed look at Firebase Auth and my general philosophy towards testing and modularization.

I spent a bunch of hours trying to figure out how to string things together with Band-Aids and glue to do something that honestly seems like a pretty basic requirement for DB testing: Test my auth rules in both development and production without modifying the very security model I'm trying to test. After all, who would want to do "real" permissions testing for the first time in a production environment??

Nothing was working. I was stuck. I missed. Then I missed again. Then I got sad. I had a popsicle. And then I passed out in the snow.

Just kidding on the last few, but what I did do what have an epiphany in the shower. I do some of my best thinking there. Anyone else? No? Okay. Moving on.

The Solution

My app in particular is using Google Login and the Google auth provider, so that's what I'm going to focus on here, but I believe this approach would translate to other auth providers as well.

The key to making this work is abstraction. Take any Firebase call that you'd normally make and hide it behind a function that may or may not do the same thing. Usually, it's the same sort of thing with some extras sprinkled in.

In this case, we'll be looking at the firebase.initializeApp function. In the normal production environment, this is super simple. We pass in a siteConfig object and we're on our merry way. However, when working locally and/or with Firebase Emulators, this doesn't work one-for-one. In the docs, they indicate that we should use initializeTestApp instead to perform our initialization. This comes from the @firebase/testing module as opposed to the firebase/app module. This might seem perfect on the surface, but the issue is that anywhere we might normally use firebase.<sometThing> to interact with the default firebase app, we can't. We instead need to work with the app instance returned from the call to firebase.initializeTestApp(). By extension, this means we should structure our code so that we're always using app.<someThing> in favor of firebase.<someThing>, regardless of whether we're using initializeApp or initializeTestApp.

Again, this doesn't seem too bad on the surface, but there's one more catch: In each case, the app instance (as provided by initialize*App(siteConfig)) is slightly different. Namely, app.auth() is not a thing for apps initialized via initializeTestApp().

This is the crux of the auth emulation problem. And this is what we are going to solve. Let's take a look at some code.

Here is a utility function to initialize either a test or production app and return it:

const createApp = async (onAuthStateChanged) => {
    const firebase = await importFirebase()

    if (isDevelopment) {
        const app = firebase.initializeTestApp(siteConfig)

        // set up custom hooks for auth mocking
        app.__internal__ = {
            onAuthStateChanged
        }

        return app
    } else {
        const app = firebase.initializeApp(siteConfig)

        // Set up the auth observer
        app.auth().onAuthStateChanged(onAuthStateChanged)

        return app;
    }
}
Enter fullscreen mode Exit fullscreen mode

There's a lot going on here, so let's break it down line by line.

const createApp = async (onAuthStateChanged) => {
Enter fullscreen mode Exit fullscreen mode

I went with async here because, in a couple lines, you'll see some dynamic imports. More on that in a sec. The other important piece here is that this createApp function takes an onAuthStateChanged callback and not a siteConfig object like initializeApp. Since we control the module containing this abstraction function, we can put our siteConfig object here too for easy access. I mean, you can put the siteConfig wherever you want, but to me, it makes sense to have the same module own the config block and the utility functions since the goal is to drive all Firebase-related functions through this abstraction module.

The onAuthStateChanged callback will be called when—you guessed it—the auth state changes. In the production case, we can simply set up an auth observer in the usual manner, but in the development case, it's a bit more interesting. More on that in a sec.

const firebase = await importFirebase()
Enter fullscreen mode Exit fullscreen mode

Here's another layer of abstraction. We want a reference to Firebase as a module, and more specifically we might want a reference to the "testing" version of Firebase, but we don't actually care how it is obtained. Dynamic imports are a huge help here. This is what the definition of importFirebase looks like:

const importFirebase = async () => {
    if (isDevelopment) {
        return await import('@firebase/testing')
    } else {
        const firebase = await import('firebase/app')

        await import('firebase/auth')
        await import('firebase/database')

        return firebase
    }
}
Enter fullscreen mode Exit fullscreen mode

There's nothing too surprising here. We are either importing "test" Firebase from @firebase/testing or we are importing "real" Firebase from firebase/app along with our other Firebase dependencies. Dynamically importing "real" Firebase is a little more involved, but it's basically the traditional way of doing it converted to dynamic import-form.

I feel like this is a good time to mention that the reason for using dynamic imports here is so that you only ever end up importing either the test Firebase or the production one, but never both. Dynamic imports give us that flexibility.

The astute reader might realize that even with dynamic imports, Webpack will still bundle both modules into the output since we don't know until runtime which type of environment we'll be in. While this is true, it can be avoided by splitting the vendor modules out as part of the build and filtering out one of the two Firebase chunks, depending on the build type.

Development Mode

if (isDevelopment) {
Enter fullscreen mode Exit fullscreen mode

Assuming this is a React app created via create-react-app, we can calculate whether or not this is a development build by looking for process.env.NODE_ENV === 'development'

const app = firebase.initializeTestApp(siteConfig)
Enter fullscreen mode Exit fullscreen mode

Next, we need to initialize the test app using the now-obtained Firebase module, providing it our siteConfig as usual. There's a key piece that needs to exist in the siteConfig for this to work though: An auth block. Here's an example config:

const siteConfig = {
    apiKey: '...',
    authDomain: window.location.host,
    databaseURL: isDevelopment
        ? 'http://localhost:9000?ns=...'
        : 'https://....firebaseio.com',
    databaseName: '...',
    projectId: '...',
    storageBucket: '....appspot.com',
    messagingSenderId: '...',
    appId: '...',
    measurementId: '...',
    auth: {
        uid: 'u111111',
        email: 'u111111@example.com'
    }
}

Enter fullscreen mode Exit fullscreen mode

That auth block is the key because that means that we can "inject" a user/email into the app manually as we see fit. There's a caveat though... Since this isn't real auth, we'll never get onAuthStateChanged callbacks fired. We're going to need to do that ourselves. And the first step towards doing that is to store a reference to the provided callback in our test app for later:

// set up custom hooks for auth mocking
app.__internal__ = {
    onAuthStateChanged
}

return app
Enter fullscreen mode Exit fullscreen mode

Here I chose __internal__ as a namespace that I figured nobody would collide with, but this could just as easily have been any other unique key on the app object.

Production Mode

The other case to consider here is the production case. Let's take a look at the else block:

} else {
    const app = firebase.initializeApp(siteConfig)

    // Set up the auth observer
    app.auth().onAuthStateChanged(onAuthStateChanged)

    return app;
}
Enter fullscreen mode Exit fullscreen mode

This is very similar to what happens in development except we end up importing "real" Firebase and setting up an actual auth observer with that callback we took in as an argument.

All of this is to say that we can now call

const app = MyFirebaseUtils.createApp(onAuthStateChanged)
Enter fullscreen mode Exit fullscreen mode

to get back a firebase app that's ready to go with either emulated auth in development or real auth in production.

I recommend holding onto this app instance in your application state so that it can be provided to any abstraction functions that may depend on it, such as simulating a login in development mode.

Simulating onAuthStateChanged

For any function we have that would trigger a login (or logout), we can add in a separate development-only flow in which we manually fire an onAuthStateChanged event. Looking at the docs, those events are either passed a user or null depending on whether the user is logged in or not.

If our production flow for logging in a user looks like this:

const doGoogleLogin = async (app, onSuccess, onFailure) => {
    const firebase = await importFirebase()
    const provider = new firebase.auth.GoogleAuthProvider()

    // Show the actual login popup. Succeeding here will update the internally managed uid and
    // auth of the app, which allows subsequent database calls (and other stuff) to work.
    app.auth().signInWithPopup(provider)
        .then(onSuccess)
        .catch(onFailure)
}
Enter fullscreen mode Exit fullscreen mode

Then we can add in a development flow, like this:

const doGoogleLogin = async (app, onSuccess, onFailure) => {
    if (isDevelopment) {
        // https://firebase.google.com/docs/reference/js/firebase.auth#usercredential
        onSuccess({
            credential: {
                accessToken: TEST_ID_AUTH_TOKEN
            },
            user: {
                uid: siteConfig.auth.uid
            }
        })

        // Fire a simulated onAuthStateChanged event, passing it the user from our siteConfig.auth block
        app.__internal__.onAuthStateChanged({
            uid: siteConfig.auth.uid,
            getIdToken: () => (TEST_ID_AUTH_TOKEN)
        })
    } else {
        // production flow
    }
}
Enter fullscreen mode Exit fullscreen mode

And there you have it! A sorta-kinda way to emulate auth from within a Firebase-enabled app. Hopefully you find this useful. I've been successfully using this approach in my project to help with offline testing using Firebase emulators.

Discussion (0)