Google Apps Script – Authorization in a cross-origin iframe

  • report
    Disclaimer
    Click for Disclaimer
    This Post is over a year old (first published about 5 years ago). As such, please keep in mind that some of the information may no longer be accurate, best practice, or a reflection of how I would approach the same thing today.
  • infoFull Post Details
    info_outlineClick for Full Post Details
    Date Posted:
    Jun. 15, 2019
    Last Updated:
    Oct. 08, 2020
  • classTags
    classClick for Tags

I’ve noticed more than a few questions on StackOverflow that have to do with using Google Apps Script or other authenticated google embeds in combination with iframes, and it has prompted me to do some digging. First and foremost, if you want to publish a Google Apps Script that can render within an iframe, that is actually surprisingly simple. To avoid any X-Frame-Options errors, simply use “HtmlService.XFrameOptionsMode.ALLOWALL“. For example:

 

  • Code.gs
    
    function doGet() {
        var template = HtmlService.createTemplateFromFile('output');
        return template.evaluate().setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
    }
                
  • output.html
    
    <!DOCTYPE html>
    <html lang="en">
    
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        <title>Document</title>
    </head>
    
    <body>
    <p>Hello World!</p>
    </body>
    
    </html>
                

And then simply load it as the source of an iframe:

<iframe src=”https://script.google.com/macros/s/SCRIPT_ID/exec”>

Note that the above will only work if

A) The script is set to be authorized by your own account (executing as yourself), not the users,

Or:

B) The script is set to be authorized by the end user (“execute the app as user accessing the web app”), but they are logged in and already authorized by the time they load the page the iframe embed is on.

Dealing with Authentication / Logged Out Users – Intro

If your script touches any other Google product (email, Google sheets, etc.) there is a very good chance you are using the user’s authentication to perform actions on their behalf. If you are using your user’s authorization, your “deploy web app” dialog should look like this:

The problem with this, is that if you embed your App Script into an iframe, and a user visits the page it is embedded into and they are logged out of Google, then this happens:

Google Login Page Blocked in Iframe - X-Frame-Options Deny

If you open your devtools when this happens, you will also see this descriptive error message:

Refused to display ‘https://accounts.google.com/ServiceLogin?passive=1209600&continue=https…’ in a frame because it set ‘X-Frame-Options’ to ‘deny’.

This is because Google realizes that your script is going to require an authenticated Google account to function, and redirects the iframe to the Google Login page. However, the Google Login page is set to not allow itself to be iframed into any site (thus the X-Frame-Options value of “deny”), so the browser blocks the iframe from loading the login page and errors out.

Obviously, this leads a poor user experience; how is the end user supposed to know what the grey sad face means, or how they should proceed?

Easiest solution – warn in advance:

The simplest solution is to just add some sort of warning message above or near where the script is embedded. Something like: “If the widget below appears as a grey box, please login to your Google account here [link to Google Login page] and then reload this page and accept permissions request.”

Dealing with Logged Out Users with iframes – Advanced

Obviously, a lot of devs are not going to like just having a warning message about “seeing a grey box appear”, and even I would have to agree that there feels like there should be a better way. Is there a way to detect if a user is not logged in and is going to get redirected inside the iframe to the non-functioning login page? Can we preemptively redirect them? This is where things started to get a little complicated and I tried some creative approaches…

If you don’t care about what doesn’t work, and are just looking for what I consider to be “the solution”, which I list last,  you can jump right to it by clicking here. Otherwise, read on to see all the ways I attempted this before getting to the solution!

Using postMessage()

One of my first ideas was to pass info about the success of the user getting authenticated, from the child iframe embedded App Script, to the parent window, which is wherever the widget is embedded. Although iframes are sandboxed so that the content can’t be accessed from the parent (or vice-versa), postMessage is a method you can use for passing small snippets of data back and forth. My idea was to have my App Script immediately send a message to the parent window saying something like “Successfully loaded”, and then the parent could start a timer, and if it does not receive that message within x # of seconds after the page loads, it knows the script got blocked from loading due to a login redirect. However, trying to implement even just the most basic of postMessage tests, I noticed none of the events were coming through, and I saw this in the console:

dropping postMessage.. was from unexpected window

What? What is that?

