Integrating Google One Tap in a Rails Application

Published: June 25, 2022
Google One Tap provides an easy and frictionless method of allowing a user to authenticate with your application.

Example of Google One Tap

This post describes the process of implementing Google One Tap into your Rails application. At the end of this post, you will be able to display a small window over your app's pages allowing your users to use their Google credentials to sign up and/or authenticate to your app.

Setup "Sign In with Google"

You will need a Google API client ID in order to use Google One Tap.

Follow the instructions from the Sign In With Google Setup guide here: https://developers.google.com/identity/gsi/web/guides/get-google-api-clientid

Be sure to set http://localhost and http://location:3000 in your "Authorized JavaScript Origins" in order to run this locally along with the domain(s) of your production app. Note that your application domain must be using SSL for this to work in production.

Your client ID should look something like this:

1234567890-abcdefg.apps.googleusercontent.com

Store your client ID in either an environment variable or in your Rails credentials. For this post, we'll use an environment variable ENV["GOOGLE_CLIENT_ID"].

Setup a Callback Endpoint to Validate and Process the ID Token from Google

Google will POST to an endpoint URI of your choosing when the user invokes the One Tap. The request params will include a JWT in params[:credential] and a CSRF token that may be used for additional request validation in params[:g_csrf_token].

At minimum, the JWT must be validated. Google provides an official Ruby Gem that contains logic to validate JWTs from Google.

The source code can be found here: https://github.com/googleapis/google-auth-library-ruby

Add the gem to your project with $ bundle add googleauth.

For this example, we'll make a one-off controller and route that will accept the JWT.

In config/routes.rb, add this route to create the endpoint Google will post to...

Rails.application.routes.draw do
  post "/google_onetap_callback", to: "callbacks#google_onetap", as: :google_onetap_callback
end

This will create a URL helper google_onetap_callback_url/_path pointing to CallbacksController#google_onetap.

Now, let's make a controller in app/controllers/callbacks_controller.rb:

class CallbacksController < ApplicationController
  # Uncomment to skip Rails' CSRF protection for this one action
  # skip_forgery_protection only: :google_onetap

  # Comment to skip checking CSRF tokens from Google
  before_action :validate_google_csrf, only: :google_onetap

  def google_onetap
    payload = Google::Auth::IDTokens.verify_oidc(params[:credential], aud: ENV["GOOGLE_CLIENT_ID"])
    # JWT is valid. Lookup or create user account and sign them in. 
  rescue Google::Auth::IDTokens::SignatureError, Google::Auth::IDTokens::AudienceMismatchError
    # The JWT could not be validated. Redirect or raise an exception here.
  end

  private

  def validate_google_csrf
    # Google One Tap provides its own csrf token and stores it in the cookie
    # along with including it in the request.
    if cookies["g_csrf_token"].blank? || 
      params["g_csrf_token"].blank? || 
      cookies["g_csrf_token"] != params["g_csrf_token"]
      # Mismatched or missing g_csrf_token. Redirect, or raise an exception here.     
    end
  end
end

Rails invokes its cross-site request forgery checks on every non-GET request by default. Since the Google library is what posts to the controller, there is no Rails provided CSRF token to check. A way to still use Rails' built-in system will be described later, but if you want to skip the check, you can add skip_forgery_protection only: :google_onetap at the top of the controller as shown in the commented code above. Alternatively, the Google One Tap library provides its own CSRF tokens that can be used to validate the request. When the user clicks the sign in button, a token will be generated and stored in a cookie and passed through to the callback request. The above controller shows how you may use a before_action to check the Google-provided token. If you don't want to use Google's tokens, feel free to exclude the this check.

At minimum, you should validate some CSRF token for the request. Either the Rails one or Google's or both.

Inside the google_onetap action, we'll validate the JWT by passing it and our client id into .verify_oidc from the Google auth gem. If valid, this method returns a hash of the decoded JWT, else it will raise an exception. The rescue block in the action can then redirect or raise a custom exception back to the front end.

The hash in the payload variable looks like this:

{
  "iss": "https://accounts.google.com",
  "nbf":  111223388877,
  "aud": "YOUR_GOOGLE_CLIENT_ID",
  "sub": "1144422255589998888",
  "hd": "gmail.com",
  "email": "john-doe@gmail.com",
  "email_verified": true,
  "azp": "YOUR_GOOGLE_CLIENT_ID",
  "name": "John Doe",
  "picture": "https://lh3.googleusercontent.com/a-/e381748923498234usr",
  "given_name": "John",
  "family_name": "Doe",
  "iat": 1287416010,
  "exp": 1287419610,
  "jti": "abc1234567890def"
}

Reference: https://developers.google.com/identity/gsi/web/reference/js-reference#credential

The "sub" key is Google's unique identifier for the user. You can use that and/or "email" to lookup or create an existing user, sign them in, and redirect. The actual user creation/authentication from this data is beyond the scope of this post.

Add Google One Tap JS and HTML

With the backend out of the way, it's time to direct our attention to the frontend.

Google One Tap frontend in comprised of a <script> tag pointing to Google's client library and a <div> for the JavaScript to render the One Tap button. Below is a basic example.

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
  data-login_uri="<%= google_onetap_callback_url %>"
>
</div>

At minimum, the client_id and login_uri are required with the former being your client id and the latter being the URL helper pointing to your callback.

Only Show One Tap for Non-Signed In Users

The above HTML will always show the One Tap button if the user is signed into their Google account either through the browser or through the Chrome browser itself (if the user is using Chrome). Your use case most likely dictates that once the user authenticates with your app, the One Tap button shouldn't show anymore. You can accomplish this in one of two ways.

Assuming you have a view helper called user_signed_in?, you can wrap the entire block of HTML in an if-statement in your view.

<% if user_signed_in? %>
  <script src="https://accounts.google.com/gsi/client" async defer></script>
  <div id="g_id_onload"
    data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
    data-login_uri="<%= google_onetap_callback_url %>"
  >
  </div>
<% end %>

Alternatively, you can set data-auto_prompt to "true" in the <div> to conditionally show the One Tap prompt.

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
  data-login_uri="<%= google_onetap_callback_url %>"
  data-auto_prompt="<%= user_signed_in? ? "false" : "true" %>"
>
</div>

Send Rails' CSRF Token in Callback

If you would like to use the baked-in Rails CSRF token validation (meaning you are not calling skip_forgery_protection in your controller), you can provide Google with additional params to send in the callback request.

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
  data-login_uri="<%= google_onetap_callback_url %>"
  data-authenticity_token="<%= form_authenticity_token %>"
>
</div>

Use One Tap on iOS and Safari

By default, the One Tap implementation does not support iOS and Safari browsers due to Intelligent Tracking Prevention (ITP). If you would like a One Tap experience on that platform/browser, you can enable it with an additional data attribute.

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
  data-login_uri="<%= google_onetap_callback_url %>"
  data-itp_support="true"
>
</div>

More information on how One Tap works with ITP browsers: https://developers.google.com/identity/gsi/web/guides/features#upgraded_ux_on_itp_browsers

Putting it all together

Enabling all the features mentioned above, the resulting markup in your view should look something similar to this:

<script src="https://accounts.google.com/gsi/client" async defer></script>
<div id="g_id_onload"
  data-client_id="<%= ENV["GOOGLE_CLIENT_ID"] %>"
  data-login_uri="<%= google_onetap_callback_url %>"
  data-auto_prompt="<%= user_signed_in? ? "false" : "true" %>"
  data-authenticity_token="<%= form_authenticity_token %>"
  data-itp_support="true"
>
</div>

A full list of available data attributes you may use with the <div> element can be found here: https://developers.google.com/identity/gsi/web/reference/html-reference

Redisplaying the Prompt Locally After Dismissing

If a user clicks the close button for the One Tap prompt, an exponential cool-down is triggered before the prompt appears again for your app. While experimenting/testing One Tap locally, if you dismiss the prompt, you would normally have to wait two hours before it appears again.

To get around this, open your browser's inspector and navigate to where you can view the list of cookies set. There will be a "g_state" cookie set for the current domain's cookies. Deleting that and refresh the page. The prompt should appear once again.

More information on dismissing the prompt and the exponential cooldown: https://developers.google.com/identity/gsi/web/guides/features#exponential_cooldown

Summary

This post went over the basics for implementing Google One Tap into your existing Rails application. The mark up for the prompt is pretty flexible and includes many more options not mentioned in this post.

There is also a JavaScript-based API available if you rather manipulate/inoke the prompt programmatically and not through an HTML attribute.

Do not forget to validate the JWT passed back from Google as well as using one if not both of the CSRF checks available to ensure a secure sign in experience for your users.