• Motivation

JavaScript authentication with Gapi is both impressive and frustrating. Frustrating because in most of the examples you come across, and indeed in Google’s own guides, gapi is the center of the universe.  They are about how to integrate your app with gapi, as opposed to the other way round – how to add gapi to your app. Gapi is super impressive in the way it takes care of the dirty details of oauth, but with frameworks like Vuejs and React the starter guides don’t fit that well.

Most Google authentication examples in Vue use Firebase, which is a super simple way of building in authentication, but if you need authorization for Google APIS as well, Firebase is not a great choice as it doesn’t know how to take care of the refresh token cycle, even as a Google provider. In this example we’ll take a look at how to integrate gapi with Vue. I’ve stripped out most of the error handling noise as how you handle that will be unique to your app (I use Vuetify v-bottom-sheet to communicate app errors)

Getting started

I’m assuming if you’ve read this far, you already know how to set up a Google project, get credentials and an API key, enable the necessary APIs etc so I’m not going to bother repeating all that. Here’s the official overview if you need it. Neither am I going to go into the details of how to set up a vuecli app or add vuex to it. I assume you know all that and have an app ready to go. So my assumption is

  • You have a cloud project setup and have the necessary credentials and API key
  • You have a Vue project ready to go
  • You are using Vuex
  • I’m using Vuetify for material design on the component examples. If youre not, you’ll need to modify to whatever it is you are using instead

Adding gapi

You’ll be using NodeJs to develop your Vue app, and you’d ideally like to add the the googleapis module. However, if you do this (everybody always wastes a lot of time on this), you’ll get this error from the Vue webpack configuration (because of the node-pre-gyp dependency) so instead you’re going to need to add it from a cdn into your index.html. There are fancy things you can do with async loading etc, but I’ll just keep it simple.

<script type="text/javascript" src="https://apis.google.com/js/api.js"></script>
Add Gapi from cdn

Files

You’ll have your own organization so you’ll need to adapt as required, but the import files we’ll touch here for me are

  • src/js/storeinitial.js – where the initial state for the vuex store is set up
  • src/js/auth.js – all code to do with auth
  • secrets/config.js – stuff in .gitignore I don’t want to commit to github such as my Google cloud project configuration details
  • src/main.js – the app

Scopes