Oh… So it looks like Google Apps Script double iframes your content in. So really my custom HTML code is nested inside of Google proprietary wrapper code, which then loads into the iframe I actually embedded into my website. Google’s custom wrapper iframe listens for postMessages, but only ones that match its own origin, so they are ignoring mine. Dang. I don’t think there is an easy way around this. Let’s try something else…

Create our own Ajax endpoint

My next idea: instead of only returning HTML, what if we set up our app script so it could respond with JSON in response to an ajax request? Then, from our website, we can use fetch(), and if the response is anything other than the JSON we are expecting, such as a redirect, we know the user has not been authenticated. Let’s set this up:

function doGet(e) {
    if (e.queryString && e.queryString.indexOf('ajaxCheck')!==-1){
        // Respond back! With JSON.
        var response = {
            success: true
        }
        return ContentService.createTextOutput(JSON.stringify(response)).setMimeType(ContentService.MimeType.JSON);
    }
    else {
        // Display HTML in iframe
        var email = Session.getActiveUser().getEmail();
        var template = HtmlService.createTemplateFromFile('output');
        template.authedEmail = email;
        return template.evaluate().setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
    }
}

As you can see, we can even reuse the same script, but change the response type (HTML vs JSON) based on the querystring used to request the script. This seems like it might work, but in practice…

Google Apps Script - Automatic 302 Redirect to Return JSON Content

What the heck? The request redirected? Well, it looks like for some reason (likely to do with character escaping or something like that), Google will not serve JSON from our script directly, instead it redirects the request to a shared common endpoint for echoing content, but with a unique querystring to tell the server what to serve:

https://script.googleusercontent.com/macros/echo?user_content_key=[unique-key]&lib=[?]

OK, well this is another wrench in the works. This would not be a deal-breaker on its own, but this is:

Access to fetch at ‘https://script.google.com/macros/s/…/exec?ajaxCheck’ from origin ‘https://joshuatz.com’ has been blocked by CORS policy: No ‘Access-Control-Allow-Origin’ header is present on the requested resource. If an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.

We just can’t catch a break! Apps Script is not sending back the proper CORs header, so our requests are being outright blocked. There is basically no workaround for this – if we set fetch to use a mode of ‘no-cors’, than all we get back is an opaque response, which really tells us nothing. We also can’t use a CORs proxy, like cors-anywhere, because we need to include credentials with our request, so Google can see if they are a logged in Google user or not, and that obviously won’t work if the domains don’t match.

This is starting to get a little frustrating, but trying to get JSON responses working actually made me stumble onto something promising…

The Solution!!! Using <script> tag with/without JSONP pattern

Script tags are not subject to the same CORs/CORB restrictions that are placed on AJAX, and I stumbled upon Google actually suggesting this method as a way to get script data into webpages here. They recommend using the JSONP pattern, but you could also use it to inject whatever JS content you want, which I’ll explore as an option.

Option A) Recommended JSONP Pattern:

There are only a few unique things about the JSONP pattern and how it applies here:

  • You must define a callback function that is a window global, that will receive the response from the app script
  • Pass the string literal name of the callback function as a querystring key/value pair
  • In your app script, return JS that will call the function by name and pass the results

You can check out the example from the Google docs here, or I have prepared a complete working solution to the original problem we were trying to solve; detecting a completely logged out user and redirecting. My solution basically works as follows:

  • In HTML, have an overlay div that is positioned over the iframe embed, with text that tells user they need to authenticate, and button to do so
    • Define callback function for JSONP that hides the “auth required” overlay if the script successfully executes
    • Load the script through a <script> tag and pass my callback through the querystring
  • Script checks querystring to see how it should respond
    • If JSONP, it returns JS that calls the callback with the authed users email
    • If embed, returns success HTML to show in actual widget
    • If accessed directly, shows message about being able to close tab, since auth was successful.

Here is what the result looks like:

Google Apps Script - Detecting User Authentication Authorization Login for Iframe Embeds

You can see that on the left side, where I am in an incognito window and have not authorized the script, it detects so, and prompts me to authorize. In the window on the right, I am signed into Google and have authorized the script, so it hides the authorization prompt and displays the success HTML.

Minimal code to reproduce:

I’ve uploaded the full code used to create the above demo, which includes both the Apps Script side and the embed side, to Github – here. If you don’t feel like visiting that, the two most important snippets of code are below:

