Serving JSON from Google Apps Script as "user accessing the web-app" - json

Trying to serve a JSON request made by a Google Apps Script as the user who is making the request. After thinking for a while, I realized that this cannot work, because the serving script needs to be authorized to run as the user who calls him, so the calling script would have to add some authorization info, which it doesn't, and I have no idea how to add that information (that is actually my question: which information to add to the http-request, and from where to get it).
But, nevertheless, when I call that server from within the browser, it works because the browser (in which I am logged into my Google account) sends the right info in the http-request to prove that it is authorized.
This question is somehow related to the question How to use Google Apps Script ContentService as a REST server, but the answer given there is exactly what I don't want: The invoked JSON-Server should not run under my account, but under the account of the invoking user and use that users resources (scriptdb, drive, ...).
So the question is: How to provide that information from one script run from a Google-account to another script, so that this other script can run within that account? I know that using gas-libraries solves that issue, but I would like to have it as JSON-client/server (Mainly to decouple dependencies). To add some code (to keep the Stack Overflow-spirit running), my client:
var scriptUrl="https://script.google.com/macros/s/paperlapapp1231231/exec";
function callIt (nm, args) {
var a=[nm];
for (x in args) {
a.push(args[x]);
}
return Utilities.jsonParse(UrlFetchApp.fetch(scriptUrl+"?args="
+encodeURIComponent(Utilities.jsonStringify(a))).getContentText());
}
function testFunction (p1, p2, p3) { // this is the proxy
return callIt ("testFunction", arguments);
}
// Testroutine
function callTest() {
var r=testFunction (9, "XXXlala", {"dudu":1, "dada":"xxx"});
Logger.log ("r="+Utilities.jsonStringify(r));
}
My server:
function doGet(request) {
var ret=null;
try {
Logger.log ("I got called with "+JSON.stringify(request));
var args=JSON.parse(decodeURIComponent (request.parameters.args));
ret=ContentService.createTextOutput(
JSON.stringify(this[args.shift()].apply (this, args)))
.setMimeType(ContentService.MimeType.JSON);
}
catch (e) {
ret=ContentService.createTextOutput(JSON.stringify(e))
.setMimeType(ContentService.MimeType.JSON);
}
return ret;
}
function testFunction (p1, p2, p3) { // this is the implementation
var s="testing called p1="+p1+", p2="+p2+", p3="+p3;
Logger.log (s);
return {testingResult:s};
}
EDIT:
Worked out a solution, but requires a Chrome extension and an intermediate proxy server. See code at https://bitbucket.org/pbhd/gas-rest-server-as-calling-user

Edit: This was not possible before, as my answer below reflects.
However, Google does allow this now, see Execution API
https://developers.google.com/apps-script/execution/rest/v1/scripts/run
Its not possible to do that from Google Apps Script. If I understand it correctly you are calling another Google Apps Script with urlFetch.
Since Google Apps Script doesn't have an oAuth scope you can't make an authenticated call. You can only call a webapp service published as anonymous thus will run always under owner permissions.
You can't do it with libraries either unless the initial script is also called with user permissions and not owner.

Related

Using Google Picker as a Library returning "ScriptError: Authorization is required to perform that action." for other users

