1

I have a script that makes uses of the Google Picker API. As I was testing it, it was running perfectly until I published it as a private add-on. Since then, the script getOAuthToken fails with the following (extremely unhelpful) error:

Exception: We're sorry, a server error occurred. Please wait a bit and try again. at getOAuthToken(Code:37:12)

What I have tried:

  1. creating a new API key
  2. adding the script owner to the GCP project (it's a generic Google account as per the company set up)
  3. enabling the Picker API in the add-on's GCP project instead of the old one and generating the a new key

The API key has the following settings:

  • application restrictions: HTTP referrers
  • website restrictions:
    • *.google.com
    • *.googleusercontent.com
  • API restrictions: Don't restrict key

These settings used to work before publication.

Also the code of the Google Picker is below. It's based on the boiler plate in the Google API documentation and used to work as is:

<!DOCTYPE html>
<html>
<head>
  <link rel="stylesheet" href="https://ssl.gstatic.com/docs/script/css/add-ons.css">
  <script>

    const pickFileType = '<?= fileType ?>';

    // IMPORTANT: Replace the value for DEVELOPER_KEY with the API key obtained
    // from the Google Developers Console.
    var DEVELOPER_KEY = 'intentionally removed';
    var DIALOG_DIMENSIONS = {width: 900, height: 500};
    var pickerApiLoaded = false;

    /**
     * Loads the Google Picker API.
     */
    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 spreadsheets. This function
     * uses advanced options to hide the Picker's left navigation panel and
     * default title bar.
     *
     * @param {string} token An OAuth 2.0 access token that lets Picker access the
     *     file type specified in the addView call.
     */
    function createPicker(token) {
      if (pickerApiLoaded && token) {
        const docsUploadView = new google.picker.DocsUploadView();
        docsUploadView.setIncludeFolders(true);
        
        const viewId = pickFileType === 'folder' ?
          google.picker.ViewId.FOLDERS : google.picker.ViewId.DOCUMENTS;
        
        const drivesView = new google.picker.DocsView(viewId);
        drivesView.setEnableDrives(true);
        drivesView.setIncludeFolders(true);
        if (pickFileType === 'folder') drivesView.setSelectFolderEnabled(true);
        
        const driveView = new google.picker.DocsView(viewId);
        driveView.setSelectFolderEnabled(true);
        driveView.setParent('root');
        if (pickFileType === 'folder') driveView.setIncludeFolders(true);

        console.log(`viewId = ${viewId}`);

        // const docsViewId = new google.picker.ViewGroup(google.picker.viewId.DOCS)
        //   .addView(viewId);

        var picker = new google.picker.PickerBuilder()
            // Instruct Picker to display only spreadsheets in Drive. For other
            // .addViewGroup(docsViewId)
            .addView(driveView)
            .addView(drivesView)
            // .addView(viewId)
            // .addView(docsUploadView)
            // Hide the navigation panel so that Picker fills more of the dialog.
            // .enableFeature(google.picker.Feature.NAV_HIDDEN)
            // .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
            .enableFeature(google.picker.Feature.SUPPORT_DRIVES)
            // Hide the title bar since an Apps Script dialog already has a title.
            .hideTitleBar()
            .setOAuthToken(token)
            .setDeveloperKey(DEVELOPER_KEY)
            .setCallback(pickerCallback)
            .setOrigin(google.script.host.origin)
            // Instruct Picker to fill the dialog, minus 2 pixels for the border.
            .setSize(DIALOG_DIMENSIONS.width - 2,
                DIALOG_DIMENSIONS.height - 2)
            .build();
        picker.setVisible(true);
      } else {
        showError('Unable to load the file picker.');
      }
    }

    /**
     * A callback function that extracts the chosen document's metadata from the
     * response object. For details on the response object, see
     * https://developers.google.com/picker/docs/result
     *
     * @param {object} data The response object.
     */
    function pickerCallback(data) {
      let selectedId;
      console.log(data);
      var action = data[google.picker.Response.ACTION];
      if (action == google.picker.Action.PICKED) {
        // const array = [['Nom', 'ID', 'URL']];
        const docs = data[google.picker.Response.DOCUMENTS];
        docs.forEach(doc => {
          var id = doc[google.picker.Document.ID];
          selectedId = id;
          // var url = doc[google.picker.Document.URL];
          // var title = doc[google.picker.Document.NAME];
          // array.push([title, id, url]);
        });

        google.script.run.withSuccessHandler(() => {
          google.script.run.showFront(true);
        }).writeVar(pickFileType, selectedId);
        
      } else if (action == google.picker.Action.CANCEL) {
        google.script.run.showFront(true);
      }
    }

    /**
     * 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>
<body>
  <div>
    <p id='result'></p>
  </div>
  <script src="https://apis.google.com/js/api.js?onload=onApiLoad"></script>
  <script>
    window.onload = getOAuthToken;
  </script>
</body>
</html>

EDIT: This is what the server-side getOAuthToken looks like. I'm leaving the comments inside.

/**
 * Gets the user's OAuth 2.0 access token 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. In this case, the function includes an unused call
 * to a DriveApp method to ensure that Apps Script requests access to all files
 * in the user's Drive.
 *
 * @return {string} The user's OAuth 2.0 access token.
 */
function getOAuthToken() {
  DriveApp.getRootFolder();
  return ScriptApp.getOAuthToken();
}

EDIT 2

I think I'm getting closer to understanding. The script is now throwing these kinds of errors every time I try to access the Drive app. I get the same error when I do DriveApp.getFolderById(id) and

Unexpected error while getting the method or property getFileById on object DriveApp when I do DriveApp.getFileById(id).

I've added scopes to my manifest, but it's still not helping. This is the manifest:

{
  "timeZone": "Europe/Paris",
  "dependencies": {
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/drive",
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/script.container.ui",
    "https://www.googleapis.com/auth/userinfo.email",
    "https://www.googleapis.com/auth/spreadsheets"
    ]
}
Dmitry Kostyuk
  • 1,354
  • 1
  • 5
  • 21
  • Can you provide the server-side code, specially the code related to the server-side function `getOAuthToken()`? – Iamblichus Dec 01 '20 at 14:40
  • It's a built-in function from Google Apps Script https://developers.google.com/apps-script/reference/script/script-app#getOAuthToken() There really is no server-side code on my part, all handled by the Picker API – Dmitry Kostyuk Dec 01 '20 at 14:52
  • `getOAuthToken()` is a method of [ScriptApp](https://developers.google.com/apps-script/reference/script/script-app), cannot be called globally in Apps Script code. So how are you supposed to call this, what's this `getOAuthToken` you are calling via `google.script.run`? – Iamblichus Dec 02 '20 at 09:53
  • The Picker example [in the documentation](https://developers.google.com/picker/docs) has different authentication. However, the downside that using that new approach is that I am now runnin into [this bug](https://stackoverflow.com/questions/64321902/add-googleusercontent-com-to-authorized-javascript-origins). What I don't get is that my approach did used to work before publication... – Dmitry Kostyuk Dec 02 '20 at 10:30
  • It's also strange that you say there's no server-side code, since there's a template scriptlet referencing some server-side variable `const pickFileType = '= fileType ?>';` (see [templates](https://developers.google.com/apps-script/guides/html/templates)). Can you please clarify that? – Iamblichus Dec 02 '20 at 10:49
  • That scriplet is irrelevant. There is obviously some code that opens up the modal dialogue, in this case it opens it as html template from file and passes a parameter. This has nothing to do with authenticaton. – Dmitry Kostyuk Dec 02 '20 at 13:16
  • The scriptlet might be irrelevant, but its existence makes me think that it's not true that there's no server-side code. – Iamblichus Dec 02 '20 at 13:18
  • Which brings up the previous issue about calling a server-side function called `getOAuthToken()`. This should error out if you have not defined this function server-side. – Iamblichus Dec 02 '20 at 13:21
  • I edited the original post and included the function. – Dmitry Kostyuk Dec 02 '20 at 13:38
  • I think I'm getting close to understanding. The script is now throwing these kinds of errors every time I try to access the Drive app. I get the same error when I do `DriveApp.getFolderById(id)` and `Unexpected error while getting the method or property getFileById on object DriveApp` when I do `DriveApp.getFileById(id)`. I've added scopes to the manifest (see new post edit), but not helping still. – Dmitry Kostyuk Dec 02 '20 at 14:37
  • 1
    Have you enabled Drive API in the GCP project? I think your problem should be solved by doing that. – Iamblichus Dec 02 '20 at 14:38
  • 1
    @lamblichus that solved it, you are a genius! Why does it have to be separately unabled though? And what other APIs should I potentially be on the lookout for? – Dmitry Kostyuk Dec 02 '20 at 14:41
  • 1
    I'm writing an answer summarizing this, I hope this will be helpful to you. – Iamblichus Dec 02 '20 at 14:47
  • Thanks very much! – Dmitry Kostyuk Dec 02 '20 at 15:51

1 Answers1

2

When using a default Cloud Platform project for your Apps Script, whatever APIs are used by the script are automatically enabled when the script project is saved.

That's not the case when you switch to a standard GCP project. In this case, the APIs are not automatically enabled, and you have to manually enable them on the GCP project:

Often an Apps Script application needs access to another Google API. This requires you to enable the API in the corresponding GCP project.

According to the documentation, this would only apply to Advanced Services, but it also applies to at least some standard services. See this issue:

Specifically this comment:

There are mentions about enabling APIs for advanced services here. But not for the standard services, I notified this to the documentation team.

Reference:

Iamblichus
  • 18,540
  • 2
  • 11
  • 27