Embed HTML:
<!-- Script loaded as iframe widget with fallback -->
<div class="appsWidgetWrapper">
    <iframe class="appsWidget" src="https://script.google.com/macros/s/SCRIPT_ID/exec?embedIframe"></iframe>
    <div class="loggedOut">
        <div class="loggedOutContent">
            <div class="loggedOutText">You need to "authorize" this widget.</div>
            <button class="authButton">Log In / Authorize</button>
        </div>
    </div>
</div>

<!-- Define JSONP callback and authbutton redirect-->
<script>
    function authSuccess(email){
        console.log(email);
        // Hide auth prompt overlay
        document.querySelector('.loggedOut').style.display = 'none';
    }
    document.querySelectorAll('.authButton').forEach(function(elem){
        elem.addEventListener('click',function(evt){
            var currentUrl = document.location.href;
            var authPage = 'https://script.google.com/macros/s/SCRIPT_ID/exec?auth=true&redirect=' + encodeURIComponent(currentUrl);
            window.open('authPage','_blank');
        });
    });
</script>

<!-- Fetch script as JSONP with callback -->
<script src="https://script.google.com/macros/s/SCRIPT_ID/exec?jsonpCallback=authSuccess"></script>
Code.gs
function doGet(e) {
    var email = Session.getActiveUser().getEmail();

    if (e.queryString && 'jsonpCallback' in e.parameter){
        // JSONP callback
        // Get the string name of the callback function
        var cbFnName = e.parameter['jsonpCallback'];
        // Prepare stringified JS that will get evaluated when called from <script></script> tag
        var scriptText = "window." + cbFnName + "('" + email + "');";
        // Return proper MIME type for JS
        return ContentService.createTextOutput(scriptText).setMimeType(ContentService.MimeType.JAVASCRIPT);
    }

    else if (e.queryString && ('auth' in e.parameter || 'redirect' in e.parameter)){
        // Script was opened in order to auth in new tab
        var rawHtml = '<p>You have successfully authorized the widget. You can now close this tab and refresh the page you were previously on.</p>';
        if ('redirect' in e.parameter){
            rawHtml += '<br/><a href="' + e.parameter['redirect'] + '">Previous Page</a>';
        }
        return HtmlService.createHtmlOutput(rawHtml);
    }
    else {
        // Display HTML in iframe
        var rawHtml = "<h1>App Script successfully loaded in iframe!</h1>"
            + "\n"
            + "<h2>User's email used to authorize: <?= authedEmail ?></h2>";
        var template = HtmlService.createTemplate(rawHtml);
        template.authedEmail = email;
        return template.evaluate().setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
    }
}

Option B) Inject whatever you want:

Since the browser does not know the difference between the response from Google Apps Script and any old Javascript file, there really doesn’t have to be anything special about the JS code that we return; it just needs to be valid JS and match the MIME type. If we want, we can return all kinds of hardcoded values, functions, or whatever we want. Here is quick example that sets a global window variable to a boolean TRUE value:

function doGet(e) {
    // Respond back with JS that sets global var
    var windowGlobalKey = 'userPassedAppsAuth'
    // Create script content
    var scriptText = "window['" + windowGlobalKey + "'] = true;"
    return ContentService.createTextOutput(scriptText).setMimeType(ContentService.MimeType.JAVASCRIPT);
}

If you embed your apps script as a <script> tag, in any code that runs after it loads, you could query “window.userPassedAppsAuth” and the result should be TRUE if the apps script successfully loaded and was authenticated. You could then take action based on that result, such as redirecting the user to login if the value is FALSE or undefined.

Another Solution: contentWindow.length

Thanks to this S/O answer, I learned of another approach that can be used as a solution, which relies on a specific quirk of how Google Apps Scripts returns HTML, and proxied window properties exposed on iframes.

For security purposes, most cross-origin Iframes block access to their content and properties from the parent window, but certain properties have their values proxied. One of those is window.length, exposed to the parent window as {iframeElement}.contentWindow.length. It is a numerical value, which corresponds to the number of frames within that window. Although it is normally an annoyance, the fact that Google wraps returned HTML from our script in its own iframe gives us a (roundabout) way to check for successful authorization: when our script successfully loads, this value should be 1, and if it fails to load, for example when our user has not authorized it yet, it will be 0.

