Obtain OAuth2 refresh token from existing authorization - google-apps-script

Is it possible to obtain an OAuth2 refresh token of a user that has previously provided authorization, without having to ask for authorization again?
Context:
I have a Google Apps Script add-on that has obtained authorization for a number of scopes (including the ability to run when they are not present - see below screenshot). I am extending the project to include a Google App Engine web application that requires access to the same scopes (no additional scopes required). I am hoping to utilize the existing scopes that have previously been granted without having to request them again.
Ability to run when they are not present screenshot:
Any guidance on whether it is possible and the best approach is appreciated.

In order to get a Refresh token you must request offline access when the user is authenticating this way the user is asked if they are willing for your application to have off line access to their data. Think of it as an extra scope of permissions.
If you need a refresh token and didnt obtain it at the time you authenticated your user then your going to have to request authentication again.

You can use a time trigger to refresh the token every hour and then save this token into your database (make sure the user had authorized your script to create the trigger)
Whenever you need to do something that needs a user's authorization, you just need to use this token without asking the user
For example:
export const generateOAuthToken = () => {
try {
const user = Session.getEffectiveUser()
let accessToken
let error = ''
try {
accessToken = ScriptApp.getOAuthToken()
} catch (err) {
error = err.message
}
// Connect to database
const db = connectDatabase()
if (error === '') {
// Generate new token and save to database
// ... here
} else {
// Save the error to display to the user
}
} catch (err) {
console.error(err)
}
}
export const setupOAuthTrigger = () => {
const form = FormApp.getActiveForm()
const triggers = ScriptApp.getUserTriggers(form)
// Create a new trigger if required; delete existing trigger if it is not needed.
let existingTrigger = null
for (let i = 0; i < triggers.length; i += 1) {
if (triggers[i].getEventType() === ScriptApp.EventType.CLOCK) {
existingTrigger = triggers[i]
break
}
}
// TODO: Optimize this later, at the moment it's for cleaning up trigger in case of error
if (existingTrigger) {
ScriptApp.deleteTrigger(existingTrigger)
}
// Trigger every hour
ScriptApp.newTrigger('generateOAuthToken')
.timeBased()
.everyHours(1)
.create()
}

Related

Google App Scripts User Cache Service: Accessing Cache Information in a Different Script but Same User

