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