0

The problem

We (me and a colleague) have some code that fits the use case of a webapp nicely, except for authentication of users outside of our domain. If not for this problem, we would have liked to use the following configuration (at the top level in our manifest file).

"webapp": {
  "access": "ANYONE",
  "executeAs": "USER_DEPLOYING"}

For users inside our domain, we can use Session.getActiveUser(). We can then compare this with our database and show sensitive data that should only be visible to this account.

For users outside our domain however, Session.getActiveUser() gives the empty string '', so we cannot use this to query information in our database, so that we need something different. What we came up with is the following setup.

The alternative setup

We deploy the webapp with "executeAs": "USER_ACCESSING" and "oauthScopes": ["https://www.googleapis.com/auth/userinfo.email"]. We deploy another script as an API executable (and follow the instructions here), with a privileged account. The main Code.gs file of the webapp looks like this.

function doGet() {
  return HtmlService.createHtmlOutputFromFile('Index.html');
}

function getUserBackendData(){
  //works for users outside our domain
  var activeUserEmail = Session.getActiveUser();

  var apiKey = 'abcabcabcabcabcabcabcabcabcabcabcabcabcabcabcabc'
  var scriptId = 'ababababababababababababababababababababababab';
  var token = 'abcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcdabcd'; //very secret

  var url = 'https://script.googleapis.com/v1/scripts/'+ scriptId +':run?key='+ apiKey;
  var functionCallData = 
      {
        "function": 'retrieveData',
        "parameters": [activeUserEmail],
        "devMode": false};
  var options = {
    'method' : 'post',
    'contentType': 'application/json',
    'payload' : JSON.stringify(functionCallData),
    'headers': {'Authorization': 'Bearer '+  token}
  };

  return UrlFetchApp.fetch(url, options).getContentText();
}

The idea is that this allows us to execute the function retrieveData(activeUserEmail) in our API executable as a privileged user, while using the webapp to figure out the email address of the user accessing the app. The reason Session.getActiveUser() now works for users outside our domain is that we deploy as "executeAs": "USER_ACCESSING".

Question(s)

Our concerns are that 1) there is an easier/better way to do this and 2) the user accessing the webapp can somehow retrieve the token used to call the function in the api executable, which we fear could be used to steal private data.