Looking into a way of sharing data via Google App Scripts's Cache Services from one web app to another.
Users load up the first webpage and filled out their information. Once submitted a function is run on this data and stored via the cache.
CacheService.getUserCache().put('FirstName','David')
CacheService.getUserCache().put('Surname','Armstrong')
Console log shows reports back that these two elements have been saved to cache.
However in the second web app when cache is called upon the console log returns null
var cache = CacheService.getUserCache().get('Firstname');
var cache2 = CacheService.getUserCache().get('Surname');
console.log(cache)
console.log(cache2)
Any ideas?
A possible solution would be to implement a service to synchronize the cache between web apps.
This can be achieved by creating a WebApp that via POST allows to add to the ScriptCache of the "Cache Synchronizer" the UserCache of the individual Web Apps.
The operation would be very simple:
From the web app that we want to synchronize, we check if we have cache of the user.
If it exists, we send it to the server so that it stores it.
If it does not exist, we check if the server has stored the user's cache.
Here is a sketch of how it could work.
CacheSync.gs
const cacheService = CacheService.getScriptCache()
const CACHE_SAVED_RES = ContentService
.createTextOutput(JSON.stringify({ "msg": "Cache saved" }))
.setMimeType(ContentService.MimeType.JSON)
const doPost = (e) => {
const { user, cache } = JSON.parse(e.postData.contents)
const localCache = cacheService.get(user)
if (!localCache) {
/* If no local data, we save it */
cacheService.put(user, JSON.stringify(cache))
return CACHE_SAVED_RES
} else {
/* If data we send it */
return ContentService
.createTextOutput(JSON.stringify(localCache))
.setMimeType(ContentService.MimeType.JSON)
}
}
ExampleWebApp.gs
const SYNC_SERVICE = "<SYNC_SERVICE_URL>"
const CACHE_TO_SYNC = ["firstName", "lastName"]
const cacheService = CacheService.getUserCache()
const syncCache = () => {
const cache = cacheService.getAll(CACHE_TO_SYNC)
const options = {
method: "POST",
payload: JSON.stringify({
user: Session.getUser().getEmail(),
cache
})
}
if (Object.keys(cache).length === 0) {
/* If no cache try to fetch it from the cache service */
const res = UrlFetchApp.fetch(SYNC_SERVICE, options)
const parsedResponse = JSON.parse(JSON.parse(res.toString()))
Object.keys(parsedResponse).forEach((k)=>{
console.log(k, parsedResponse[k])
cacheService.put(k, parsedResponse[k])
})
} else {
/* If cache send it to the sync service */
const res = UrlFetchApp.fetch(SYNC_SERVICE, options)
console.log(res.toString())
}
}
const createCache = () => {
cacheService.put('firstName', "Super")
cacheService.put('lastName', "Seagull")
}
const clearCache = () => {
cacheService.removeAll(CACHE_TO_SYNC)
}
Additional information
The synchronization service must be deployed with ANYONE access. You can control the access via an API_KEY.
This is just an example, and is not fully functional, you should adapt it to your needs.
The syncCache function of the web App is reusable, and would be the function you should use in all Web Apps.
There is a disadvantage when retrieving the cache, since you must provide the necessary keys, which forces you to write them manually (ex CACHE_TO_SYNC).
It could be considered to replace ScriptCache with ScriptProperties.
Documentation
Cache
Properties
Session
The doc says:
Gets the cache instance scoped to the current user and script.
As it is scoped to the script, accessing from another script is not possible. This is also the case with PropertiesService:
Properties cannot be shared between scripts.
To share, you can use a common file shared between them, like a drive text file or a spreadsheet.

Facing challenge to invoke cloud Function from cloud task using oidcToken

I am facing challenge to invoke cloud Function from cloud task using oidcToken.
Here are details of my IAM & Code:
const { CloudTasksClient } = require('#google-cloud/tasks');
const client = new CloudTasksClient();
//See https://cloud.google.com/tasks/docs/tutorial-gcf
module.exports = async (payload, scheduleTimeInSec) => {
const project = process.env.GOOGLE_APPLICATION_PROJECTID;
const queue = process.env.QUEUE_NAME;
const location = process.env.QUEUE_LOCATION;
const callBackUrl = https://asia-south2-trial-288318.cloudfunctions.net/cloud-function-node-expres/;
// Construct the fully qualified queue name.
const parent = client.queuePath(project, location, queue);
const body = Buffer.from(JSON.stringify(payload)).toString('base64');
const task = {
httpRequest: {
httpMethod: 'POST',
url: callBackUrl,
headers: { 'Content-Type': 'application/json' },
body
},
scheduleTime: {
seconds: scheduleTimeInSec,
}
};
if (process.env.GOOGLE_APPLICATION_SERVICE_ACCOUNT_EMAIL) {
task.httpRequest.oidcToken = {
serviceAccountEmail: process.env.GOOGLE_APPLICATION_SERVICE_ACCOUNT_EMAIL
}
}
const request = {
parent: parent,
task: task,
};
// Send create task request.
try {
let [responses] = await client.createTask(request);
return ({ sts: true, taskName: responses.name, msg: "Email Schedule Task Created" })
}
catch (e) {
return ({ sts: true, err: true, errInfo: e, msg: "Unable to Schedule Task. Internal Error." })
}
}
The process.env.GOOGLE_APPLICATION_SERVICE_ACCOUNT_EMAIL has Cloud Functions Invoker role and the Cloud Function has allAuthenticatedUsers member with role Cloud Functions Invoker as per the doc.
But still I am seeing the 401 resposnse recevied by Cloud Task and Cloud Function is not getting called(See below image):
Any comment on this, whats going wrong here
This seems to be related that you have created the function in Firebase (guessing from the url). Seems the "Cloud Functions Invoker" is not enough for Firebase functions. I have replicated similar behavior on HelloWorld function from Firebase. The error is differnet (403) but I hope it will help you to troubleshoot the same way.
After creation helloWorld in Firebase I tested it with glcoud command in following steps:
Create service acount with role "Cloud Functions Invoker" or use exiting one
Download key for the account in JSON.
Change gcloud to act as service account:
gcloud auth activate-service-account <service-account#email> --key-file=<key-form-step-2.json>
gcloud functions call helloWorld
As the result of last action I got this error:
ERROR: (gcloud.functions.call) ResponseError: status=[403], code=[Forbidden], message=[Permission 'cloudfunctions.functions.call' denied on resource 'projects/functions-asia-test-vitooh/locations/us-central1/functions/helloWorld' (or reso
urce may not exist).]
So I created custom role in IAM: Cloud Functions Invoker + Firebase adding permission from the error massage cloudfunctions.functions.call.
The function started to work with the same gcloud functions call:
executionId: 3fgndpolu981
result: Hello from Firebase!
I think it will work as well. You can try add the same permission. If it wont work, try the same testing.
References:
gcloud auth command
create custom role in Cloud IAM
gcloud function call