I've scoured this website for the better part of the week for any information in regards to this issue, but most resources are either a few years old and the documentation hasn't been helpful to me.
I'm creating a script inside Google Sheets that allows the user to retrieve their files and folders from the Google Picker. When a folder is selected, the script will copy the folders and files into a new folder.
I've followed the documentation from https://developers.google.com/picker/docs and https://developers.google.com/apps-script/guides/dialogs. I've created a Google Cloud Profile, enabled the necessary APIs (Google Picker API & Google Drive API), called the appropriate scopes (/auth/drive, /auth/spreadsheets.currentonly). Everything works at an Outer Script level (from the Google Sheets) and works for multiple users. Fantastic.
The goal however is to implement the script into a library as a means to protect the code from unnecessary modifications, for easy updates, and to be easily referenced if users create a copy of the Google Sheet. This is where things start to break.
When running the script as a library and through my development account (the same one that is linked to the GCP), everything works well, as I can authenticate both the Outer Script AND the library script (because I have access to both). The end-user will NOT have access to the library script and won't be able to run a function in order to authenticate.
When using a separate account for testing purposes, completely disconnected from the development side, I receive a ScriptError: Authorization is required to perform that action. error. This is likely due to the fact that I haven't been able to authenticate the library.
Outer script:
function onOpen() {
lib_.onOpen();
}
function doGet(e) {
lib_.doGet(e);
}
function getOAuthToken() {
lib_.getOAuthToken();
}
Code.gs:
var ss = SpreadsheetApp.getActiveSpreadsheet();
// Menu
function onOpen(e) {
SpreadsheetApp.getUi().createAddonMenu()
.addItem("Select Folders", "doGet")
.addToUi();
}
// Display Google Picker
function doGet(e) {
// Display Google Picker.
var html = HtmlService.createHtmlOutputFromFile('picker')
.setWidth(1051)
.setHeight(650)
.setSandboxMode(HtmlService.SandboxMode.IFRAME.ALLOWALL);
SpreadsheetApp.getUi().showModalDialog(html, 'Select a folder');
}
// Get the user's OAuth 2.0 Token //
function getOAuthToken() {
DriveApp.getRootFolder();
return ScriptApp.getOAuthToken();
}
picker.html:
<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
<script type="text/javascript">
var DEVELOPER_KEY = '{DEVELOPER_KEY}'; // Developer Key from Google Cloud Project.
var DIALOG_DIMENSIONS = {width: 1051, height: 650}; // The max dimensions for the dialog box is 1051,650.
var pickerApiLoaded = false;
// Use the Google API Loader script to load the google.picker script.
function onApiLoad() {
gapi.load('picker', {'callback': function() {
pickerApiLoaded = true;
}});
}
/**
* Gets the user's OAuth 2.0 access token from the server-side script so that
* it can be passed to Picker. This technique keeps Picker from needing to
* show its own authorization dialog, but is only possible if the OAuth scope
* that Picker needs is available in Apps Script. Otherwise, your Picker code
* will need to declare its own OAuth scopes.
*/
function getOAuthToken() {
google.script.run.withSuccessHandler(createPicker)
.withFailureHandler(showError).getOAuthToken();
}
/**
* Creates a Picker that can access the user's folders.
*
* #param {string} token An OAuth 2.0 access token that lets Picker access the
* file type specified in the addView call.
*/
function createPicker(token) {
console.log(pickerApiLoaded,token)
if (pickerApiLoaded && token) {
var docsView = new google.picker.DocsView()
.setIncludeFolders(true) // As we're copying folders, we want to include folders in the picker.
.setParent('root') // By setting the parent, we can see the directory properly. If not set, then we see ALL folders in one screen.
.setSelectFolderEnabled(true); // Enables the user to select folders.
//.setMimeTypes('application/vnd.google-apps.folder') // If we want, we can make it that ONLY folders are viewable.
var picker = new google.picker.PickerBuilder()
.addView(docsView) // Adds the settings set above.
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED) // Allows the user to select multiple folders at once. Disable this to only allow one folder at a time.
.enableFeature(google.picker.Feature.NAV_HIDDEN) // Removes the header. Disable to allow view of the header.
//.enableFeature(google.picker.Feature.MINE_ONLY) // Only allows the person to view their own folders. Disable to allow shared folders (not shared drives).
.setSelectableMimeTypes('application/vnd.google-apps.folder') // Only allows the selection of folders. Disable to allow all files and folders.
.hideTitleBar() // Hides title bar as it only takes up space.
.setOAuthToken(token)
.setDeveloperKey(DEVELOPER_KEY)
.setCallback(pickerCallback)
.setOrigin(google.script.host.origin)
.setSize(DIALOG_DIMENSIONS.width - 2,
DIALOG_DIMENSIONS.height - 2) // Reducing width & height to see the border.
.build();
picker.setVisible(true); // Make picker visible.
} else {
showError('Unable to load the file picker.');
}
}
/**
* Perform pickerCallback function... and rest of code.
*/
/**
* Displays an error message within the #result element.
*
* #param {string} message The error message to display.
*/
function showError(message) {
document.getElementById('result').innerHTML = 'Error: ' + message;
}
</script>
</head>
<!-- Display the Picker as soon as the dialog loads. -->
<body onload="getOAuthToken()">
<div>
<p id='result'></p>
</div>
<script type="text/javascript" src="https://apis.google.com/js/api.js?onload=onApiLoad"></script>
</body>
</html>
A couple of notes:
I've logged in console whether the Picker API is able to load. It returns as True. However, the OAuthToken returns as undefined.
I'm running the script from a single account at a time and testing with a separate account with incognito mode. I understand that being logged in with multiple accounts in a single browser will often trigger this error.
EDIT The standalone script is deployed as a web app (but not verified by Google). I'm not too familiar on how to deploy web apps, and could use some guidance on this front as well.
One solution I've tried was to implement the oAuth2 Library which can be found here: https://github.com/googleworkspace/apps-script-oauth2. However, this also does not work as it requires the scriptID from the Outer Script for the URI Redirect in GCP (which won't work if users create a copy of the outer script). Using the library scriptID will return as a 400 Mismatch error. In theory, however, this library returns an oAuth key, but suspect it's the one for the Outer Script (not the library script required to make the Picker work).
My theory (and correct me if I'm wrong), is that the getOAuthToken() function in code.gs isn't able to pull the oAuthToken as it's a standalone script, and not in a Google Sheets. There's theoretically "nothing to get", which is why it returns as undefined. I'm just a little stumped on how to proceed from here.
Is there a way to "force" authentication of a library to the user without asking them to open the library script, run an empty function, and authorize it, in order for the user to be able to see the Google Picker? Or is there an easy way to get the oAuthToken from the Outer Script and push it to the library to be used to authenticate the Picker?
I hope this is clear enough. Let me know if you need additional info.
Currently, it is required in OAuth2 that each user must give manual consent to call Google APIs and access user data:
Authentication flow
When an application needs to access Google Cloud APIs on behalf of an end user, the application initiates an OAuth consent flow. After the user completes the flow, your application receives the user's credentials. With the credentials, your application can call Google Cloud APIs on behalf of the user.
The only alternative to this is to use service accounts, which allows you to obtain access tokens (and refresh them) in order to authenticate.
You can read on these references for more information:
Authenticating as an end user
Authenticating as a service account

How to add authentication to a Google apps script without it expiring?

I have a Google apps script project which I use as a web application to save some data gathered from a web page (JavaScript only) to My Google sheets (think of it as a simple database for just me).
It's no need to use auth for anyone else other than my account, because, I don't use their accounts/data for anything at all. I just need to use my account only, so, when I deploy it, I make it execute as me, and accessible to anyone:
.
When I click deploy after previous screen, it asks for my permission (the consent dialogue) to access my account's data, which I grant it, and after that everything is good, and the HTTP requests to this script works just fine.
.
The problem is:
This authentication expires after maybe 7 days (I'm not sure) and the script stops working, I discover that when the HTTP requests to it return error 403
To fix that and make it work again, I need to run the script again from the Google apps script editor which asks for the permissions (consent) again:
.
I can't use it like that and the web page stop working when the authentication gets revoked!
I'm not publishing the script (I don't want/don't need to). Do I?
My question is, how can I add the authentication in a way that makes it lasts and stops asking me for it over and over again?
The script on Google apps script works like this:
function doPost(request) {
return checkRequest(request);
}
function checkRequest(request) {
//check the request & save the sent data to a google sheet here;
//...
return sendResponse({
success: true,
data: {result: 'Saved the data!' }
});
}
function sendResponse(response) {
return ContentService
.createTextOutput(JSON.stringify(response))
.setMimeType(ContentService.MimeType.JSON);
}
And I call it from the web page using Ajax HTTP POST request, like this:
jQuery.ajax({
url: 'https://script.google.com/macros/s/{script-id}/exec',
method: 'POST',
dataType: "json",
data: {key: 'value'},
success: function (response) {
console.log(response);
},
error: function (response) {
console.error(response);
}
});
And this is the response the script returns after few days when the authentication expires:
This has been reported to Google
There is already a report on Google's Issue Tracker which detail the same kind of behaviour:
Random Deauthorizations for script since Editor Update with no changes to code
Google does seem to know about this issue. From the issue tracker link, a response was given:
[...] reviewing the documentation, it reads:
Authorizations by a test user will expire seven days from the time of consent.
Source
So I'm not sure we should expect anything different from these tests.
Also re-reading this thread, in your first comment you said that this was affecting projects that already published. Though I understand that you fixed the original projects that were having issues by un-linking the GCP projects.
A possible fix would be filling the scopes manually in the manifest for these types of issues. This is because Apps Script tries to infer the scopes needed at runtime, and sometimes this can result in Apps Script trying to gain more permissive scope than what is authorized.
See Setting Explicit Scopes
However, token expiry in around 7 days is to be expected for projects in "testing" state.
Which seems to be the case for the users in the report thread.
There are multiple reasons that this expiration may occur, as explained in the Using OAuth 2.0 to Access Google APIs page.
That being said, if it's causing problems you can file your own bug about it here in the Google Apps Script component.
References:
Random Deauthorizations for script since Editor Update with no changes to code
Using OAuth 2.0 to Access Google APIs | Google Identity
Setting up your OAuth consent screen - Google Cloud Platform Console Help
Authorization Scopes - Setting explicit scopes | Apps Script | Google Developers

Oauth2 service creation hasAccess() fails in Google Sheets script for all users except owner

I'm using the https://github.com/googleworkspace/apps-script-oauth2 library in a Google Sheet script to call an external API. It works well for the owner of the sheet. For all other users it fails in creating the service/property store? It fails the "Service.hasAccess()" condition.
I suspect I am missing some sort of permissions somewhere. I have given users Edit permissions on the Sheet and have gone through other various gyrations trying to figure this out. I decided to apply this script via a Standard Project.
Scope are applied explicitly in the manifest and all works swimmingly for the sheet owner.
''''''''Google Apps Script, Spreadsheet Script in GCP Standard Project
function authorizeUser() {
var myService = getMyService();
if (myService.hasAccess()) {
>FAILS THIS CONDITION for all except spreadsheet owner
}
}
function getMyService() {
return OAuth2.createService('sky')
.setAuthorizationBaseUrl('https://oauth2../authorization')
.setTokenUrl('https://oauth2.../token')
.setClientId('fee......')
.setClientSecret('Ighh.....')
.setCallbackFunction('authCallback')
.setPropertyStore(PropertiesService.getUserProperties())
//.setScope('https://www.googleapis.com/auth/drive')
// Below are Google-specific OAuth2 parameters.
.setParam('login_hint', Session.getEffectiveUser().getEmail())
}
>I believe the failure is occurring in OAuth2.gs here
function createService(serviceName) {
return new Service_(serviceName);
}
OAuth2.gs: https://github.com/googleworkspace/apps-script-oauth2/tree/master/dist
Any thoughts?
D M
Apparently the suggested code to validate the service state in the apps-script-oauth2 library is not indicative of whether or not the Oauth process can be completed.
Direct the user to the authorization URL
Apps Script UI's are not allowed to redirect the user's window to a new URL, so you'll >need to present the authorization URL as a link for the user to click. The URL is >generated by the service, using the function getAuthorizationUrl().
function showSidebar() {
var driveService = getDriveService();
if (!driveService.hasAccess()) {
I was able to complete my Oauth process regardless of the state returned by has.Access() . I'm not sure if this is a time sensitive operation or something else is at play. At any rate I was able to proceed and develop a final solution as a GAS web app.

Is it possible to get fresh Google Slides presentation data in AppsScript?

It seems that if one uses SlidesApp.getActivePresentation() in AppsScript, the result of the function is not fresh but rather something that was already prepared beforehand.
Scenario
Imagine you have two users performing the following function in AppsScript simultaneously:
function updateSlideText(slideId) {
// Request exclusive write access to the document
var lock = LockService.getDocumentLock();
lock.waitLock(15000);
// Perform changes
var presentation = SlidesApp.getActivePresentation();
var textBox = presentation.getSlideById(MY_SLIDE_ID).getPageElementById(MY_TEXTBOX_ID);
textBox.asShape().getText().setText('My text');
// Save and release lock
presentation.saveAndClose();
lock.releaseLock();
}
If this function is called twice at the same time, the resulting slide contains text "My textMy text".
When I add Utilities.sleep(10000) just before the lock release, it delays the 2nd execution by 10s but after those 10s I still end up with the same result. On the other hand, if I actually delay calling the function 10s, the output is fine.
From this I conclude that it does not matter if I call saveAndClose and use locks. Once the function is called, it will always have stale data. Is there a way around this? Is it not possible to request that fresh data will be loaded after the lock is aquired?
More details
Some more pseudo-code to better illustrate the problem use-case:
// The addon frontend
websocket.onMessage((message) => {
if (message.type === 'pollUpdate') {
const slideWithPoll = store.getState().slides.find(
slide => slide.pollId === message.pollId
);
if (slideWithPoll.title !== message.poll.title) {
google.script.run.updateSlideText(slideWithPoll.id, message.poll.title);
}
}
});
I believe your goal as follows.
When 2 users are run your script for Google Slides, simultaneously, you want to run the script individually.
For this, how about this answer?
Issue and workaround:
When I tested your situation, I could confirm the same issue like My textMy text. When I tested several times, in this case, I thought that the LockService might not affect to Google Slides. So as a workaround, I would like to propose to use Web Apps as the wrapper. Because it has already been known that Web Apps can run exclusively by the LockService. The flow of this workaround is as follows.
When the script is run, the script requests to Web Apps.
At Web Apps, your script is run.
By this, even when the script is run, simultaneously, the script can be exclusively run with the LockService.
Usage:
The usage of this sample script is as follows. Please do the following flow.
1. Prepare script.
When your script is used, it becomes as follows. Please copy and paste the following script to the script editor. Please set MY_SLIDE_ID and MY_TEXTBOX_ID.
function doGet() {
// This is your script.
var presentation = SlidesApp.getActivePresentation();
var textBox = presentation.getSlideById(MY_SLIDE_ID).getPageElementById(MY_TEXTBOX_ID);
var text = textBox.asShape().getText();
text.setText('My text');
return ContentService.createTextOutput("ok");
}
// Please run this function.
function main() {
var lock = LockService.getDocumentLock();
if (lock.tryLock(10000)) {
try {
const url = "https://script.google.com/macros/s/###/exec"; // Please set the URL of Web Apps after you set the Web Apps.
const res = UrlFetchApp.fetch(url);
console.log(res.getContentText())
} catch(e) {
throw new Error(e);
} finally {
lock.releaseLock();
}
}
}
2. Deploy Web Apps.
On the script editor, Open a dialog box by "Publish" -> "Deploy as web app".
Select "Me" for "Execute the app as:".
By this, the script is run as the owner.
Select "Anyone, even anonymous" for "Who has access to the app:".
In this case, no access token is required to be request. I think that I recommend this setting for testing this workaround.
Of course, you can also use the access token. At that time, please set this to "Anyone". And please include the scope of https://www.googleapis.com/auth/drive.readonly and https://www.googleapis.com/auth/drive to the access token. These scopes are required to access to Web Apps.
Click "Deploy" button as new "Project version".
Automatically open a dialog box of "Authorization required".
Click "Review Permissions".
Select own account.
Click "Advanced" at "This app isn't verified".
Click "Go to ### project name ###(unsafe)"
Click "Allow" button.
Click "OK".
Copy the URL of Web Apps. It's like https://script.google.com/macros/s/###/exec.
When you modified the Google Apps Script, please redeploy as new version. By this, the modified script is reflected to Web Apps. Please be careful this.
Please set the URL of https://script.google.com/macros/s/###/exec to url of above script. And please redeploy Web Apps. By this, the latest script is reflected to the Web Apps. So please be careful this.
4. Test this workaround.
Please run the function of main() by 2 users, simultaneously as you have tested. By this, it is found that the script is run exclusively. In my environment, in this case, I confirmed that even when the LockService is not used, the script is exclusively run. But I would like to recommend to use the LockService just in case.
Note:
This is a simple sample script for explaining this workaround. So when you use this, please modify it for your actual situation.
About the situation that the LockService might not affect to Google Slides, in the current stage, although I'm not sure whether this is the bug, how about reporting this to the Google issue tracker? Unfortunately, I couldn't find this issue at the current Google issue tracker.
References:
Web Apps
Taking advantage of Web Apps with Google Apps Script

Failure in Fetching Google Script's URL

URL: https://script.google.com/a/macros/MyDomain.com/s/MyProjectId/exec
To trigger the doGet() inside my Google Script, with URL:
Success: Manually Input above Url in the browser.
Failure: Fetch by Google Script UrlFetchApp.fetch(URL)
Is Fetch by program not allowed for Google Script's URL? Or have I made any stupid mistake?
I have tried to fetch by post method also to test doPost(), but got same result.
N.B.I have added my domain & script.google.com into APIs & auth/Push, and handled OAuth. No more authorization popup. So suppose not these issues.
~~~~~~~~~~~~~~~~~~~~~~~~~
Some people say this problem come from Google Apps's security in front of doGet() & doPost(). But after testing, it is failure no matter I run it on personal Gmail account or Google Apps account, and no matter run it as Developer, Domain users or Anonymous Anyone. Below is the demo code, without including the lengthy standard authorization code.
var REDIRECT_URL = 'https://script.google.com/macros/s/.../exec';
var SysEmail = "EmailAddress";
function doGet(e) {
MailApp.sendEmail(SysEmail, 'Test', 'doGet has received Push Notification');
}
//Running below function should trigger above doGet() theoretically, but not actually.
function TestGetByManual(){
var payload =
{
"kind" : "Test",
};
var options =
{
"method" : "get",
"followRedirects" : true,
"payload" : payload
};
var vResult = UrlFetchApp.fetch(REDIRECT_URL, options);
//vResult is Google's Login page.
}
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
After latest testings, I find that Google behave like this:
Since Google's security is so strong, setting parameter "followRedirects" to true is helpless. When Fetch meet Google before doGet() or doPost(), it will be stopped due to Authority reviewing. If it is fetched by manual (e.g. users' installation through browser), an authorization window will be pop-up (esp. if doGet() include authorization code inside). But if it is fetched by another App like my case, everything seems stop and doGet() and its authorization code will never be called!
The only workaround is: If I can set the App to execute as me and accessible by Anonymous, then there will be no Authority checking and everything go directly. Unluckily, even ignoring security issue, only personal Gmail account can make this setting. I, using Google Apps account, cannot make this "Anonymous" setting.
If authorization code will be called, I think there is still hope. But if my testing result is correct and no authorization code will be called before stop, I believe it is a dead corner.
The case here is still better. If the caller is not a controllable custom function, but an uncontrollable system function like Drive.changes.watch(), everything will be further helpless. (See my another post, which is my ultimate goal: Use Google Script's Web App as Webhook to receive Push Notification directly)
If any senior here can contact Google's Arun Nagarajan, really please help get a confirmation from him.
Works fine for me with the following code:
var REDIRECT_URL = 'https://script.google.com/macros/s/AKf.../exec';
var SysEmail = "anemail";
function doGet(e) {
MailApp.sendEmail(SysEmail, 'Test', 'doGet has received Push Notification');
}
function TestGetByManual(){
var vResult = UrlFetchApp.fetch(REDIRECT_URL);
Logger.log(vResult);
}
Make sure you save a new version and re-publish the webapp with that version and using the correct published url.