0

Problem

After days of reading and attempting trial-and-error, I am trying to make a call from a GAS Web App (executed as any Google User) to a GAS API Executable (executed as Me), but consistently receive an error message after Reviewing/Granting permissions:

*"Error: Access not granted or expired."*

That is not a response from the server, but simply a notification from the OAuth2 library: "This method will throw an error if the user's access was not granted or has expired."

So it seems there may be some otherwise obvious step that is missing from instructions and Q&As. Somehow, after doing all of the setup, the web app cannot access the API Executable.

I also spent a few hours writing this question and formulating a minimal test/example. Here are the files in Google Drive, for viewing directly.

Desired Outcome

The desired outcome is to be able to have other users use the Web App as themselves and, from that app, execute the API Executable as Me.

Question

What is wrong with my configuration and/or code, and how can I receive data back from the API Executable?

What I've tried

I've combined various tutorials and Q&As and attempted to make a minimal example. The three most closely related are:

  1. Google Groups - "Webapp execute as user BUT save data to spreadsheet"

...Service accounts, while applicable, are not the best fit for this use-case. They are better suited to situations where the service account acts as a proxy for multiple users...

...Basically, you'll need to generate OAuth2 credentials specific to your account and use the OAuth2 library to generate access tokens. That access token can then be used to make direct calls against the Spreadsheet REST API OR alternatively, the Apps Script API (formerly the Execution API) to invoke a function in the script under your own authority...

  1. SO - "Can I have part of Google Apps Script code execute as me while the rest executes as the accessing user?"
  2. SO - "Get user info when someone runs GAS web app as me"

The first link seems directly applicable to my scenario. However, the instructions are sparse, though I have done my best to follow them. The second is also applicable, but sparse. The third is related, but is actually the inverse of what I want to do, and so of limited help.

Summary of Steps in GCP

Here is what I did within console.cloud.google.com:

  1. Created a new project named "apiExecTest".
  2. Within "APIs & Services", enabled two APIs:
    • Apps Script API (unsure if necessary)
    • Google Sheets API (unsure if necessary)
  3. Within "APIs & Services", configured the Oauth Consent Screen
    • Internal
    • Set App name, User support email, and Developer contact email. Did nothing else. Did not set "App domain" nor "Authorized domains".
    • Added all 61 scopes for Apps Script and Sheets (unsure if necessary)
  4. Within "APIs & Services", created credentials
    • OAuth client ID
    • Web Application
    • Added Client name.
    • Added Authorized Redirect URI:
      https://script.google.com/macros/d/1zj4ovqMWoCUgBxJJ8u518TOEKlckeIazVBL4ASdYFiVmjoZz9BLXbJ7y/usercallback
    • Obtained Client ID & Client Secret to insert into webApp code.

Summary of Steps in GAS

Here is what I did in Google Drive / Apps Script. The files can be viewed here:

  1. Created a new folder in Google Drive containing three things:

    • GAS file: "webApp"
      • Deployed as Web App
      • Execute as: User accessing the web app
      • Who has access: Anyone with Google account
    • GAS file: "apiExec"
      • Deployed as API Executable
      • Who has access: Anyone with Google account
    • Google Sheet: sSheet
      • Not shared with anyone, owned by Me.
  2. Added a basic function to apiExec that obtains the first cell of the first sheet in sSheet, and confirmed it works by executing it within the GAS editor and observing the console output.

  3. Added the OAuth2 library to webApp as oauth2.gs, copy/pasted from GitHub. Setup and configured setClientId(), setClientSecret(), API URL and other settings per the readme and examples cited above. For setScope(), I used:
    .setScope('https://www.googleapis.com/auth/script.external_request https://www.googleapis.com/auth/spreadsheets')

  4. Added a basic functionality to webApp that makes a call to apiExec to obtain data from sSheet.

  5. Added the following to the webApp appsscript.json (unsure if correct, have tried variations):
    "oauthScopes": ["https://www.googleapis.com/auth/script.external_request", "https://www.googleapis.com/auth/spreadsheets"]

  6. I changed the GCP Project Number for both apiExec and webApp to that of the GCP project created in the steps above.

  7. I then executed the doGet() function of webApp within the GAS editor. It does ask for authorization, which I granted. After authorization, as the execution continues, the error mentioned above is thrown. I also ran the function via webApp's URL, which of course also results in the error.

After attempting this multiple times, and spending days reading and with trial-and-error, I've made no progress. Any help is greatly appreciated.



To be thorough, here are the contents of the GAS files:

apiExec

appsscript.json

{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "executionApi": {
    "access": "ANYONE"
  }
}

Code.gs

function doPost() {
  var spreadsheet = SpreadsheetApp.openById("1aIMv1iH6rxDwXLx-i0uYi3D783dCtlMZo6pXJGztKTY");
  var sheet = spreadsheet.getSheetByName("test sheet");
  var data = sheet.getRange("A1").getValues()
  console.log(data)

  return data;
}

