It’s very convenient to use ScriptApp.getOAuthToken() in an addon to reuse the token from the server side in your client side add-on code. However, these have a limited time to live (1 hour), so what to do if your add on sits around for longer than that? One solution would be to always go for another token every time you needed one, but that would be wasteful. Luckily, there’s a way to check a token to see how much life it has, and only get a new one if it’s almost expired. This is the same technique as is used in cGoa, a generalized oauth token library.

Token generator, info and checker

The token checker will run server side, and has 2 exported functions accessible from the client. Each will return a TokenInfo object, which looks like this

/**
* TokenInfo
* @typedef {Object} TokenInfo
* @property {string} token - the token
* @property {boolean} ok - whether valid
* @property {string} scope scope the token applies to
* @property {number} expiresIn how long till it expires in ms
* @property {number} timeNow server time when check was done for convenience
*
* some of these will be defined if there's an error
* @property {string| undefined} error_description - text describing error
* @property {Error | undefined} err js error if parsing failed
* @property {string| undefined} data if parsing failed, what urlfetch returned
*/
TokenInfo

Here’s an example of a valid TokenInfo object

{
"expiresIn": 3599000,
"ok": true,
"error_description": null,
"scope": "https://www.googleapis.com/auth/script.external_request",
"timeNow": 1641470832253,
"token": "ya29.a0ARxxxxxxxxx98"
}
example tokeninfo

getToken

getToken returns a newly minted token, along with info on when it wil expire and the scopes it is enabled for in a TokenInfo object

// exports
/**
* gets a token and returns info about it
* @return {TokenInfo}
*/
var getToken = () => _Tokener.getToken();
getToken

checkToken

checkToken accepts a token as an argument and returns the same TokenInfo object. It can be used to see how long a given token has left to live


/**
* checks a given token
* @param {string} token
* @return {TokenInfo}
*/
var checkToken = (token) => _Tokener.checkToken(token);
checkToken

_Tokener

This private code runs server side, and does the work of get and check token

/**
* namespace to manage token
*/
var _Tokener = (() => {

// url to validate google oauth token
const _checkUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={{token}}"

// gets info about the token
const checkToken = (token) => {

// bounce that off the token checker
let response = null;

// build response value
const value = {
token,
timeNow: new Date().getTime(),
ok: false,
token,
expiresIn:0,
scope:''
}

try {
response = UrlFetchApp.fetch(_checkUrl.replace(/\{\{token\}\}/, token), { muteHttpExceptions: true });
}
catch (error) {
return {
...value,
error_description: 'token check fetch failure',
error
}
}

// unravel the response
try {
const result = JSON.parse(response.getContentText());
// minimize the info returned to essentials
// ie. drop audience, issued_to and accessType
const {scope, expires_in} = result || {}
return {
...value,
ok: result && result.error ? false : true,
scope,
expiresIn: (expires_in || 0) * 1000,
error_description: result && result.error
}
}
catch (error) {
return {
...value,
error_description: 'parse error',
error: error,
data: response && response.getContentText()

};
}
}

const getToken = () => checkToken(ScriptApp.getOAuthToken())

return {
getToken,
checkToken
}
})()
_Tokener

How to use

You can call getToken from client side at the beginning of your addon, store the Tokeninfo object, and check if it’s expired before using it. When it’s close to expiry simply get another when you need to use it. Here are some scenarios, but first a couple of utilities so we can test this thing and simulate an add-on

Waiter

To promisify a delay

      // promisfy delay
const waiter = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));
waiter

Runner

To promisify google.script.run

      // a general promisified script.run
const runner = (name, ...args) =>
new Promise((resolve, reject) =>
google.script.run
.withFailureHandler((err) => {
console.log("failed", err);
reject(err);
})
.withSuccessHandler((result) => resolve(result))
[name](...args)
);
runner

Divify

To update logging on page

      // log
divify = (name, text) => document.getElementById(name).innerHTML =document.getElementById(name).innerHTML "<br>" text
divify

Some divs for validation

For testing, we’ll set a few areas up to receive the results of various scenarios

  <div id="info">not started yet</div>
