Migrating a React app to the new Google Sign-In library
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):
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:
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:
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.