Demystifying user authentication with Google in Electron

Let's see how we can implement browser-based authentication for an Electron App using Firebase Auth, Cloud Functions, Realtime Database, and React.

Demystifying user authentication with Google in Electron

A few weeks back I got an interesting idea popped up in my mind, and soon I started working on its architectural design and flow. I wanted an application that can run in multiple desktop environments and I stumbled upon Electron. I knew this was gonna be easy for me because I've had experience with Web Applications before.

I decided to use Firebase Authentication to sign up and log in the users into the application, which is pretty straightforward to code in normal web applications. But Electron world had something else cooked up for me. Actually, it's not Electron it's Google. After I wrote the code for authentication inside the Electron App, I was not able to sign up and I was shown this Screen.

image.png

Upon reading and researching more on this obstacle I came to know that Google had deprecated the OAuth login for non-standard browsers and unfortunately Electron (which is interestingly Chromium-based) was also on the list.

So what now, how will the users sign up or login into my application? This was a total development blocker. Also, there was no good explanation available on the internet which talks about this problem in enough depth which can help people write their custom authentication logic.

After reading a lot of Firebase and Google APIs documentation, StackOverflow answers, blogs, and whatnot, I finally figured out a semi-custom auth architecture for my Electron App. So without further ado let's dive into the code.

What will we be using?

  • Electron (obviously :p)
  • Firebase Auth
  • Firebase Cloud Functions
  • React (you can skip and replace it whatever you like)

Basic Idea

TL;DR Refer to the diagram below directly if you want

  1. Electron App will create a UUID for the login session and launch the hosted web endpoint passing the created UUID.
  2. We'll host a custom web endpoint that will be running in the user's default browser and let the user sign in using their Google Account on behalf of our Electron App.
  3. Once the user signs in, we'll grab the ID TOKEN returned by Firebase, and invoke the cloud function with the UUID, and the grabbed ID TOKEN.
  4. Firebase Cloud Function will verify the ID TOKEN using the Firebase Admin SDK.
  5. On successful verification, the function will create a custom firebase token and update it to the corresponding UUID node in the Firebase Realtime Database.
  6. Electron App will be listening to the value for the generated UUID in the first step and will receive the custom token, which can be used to authenticate the user using Firebase Auth.

Architecture Flow Diagram:

auth arch.png

Electron App Renderer Side Code

As I will be using React, so I created on Component called SignInButton which handles UUID creation and sending it to the main process for opening the web-hosted custom endpoint on the user's default browser along with the UUID.

import React from "react";
import { v4 } from "uuid";
import firebase from "firebase";

const SignInButton = (): JSX.Element => {

  const handleSignIn = () => {
    // generating uuid
    const uuid = v4();

    // grabbing reference to the firebase realtime db document for the uuid
    const oneTimeUuidDocRef = firebase.database().ref(`onetime-uuids/${uuid}`);

    // applying listener to the reference document
    oneTimeUuidDocRef.on("value", async (snapshot) => {
      // getting the custom firebase token
      const authToken = snapshot.val();

      // use this credential accordingly
      const credential = await firebase.auth().signInWithCustomToken(authToken);

      /*
        Your rest auth code
      */
    });

    // invoking main process method to open user's default browser
    window.electronApi.ipcRenderer.invoke("initiate-login", uuid);
  };

  return (
    <div>
      <button onClick={handleSignIn}>Initiate Google Sign In</button>
    </div>
  );
};
export default SignInButton;

handleSignIn(): Called when user clicks on Sign In button inside our Electron App.

uuid: Random generated UUID unique for each Sign In request.

oneTimeUuidDocRef: Reference to Firebase Realtime Database document.

authToken: Custom token generated by Cloud Function, fetched from database document.

credential: Firebase Auth credentials after signing in via signInWithCustomToken().

window.electronApi.ipcRenderer: This comes from preload.ts script which helps us to call main process methods. This is a separate topic and we'll not be covering it in this blog, you can always read about it in Electron Documentation.

Refer to the below screenshot to know how this looks. Pretty basic.

Screenshot 2021-08-08 at 3.20.49 PM.png

Setting up Web Hosted Endpoint for Sign Up / Login to get ID TOKEN

