Migrating a React app to the new Google Sign-In library

WEB
9 min read

Google announced last year that they will be discontinuing their Google Sign-In Javascript Platform Library for the web. It will be replaced with Google Identity Services, their new family of Identity APIs that consolidate multiple identity offerings under one SDK. If you use the existing library, you have until March 23, 2023 to migrate. However, the old library was officially deprecated on April 30, 2022 for new applications.

Google provides a migration guide for web applications using the Google Platform Library for both authentication and authorization. We use Google for authenticating users signing up or signing in into our web application, DoltHub.

DoltHub is a Next.js web application written in Typescript. In Google's migration guide they give a migration example for HTML and Javascript the old and new way, as well as an object reference that tells you what libraries and methods to replace.

While this is better than nothing, it wasn't completely clear what changes we needed to make to get the new library to work with React. I turned to the internet in hopes that someone had written about migrating a React application. I found one article that helped me get started, but their solution had some issues and wasn't completely working.

After some wrestling with our code, I successfully migrated our Google Sign-In button to the new library. The code ended up being a lot simpler than our original code, and migrating also fixed a bug we had where the Google button did not work in Incognito mode or private browsers.

So here's how we migrated our old code from the existing Google Platform Library to the new Google Identity Services.

The old way

Our old Google Sign-In button looked like this (we customized the colors a bit):

Google sign-in

Our whole Next.js app page was wrapped in a custom context, which included some scripts for Google sign in and analytics and handled our signout logic. A simple version of the context looked like this.

// contexts/google.tsx
import Head from "next/head";
import { useRouter } from "next/router";
import Script from "next/script";
import { createContext, ReactNode, useContext } from "react";
import { useSignoutMutation } from "../gen/graphql-types";
import { useServerConfig } from "./serverConfig";

export type GoogleContextValue = {
  signOut: () => Promise<void>;
};

export const GoogleContext = createContext<GoogleContextValue>({
  signOut: async () => {},
});

export function useGoogleContext(): GoogleContextValue {
  return useContext(GoogleContext);
}

export function GoogleProvider(props: { children: ReactNode }) {
  const router = useRouter();
  const [signout] = useSignoutMutation();
  const { googleOAuthClientId, gaTrackingID } = useServerConfig();

  const signOut = async () => {
    try {
      await _signOutOfGoogle(googleOAuthClientId);
      await signout();
      await router.push("/");
    } catch (err) {
      console.error(err);
    }
  };

  return (
    <>
      <Script src="https://apis.google.com/js/platform.js" />
      <Head>
        <meta name="google-signin-scope" content="profile email" />
        <meta name="google-signin-client_id" content={googleOAuthClientId} />
      </Head>
      <AnalyticsTags gaTrackingId={gaTrackingID} />
      <GoogleContext.Provider value={{ signOut }}>
        {props.children}
      </GoogleContext.Provider>
    </>
  );
}

async function _signOutOfGoogle(clientId: string): Promise<unknown> {
  const w = window as any;
  const gapi = w?.gapi;
  if (!gapi) {
    throw new Error("no gapi");
  }

  return new Promise((resolve, reject) => {
    gapi.load("auth2", () => {
      gapi.auth2
        .init({ client_id: clientId })
        .then((ga: any) => ga.signOut())
        .then(resolve)
        .catch(reject);
    });
  });
}

We rendered our Google sign-in button within our SignInPage component.

// components/GoogleButton.tsx
<div id="gSignInWrapper">
  <button id="googleSigninButton" type="button">
    <img src="/images/google.png" alt="" />
    <span>Sign in with Google</span>
  </button>
</div>

And created a custom hook to attach the click handler using gapi.

// hooks/useGoogleButton.ts
import { useServerConfig } from "../../../contexts/serverConfig";
import { IdentityProvider } from "../../../gen/graphql-types";
import { SigninDispatch } from "./state";

type Props = {
  setState: SigninDispatch;
  signin: (p: IdentityProvider, t?: string) => Promise<void>;
};

type ReturnType = {
  attachGoogleButton: (id: string) => Promise<unknown>;
};

export default function useGoogleButton(props: Props): ReturnType {
  const { googleOAuthClientId } = useServerConfig();

  async function attachGoogleButton(id: string): Promise<unknown> {
    const w = window as any;
    const gapi = w?.gapi;
    if (!gapi) {
      console.error("no gapi");
      return undefined;
    }

    return new Promise((resolve, reject) => {
      gapi.load("auth2", () => {
        gapi.auth2
          .init({
            client_id: googleOAuthClientId,
          })
          .then((ga: any) =>
            attachClickHandler(document.getElementById(id), ga)
          )
          .then(resolve)
          .catch((err: any) => {
            props.setState({
              error: new Error(`Error attaching Google button: ${err.details}`),
            });
          })
          .catch(reject);
      });
    });
  }

  function attachClickHandler(button: HTMLElement | null, auth2: any) {
    if (!button) return;
    auth2.attachClickHandler(
      button,
      {},
      async (googleUser: any) => {
        await props.signin(
          IdentityProvider.Google,
          googleUser.getAuthResponse().id_token
        );
      },
      (error: any) => {
        props.setState({
          error: new Error(`Sign-in error: ${error}`),
        });
      }
    );
  }

  return { attachGoogleButton };
}