<div id="check">not checked yet</div>
<div id="force">not forced error yet</div>
<div id="expires">see what happens when one expires</div>
<div id="useexisting">should reuse existing</div>
<div id="getnew">should get a new one</div>
testing displays

get a token and display its TokenInfo

      // get a token and see when it expires
const info = await runner("getToken");
divify("info",JSON.stringify(info));
get a token
first token 
{
"expiresIn": 3599000,
"ok": true,
"error_description": null,
"scope": "https://www.googleapis.com/auth/script.external_request",
"timeNow": 1641484315170,
"token": "ya29.a0AR....VIYIz_yw"
}
result

Check a token

Here we’ll wait a bit, then send the original token back to see how much time is left on it after that wait.

      // wait a bit and check a token
waiter(5000)
.then(async () => divify("check",JSON.stringify(await runner("checkToken", info.token))));
check a token
check it now 
{
"expiresIn": 3594000,
"ok": true,
"error_description": null,
"timeNow": 1641484320738,
"scope": "https://www.googleapis.com/auth/script.external_request",
"token": "ya29.a0AR....VIYIz_yw"
}
result

Force an invalid token

let’s see what happens when we send an invalid token for checking

    
// check what happens with an invalid token
divify("force",JSON.stringify(await runner("checkToken", "bidule")));
invalid token
invalid token 
{
"expiresIn": 0,
"ok": false,
"error_description": "invalid_token",
"timeNow": 1641484315710,
"scope": null,
"token": "bidule"
}
result

Check we get a new token

Make sure we get a completely fresh token with getToken

      // make sure we actually get a new one
waiter(10000).then( async () => divify("checknew", JSON.stringify(
await runner("getToken")
))
);
check its brand new
check its an updated one 
{
"expiresIn": 3599000,
"ok": true,
"error_description": null,
"timeNow": 1641484326337,
"scope": "https://www.googleapis.com/auth/script.external_request",
"token": "ya29.a0AR....IsXl4wQ"
}
result

Optimizing

Of course we don’t want to bother calling up a server function to see if a token has expired when we already know from the Tokeninfo object if it should be expired yet. We’ll create a function that operates on the tokeninfo and only gets a new token if the known one is likely to expire shortly

useOrGetAToken

This function will just return the exiting token if it’s still got life in it, otherwise it’ll get a new one

      // generalized function to get check or refresh a token when its almost expired
const useOrGetAToken = async (tokenInfo, minLife) =>
new Date().getTime() minLife > tokenInfo.timeNow tokenInfo.expiresIn
? await runner("getToken")
: Promise.resolve(tokenInfo);
useOrGetAToken

This will wait for half the token’s lifetime, then reuse the same token as it’s still got life in it.

      // this one should reuse existing token - 30 minutes min life
waiter(12000).then(
async () =>
divify("useexisting",JSON.stringify(
await useOrGetAToken(info, 1000 * 60 * 30)
))
);
token not expired yet
should reuse existing 
{
"expiresIn": 3599000,
"ok": true,
"error_description": null,
"scope": "https://www.googleapis.com/auth/script.external_request",
"timeNow": 1641484315170,
"token": "ya29.a0AR....VIYIz_yw"
}
result

This one will detect that the token is almost expired so better get another

      // this one should get a new one - 59mins 50 secs min life
waiter(15000).then(
async () =>divify ("getnew",JSON.stringify(
await useOrGetAToken(info, 1000 * 60 * 59 50 * 1000)
))
);
token almost expired
should get a new one 
{
"expiresIn": 3599000,
"ok": true,
"error_description": null,
"timeNow": 1641484332132,
"scope": "https://www.googleapis.com/auth/script.external_request",
"token": "ya29.a0AR....BiCMQ"
}
result

The whole thing

Let’s put all that together

Server side code

// deploy the html service app
const doGet = () => HtmlService.createHtmlOutputFromFile('tokener');