This Q&A touches on concern 2. Unfortunately the answers do not cite any official documentation/resources (and don't all agree). Moreover, there, the secret library key is not present in the source code of the webapp (it is in the manifest file), whereas in our case the secret token is in the source code.

Question: Is this alternative setup secure and reasonable?

Update: I just found this answer, written by a user that seems credible. The answer says that the user of the web app will not be able to see the code of the web app, even when the app is deployed as "Anyone, even anonymous". This claim is hedged with "That I know of" though.

jano
  • 59
  • 1
  • 5
  • I don't think you should write the token in your script. Not because of a security issue, as the users are not able to see the library, but because it has an expiration time. You should try to generate it by code. [This](https://stackoverflow.com/questions/23867189/how-to-correctly-construct-state-tokens-for-callback-urls-in-managed-libraries) might help you. Also [this](https://developers.google.com/apps-script/reference/script/state-token-builder#detailed-documentation) – Jescanellas Aug 05 '19 at 09:22
  • @Jescanellas thanks for your comment. Re: "the users are not able to see the library"; I assume by library you mean "Google Apps Script file" and I assume you mean even the file corresponding to the webapp cannot be inspected (and hopefully not debugged etc either). That's valuable information for me, do you maybe have a source on that? – jano Aug 05 '19 at 11:08
  • Re: "You should try to generate it by code"; note that my setup is not typical in the sense that normally the server asks the user for a token (which involves interaction by the user), using which the server can access the users data. Your [first link](https://stackoverflow.com/questions/23867189/how-to-correctly-construct-state-tokens-for-callback-urls-in-managed-libraries) seems to be about the typical situation. It also seems [StateTokenBuilder](https://developers.google.com/apps-script/reference/script/state-token-builder) (your second link) is designed to help in the typical setup... – jano Aug 05 '19 at 11:09
  • ... In my setup, the server/backend/privileged-user does not need a token to access the users data, instead the webapp script running "as the user" tells the privileged-user to run a function, for which it needs a token associated with the privileged user. Assuming we really need a secret that we do not want to share with the user, whether this is the token itself or something to generate it with (like a refresh token), my difficulty is that the script executing on behalf on the user can only access information available to the user, except maybe the source of the script (or something). – jano Aug 05 '19 at 11:10
  • Thank you for pointing out that the expiration of tokens is an issue. I had not given that much thought. Perhaps this can still work if we store we hard-code the refresh token in the webapp instead. Again I would be interested to know whether this is reasonable, or what alternatives exist, if any. Maybe something that is more reasonable is having [google cloud function](https://cloud.google.com/functions/) that in turns calls the api executable, but I'm out of my depth. – jano Aug 05 '19 at 11:19
  • The thing is if you do this in Apps Script, all the OAuth 2 process is [automated](https://developers.google.com/apps-script/api/how-tos/enable) so you don't actually need to do "anything" about it, unless there is any permission issue. So about the security I haven't find anything explicit, but as it's done by Apps Script it's as secure as OAuth 2 and Apps Script can be, so I wouldn't worry about any data leak. – Jescanellas Aug 05 '19 at 14:33
  • @Jescanellas you say the process is automated, but that link doesn't make it clear what you are suggesting I automate and how to do it. The page you linked to points to the quickstarts and I have looked at the [node.js quickstart](https://developers.google.com/apps-script/api/quickstart/nodejs). In that example there is indeed some nice code automating the process of obtaining a token, by sending a user to a url where they obtain a code that can be converted into a token. This is in fact the code I was using to generate tokens to allow my webapp to access my api executable... – jano Aug 05 '19 at 16:29
  • ... But again the token is associated with the privileged user, not the user of the web app. So there is no point in including code like this in my current setup, as it is of course not practical to require the privileged-user/backend-daemon to click through a gui while the user of the web app waits. I figured this might all be bypassed by hard coding a refresh token, though I haven't looked at how to generate access tokens from this. – jano Aug 05 '19 at 16:29
  • Sorry for not explaining myself better. The Quickstarts you found are indeed examples to create tokens, but because those aren't meant to run in Apps Script so they need to generate them. There is no Apps Script example because it does it by itself, you don't need to do anything. Any API call you do from Apps Script will ask for permissions to your Google account. Once you have given the permissions you can see the Oauth scopes of your script in View -> Show manifest file. Once the user executes your webApp as `USER_ACCESSING` the app will use OAuth to ask for permissions to his Google account – Jescanellas Aug 06 '19 at 07:41
  • @Jescanellas Re: "Any API call you do from Apps Script will ask for permissions to your Google account". I understand that that normally apps script will manage scopes, so that if you use e.g. `DriveApp.getFiles()`, the user of the web app is asked to give the app permission to view his drive files. If the scopes are managed automatically, those scopes will not be visible in the manifest file though right? I always look them up in File > Project Properties > Scopes... – jano Aug 06 '19 at 11:16
  • ... Anyway if I do a request to my API Executable, I need a token, if I comment out the line `'headers': {'Authorization': 'Bearer '+ token}`, I get the error "Request is missing required authentication credential. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project." The only scope that is automatically introducted by using UrlFetchApp in this way is script.external_request. And again, even if the script would ask for the right scopes, it would ask the wrong user for access... – jano Aug 06 '19 at 11:16
  • ... To avoid unnecessary complications with databases, let's say only the privileged user has access to a spreadsheet containing all the sensitive data. So if the webapp user allows the app to access its files on google drive, that is not useful, as the file is not on their drive. The spreadsheet cannot be shared with users of the webapp, as the users should only have access to the row in the spreadsheet that contains their information... – jano Aug 06 '19 at 11:16
  • ... That is why I introduced the API executable into my setup, so that this could be executed by a privileged user. I don't know of any other way to do calls to my API executable other than `UrlFetchApp`. – jano Aug 06 '19 at 11:16

1 Answers1

0

In most cases, instead of using an API executable, it probably more sensible to make requests to a web app. It is possible to make a get or post request to a web app, in such a way that the web app knows who is making the request (i.e. Session.getActiveUser works in the web app).

For details, see (my other answer) here: https://stackoverflow.com/a/59428929/11873333

jano
  • 59
  • 1
  • 5