We used this hook in another context that wraps just the sign-in page and handles our sign in API logic. attachGoogleButton was called when the sign-in section component renders (using useEffect).

The new way

The new Google sign-in button looks like this:

New google button

With the new Google Identity Services, we were able to simplify some of the logic above. We could remove any instances of gapi, including our signout logic, since we are now responsible for managing our own session state (which we were doing before, but we still had to sign out of Google as well).

This means we no longer needed our google context from above, and could just add the following script to our application.

<Script
  src="https://accounts.google.com/gsi/client"
  id="gsi-client"
  async
  defer
/>

The old attachClickHandler has been replaced by a callback function, which handles the ID token returned from the popup window. Our GoogleButton component could be simplified to look like this.

import { useEffect, useRef } from "react";
import { useServerConfig } from "../../../../contexts/serverConfig";
import { IdentityProvider } from "../../../../gen/graphql-types";
import { useSigninContext } from "../context";

export default function GoogleButton(): JSX.Element {
  const { signin, setState } = useSigninContext();
  const divRef = useRef<HTMLDivElement>(null);
  const { googleOAuthClientId } = useServerConfig();

  useEffect(() => {
    if (typeof window === "undefined" || !window.google || !divRef.current) {
      return;
    }

    try {
      window.google.accounts.id.initialize({
        client_id: googleOAuthClientId,
        callback: async (res) => {
          await signin(IdentityProvider.Google, res.credential);
        },
      });
      window.google.accounts.id.renderButton(divRef.current, opts);
    } catch (error) {
      setState({ error });
    }
  }, [googleOAuthClientId, window.google, divRef.current]);

  return <div ref={divRef} />;
}

In an easy world, this code would have worked. But the world is cold and hard so there are four roadblocks I had to get through to get the new button to successfully sign in a user.

1. Origin not allowed error

Before I could get the button to render, I ran into this error testing out the new code on localhost:3000.

The given origin is not allowed for the given client ID

The Google guide said I could use the same client ID for both the old and new libraries. I confirmed that http://localhost:3000 was an authorized origin for that client ID, so the error didn't make sense. Then I noticed:

Google client ID key point

Our client ID worked with the old library with just http://localhost:3000 as an origin, but the new one required http://localhost as well. Once we added it the error was gone.

2. Typescript complains about the missing google types on the window object

This one was also pretty easily solved by adding a google.d.ts file with the Google types. These types were not provided by Google, but I was able to create the file using this article and Google's Javascript API reference.

// google.d.ts

interface IdConfiguration {
  client_id: string;
  auto_select?: boolean;
  callback: (handleCredentialResponse: CredentialResponse) => void;
  login_uri?: string;
  native_callback?: (...args: any[]) => void;
  cancel_on_tap_outside?: boolean;
  prompt_parent_id?: string;
  nonce?: string;
  context?: string;
  state_cookie_domain?: string;
  ux_mode?: "popup" | "redirect";
  allowed_parent_origin?: string | string[];
  intermediate_iframe_close_callback?: (...args: any[]) => void;
}

interface CredentialResponse {
  credential?: string;
  select_by?:
    | "auto"
    | "user"
    | "user_1tap"
    | "user_2tap"
    | "btn"
    | "btn_confirm"
    | "brn_add_session"
    | "btn_confirm_add_session";
  clientId?: string;
}

interface GsiButtonConfiguration {
  type: "standard" | "icon";
  theme?: "outline" | "filled_blue" | "filled_black";
  size?: "large" | "medium" | "small";
  text?: "signin_with" | "signup_with" | "continue_with" | "signup_with";
  shape?: "rectangular" | "pill" | "circle" | "square";
  logo_alignment?: "left" | "center";
  width?: string;
  local?: string;
}