webApp

appsscript.json

{
  "timeZone": "America/New_York",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
"https://www.googleapis.com/auth/script.external_request",
"https://www.googleapis.com/auth/spreadsheets"
  ],
  "webapp": {
"executeAs": "USER_ACCESSING",
"access": "ANYONE"
  }
}

Code.gs

function doGet(e) {
  var myParam = "someParam";
  console.log(myParam);

  var apiExecResponse = makeRequest('doPost', [myParam]);
  console.log(apiExecResponse);
  
  var appsScriptService = getService();

  if (!appsScriptService.hasAccess()) {
// This block should only run once, when I authenticate as myself to create the refresh token.
var authorizationUrl = appsScriptService.getAuthorizationUrl();
var htmlOutput = HtmlService.createHtmlOutput('<a href="' + authorizationUrl + '" target="_blank">Authorize</a>.');
htmlOutput.setTitle('GAS Authentication');
return htmlOutput;
  }
  else {
console.log("It worked: " + myParam + " " + apiExecResponse);
htmlOutput.setTitle("The Results");
return HtmlService.createHtmlOutput("<p>It worked: " + myParam + " " + apiExecResponse + "</p>");
  }
}


function getService() {
  // Create a new service with the given name. The name will be used when
  // persisting the authorized token, so ensure it is unique within the
  // scope of the property store.
  return OAuth2.createService('apiExecService')

  // Set the endpoint URLs, which are the same for all Google services.
  .setAuthorizationBaseUrl('https://accounts.google.com/o/oauth2/auth')
  .setTokenUrl('https://accounts.google.com/o/oauth2/token')

  // Set the client ID and secret, from the Google Developers Console.
  .setClientId('390208108732-s7geeikfvnqd52a0fhf6e015ucam0vqk.apps.googleusercontent.com')
  .setClientSecret('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')

  // Set the name of the callback function in the script referenced
  // above that should be invoked to complete the OAuth flow.
  .setCallbackFunction('authCallback')

  // Set the property store where authorized tokens should be persisted.
  .setPropertyStore(PropertiesService.getScriptProperties())

  // Set the scopes to request (space-separated for Google services).
  .setScope('https://www.googleapis.com/auth/script.external_request https://www.googleapis.com/auth/spreadsheets')

  // Below are Google-specific OAuth2 parameters.

  // Sets the login hint, which will prevent the account chooser screen
  // from being shown to users logged in with multiple accounts.
  //.setParam('login_hint', Session.getEffectiveUser().getEmail())

  // Requests offline access.
  .setParam('access_type', 'offline')

  // Consent prompt is required to ensure a refresh token is always
  // returned when requesting offline access.
  .setParam('prompt', 'consent');
}



function authCallback(request) {
  var apiExecService = getService();
  var isAuthorized = apiExecService.handleCallback(request);
  if (isAuthorized) {
return HtmlService.createHtmlOutput('Success! You can close this tab.');
  }
  else {
return HtmlService.createHtmlOutput('Denied. You can close this tab');
  }
}




function makeRequest(functionName, paramsArray) {
  console.log("Running " + functionName + " via 'makeRequest'.");

  var apiExecUrl = 'https://script.googleapis.com/v1/scripts/AKfycbzHV5_Jl2gJVv0wDVp93wE0BYfxNrOXXKjIAmOoRu3D8W6CeqSQM9JKe8pOYUK4fqM_:run';

  var payload = JSON.stringify({
"function": functionName,
"parameters": paramsArray,
"devMode": false
  });

  var params = {
method:"POST",
headers: {
  Authorization: 'Bearer ' + getService().getAccessToken()
},
payload: payload,
contentType: "application/json",
muteHttpExceptions: true
  };

  var result = UrlFetchApp.fetch(apiExecUrl, params);

  return result;
}

OAuth2.gs

