TransWikia.com

Google One Tap Integration with Meteor

Stack Overflow Asked by maudulus on December 25, 2021

I am integrating a Meteor application with Google’s One Tap. Attempting to use Meteor’s loginWithGoogle in order to get the user to save to Meteor Accounts (built into Meteor.js). The complexity of this is that

One-Tap library is not meant to authorize the user (i.e. produce
Access Token), only to authenticate the user

Thus, what I’ve had to do is authenticate the user using Google Api, or gapi to retrieve the necessary access_token and id_token. Props to this post.

What I’ve got so far is as follows:

HTML

<div data-prompt_parent_id="g_id_onload" style={{ position: "absolute", top: "5em", right: "1em" }} id="g_id_onload"></div>

CLENT SIDE

google.accounts.id.initialize({
  prompt_parent_id: "g_id_onload",
  client_id: "42424242-example42.apps.googleusercontent.com",
  auto_select: false,
  callback: handleCredentialResponse
});

const handleCredentialResponse = async oneTapResponse => {
  // see the SERVER SIDE code, which is where validation of One Tap response happens
  Meteor.call("verifyOneTap", oneTapResponse.credential, oneTapResponse.clientId, (error, result) => {
    if (error) {
      console.log(error);
    }
    if (result) {
      // Initialize the JavaScript client library.
      gapi.load("auth2", function() {
        // Ready. Make a call to gapi.auth2.init or some other API 
        gapi.auth2.authorize(
          {
            client_id: oneTapResponse.clientId,
            scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
            response_type: "code token id_token",
            prompt: "none",
            // this is the actual email address of user, [email protected], passed back from the server where we validated the One Tap event...
            login_hint: result.email
          },
          function(result, error) {
            if (error) {
              // An error happened.
              console.log(error);
              return;
            }
            //these are the authentication tokens taht are so difficult to capture...
            let theAccessToken = result.access_token;
            let theIdToken = result.id_token;

            //*********************************
            //this is the part that doesn't work
            //trying to get it to create the account without another Google prompt...
            Meteor.loginWithGoogle({ accessToken: theAccessToken, idToken: theIdToken, prompt: "none" }, function(err, res) {
              if (err) {
                console.log(err)
              }
            });
            //*********************************
          }
        );
      });
    }
  });
};

google.accounts.id.prompt(notification => {
  //this just tells you when things go wrong...
  console.log(notification);
});

SERVER SIDE

const { OAuth2Client } = require("google-auth-library");
const clientOA2 = new OAuth2Client("42424242-example42.apps.googleusercontent.com");

// the token and clientId are returned from One Tap in an object, are credential (token) and clientId (clientId)
verifyOneTap: async (token, clientId) => {
  const ticket = await clientOA2.verifyIdToken({
    idToken: token,
    audience: clientId // Specify the CLIENT_ID of the app that accesses the backend
    // Or, if multiple clients access the backend:
    //[CLIENT_ID_1, CLIENT_ID_2, CLIENT_ID_3]
  });
  const payload = await ticket.getPayload();

  //perform validation here so you don't get hacked...

  return payload;
  // If request specified a G Suite domain:
  // const domain = payload['hd'];
}

Tried writing this in different ways on the client/server, as well as considered ways to go around this and just signing up with Meteor’s Accounts.createUser, but it is less than ideal. What is wrong with the [options] that I am passing to loginWithGoogle? I would think accessToken and idToken were enough…

What happens is that on login, it does log me in through the first stage of Google One Tap, but then options that I threw into Meteor.loginWithGoogle are somehow not being recognized:

this works (first step of One Step flow) =>

google one tap failure 2

but then it asks for login again 😐

second request google one tap...

The documentation on loginWithGoogle states that the format is typically:

Meteor.loginWith<ExternalService>([options], [callback])

and with regards to loginWithGoogle:

options may also include Google’s additional URI parameters


Google’s Additional URI Parameters

Required:
client_id, nonce, response_type, redirect_uri, scope

Optional:
access_type, display, hd, include_granted_scopes, login_hint, prompt


Unfortunately, it is clearly not recognizing something in the [options] that I am passing, otherwise it would save the user to MongoDB, which it isn’t doing.

One Answer

Ok, found an answer - I'm working on something cleaner but this is the current fix - thank you jimmy knoot and methodx for some inspiration.

Note: everything else same as original question above.

CLIENT

// this is the callback from the Google One Tap `google.accounts.id.initialize` (see original Stack Overflow question above)
const handleCredentialResponse = async oneTapResponse => {
  // see the SERVER SIDE code, which is where validation of One Tap response happens
  Meteor.call("verifyOneTap", oneTapResponse.credential, oneTapResponse.clientId, (error, result) => {
    if (error) {
      console.log(error);
    }
    if (result) {
      // Initialize the JavaScript client library.
      gapi.load("auth2", function() {
        // Ready. Make a call to gapi.auth2.init or some other API 
        gapi.auth2.authorize(
          {
            client_id: oneTapResponse.clientId,
            scope: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
            response_type: "code token id_token",
            prompt: "none",
            // this is the actual email address of user, [email protected], passed back from the server where we validated the One Tap event...
            login_hint: result.email
          },
          function(tokens, error) {
            if (error) {
              // An error happened.
              console.log(error);
              return;
            }
            //gapi returns tokens including accessToken and idToken...
            Meteor.call("createOneTapUser", result, tokens, (error, stampedLoginToken) => {
              if (error) {
                console.log(error);
              }
              //this logs in with the token created earlier...should do whatever your normal google login functionality does...
            //*********************************
            // this is where you skip the Google login popup :) 
              Meteor.loginWithToken(stampedLoginToken);
            });
            //*********************************
          }
        );
      });
    }
  });
};

SERVER

createOneTapUser: async (userDetails, accessDetails) => {
  //just including details here for what part of a user object would look like from Meteor.loginWithGoogle > note especially resume > loginTokens
  let oneTapUserObj = {
    services: {
      google: {
        accessToken: accessDetails.access_token,
        idToken: accessDetails.id_token,
        scope: ["https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile", "openid"], // yours may be different...
        expiresAt: accessDetails.expires_at,
        id: userDetails.sub,
        email: userDetails.email,
        verified_email: userDetails.email_verified,
        name: userDetails.name,
        given_name: userDetails.given_name,
        family_name: userDetails.family_name,
        picture: userDetails.picture,
        locale: "en"
      },
      resume: {
        loginTokens: []
      }
    } //...whatever your user object normally looks like.
  };
  //manually inserting the user
  Meteor.users.insert(oneTapUserObj);

  let newOneTapUser = await Meteor.users.findOne({ "profile.email": userDetails.email });
  // generates the login token that goes under user > services > resume > loginTokens...
  let stampedLoginToken = Accounts._generateStampedLoginToken();
  Accounts._insertLoginToken(newOneTapUser._id, stampedLoginToken);

  //sets the social media image from the google account...you'll need to build your own...
  userDetails.picture ? scrapeSocialMediaImage(newOneTapUser._id, userDetails.picture) : console.log("Google One Tap user " + newOneTapUser._id + " has no profile picture...");

  return stampedLoginToken.token;
}

Answered by maudulus on December 25, 2021

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP