How to add Google Login (OAuth2/OIDC) with JavaScript

The Big Picture

We want to be able to use Google Login, with our own JavaScript, on our website (essentially “build a custom google sign-in button”).

To do this we have to solve two core problems:

  1. How the heck do we get an “app” for the website?
  2. How do we use Google’s JavaScript API?

Google has such a plethora of semi-technical tutorials and Quick Starts from various ages of the Google Developer Account and Google Platform JS evolutions that it’s difficult to find helpful documentation.

Also, since the management UI is “evergreen”… it’s really hard to figure out where things are on any given day.

And so, here we are. :)

1. Activate Google Login for your Domain

You need to have a developer account, a “project”, a domain (or test domain), and a set of “credentials”.

Create an account, and project

  1. Create an account at https://console.developers.google.com/apis/dashboard
  2. Go back to https://console.developers.google.com/apis/dashboard
  3. Create a New Project from the dropdown in the upper left that lists the current project name
    • Give the project a name such as Example Web App and accept its generated ID
    • Click “Create”
    • (if you can’t find it there, try the Manage Resourses page)

Add your test domain

  1. Go back to https://console.developers.google.com/apis/dashboard
  2. Select your new project from the upper-left drop-down
  3. Select Domain Verification from the left hand side of the screen
  4. Add your test domain (ex: beta.example.com), but a domain that you actually own
  5. Select Verify Ownership
  6. Follow the specific instructions for adding a txt record to the subdomain you chose
  7. Add a collaborator / co-owner if you wish

Enable OAuth2

  1. Go back to https://console.developers.google.com/apis/dashboard
  2. Select OAuth consent screen
  3. Select External
  4. Follow the Flow
    • OAuth consent screen: You may need to create a Google Group to assign a custom support email
    • Scopes: Add auth/userinfo.email and perhaps auth/userinfo.profile
    • Test Users: Add some test users, duh

Create Google Credentials

  1. Go back to https://console.developers.google.com/apis/dashboard
  2. Select Credentials from the left sidebar
  3. Click + Create Credentials
  4. Select OAuth client ID
  5. Select Web Application
  6. Fill out the same test domain and test app name as before (ex: beta.example.com)
  7. Save the ID and Secret to a place you won’t forget (perhaps a .gitignored .env)
CLIENT_ID=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com
CLIENT_SECRET=XXXXXXXXXXXXXXXXXXXXXXXX

Add Collaborators

If you want to be able to work on this project with other, and give them access to change settings (such as all developers on the test/dev/staging version of the project), you use Google Cloud’s IAM to do this:

  1. Go to https://console.cloud.google.com/iam-admin/iam
  2. Select the project from the dropdown (if not already selected)
  3. Click +ADD to get to the Add members, roles to “xxxx” project.
    • In New members you can add by name, or by business or personal email address
    • Select Role => Basic => Editor (or Owner)
    • Save

For instructions with pictures see https://www.cloudsavvyit.com/4922/how-to-add-new-users-to-your-google-cloud-platform-projects/.

2. Add Google Login with JavaScript

Finding the actual, current JS API documentation is nearly impossible, but I managed. It’s here:

Likewise, not even the folks at Google seem to know which JS API is the correct one to use. Update: If you want the leanest JS file… and no real drawbacks, use this:

Altertatively, if you want a to use the built-in button and don’t mind the latency, you can use this:

Also, you can create the necessary URL all on your own, and just handle the token response in JavaScript

Okay. On we go…

3a. Using Google Platform

From the JavaScript perspective this is the fattest and slowest, but it’s also the easiest to implement and the slowness probably won’t matter.

This is also a good one to start with to make sure that “everything works” before you customize.

  1. You need to put your default scopes (i.e. profile email) and client ID in the meta tag of your login page HTML. profile is the minimum scope and is always returned.
<head>
    <meta name="google-signin-scope" content="email" />
    <meta
        name="google-signin-client_id"
        content="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"
    />
</head>
  1. Although it should be possible to use an thin OAuth client, you’ll probably want to start by including Google’s platform.js, which will autoload a lot of JavaScript.
<script src="https://apis.google.com/js/platform.js" async defer></script>
  1. You can start off with the Google’s sign in button, but you need your own data-onsuccess callback. You can also adjust the data-scope per button to include more stuff. Scopes are defined at https://developers.google.com/identity/protocols/oauth2/scopes
<div
    class="g-signin2"
    data-onsuccess="ongsignin"
    data-scope="profile email https://www.googleapis.com/auth/spreadsheets.readonly https://www.googleapis.com/auth/drive.readonly"