For this  app (https://scrviz.web.app) I’m going to need these scopes, which i’ll set up in in secrets/config.js

export const googleScopes = [
    "https://www.googleapis.com/auth/script.projects",
    "https://www.googleapis.com/auth/drive.file",
  ]
scopes required

I’m going to use the Drive picker and the Apps Script API, so I’ll need these discovery docs – more about them later. Note that the scopes are in fact space delimited for gapi (as opposed to an array for firebase), which is why I join them here, so take note to avoid wasting a lot of time on that.

export const googleConfig = {
  projectId: "myprojectid - its the number not the name",
  clientId:
    "mygoogleclientid.apps.googleusercontent.com",
  apiKey: "myapikey",
  discoveryDocs: [
    "https://script.googleapis.com/$discovery/rest?version=v1",
    "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
  ],
  scope: googleScopes.join(" "),
};
google config

Vuex store

I like to put everything that’s required across components in the store, but with the details of store actions in a script file dedicated to that subject. So although gapi is all executed from auth.js, they are accessed via the store. So here’s what I have in storeinitial.js. You may not need all this, but you probably will.

// we'l define these imports later
import {
  signin,
  signout,
  gapiInit,
  getPickerKey,
  getProjectId,
  gapiCheckScopes,
  gapiAdditionalScopes
} from "./auth";

{
  state: {
    appId: getProjectId(),
    pickerKey: getPickerKey(),
    isSignedIn: false,
    user: null,
  },

  getters: {
    checkScopes(state, getters) {
      return gapiCheckScopes(getters.isLoggedIn && state.user);
    },
    googleToken(state, getters) {
      const t = getters.isLoggedIn && state.user.getAuthResponse(true);
      return t && t.access_token;
    },
    isLoggedIn(state) {
      return state.user && state.user.isSignedIn();
    },
    userImage(state, getters) {
      return getters.isLoggedIn ?
        state.user.getBasicProfile().getImageUrl() :
        null;
    },
    userName(state, getters) {
      return getters.isLoggedIn ? state.user.getBasicProfile().getName() : null;
    },
    userEmail(state, getters) {
      return getters.isLoggedIn ?
        state.user.getBasicProfile().getEmail() :
        null;
    }
  },
  mutations: {
    setUser(state, value) {
      state.user = value;
    },
    actions: {
      signout() {
        return signout();
      },
      signin() {
        return signin();
      },
      gapi({
        commit
      }) {
        gapi.load("picker:auth2:client", () => {
          // the gapi modules are loaded
          // now initialize the auth
          gapiInit((user) => {
            // record this user
            commit("setUser", user);
          }).catch((error) => {
            console.log("failed to gapiinit", error);
          });
        });
      }
    }
  }
vuex store initial values

Auth

You’ll notice that various imports are referred to in storeinitial. They are defined in auth.js. Let’s take a look at what they do.

Import the secrets

These are kept in a separate file to avoid committing them to github. It’s impossible to completely hide secrets client side, which is why it’s important to properly scope your apikey and api enablement in your cloud project, but you don’t want them on github either. Another way to do this is use .env variables. In any case, luckily gapi is able to work with these ‘semi-secret’ credentials.

import {
  googleScopes,
  googleConfig,
} from "../../secrets/config";
secrets

 

Signin

The interface to signin is via a store dispatch action. It simply calls this gapi method

// sign in to google using gapi
export const signin = () => {
  return gapi.auth2.getAuthInstance().signIn();
};
signin with gapi

Signout

Similarily interface to signin is via a store dispatch action . It simply calls this gapi method

// signout of both google and github
export const signout = () => {
  const ai = gapi.auth2.getAuthInstance();
  ai.signOut();
};
signout

Picker

I’ll be using the Drive picker in this app, so this is just a pass through to the initial store of credentials required to use the picker

// these will be needed when creating a picker instance for container bound scripts
export const getPickerKey = () => googleConfig.apiKey;
export const getProjectId = () => googleConfig.projectId;
picker credentials

Initializing gapi

The first action after loading the required gapi modules is to initialize gapi. This is called when the gapi action is dispatched to the store. Note the onUser argument – this is a function that gapi should call when there’s a change of user.

export const gapiInit = (onUser) => {
  return gapi.client.init(googleConfig).then(function() {
    // Listen for sign-in state changes.
    const instance = gapi.auth2.getAuthInstance();
    instance.currentUser.listen(onUser);

    // Handle the initial sign-in state.
    onUser(instance.currentUser.get());
  });
};
initialize gapi

In this case, it commits the new user to store as directed by the storeinitial action

 gapiInit((user) => {
            // record this user
            commit("setUser", user);
          })
gapi init store action

Checking scopes

It’s possible that a user can be logged in, but not have adequate scopes – perhaps your app doesnt request all the scopes it needs on initial login. It’s a good idea to check on whether any api call is likely to succeed by ensuring you have all the scopes granted. What’s happening here is that the scopes required by the app are being checked against those actually granted. The client app can then request more if any are missing

export const gapiCheckScopes = (user) => {
  if (!user)
    return {
      ok: false,
      granted: null,
    };
  // these are the scopes we've been granted
  const granted = (user.getGrantedScopes() || "").split(" ");
  const ok = googleScopes.every((f) => granted.indexOf(f) !== -1);
  const denied = googleScopes.filter((f) => granted.indexOf(f) === -1);
  return {
    ok,
    granted,
    requested: googleScopes,
    denied,
  };
};
check granted scopes

 

Requesting extra scopes

If you find you need more scopes for a signed in user you can do this to request the additional ones you need

export const gapiAdditionalScopes = (user) => { 
  if (!user) return Promise.resolve(null);

  // get currently assigned scopes
  const checkScopes = gapiCheckScopes(user)

  // if we have all we need it's done
  if (checkScopes.ok) return Promise.resolve(checkScopes)

  // now we have to get the previously denied scopes
  const option = new gapi.auth2.SigninOptionsBuilder();
  option.setScope(checkScopes.denied.join(" "));
  return user.grant(option)
    .then(() => { 
      return gapiCheckScopes(user)
    })
}
granting extra scopes

Kicking it all off

Now everything is set up, and we can go back to main.js, just after we’ve initialized the Vuex store and get gapi to do its thing. It’s as simple as this.

// initialize gapi
store.dispatch("gapi");
kick off gapi

Gapi will attempt to login and reauthorize (if required) the current user. All the user profile data and so on will be available from the store via the getters we previously set up.

The components

Now we have gapi ticking over, we can integrate them with out components. Here’s my loginchip component – if the user is logged in, it’ll show their avatar if they have one, or a person icon if they don’t. It’ll also show a login icon (I have a custom <icons> component – you’d probaby use a regular <v-icon> component here instead).

<template>
  <v-tooltip bottom>
    <template v-slot:activator="{ on, attrs }">
      <v-avatar left v-on="on" v-bind="attrs" @click.stop = "handleSigning">
        <v-img v-if="userImage && isLoggedIn" :src="userImage"></v-img>
        <v-icon v-else-if="isLoggedIn">mdi-person</v-icon>
        <icons v-else name="login" unmouse />
      </v-avatar>
   
    </template>
    <span v-if="isLoggedIn">{{ userName }} - click to sign out</span><span v-else>click to sign in</span>
  </v-tooltip>
</template>
<script>
import maps from "@/js/storemaps";
import icons from "@/components/icons";

export default {
  components: {
    icons
  },
  methods: {
    handleSigning () {
      if (this.isLoggedIn) {
        this.signout()
      } else {
        this.signin()
      }
    },
    ...maps.actions
  },
  computed: {
    ...maps.state,
    ...maps.getters,
  },
};
</script>
loginchip.vue

which produces this when logged in

and this when not

Discovery docs

Back at gapi initialization stage, we included discovery docs in the configuration. That’s because gapi doesn’t actually know how to access any given API. The discovery docs are a generalized way for it to build a client you can use to access any given api. It’s really easy to call them. Note that you don’t need to worry about access tokens. gapi will get one for you and keep it refreshed in some behind the scenes magic.

Here’s an example of calling the script API from gapi. It really is this simple

  return gapi.client.script.projects.create(pack).then((response) => {
    const { result } = response;
    const { error, scriptId } = result;
    .... etc
calling an api with gapi.client

Scope checking

Previously we set up a way to check the granted scopes contain all the requested scopes. At some point in my app I need to make that call to an api that needs given scopes. I can conditionally set up the button to behave different if I’m not logged in, logged but not with enough scopes, or completely ready to go.

Here it is in not logged in state

versus ready to go

The component

will take one of 3 states

          <v-list-item-content
            ><span v-if="isAuthorized"
              >{{ userName }} has authorized apps script access</span
            ><span v-else-if="needMore">
              Please grant these additional scopes {{ denied }} </span
            ><span v-else>
              scrviz will need write access your apps script projects
            </span></v-list-item-content
          >
          <v-list-item-action>
            <v-btn color="accent" @click="doAuth" :disabled="isAuthorized"
              ><icons name="appsscript" /><span class="ml-2"
                >{{authButton}}</span
              ></v-btn
            ></v-list-item-action
          >
check for authorized

computed properties

  computed: {
    denied() {
      return (
        this.checkScopes &&
        this.checkScopes.denied &&
        this.checkScopes.denied.join(",")
      );
    },
    authButton() {
      return this.needMore ? "grant" : "authorize";
    },
    needMore() {
      return (!this.checkScopes || !this.checkScopes.ok) && this.isLoggedIn;
    },
    isAuthorized() {
      return this.isLoggedIn && !!this.googleToken && this.checkScopes.ok;
    }
  }
3 auth states

 

The input to these states are all maintained automatically by gapi in the vuex store so this is all we have to do in the component itself.

auth method

    doAuth() {
      this.$emit("pin");
      if (this.needMore) {
        this.moreScopes();
      } else {
        this.signin();
      }
    },
signin or get more scope

The picker

The picker is a bit special. Even though we’ve provided the Drive Discovery document to gapi, we still need to build the kind of picker we want. In my case, I want to show all Google Workspace documents that can host an Apps Script project.

Here’s the entire picker component, showing how to add views. Note also that we need to pull in the projectId, the apikey and a access token all of which are available via the vuex store. (You’ll notice that use customized vuex mapActions, mapState, mapGetters and mapMutations modules to expose the store contents)

<template>
  <v-card flat>
    <v-card-text>
      <span class="mx-2"><icons name="drive"/></span>
      <span class="ml-2"
        >Since this will be a container bound project, you need to pick an
        appropriate Drive file to contain this project</span
      >
    </v-card-text>
    <v-card-actions>
      <v-btn @click="createPicker" class="ml-4" color="primary">pick</v-btn>
    </v-card-actions>
  </v-card>
</template>
<script>
/* global google */
import maps from "@/js/storemaps";
import icons from "@/components/icons";

export default {
  components: {
    icons,
  },
  computed: {
    ...maps.state,
    ...maps.getters,
  },
  methods: {
    createPicker() {
      if (!this.picker) {
       

        this.picker = new google.picker.PickerBuilder()

          .addView(
            new google.picker.DocsView(google.picker.ViewId.SPREADSHEETS)
          )
          .addView(new google.picker.DocsView(google.picker.ViewId.DOCUMENTS))
          .addView(
            new google.picker.DocsView(google.picker.ViewId.PRESENTATIONS)
          )
          .addView(new google.picker.DocsView(google.picker.ViewId.FORMS))
          .addView(google.picker.ViewId.RECENTLY_PICKED)

          .setOAuthToken(this.googleToken)
          .setDeveloperKey(this.pickerKey)
          .setAppId(this.appId)
          .setCallback(this.pickerCallback)
          .setOrigin(window.location.protocol + "//" + window.location.host)
          .setTitle("Select the document that will contain this script")

          .build();
      }
      this.picker.setVisible(true);
    },
    pickerCallback(data) {
      let url = "nothing";
      let doc = null;

      const g = google.picker;

      if (data[g.Response.ACTION] == g.Action.PICKED) {
        doc = data[g.Response.DOCUMENTS][0];

        url = doc[g.Document.URL];
      }
      this.message = "You picked: " + url;
      this.$emit(
        "picked",
        doc
          ? {
              name: doc[g.Document.NAME],
              id: doc[g.Document.ID],
              url: doc[g.Document.URL],
              iconUrl: doc[g.Document.ICON_URL],
            }
          : null
      );
    },
    ...maps.actions,
  },
  data: () => {
    return {
      message: null,
      picker: null,
    };
  },
};
</script>
picker.vue

This produces a dialog that looks like this

picker dialog

Summary

This has been a longer article than normal, as I wanted to tell the whole story to provide a reusable starting point for gapi and vue together that’s a little more than the usual starter gapi examples.

Links

App https://scrviz.web.app

Github https://github.com/brucemcpherson/gitvizzy