Search code examples
javascriptgoogle-chrome-extensionoauthgoogle-apiaccess-token

OAuth for GAPI - Avoid Authentication and Authorization after initial sign in for Javascript


I have created a chrome extension that reads email, does something and create tasks using google client API for javascript. I am using chrome identity for authentication and authorization. The extension works as expected. However, it keeps asking for sign every once in a while. What I want is to authorize the user in the background script so that they don't need to do it over and over again, after the initial authentication and authorization.

What I have done so far:

  • I read that I need a refresh token to avoid this. However, refresh tokens are expected to be exchanged and stored on the server side and not client side (which wouldn't work because the background script is doing the job here which is client side)
  • Using gapi.auth.authorize with immediate true. That gives error regarding external visibility. When I read else, they suggested using it inside a server. I am not sure how can I do that in a chrome extension.
  • Turn interactive to false in getAuthToken, which starts giving error 401 due to authentication problem after the access token expires.

Following is the code I am using for authentication and authorization, with function onGoogleLibraryLoaded being called after loading the google api's client js file.

    var signin = function (callback) {
        chrome.identity.getAuthToken({interactive: true}, callback);
    };

    function onGoogleLibraryLoaded() {
        signin(authorizationCallback);
    }

    var authorizationCallback = function (data) {
        gapi.auth.setToken({access_token: data});
        gapi.client.load('tasks', 'v1')
        gapi.client.load('gmail', 'v1', function () {

            console.log("Doing my stuff after this ..")
        });
    };

UPDATE: As per the suggestion in the answer, I made some changes to the code. However, I am still facing the same issue. Following is the updated code snippet

jQuery.loadScript = function (url, callback) {
    jQuery.ajax({
        url: url,
        dataType: 'script',
        success: callback,
        async: false

   });
}
//This is the first thing that happens. i.e. loading the gapi client 
if (typeof someObject == 'undefined') $.loadScript('https://apis.google.com/js/client.js', 
    function(){
    console.log("gapi script loaded...")
});

//Every 20 seconds this function runs with internally loads the tasks and gmail 
// Once the gmail module is loaded it calls the function getLatestHistoryId()
setInterval(function() {
    gapi.client.load('tasks', 'v1')
    gapi.client.load('gmail', 'v1', function(){
        getLatestHistoryId()
    })
    // your code goes here...
}, 20 * 1000); // 60 * 1000 milsec

// This is the function that will get user's profile and when the response is received 
// it'll check for the error i.e. error 401 through method checkForError
function getLatestHistoryId(){
  prevEmailData = []

  var request = gapi.client.gmail.users.getProfile({
        'userId': 'me'
    });
    request.execute(function(response){
      console.log("User profile response...")
      console.log(response)
      if(checkForError(response)){
        return
      }
    })
}

// Now here I check for the 401 error. If there's a 401 error 
// It will call the signin method to get the token again. 
// Before calling signin it'll remove the saved token from cache through removeCachedAuthToken
// I have also tried doing it without the removeCachedAuthToken part. However the results were the same.  
// I have left console statements which are self-explanatory
function checkForError(response){
  if("code" in response && (response["code"] == 401)){
    console.log(" 401 found will do the authentication again ...")
    oooAccessToken = localStorage.getItem("oooAccessTokenTG")
    console.log("access token ...")
    console.log(oooAccessToken)
    alert("401 Found Going to sign in ...")

    if(oooAccessToken){
        chrome.identity.removeCachedAuthToken({token: oooAccessToken}, function(){
        console.log("Removed access token")
        signin()
      })  
    }
    else{
      console.log("No access token found to be remove ...")
      signin()
    }
    return true
  }
  else{
    console.log("Returning false from check error")
    return false
  }
}

// So finally when there is 401 it returns back here and calls 
// getAuthToken with interactive true 
// What happens here is that everytime this function is called 
// there is a signin popup i.e. the one that asks you to select the account and allow permissions
// That's what is bothering me. 
// I have also created a test chrome extension and uploaded it to chrome web store. 
// I'll share the link for it separately. 

var signin = function (callback) {
    console.log(" In sign in ...")
    chrome.identity.getAuthToken({interactive: true}, function(data){
        console.log("getting access token without interactive ...")
        console.log(data)

        gapi.auth.setToken({access_token: data});
        localStorage.setItem("oooAccessTokenTG", data)

        getLatestHistoryId()
    })
};

Manifest goes like this:

{
  "manifest_version": 2,

  "name": "Sign in Test Extension ",
  "description": "",
  "version": "0.0.0.8",
  "icons": {
      "16": "icon16.png", 
      "48": "icon48.png", 
      "128": "icon128.png" 
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval' https://apis.google.com; object-src 'self'",
  "browser_action": {
   "default_icon": "icon.png",
   "default_popup": "popup.html"
  },
  "permissions": [   
    "identity",
    "storage"
   ],

  "oauth2": {
        "client_id": "1234.apps.googleusercontent.com",
        "scopes": [
            "https://www.googleapis.com/auth/gmail.readonly"
        ]
    },
    "background":{
      "scripts" : ["dependencies/jquery.min.js", "background.js"]
    }
}

Anyone else facing the same issue?


Solution

  • So this is what I believe would be the answer to my question.

    Few important things to know

    • Chrome sign in is not same as gmail sign in. You could have UserA signed into chrome, while you plan to use the chrome extension with UserB. chrome.identity.getAuthToken won't work in that case, because it looking for the user signed into chrome.
    • For using other google accounts i.e. the one not signed into chrome, you would need to use chrome.identity.launchWebAuthFlow. Following are the steps you can use. I am referring the example given here (Is it possible to get an Id token with Chrome App Indentity Api?)

    1. Go to google console, create your own project > Credentials > Create Credentials > OAuthClientID > Web Application. On that page in the field Authorized redirect URIs, enter the redirect url in the format https://.chromiumapp.org. If you don't know what chrome extension ID is, refer this (Chrome extension id - how to find it)
    2. This would generate a client id that would go into your manifest file. Forget about any previous client id you might have created. Let's say in our example the client id is 9999.apps.googleusercontent.com

    Manifest file:

        {
          "manifest_version": 2,
          "name": "Test gmail extension 1",
          "description": "description",
          "version": "0.0.0.1",
          "content_security_policy": "script-src 'self' 'unsafe-eval' https://apis.google.com; object-src 'self'",
          "background": {
            "scripts": ["dependencies/jquery.min.js", "background.js"]
          },
          "browser_action": {
           "default_icon": "icon.png",
           "default_popup": "popup.html"
          },
          "permissions": [
            "identity",
            "storage"
    
          ],
          "oauth2": {
            "client_id": "9999.apps.googleusercontent.com",
            "scopes": [
              "https://www.googleapis.com/auth/gmail.readonly",
               "https://www.googleapis.com/auth/tasks"
            ]
          }
        }
    

    Sample code for getting user's info in background.js

        jQuery.loadScript = function (url, callback) {
            jQuery.ajax({
                url: url,
                dataType: 'script',
                success: callback,
                async: false
           });
        }
        // This is the first thing that happens. i.e. loading the gapi client 
        if (typeof someObject == 'undefined') $.loadScript('https://apis.google.com/js/client.js', 
            function(){
            console.log("gapi script loaded...")
        });
    
    
        // Every xx seconds this function runs with internally loads the tasks and gmail 
        // Once the gmail module is loaded it calls the function getLatestHistoryId()
        setInterval(function() {
    
            gapi.client.load('tasks', 'v1')
            gapi.client.load('gmail', 'v1', function(){
                getLatestHistoryId()
            })
            // your code goes here...
        }, 10 * 1000); // xx * 1000 milsec
    
        // This is the function that will get user's profile and when the response is received 
        // it'll check for the error i.e. error 401 through method checkForError
        // If there is no error i.e. the response is received successfully 
        // It'll save the user's email address in localstorage, which would later be used as a hint
        function getLatestHistoryId(){
          var request = gapi.client.gmail.users.getProfile({
                'userId': 'me'
            });
            request.execute(function(response){
              console.log("User profile response...")
              console.log(response)
              if(checkForError(response)){
                return
              }
                userEmail = response["emailAddress"]
                localStorage.setItem("oooEmailAddress", userEmail);
            })
        }
    
        // Now here check for the 401 error. If there's a 401 error 
        // It will call the signin method to get the token again. 
        // Before calling the signinWebFlow it will check if there is any email address 
        // stored in the localstorage. If yes, it would be used as a login hint.  
        // This would avoid creation of sign in popup in case if you use multiple gmail accounts i.e. login hint tells oauth which account's token are you exactly looking for
        // The interaction popup would only come the first time the user uses your chrome app/extension
        // I have left console statements which are self-explanatory
        // Refer the documentation on https://developers.google.com/identity/protocols/OAuth2UserAgent >
        // Obtaining OAuth 2.0 access tokens > OAUTH 2.0 ENDPOINTS for details regarding the param options
        function checkForError(response){
          if("code" in response && (response["code"] == 401)){
            console.log(" 401 found will do the authentication again ...")
            // Reading the data from the manifest file ...
            var manifest = chrome.runtime.getManifest();
    
            var clientId = encodeURIComponent(manifest.oauth2.client_id);
            var scopes = encodeURIComponent(manifest.oauth2.scopes.join(' '));
            var redirectUri = encodeURIComponent('https://' + chrome.runtime.id + '.chromiumapp.org');
            // response_type should be token for access token
            var url = 'https://accounts.google.com/o/oauth2/v2/auth' + 
                    '?client_id=' + clientId + 
                    '&response_type=token' + 
                    '&redirect_uri=' + redirectUri + 
                    '&scope=' + scopes
    
            userEmail = localStorage.getItem("oooEmailAddress")
            if(userEmail){
                url +=  '&login_hint=' + userEmail
            } 
    
            signinWebFlow(url)
            return true
          }
          else{
            console.log("Returning false from check error")
            return false
          }
        }
    
    
        // Once you get 401 this would be called
        // This would get the access token for user. 
        // and than call the method getLatestHistoryId again 
        async function signinWebFlow(url){
            console.log("THE URL ...")
            console.log(url)
            await chrome.identity.launchWebAuthFlow(
                {
                    'url': url, 
                    'interactive':true
                }, 
                function(redirectedTo) {
                    if (chrome.runtime.lastError) {
                        // Example: Authorization page could not be loaded.
                        console.log(chrome.runtime.lastError.message);
                    }
                    else {
                        var response = redirectedTo.split('#', 2)[1];
                        console.log(response);
    
                        access_token = getJsonFromUrl(response)["access_token"]
                        console.log(access_token)
                        gapi.auth.setToken({access_token: access_token});
                        getLatestHistoryId()
                    }
                }
            );
        }
    
        // This is to parse the get response 
        // referred from https://stackoverflow.com/questions/8486099/how-do-i-parse-a-url-query-parameters-in-javascript
        function getJsonFromUrl(query) {
          // var query = location.search.substr(1);
          var result = {};
          query.split("&").forEach(function(part) {
            var item = part.split("=");
            result[item[0]] = decodeURIComponent(item[1]);
          });
          return result;
        }
    

    Feel free to get in touch with me if you have any questions. I have spent quite a few days joining these dots. I wouldn't want someone else to do the same.