A power on lightbulbA power off lightbulb

Google One Tap login with NextAuth.js

published on Ramiel's Creations



Saturday, October 2, 2021

Google One Tap login with NextAuth.js
Original photo by  PhotoMIX Company   from  Pexels

I want to thank Balázs Orbán for helping me writing this tutorial

In this post we're going to explore another way of login into your website powered by NextAuth.js: Google One Tap.

Google One Tap is the quickest way to login through a Google account. Basically the idea is that your website is able to understand if the current user has any Google profile and show a little interface where the user can select which account to use to login, even before starting any login process. So we don't even have to ask the user "Do you want to login with Google?". We already know they can use Google. Here we're going to see how to integrate this login process with NextAuth.js so that we can keep using this library to handle our users and their authentication. I won't go in details about how NextAuth.js works, so I'll assume that you already know about the Credentials Provider and Adapters.

Ingredients and recipe

In order to use Google One Tap, we need to build a custom Credentials Provider

We can configure it to either simply return the user's data, or have access to our user database through an Adapter (or any other way).

Provider

We need to build a custom provider, one that's able to retrieve from Google the information about the user. Once we get the user data, we can return it, or do something else with it, like create the user in our own database.

The Provider is the core part of this login process. We need a Credentials Provider. This provider will receive a token from the front-end generated by Google. We can use this token to query the Google API, retrieve the user and map it to our system. Since we need to interact with Google, we need to get an id and secret from a Google app. You can refer to the Google Provider documentation.

Let's see the code:

import CredentialsProvider from `next-auth/providers/credentials`; import { OAuth2Client } from 'google-auth-library'; // This is an instance of a google client that we need to ask google informations about the user const googleAuthClient = new OAuth2Client(process.env.NEXT_PUBLIC_GOOGLE_ID); export default NextAuth({ providers: [ CredentialsProvider({ // The id of this credential provider. It's important to give an id because, in frontend we don't want to // show anything about this provider in a normal login flow id: 'googleonetap', // A readable name name: 'google-one-tap', // This field define what parameter we expect from the FE and what's its name. In this case "credential" // This field will contain the token generated by google credentials: { credential: { type: 'text' }, }, // This where all the logic goes authorize: async (credentials) => { // The token given by google and provided from the frontend const token = (credentials as unknown as OneTapCredentials).credential; // We use the google library to exchange the token with some information about the user const ticket = await googleAuthClient.verifyIdToken({ // The token received from the interface idToken: token, // This is the google ID of your application audience: process.env.NEXT_PUBLIC_GOOGLE_ID, }); const payload = ticket.getPayload(); // This is the user if (!payload) { throw new Error('Cannot extract payload from signin token'); } // Check out the jwt https://next-auth.js.org/configuration/callbacks#jwt-callback // and session https://next-auth.js.org/configuration/callbacks#session-callback callbacks // to see how to store the user in the session. // We return the retrieved user return payload; }, }), ], });

Adapter

We don't want to access the database directly, because NextAuth.js already knows how to do so. We can instead use the same adapter we set in NextAuth.js options because that exposes all the needed methods to retrieve or create a user. It's possible to use NexthAuth.js without an adapter: in this case we may decide to implement our own way to access the database but this is definitely less easier. We won't cover this case here.

We want to create an adapter but retain an instance to use it later (in the authorize callback). For this example, we are going to use the MongoDB Adapter, but any other official (or your custom) adapter should do:

Let's see the code:

import NextAuth from 'next-auth'; import CredentialsProvider from `next-auth/providers/credentials`; import { OAuth2Client } from 'google-auth-library'; import { MongoDBAdapter } from '@next-auth/mongodb-adapter'; import clientPromise from '@lib/mongodb'; // This is an instance of a google client that we need to ask google informations about the user const googleAuthClient = new OAuth2Client(process.env.NEXT_PUBLIC_GOOGLE_ID); export default async function auth() { // An example using the MongoDB adapter const adapter = MongoDBAdapter({ db: (await clientPromise).db('your-database'), }); return await NextAuth(req, res, { adapter, providers: [ CredentialsProvider({ // The id of this credential provider. It's important to give an id because, in frontend we don't want to // show anything about this provider in a normal login flow id: 'googleonetap', // A readable name name: 'google-one-tap', // This field define what parameter we expect from the FE and what's its name. In this case "credential" // This field will contain the token generated by google credentials: { credential: { type: 'text' }, }, // This is where all the logic goes authorize: async (credentials) => { // The token given by google and provided from the frontend const token = (credentials as unknown as OneTapCredentials) .credential; // We use the google library to exchange the token with some information about the user const ticket = await googleAuthClient.verifyIdToken({ // The token received from the interface idToken: token, // This is the google ID of your application audience: process.env.NEXT_PUBLIC_GOOGLE_ID, }); const payload = ticket.getPayload(); // This is the user if (!payload) { throw new Error('Cannot extract payload from signin token'); } // If the request went well, we received all this info from Google. const { email, sub, given_name, family_name, email_verified, picture: image, } = payload; // If for some reason the email is not provided, we cannot login the user with this method if (!email) { throw new Error('Email not available'); } // Let's check on our DB if the user exists const user = await adapter.getUserByEmail(email); // If there's no user, we need to create it if (!user) { user = await adapter.createUser({ name: [given_name, family_name].join(' '), email, image, emailVerified: email_verified ? new Date() : undefined, }); } // Let's also retrieve any account for the user from the DB, if any const account = user && (await adapter.getUserByAccount({ provider: 'google', id: sub })); // In case the account is not yet present on our DB, we want to create one and link to the user if (!account && user) { await adapter.linkAccount({ userId: user.id, provider: 'google', providerAccountId: sub, accessToken: null, accessTokenExpires: null, refresh_token: null, }); } // We can finally returned the retrieved or created user return user; }, }), ], }); }

Front-end

So, on the front-end we need to show the Google One Tap interface (a Google library will do the job) and to pass the token generated by Google to our provider. Usually this interface is shown in the home page, without asking the user to start the login process. This is the power of the Google One Tap, just one click to get in, without ever leaving the homepage of your website.

First of all, let's make sure to include the Google script somewhere in the page. A good place is your _app.tsx and we can use the Script component from Next.js:

<Script src="https://accounts.google.com/gsi/client" strategy="afterInteractive" />

We also need to interact with this script. We can create a custom hook to contain the logic

import { useEffect, useState } from 'react'; import { useSession, signIn, SignInOptions } from 'next-auth/react'; interface OneTapSigninOptions { parentContainerId?: string; } const useOneTapSignin = ( opt?: OneTapSigninOptions & Pick<SignInOptions, 'redirect' | 'callbackUrl'> ) => { const { status } = useSession(); const isSignedIn = status === 'authenticated'; const { parentContainerId } = opt || {}; const [isLoading, setIsLoading] = useState(false); useEffect(() => { if (!isLoading && !isSignedIn) { const { google } = window as any; if (google) { google.accounts.id.initialize({ client_id: process.env.NEXT_PUBLIC_GOOGLE_ID, callback: async (response: any) => { setIsLoading(true); // Here we call our Provider with the token provided by google await signIn('googleonetap', { credential: response.credential, redirect: true, ...opt, }); setIsLoading(false); }, prompt_parent_id: parentContainerId, style: 'position: absolute; top: 100px; right: 30px;width: 0; height: 0; z-index: 1001;', }); // Here we just console.log some error situations and reason why the google one tap // is not displayed. You may want to handle it depending on yuor application google.accounts.id.prompt((notification: any) => { if (notification.isNotDisplayed()) { console.log(notification.getNotDisplayedReason()); } else if (notification.isSkippedMoment()) { console.log(notification.getSkippedReason()); } else if (notification.isDismissedMoment()) { console.log(notification.getDismissedReason()); } }); } } }, [isLoading, isSignedIn, parentContainerId]); return { isLoading }; }; export default useOneTapSignin;

Now, we need to place a div somewhere, with a specific id and use the hook we just created:

const Component = () => { const { isLoading: oneTapIsLoading } = useOneTapSignin({ redirect: false, parentContainerId: 'oneTap', }); return ( <div id="oneTap" style={{ position: 'absolute', top: '50', right: '0' }} /> ); };

That's all.