interface PromptMomentNotification {
  isDisplayMoment: () => boolean;
  isDisplayed: () => boolean;
  isNotDisplayed: () => boolean;
  getNotDisplayedReason: () =>
    | "browser_not_supported"
    | "invalid_client"
    | "missing_client_id"
    | "opt_out_or_no_session"
    | "secure_http_required"
    | "suppressed_by_user"
    | "unregistered_origin"
    | "unknown_reason";
  isSkippedMoment: () => boolean;
  getSkippedReason: () =>
    | "auto_cancel"
    | "user_cancel"
    | "tap_outside"
    | "issuing_failed";
  isDismissedMoment: () => boolean;
  getDismissedReason: () =>
    | "credential_returned"
    | "cancel_called"
    | "flow_restarted";
  getMomentType: () => "display" | "skipped" | "dismissed";
}

interface RevocationResponse {
  successful: boolean;
  error: string;
}

interface Credential {
  id: string;
  password: string;
}

interface Google {
  accounts: {
    id: {
      initialize: (input: IdConfiguration) => void;
      prompt: (
        momentListener?: (res: PromptMomentNotification) => void
      ) => void;
      renderButton: (
        parent: HTMLElement,
        options: GsiButtonConfiguration
      ) => void;
      disableAutoSelect: () => void;
      storeCredential: (credentials: Credential, callback: () => void) => void;
      cancel: () => void;
      onGoogleLibraryLoad: () => void;
      revoke: (
        hint: string,
        callback: (done: RevocationResponse) => void
      ) => void;
    };
  };
}

interface Window {
  google?: Google;
}

3. useEffect doesn't listen to when the window object changes

This one took me the longest to figure out. How do I ensure window.google has loaded when I call window.google.accounts.id.initialize? useEffect only listens to when props and state change. As this was a more general React problem, I found a few things to try. One of them is to load the GSI client script on component mount, and use the onload method to handle the initialize function. That looked like this:

const [scriptLoaded, setScriptLoaded] = useState(false);

useEffect(() => {
  if (scriptLoaded) return undefined;

  const initializeGoogle = () => {
    if (!window.google || scriptLoaded) return;

    setScriptLoaded(true);
    window.google.accounts.id.initialize({
      client_id: googleOAuthClientId,
      callback: handleGoogleSignIn,
    });
  };

  const script = document.createElement("script");
  script.src = "https://accounts.google.com/gsi/client";
  script.onload = initializeGoogle;
  script.async = true;
  script.id = "google-client-script";
  document.querySelector("body")?.appendChild(script);

  return () => {
    window.google?.accounts.id.cancel();
    document.getElementById("google-client-script")?.remove();
  };
}, [scriptLoaded]);

This was better than before, but it was still not guaranteed that window.google existed when the script had loaded, so the button only showed up sometimes.

The next option was to create a useEffect that rerendered infinitely until window.google was found. This was closer and worked, but didn't seem ideal.

After more thinking and research, I found a solution: useInterval. Using this custom hook, we could execute a callback function every x milliseconds until a condition is satisfied. In our case until window.google exists.

We added the useInterval hook to our GoogleButton with some additional state.

const [google, setGoogle] = useState<Google>();
const [googleIsLoading, setGoogleIsLoading] = useState(true);

useInterval(
  () => {
    if (typeof window !== "undefined" && window.google) {
      setGoogle(window.google);
      setGoogleIsLoading(false);
    }
  },
  googleIsLoading ? 100 : null
);

Now, every 100ms we check for the existence of window.google. When it is found, we set it to a state and set the interval to null. Then we can add google to the dependency array in our useEffect above, which will cause a rerender every time google changes, guaranteeing the google.accounts.id.initialize method will exist when we call it.

We also ended up adding max retries to our useInterval hook to prevent the component from showing the loading state with no output to the user. If window.google hasn't loaded after 10 tries, we display an "Error loading Google button" error.

Now our button successfully renders and lets the user choose a Google account to authenticate with!

4. Invalid issuer claim

But not so fast, now we're having an issue validating the token from the callback function. Our backend is written in Go, and we use Go JOSE to validate the JWT token Google gives us when we authenticate. It gave us this error:

failed to validate identity token, square/go-jose/jwt: validation failed, invalid issuer claim (iss)

I decoded the token from the old library and compared it to the token from the new library. I noticed the issuers were slightly different.

- accounts.google.com
+ https://accounts.google.com

I updated our issuer variable in our Go code, and I was successfully logged in!

Conclusion

While it took a minute to migrate to Google's new Identity Services, the end result was worth it. The code is simpler, the button is more personalized, and users can now log in from private browsers.

Next we may look into One Tap to create a more frictionless user sign-in experience. We didn't add this initially because Google is not the only form of authentication we offer and it could get annoying for users that sign up with GitHub or username and password to see the Google popup all the time. There may be a simple solution when we look into it more in the future.

If you have any feedback or found better ways to migrate your code to Google Identity Services, I'd love to hear from you in our Discord.

SHARE

JOIN THE DATA EVOLUTION

Get started with Dolt

Or join our mailing list to get product updates.