/**
* TokenInfo
* @typedef {Object} TokenInfo
* @property {string} token - the token
* @property {boolean} ok - whether valid
* @property {string} scope scope the token applies to
* @property {number} expiresIn how long till it expires in ms
* @property {number} timeNow server time when check was done for convenience
*
* some of these will be defined if there's an error
* @property {string| undefined} error_description - text describing error
* @property {Error | undefined} err js error if parsing failed
* @property {string| undefined} data if parsing failed, what urlfetch returned
*/


/**
* namespace to manage token
*/
var _Tokener = (() => {

// url to validate google oauth token
const _checkUrl = "https://www.googleapis.com/oauth2/v1/tokeninfo?access_token={{token}}"

// gets info about the token
const checkToken = (token) => {

// bounce that off the token checker
let response = null;

// build response value
const value = {
token,
timeNow: new Date().getTime(),
ok: false,
token,
expiresIn:0,
scope:''
}

try {
response = UrlFetchApp.fetch(_checkUrl.replace(/\{\{token\}\}/, token), { muteHttpExceptions: true });
}
catch (error) {
return {
...value,
error_description: 'token check fetch failure',
error
}
}

// unravel the response
try {
const result = JSON.parse(response.getContentText());
// minimize the info returned to essentials
// ie. drop audience, issued_to and accessType
const {scope, expires_in} = result || {}
return {
...value,
ok: result && result.error ? false : true,
scope,
expiresIn: (expires_in || 0) * 1000,
error_description: result && result.error
}
}
catch (error) {
return {
...value,
error_description: 'parse error',
error: error,
data: response && response.getContentText()

};
}
}

const getToken = () => checkToken(ScriptApp.getOAuthToken())

return {
getToken,
checkToken
}
})()

// exports
/**
* gets a token and returns info about it
* @return {TokenInfo}
*/
var getToken = () => _Tokener.getToken();

/**
* checks a given token
* @param {string} token
* @return {TokenInfo}
*/
var checkToken = (token) => _Tokener.checkToken(token);
Code.gs

Client side code

<!DOCTYPE html>
<html>

<head>
<base target="_top">
</head>

<body>
<div id="info">not started yet</div>
<div id="check">not checked yet</div>
<div id="force">not forced error yet</div>
<div id="expires">see what happens when one expires</div>
<div id="useexisting">should reuse existing</div>
<div id="getnew">should get a new one</div>
<script>

(async () => {
// promisfy delay
const waiter = (delayMs) =>
new Promise((resolve) => setTimeout(resolve, delayMs));

// a general promisified script.run
const runner = (name, ...args) =>
new Promise((resolve, reject) =>
google.script.run
.withFailureHandler((err) => {
console.log("failed", err);
reject(err);
})
.withSuccessHandler((result) => resolve(result))
[name](...args)
);

// get a token and see when it expires
const info = await runner("getToken");
document.getElementById("info").innerHTML = JSON.stringify(info);

// wait a bit and check a token
await waiter(5000).then(
async () =>
(document.getElementById("check").innerHTML = JSON.stringify(
await runner("checkToken", info.token)
))
);

// check what happens with an invalid token
document.getElementById("force").innerHTML = JSON.stringify(
await runner("checkToken", "bidule")
);

// see what happens when it expires
await waiter(info.expiresIn 1000).then(
async () =>
(document.getElementById("check").innerHTML = JSON.stringify(
await runner("checkToken", info.token)
))
);

// generalized function to get check or refresh a token when its almost expired
// if a token has less than this to live get a new one
const minLife = 1000 * 5 * 60; // 5 mins

const useOrGetAToken = async (tokenInfo) =>
tokenInfo.timeNow tokenInfo.expiresIn < new Date().getTime() - minLife
? await runner("getToken")
: Promise.resolve(tokenInfo);

// this one should reuse existing token
await waiter(info.expiresIn - minLife * 2).then(
async () =>
(document.getElementById("useexisting").innerHTML = JSON.stringify(
await useOrGetAToken(info)
))
);

// this one should get a new one
await waiter(info.expiresIn - minLife / 2).then(
async () =>
(document.getElementById("getnew").innerHTML = JSON.stringify(
await useOrGetAToken(info)
))
);
})();

</script>

</body>

</html>
tokener.html

Links

script – IDE

github – bmTokenRefresh