Here for the explanation, I've created a React Web Application which has a route called https://{your_app_base_url}/gsigninhelper/${uuid}. I've created a component called GSignInHelper that gets rendered on calling this endpoint.

import React, { useEffect } from "react";
import firebase from "firebase";

const GSignInHelper = (props: any): JSX.Element => {

  // called immediatelly after component loads
  useEffect(() => {
    // boolean to keep only one request instance active and avoid multiple function triggers
    let didCancel = false;

    // setting up the provider for login [here Google Auth]
    const provider = new firebase.auth.GoogleAuthProvider();

    // wanted to use async inside useEffect
    const performGSignIn = async () => {
      // result object after user signs in
      const result = await firebase.auth().getRedirectResult();

      if (!didCancel && !result.user) {
        // if no result found, take user to Google Sign In page
        // happens only for the first time
        firebase.auth().signInWithRedirect(provider);
      } else {
        if (!result.user) {
          console.log("result.user is empty");
          return;
        }

        // UUID sent by Electron App
        const oneTimeToken: string = props.match.params.onetimetoken;

        // ID TOKEN
        const token = await result.user.getIdToken();

        // send UUID and ID TOKEN to cloud function
        // hosted firebase cloud function locally
        const response = await fetch(
          `http://localhost:5001/gsignin-electron/us-central1/gSignInFireFun?oneTimeToken=${oneTimeToken}&token=${token}`
        );
      }
    };

    // call the method
    performGSignIn();

    return () => {
      didCancel = true;
      console.log("cancelled..");
    };
  }, []);

  return <div>Signing you in please wait...</div>;
};

export default GSignInHelper;

provider: The provider via which we want to log in, in our case we are using Google Auth Provider.

result: Object which contains info about user and other sign-in creds like ID TOKEN once the user signs in.

performGSignIn(): Contains asynchronous logic to handle Sign In result object.

oneTimeToken: UUID sent by Electron App.

token: Id token from result object.

See the below GIF to get an idea, how this works. We get the token logged in our console.

auth crop gif.gif

Setting up Cloud Function to generate custom Firebase Token

In this example, we'll be using the local deployment for firebase functions, but you can also deploy to firebase anytime you want and use that, there will be no change in the working. This function will be receiving the UUID and the ID token from our React Web Application, and upon verifying the token, it will create a custom token that will be used by our Electron App to sign the user in. Let's see how it goes.

import * as functions from "firebase-functions";
import * as admin from "firebase-admin";
import * as corsLib from "cors";

const cors = corsLib({ origin: true });

/*
ADMIN SDK INITIALIZATIION CODE
*/

export const gSignInFireFun = functions.https.onRequest((request, response) => {
  // wrapping inside CORS
  cors(request, response, async () => {
    // getting query parameters from request object
    const query = request.query;

    // UUID from Electron App
    const oneTimeToken = query["oneTimeToken"];

    // ID TOKEN
    const token = query["token"] ?? "";

    // get decoded token if the token is valid
    const decodedToken = await admin.auth().verifyIdToken(token as string);

    // get the firebase user uid
    const uid = decodedToken.uid;

    // custom auth token
    const authToken = await admin.auth().createCustomToken(uid);

    // save the custom token to realtime database againsts the UUID
    await admin
      .database()
      .ref(`onetime-uuids/${oneTimeToken}`)
      .set(authToken, (err) => {
        console.log("error: ");
        console.log(err);
      });

    return response.status(200).send({
      token: authToken,
    });
  });
});

query: Query parameters from the request object.

oneTimeToken: UUID token from Electron App.

token: ID Token from our React Web Application.

decodedToken: This object contains the decoded claims if the token is validated successfully.

Let's see a complete sign-up flow

In the below gif, the sign-up is initiated by our Electron App, which opens our Web Hosted Custom Auth endpoint in the default browser, and finally, after signing in, the custom token is received by our Electron App as logged in the console.

full flow gif.gif

Concluding thoughts

Sometimes we have to make hard decisions and travel a bit further to achieve something scalable and customizable enough, as we saw in this blog. This was my very first blog, hope you like it, if you have any suggestions to this blog content or unrelated, please feel free to contact me on Twitter or LinkedIn. I would love to discuss with you! :)

References

  • Google Auth in Electron Blog by Vivek Nair on Pragli's blog
  • StackOverflow
  • Firebase and Google API documentation