Here is the relevant code for this approach:


// Give iframe some time to load, while re-checking
var retries = 5;
var attempts = 0;
var done = false;
function checkIfAuthed() {
    attempts++;
    console.log(`Checking if authed...`);
    var iframe = document.querySelector('.appsWidget');
    if (iframe.contentWindow.length) {
        // User has signed in, preventing x-frame deny issue
        // Hide auth prompt overlay
        document.querySelector('.loggedOut').style.display = 'none';
        done = true;
    } else {
        console.log(`iframe.contentWindow.length is falsy, user needs to auth`);
    }

    if (done || attempts >= retries) {
        clearInterval(authChecker);
    }
}
window.authChecker = setInterval(checkIfAuthed, 200);
document.querySelectorAll('.authButton').forEach(function(elem){
    elem.addEventListener('click',function(evt){
        var currentUrl = document.location.href;
        var authPage = 'https://script.google.com/macros/s/SCRIPT_ID/exec?auth=true&redirect=' + encodeURIComponent(currentUrl);
        window.open(authPage,'_blank');
    });
});

Solutions that would work, but take a lot of effort:

Here are some solutions that would work for making sure a user gets redirected to Google login if they are logged out and visit your site with  your widget embedded. However, all of these would take some considerable time, skills, and possibly extra paid services, like paid hosting, to implement.

Random IDs + webhook to server

This idea work as follows: On the load of your webpage, you create a completely random string (maybe timestamp + random output) and append it as a query string to the iframe source that loads the App Script. In your app script, you retrieve it inside your doGet() function, and immediate use URLFetchApp to send it back to your website host. You would need to  have an endpoint set up that URLFetchApp could use, maybe something like “example.com/authCallback.php”.

When Apps Script pings your endpoint, you save the random ID to a database table, where it can easily be looked up. You also expose your own internal endpoint, which can be queried with an ID to see if it exists in the table.

Finally, back on your webpage, you have a short timeout delay, maybe 15 seconds. After the delay ends, you query the ID against your own database, to see if Apps Script has pinged you for that specific user yet. If it has not, you know that the script most likely failed due to the blocked login, and you can at that point redirect the user / display a popup / do whatever you want.

Integrate Google Sign-In to your website

This feels like a lame answer, because there is nothing unique about it, but I’m guessing that if you were to use Google Sign-In for Web as a requirement to entering your site, their SDK would have some sort of method that would let you check to see if the current user is authed. Of course, this has all sorts of other issues, including yet another thing that your user has to agree to and be comfortable sharing permission with.

5 thoughts on “Google Apps Script – Authorization in a cross-origin iframe”

  1. Basilio says:

    Hi, thanks for this work! But I got a problem: when I click the “Log In / Authorize” button an error 400 occurs in a new tab. How could I solve it?

    1. joshuatz says:

      Did you make sure to replace all instances of “SCRIPT_ID” with your actual Google Apps Script ID in my code? HTTP 400 is for a “Bad Request”, which would make a malformed URL the most likely culprit, which could be the case if you forgot to replace it, or have the wrong SCRIPT_ID. Your SCRIPT_ID should be a long string of random numbers and letters – you can find it by looking at the URL that Google shows you when you use “deploy as web app” – the ID is between “https://script.google.com/macros/s/” and “/exec”.

      If this doesn’t fix it, I would be more than happy to take a look at your setup and code.

  2. vmailtk says:

    Option A does not work, I don’t think the others will either. I did something similar before visiting this site. After the third party user has authorized the app and then they go back to the original page then the have the same accounts.google.com error. Could you check that this still works?

  3. vmailtk says:

    I’ve now got it to work, the issue that I was testing from incognito mode. It works if I’m not in incognito. The issue that I’m now having is as I am navigating the 3rd visited page goes blank

    1. joshuatz says:

      Hmm. I’m not sure what you mean by “the 3rd visited page”. The code should work, regardless of how many times the page has been reloaded or which order it has been visited in. Perhaps there are some details of your implementation (code, live website, etc) you could share with me so I can help troubleshoot? Feel free to email instead if that is more convenient.

      For reference, here is a live demo I setup a while ago that shows this working in action (created to answer this StackOverflow question).

Leave a Reply

Your email address will not be published.