How do I get a new Access Token before its expiration after user login with chrome.identity.launchWebAuthFlow?

Sorry if this have been asked before but I've spend the whole day googling without any results.
I'm using Chrome Identity API for a Chrome Extension I'm building for my work (interacting with Google Sheets API), and I'm looking to persist the login for the user but refresh tokens with this API are nowhere to be found.
I first implemented chrome.identity.getAuthToken() and I was able to authorize the request to gSheets API, the problem came when the Access Token expired. I'm aware that calling getAuthToken() again refreshes the token if you call it with interactive: false, but only if the People you're logged in with has the same email you authorized in the initial interaction with the popup, and this is not what I want simply because in my browser I'm logged with my personal email, and I want to authorize the work email for the extension. So, after 1 hour I need to request a new token which will prompt a login popup and this is a poor user experience.
Then I tried with chrome.identity.launchWebAuthFlow() with the endpoint https://accounts.google.com/o/oauth2/v2/auth knowing that launchWebAuthFlow() is for non-Google accounts, and even though it worked well, as far as I know, there is no way to refresh the token after — let's say — 45 minutes to avoid kicking out the user.
chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
switch (request.type) {
case "login":
let authURL = "https://accounts.google.com/o/oauth2/v2/auth";
const clientId = `<CLIENT_ID>`;
const redirectURL = chrome.identity.getRedirectURL("oauth2");
const auth_params = {
client_id: clientId,
redirect_uri: redirectURL,
response_type: "token",
scope: "https://www.googleapis.com/auth/spreadsheets"
};
const paramString = Object.keys(auth_params)
.map(function(k) {
return k + "=" + auth_params[k];
})
.join("&");
authURL += "?" + paramString;
chrome.identity.launchWebAuthFlow(
{ url: authURL, interactive: true },
function(responseURL) {
if (chrome.runtime.lastError) {
console.log(chrome.runtime.lastError);
return;
}
if (responseURL) {
const paramsString = responseURL.substring(
responseURL.indexOf("#") + 1
);
const params = new URLSearchParams(paramsString);
const paramsObj = () => {
const obj = {};
for (var entry of params.entries()) {
obj[entry[0]] = entry[1];
}
return obj;
};
const { access_token } = paramsObj();
sendResponse({
access_token
});
} else {
console.log(`${responseURL} defined?`);
}
}
);
return true;
default:
console.log("placeholder");
break;
}
});
I'm willing to ditch Chrome Identity API and use another OAuth2 flow if it means I can improve the user experience for the user.
TL;DR: I want to get a new access token either with Chrome Identity API or any other OAuth2 implementation a few minutes before the previous token expires, while being able to use a Google Account different than the logged in Chrome (People).
I could've edited the previous question, but instead I'm going to answer it:
There's a way to get a new token by issuing a code instead of a token using
access_type=offline and response_type:'code'.
let authURL = 'https://accounts.google.com/o/oauth2/v2/auth';
const redirectURL = chrome.identity.getRedirectURL("oauth2");
const auth_params = {
client_id: clientId,
redirect_uri: redirectURL,
response_type: 'code',
access_type: 'offline',
scope: 'https://www.googleapis.com/auth/spreadsheets',
};
chrome.identity.launchWebAuthFlow({url: authURL, interactive: true}, responseURL => console.log(responseURL))
The response will be a URL:
https://<extension_id>.chromiumapp.org/oauth2?code=4/A<code>#
The code returned can be redeemed for a token that can be used only 1 time. You then send a POST request to https://www.googleapis.com/oauth2/v4/token with the following snippet as the request:
POST /oauth2/v4/token HTTP/1.1
Host: www.googleapis.com
Content-Type: application/x-www-form-urlencoded
code=4/A<code>&
client_id=your_client_id&
client_secret=your_client_secret&
redirect_uri=https://<extension_id>.chromiumapp.org/oauth2&
grant_type=authorization_code
The response will be a JSON object which you can use for ~1 hour to make requests to Google APIs:
{
"access_token": "<access_token>",
"token_type": "Bearer",
"expires_in": 3578
}
Finally before the access token expires you can call launchWebAuthFlow() with interactive: false and it will return a new code and with that you get a new token.
To get a new token by a code is the wrong way. Instead, a refresh token is designed to get a new token after the old one expired.
Exchange a code for refresh and access tokens.
https://developers.google.com/identity/protocols/oauth2/web-server?hl=en#exchange-authorization-code
Save the refresh token. When the current access token expires, get a new token by the refresh token.
https://developers.google.com/identity/protocols/oauth2/web-server?hl=en#offline
Good luck to coding.