></div>
<script>
    window.ongsignin = function (gauth) {
        // Note: this is a special prototype-style instance object with few
        // enumerable properties (which don't make sense). Requires API docs.
        // See https://developers.google.com/identity/sign-in/web
        console.log(goauth);
    };
</script>

3b. Using Google API client

The secret that you need to know is that the Google API Client now supports promises. This isn’t documented anyhere (that I could find), but it’s true.

<script src="MY-APP.js"></script>
<!-- The Google Client API will call the 'onload' function when it is ready -->
<script
    src="https://apis.google.com/js/platform.js?onload=googleSignInInit"
    async
    defer
></script>
var clientId = "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com";
// Most of the Google API has been retrofitted with promises,
// but the very first initialization still needs a little wrapper
window.googleSigninResolve;
window.googleSigninPromise = new Promise(function (resolve) {
  window.googleSigninResolve = resolve;
});
function googleSigninInit() {
  // This can be "automatically" initiated, but it cannot be truly pre-loaded
  window.gapi.load("auth2", function () {
    console.log("gapi loaded");
    // Retrieve the singleton for the GoogleAuth library and set up the client.
    auth2 = window.gapi.auth2.init({
      client_id: clientId,
      cookiepolicy: "single_host_origin",
      // Request scopes in addition to 'profile' and 'email'
      //scope: 'additional_scope'
    });
    auth2.then(window.googleSigninResolve);
  });
}
var token;
window.googlePromise.then(function (auth2) {
  console.log("initializing google user");
  var googleUser = auth2.currentUser.get();
  if (googleUser) {
    token = googleUser.getAuthResponse().id_token;
    console.log('User is already logged in:', token);
    // do stuff for logged in user
    return
  }
  // prompt the pop-up dialog
  auth2
    .signIn()
    .then(function (googleUser) {
      console.log("google signin success (user):", googleUser);
      var token = googleUser.getAuthResponse().id_token;
      // check the origin a second time
});

See also: https://developers.google.com/identity/sign-in/web/build-button

3c. Using OIDC URLs

https://accounts.google.com/o/oauth2/v2/auth?client_id=000000000000-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com&redirect_uri=https%3A//EXAMPLE.COM/EXACT_PATH.html&response_type=token&scope=email%20profile&access_type=online&state=RND.CHANGE-ME&login_hint=JOHN.DOE@EXAMPLE.COM

# Example (with line breaks)
https://accounts.google.com/o/oauth2/v2/auth
  ?client_id=000000000000-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX.apps.googleusercontent.com
  &redirect_uri=https%3A//EXAMPLE.COM/EXACT_PATH.html
  &response_type=token
  &scope=email%20profile
  &access_type=online
  &state=RND.CHANGE-ME
  &nonce=RND.CHANGE-ME-TOO
  &login_hint=JOHN.DOE@EXAMPLE.COM

An example of how to create this dynamically in JavaScript:

<a class="js-google-oidc-url">Google Sign In</a>
function generateOidcUrl(client_id, redirect_uri, scope, login_hint) {
    // a secure-enough random state value
    // (all modern browsers use crypto random Math.random, not that it much matters for a client-side state cache)
    var rnd = Math.random().toString();
    // transform from 0.1234... to hexidecimal
    var state = parseInt(rnd.slice(2).padEnd(16, '0'), 10)
        .toString(16)
        .padStart(14, '0');
    // response_type=id_token requires a nonce (one-time use random value)
    // response_type=token (access token) does not
    var nonceRnd = Math.random().toString();
    var nonce = parseInt(nonceRnd.slice(2).padEnd(16, '0'), 10)
        .toString(16)
        .padStart(14, '0');
    var baseUrl = 'https://accounts.google.com/o/oauth2/v2/auth';
    var options = { state, client_id, redirect_uri, scope, login_hint, nonce };
    // transform from object to 'param1=escaped1&param2=escaped2...'
    var params = Object.keys(options)
        .filter(function (key) {
            return options[key];
        })
        .map(function (key) {
            // the values must be URI-encoded (the %20s and such)
            return key + '=' + encodeURIComponent(options[key]);
        })
        .join('&');
    return baseUrl + '?response_type=id_token&access_type=online&' + params;
}
var url = generateOidcUrl(
    'XXXX',
    'https://EXAMPLE.COM/EXACT_PATH.html',
    'email profile',
    'JOHN.DOE@EXAMPLE.COM'
);
document.body.querySelector('.js-google-oidc-url').href = url;

Result

You will be redirected back to the same page, but with the token and a few additional parameters in the URL, such as one of these:

https://EXAMPLE.COM/EXACT_PATH.html#state=RNDCHANGE-ME&id_token=XXXX.YYYY.ZZZZ&authuser=2&hd=EXAMPLE.COM&prompt=none

https://EXAMPLE.COM/EXACT_PATH.html#state=2213c300b24c30&access_token=ya29.XXXX&token_type=Bearer&expires_in=3600&scope=email%20profile%20https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile%20openid&authuser=2&hd=EXAMPLE.COM&prompt=none

document.location can easily be parsed to reveal the values:

function parseQuerystring(querystring) {
    var query = {};
    querystring.split('&').forEach(function (pairstring) {
        var pair = pairstring.split('=');
        var key = pair[0];
        var value = decodeURIComponent(pair[1]);

        query[key] = value;
    });
    return query;
}

var querystring = document.location.hash.slice(1);
var query = parseQuerystring(querystring);
console.log(query);

ID Token

The ID Token comes back as a JWT. It’s long, but completely parsable on the client-side.

https://EXAMPLE.COM/EXACT_PATH.html#state=RNDCHANGE-ME&id_token=XXXX.YYYY.ZZZZ&authuser=2&hd=EXAMPLE.COM&prompt=none

https://EXAMPLE.COM/EXACT_PATH.html
  #state=RNDCHANGE-ME
  &id_token=XXXX.YYYY.ZZZZ
  &authuser=2
  &hd=EXAMPLE.COM
  &prompt=none

It can be parsed rather simply. The most tricky part is knowing that browser JavaScript, ironically, has no native method for converting between URL-safe and traditional Base64 encodings. Here’s a working sample:

function parseJwt(jwt) {
    var parts = jwt.split('.');
    var jws = {
        protected: parts[0],
        payload: parts[1],
        signature: parts[2]
    };
    jws.header = urlBase64ToJson(jws.protected);
    jws.claims = urlBase64ToJson(jws.payload);
    return jws;
}

// because JavaScript's Base64 implementation isn't URL-safe
function urlBase64ToBase64(str) {
    var r = str % 4;
    if (2 === r) {
        str += '==';
    } else if (3 === r) {
        str += '=';
    }
    return str.replace(/-/g, '+').replace(/_/g, '/');
}

function urlBase64ToJson(u64) {
    var b64 = urlBase64ToBase64(u64);
    var str = atob(b64);
    return JSON.parse(str);
}

var jwt = query.id_token;
parseJwt(jwt);

Access Token

https://EXAMPLE.COM/EXACT_PATH.html#state=2213c300b24c30&access_token=ya29.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX&token_type=Bearer&expires_in=3600&scope=email%20profile%20https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile%20openid&authuser=2&hd=EXAMPLE.COM&prompt=none

https://EXAMPLE.COM/EXACT_PATH.html
  #state=2213c300b24c30
  &access_token=ya29.XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
  &token_type=Bearer&expires_in=3600
  &scope=email%20profile%20https://www.googleapis.com/auth/userinfo.email%20https://www.googleapis.com/auth/userinfo.profile%20openid
  &authuser=2
  &hd=EXAMPLE.COM
  &prompt=none

4. Using the Access Token / ID Token

You probably want to build an endpoint where you verify the token on the server side and then exchange it for your own token. You can do this two ways:

4a. Use Google’s Token Inspect Endpoint

Both the id_token (JWT) and the access_token (bespoke?) can be inspected with Google’s userinfo and two tokeninfo endpoints:

  • GET https://oauth2.googleapis.com/tokeninfo?id_token=<token>
  • GET https://www.googleapis.com/oauth2/v3/userinfo?access_token=<token>
  • GET https://www.googleapis.com/oauth2/v3/tokeninfo?access_token=<token>

Despite the documentation stating that passing a token as a query is deprecated and to use the Authorization header, these URLs appear to only support the respective id_token and access_token query parameters.

Here’s what the result of the tokeninfo endpoint looks like for an id_token:

{
    "iss": "https://accounts.google.com",
    "azp": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
    "aud": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com",
    "sub": "000000000000000000000",
    "hd": "example.com",
    "email": "ash@example.com",
    "email_verified": "true",
    "nonce": "ffffffffffffff",
    "name": "Ash Ketchum",
    "picture": "https://lh3.googleusercontent.com/a-/XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
    "given_name": "Ash",
    "family_name": "Ketchum",
    "locale": "en",
    "iat": "1577883599",
    "exp": "1577887199",
    "jti": "ffffffffffffffffffffffffffffffffffffffff",
    "alg": "RS256",
    "kid": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
    "typ": "JWT"
}

Most of these are part of the OIDC spec, as you can see in these tables:

(I believe this contains some of the same elements that you get from userinfo with an access_token)

4b. Use Google’s Public Key

The id_token can be verified with Google’s Public Key:

The OIDC metadata looks like this:

{
    "issuer": "https://accounts.google.com",
    "authorization_endpoint": "https://accounts.google.com/o/oauth2/v2/auth",
    "device_authorization_endpoint": "https://oauth2.googleapis.com/device/code",
    "token_endpoint": "https://oauth2.googleapis.com/token",
    "userinfo_endpoint": "https://openidconnect.googleapis.com/v1/userinfo",
    "revocation_endpoint": "https://oauth2.googleapis.com/revoke",
    "jwks_uri": "https://www.googleapis.com/oauth2/v3/certs"
    // ... lots more stuff
}

The “certs” are in JWK format:

{
    "keys": [
        {
            "kid": "ffffffffffffffffffffffffffffffffffffffff",
            "use": "sig",
            "alg": "RS256",
            "e": "AQAB",
            "kty": "RSA",
            "n": "XXXXXXXX"
        },
        {
            "kid": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
            "use": "sig",
            "alg": "RS256",
            "e": "AQAB",
            "kty": "RSA",
            "n": "XXXXXXXX"
        }
    ]
}

I recommend using keyfetch.js for verifying the id_token. As the name implies it will automatically fetch the OIDC keys from the issuer:

let Keyfetch = require('keyfetch');
let jwt = 'xxxx.yyyy.zzzz';

let decoded = await Keyfetch.jwt
    .verify(jwt, { strategy: 'oidc' })
    .catch(function (err) {
        console.error('could not verify token:');
        console.error(err);
        return null;
    });

If you prefer a commandline solution for debugging, check out keypairs:

keypairs inspect 'xxxx.yyyy.zzzz'

This is what the middleware would look like with Express in Node.js:

function googleSignIn() {
    // Only tokens signed by accounts.google.com are valid
    let verifyOpts = { iss: 'https://accounts.google.com' };

    return async function (req, res, next) {
        let jwt = (req.authorization||'').replace('Bearer ', '');
        Keyfetch.jwt.verify(jwt, verifyOpts).then(function (decoded) {
            // "jws" is the technical term for a decoded "jwt"
            req.jws = decoded;
            next();
        }).catch(next);
    }
}

function exchangeGoogleToken(req, res, next) {
   // TODO Check req.jws.sub and get user info.
   // TODO Set a cookie with a long-lived token.
   // TODO Send back an access_token - you can use Keypairs.sigtJwt() for this.
}

app.post('/api/session/oidc/google.com', googleSignIn(), exchangeGoogleToken);

5. Signing out

If you want to “sign out” - meaning that it will require user interaction to get a new token - that can be accomplished with a button that calls signOut() on an “auth instance”:

gapi.auth2
    .getAuthInstance()
    .signOut()
    .then(function () {
        // do signout stuff
    });

I may figuring this out using the inspector and come back later to report on it.

I’m not yet sure how you can sign out without using the Google Client API (included with platform).

Revoking Access (so you can re-test the flow)

While testing you’ll probably want to revoke the app’s permissions

  • Go to https://myaccount.google.com/permissions
  • Under “Third-party apps with account access” click “Manage third-party access” and search in the long list and click “Remove access”.
  • Under “Signing in to other sites” click “Signing in with Google” and search in the list to revoke access
  • Active tokens will persist until they expire (1 hour), so you may need to clear cache, cookies, etc, which can be a pain

Deleting Old Projects

There are two ways to delete unused projects. One is “Manage Resources” page, under the “No Organization” dropdown.
I don’t know how you get there on purpose, but I happened upon it by chance once and saved the URL:

You need to go to “Project Settings” and “Shut Down” your test project if you don’t want it to count against your quote.

In the upper right corner you might see a ... button with those options. If not, the URL looks like this:

https://console.cloud.google.com/iam-admin/settings?folder=&organizationId=&project=EXAMPLE-PROJECT-123456

https://console.cloud.google.com/iam-admin/settings
 ?folder=
 &organizationId=
 &project=EXAMPLE-PROJECT-123456