See: https://github.com/googleworkspace/apps-script-oauth2/blob/master/dist/OAuth2.gs
Brandon
  • 3,572
  • 2
  • 12
  • 27
  • If I understood your question correctly - you need to have a [Service account](https://daubejb.medium.com/how-to-use-gcp-service-accounts-with-google-apps-script-projects-to-automate-actions-in-g-suite-7020a520bef7) for executing the API (it won't execute as you but it will be executed as Admin) – Darpan Sanghavi Nov 18 '21 at 06:00
  • Have you tried adding within the apiExec `appsscript.json` the scope to authorize the reading of Google Sheets? `"oauthScopes": ["" https://www.googleapis.com/auth/spreadsheets"]`. More info [here](https://developers.google.com/apps-script/concepts/scopes) – Emel Nov 18 '21 at 09:28
  • @DarpanSanghavi : Thanks for the suggestion. It is true that creating a Service Account is another way to approach it. However, the first question I linked-to in the question discusses why that is less ideal than the API Executable "As Me" approach. Furthermore, the third link seems to validate that it can work without a Service Account. – Brandon Nov 18 '21 at 10:33
  • @Emel : Thank you. Yes, I have tried adding that scope. I have now added it again, just to be sure. However, the same issue remains. – Brandon Nov 18 '21 at 10:33
  • ```````````Why?``````````` – TheMaster Nov 18 '21 at 11:30
  • `It does ask for authorization, which I granted. Then, the error mentioned above occurs.`. What does `Then` mean? Did you get `return HtmlService.createHtmlOutput('Success! You can close this tab.');` or `Denied`? – TheMaster Nov 18 '21 at 12:42
  • Your question is quite broad. If the issue is isolated to the oath library authorization, you should focus on that issue only. The rest seems irrelevant. – TheMaster Nov 18 '21 at 12:43
  • Thank you, @TheMaster. I have edited the question to clarify `Then` and what I "get" when running the function. The OAuth2 library is most likely working properly; it is a well-tested and widely-used library. After much study on this topic, the issue likely lies somewhere within the configuration of GAS and/or GCP, which is why the information is included, and why the title of the question focuses on those two things. – Brandon Nov 18 '21 at 12:53
  • Check [this quiestion](https://stackoverflow.com/questions/69905684), use the same logic as your application but using Web App deploy instead of Executable API deploy. – Emel Nov 18 '21 at 14:04
  • Could you provide console logs? It's still unclear where the execution stops. I would also agree with Emel - using a webapp instead of the api would be hassle less. – TheMaster Nov 18 '21 at 14:13
  • Thank for the suggestion, @Emel. I hadn't come across that specific one, but had come across similar. I would indeed be simpler to to use a Web App. However, that approach is less secure for my application (for reasons I won't get into here) and is therefore less desirable. It is, however, an option and that example will be helpful. – Brandon Nov 18 '21 at 21:43
  • I apologize for the continued lack of clarity in that regard, @TheMaster, and appreciate your time and feedback. I will do a bit more work on my own and then I will edit the question to be more clear, including console logs. I will also attempt to further isolate the issue to narrow the scope of the question, if at all possible. – Brandon Nov 18 '21 at 21:46
  • Note that your webApp must include ALL of the scopes used by the API Executable, not just the ones needed for the function you're calling (https://developers.google.com/apps-script/api/how-tos/execute). View that list in the "Overview" section of the code editor for your apiExec project. – Aaron Dunigan AtLee Nov 19 '21 at 03:28
  • You may also need to set the property store: `.createService`...`.setPropertyStore(PropertiesService.getScriptProperties())`. It needs to be `ScriptProperties` and not `UserProperties` so that the user of the web app has access to the token captured under your authorization. – Aaron Dunigan AtLee Nov 19 '21 at 14:23

1 Answers1

3

If I understand correctly, your current flow is as follows:

  1. Use the OAuth2 library to do a one-time capture of the auth token for your own Google account.
  2. Use that stored token to authenticate the request to the API Executable (when running the web app as another user).

Apps Script has a built-in method for accomplishing step 1: ScriptApp.getOAuthToken(), so I'm not sure you even need the OAuth2 library for this. (You would need that library for authorizing services other than Google.)

Possibly you can avoid using the OAuth2 library completely by doing the following:

Add this function to your web app project and run it once from the editor, i.e. under your own authorization:

function storeOauthToken() {
  PropertiesService.getScriptProperties().setProperty(
    'myToken',
    ScriptApp.getOAuthToken()
  )
}

Change the headers in the makeRequest function of your webApp project from this

headers: {
  Authorization: 'Bearer ' + getService().getAccessToken()
},

to this:

headers: {
  Authorization: 'Bearer ' + PropertiesService.getScriptProperties().getProperty('myToken')
},

I created a copy of your projects and was able to confirm that this technique works.

Token refresh

I assume that the token may expire like any other OAuth2 token, so you may need to set a timed trigger (again, under your own authorization) to run storeOAuthToken() periodically.

Aaron Dunigan AtLee
  • 1,860
  • 7
  • 18
  • The webapp is set to `Execute as: User accessing the web app`. So, I don't think you'll get the owner's access token from `ScriptApp.getOAuthToken()` – TheMaster Nov 19 '21 at 07:25
  • 1
    @TheMaster The instruction is that the owner should run `storeOauthToken()` from the editor, i.e. before deploying the web app, which will capture his token for later use by the webapp. – Aaron Dunigan AtLee Nov 19 '21 at 13:19
  • This is so much simpler and works great. Thank you very much. – Brandon Dec 02 '21 at 23:33