Login to Chrome extension with a Google user other than the one in use by Chrome

I have a Chrome extension that requests a user to login using the chrome.identity.getAuthToken route. This works fine, but when you login you can only use the users that you have accounts in Chrome for.
The client would like to be able to login with a different Google account; so rather than using the.client#gmail.com, which is the account Chrome is signed in to, they want to be able to login using the.client#company.com, which is also a valid Google account.
It is possible for me to be logged in to Chrome with one account, and Gmail with a second account, and I do not get the option to choose in the extension.
Is this possible?
Instead of authenticating the user using the chrome.identity.getAuthToken , just implement the OAuth part yourself.
You can use libraries to help you, but the last time I tried the most helpful library (the Google API Client) will not work on a Chrome extension.
Check out the Google OpenID Connect documentation for more info. In the end all you have to do is redirect the user to the OAuth URL, use your extension to get Google's answer (the authorization code) and then convert the authorization code to an access token (it's a simple POST call).
Since for a Chrome extension you cannot redirect to a web server, you can use the installed app redirect URI : urn:ietf:wg:oauth:2.0:oob. With this Google will display a page containing the authorization code.
Just use your extension to inject some javascript code in this page to get the authorization code, close the HTML page, perform the POST call to obtain the user's email.
Based on David's answer, I found out that chrome.identity (as well as generic browser.identity) API now provides a chrome.identity.launchWebAuthFlow method which can be used to launch an OAuth workflow. Following is a sample class showing how to use it:
class OAuth {
constructor(clientId) {
this.tokens = [];
this.redirectUrl = chrome.identity.getRedirectURL();
this.clientId = clientId;
this.scopes = [
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/gmail.compose",
"https://www.googleapis.com/auth/gmail.send"
];
this.validationBaseUrl = "https://www.googleapis.com/oauth2/v3/tokeninfo";
}
generateAuthUrl(email) {
const params = {
client_id: this.clientId,
response_type: 'token',
redirect_uri: encodeURIComponent(this.redirectUrl),
scope: encodeURIComponent(this.scopes.join(' ')),
login_hint: email
};
let url = 'https://accounts.google.com/o/oauth2/auth?';
for (const p in params) {
url += `${p}=${params[p]}&`;
}
return url;
}
extractAccessToken(redirectUri) {
let m = redirectUri.match(/[#?](.*)/);
if (!m || m.length < 1)
return null;
let params = new URLSearchParams(m[1].split("#")[0]);
return params.get("access_token");
}
/**
Validate the token contained in redirectURL.
This follows essentially the process here:
https://developers.google.com/identity/protocols/OAuth2UserAgent#tokeninfo-validation
- make a GET request to the validation URL, including the access token
- if the response is 200, and contains an "aud" property, and that property
matches the clientID, then the response is valid
- otherwise it is not valid
Note that the Google page talks about an "audience" property, but in fact
it seems to be "aud".
*/
validate(redirectURL) {
const accessToken = this.extractAccessToken(redirectURL);
if (!accessToken) {
throw "Authorization failure";
}
const validationURL = `${this.validationBaseUrl}?access_token=${accessToken}`;
const validationRequest = new Request(validationURL, {
method: "GET"
});
function checkResponse(response) {
return new Promise((resolve, reject) => {
if (response.status != 200) {
reject("Token validation error");
}
response.json().then((json) => {
if (json.aud && (json.aud === this.clientId)) {
resolve(accessToken);
} else {
reject("Token validation error");
}
});
});
}
return fetch(validationRequest).then(checkResponse.bind(this));
}
/**
Authenticate and authorize using browser.identity.launchWebAuthFlow().
If successful, this resolves with a redirectURL string that contains
an access token.
*/
authorize(email) {
const that = this;
return new Promise((resolve, reject) => {
chrome.identity.launchWebAuthFlow({
interactive: true,
url: that.generateAuthUrl(email)
}, function(responseUrl) {
resolve(responseUrl);
});
});
}
getAccessToken(email) {
if (!this.tokens[email]) {
const token = await this.authorize(email).then(this.validate.bind(this));
this.tokens[email] = token;
}
return this.tokens[email];
}
}
DISCLAIMER: above class is based on open-source sample code from Mozilla Developer Network.
Usage:
const clientId = "YOUR-CLIENT-ID"; // follow link below to see how to get client id
const oauth = new OAuth();
const token = await oauth.getAccessToken("sample#gmail.com");
Of course, you need to handle the expiration of tokens yourself i.e. when you get 401 from Google's API, remove token and try to authorize again.
A complete sample extension using Google's OAuth can be found here.

Users have no access to application

I'm building an application in Google Apps Script. I'm authenticating the domain users by checking if their logonid is permitted to use the application.
I developed it and when I entered the testing phase, I was the only user that could actually use the application.
Although I had set the "Who has Access" combobox on the web-app publish wizard to "anyone".
When a user executes the application he will get an exception saying he has no access when executing the first function after doGet(). Did I overlook some settings or did I do something wrong?
I use the following classes:
UserManager
Session
Jdbc
Utilities
Logger
This is the function that is called after the doGet() function:
function authenticateUser() {
try {
var user = UserManager.getUser(Session.getActiveUser());
Logger.log('User: ' + user.getEmail());
if (user == undefined || user == null) {
return {authenticated: false};
} else {
var auth = _getAuth();
if (!auth.isAuthorized(user.getEmail())) {
Logger.log('Not authorized in database.');
return {authenticated: false};
} else {
var profile = auth.getProfile(user.getEmail());
authenticated = true;
auth.setLogin(user.getEmail());
if(!profile.firstLogin) {
activeProfile = profile;
}
activeUser = user;
return {profile: profile, authenticated: true};
}
}
} catch(ex) {
Logger.log(ex);
return {authenticated: false};
}
}
Access to UserManager is the issue here. As the documentation states, UserManager is only accessible to administrators.
If the app is running as a normal user, it cannot access UserManager. You may need to rethnk the deployment/code to run it as yourselves (or admin).