google-apps-scriptx-frame-optionsgoogle-sites-2016

How do I embed a standalone Google Apps Script web app that requires authorization into the new Google Sites?


I created a standalone Google Apps Script web app that I am trying to embed into new Google Sites. It works correctly when I'm signed into the account used to create the Apps Script project. However, if I'm logged into another account that has not yet authorized the web app, the Google Sites page loads, but the iFrame with the embedded Apps Script project does not load correctly.

Instead the iFrame shows "accounts.google.com refused to connect" and the console shows "Refused to display 'https://accounts.google.com/ServiceLogin?passive=1209600&continue=https%3A%2F%2Fscript.google.com%2Fmacros%2Fs%2FAKfycbzizTNkflSXZbKSF8TTxTR5QoF4LAhPPuSq-1juFdIOdL_IlFM%2Fexec&followup=https%3A%2F%2Fscript.google.com%2Fmacros%2Fs%2FAKfycbzizTNkflSXZbKSF8TTxTR5QoF4LAhPPuSq-1juFdIOdL_IlFM%2Fexec' in a frame because it set 'X-Frame-Options' to 'deny'."

As I understand it, new users are not authorized to my Apps Script Web App, which triggers an authorization flow. However, when the authorization flow begins by loading the Google sign in page (https://accounts.google.com/ServiceLogin?... from above), it breaks because the X-Frame-Options header for the sign in page is set to Deny.

I did experiment with HTMLoutput.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL) (see https://developers.google.com/apps-script/reference/html/html-output#setxframeoptionsmodemode), but I'm pretty sure the issue causing the Google Sites iFrame to load incorrectly is not my app, but Google's sign on page.

Link to Google Site: https://sites.google.com/view/create-user-filter-views/home

Link to Apps Script Web App: https://script.google.com/macros/s/AKfycbzizTNkflSXZbKSF8TTxTR5QoF4LAhPPuSq-1juFdIOdL_IlFM/exec

Documentation from Google on how to embed Apps Script in New Sites: https://developers.google.com/apps-script/guides/web#embedding_a_web_app_in_new_sites

How can I authorize new users to my web app from Google Sites?

Do I need to direct them first to my published apps script site to go through the authorization flow and then direct them to return to my Google Site (this would be a terrible option obviously)?


Solution

  • First of all, you are right in your analysis. Google's login page (and in fact a large % of Google hosted content) has X-Frame-Options set to deny, and the redirect is blocked from loading inside the iframe due to that setting. If a user is already logged in to Google, but has not authorized the app, I believe that most of the time they should see the authorization dialog flow within the iframe without an error (what Alan Wells was reporting). However, I did not test completely, and it could be for users with multiple simultaneous logins (e.g. signed into multiple Gmails), it will kick you out to the login page and trigger an X-Frame-Options block.

    Either way, after some digging, I figured out a working solution for this. It is a little kludgy, because of all the various restrictions that Apps Script places on what can be used. For example, I first wanted to use postMessage to pass a message from the embedded iframe to the parent page, and if the parent didn't receive the message in X # of seconds, it would assume the iframe failed to load and would redirect the user to login / authorize the app. Alas, postMessage does not play nice with Apps Script, due to how they double-embed iframes.

    Solutions:

    JSONP:

    The first solution I got working was to use a JSONP approach. This is briefly mentioned by Google here. First, place an overlay over the iframe that prompts the user to authenticate the app, with a link to do so. You then load the app script twice, once as an iframe, and then again, as a <script></script> tag. If the <script> tag succeeds in loading, it calls a callback function that hides the prompt overlay so the iframe underneath can become visible.

    Here is my code, stripped down so you can see how this works:

    Emedded HTML:

    <style>
    .appsWidgetWrapper {
        position: fixed;
    }
    .appsWidget {
        width: 100%;
        height: 100%;
        min-width: 300px;
        min-height: 300px;
        border: none !important;
    }
    .loggedOut {
        top: 0px;
        left: 0px;
        position: absolute;
        width: 100%;
        height: 100%;
        background-color: darksalmon;
        text-align: center;
    }
    </style>
    
    <!-- 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>
    

    And Code.gs (Apps Script)

    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);
        }
    }
    

    In this example, "authSuccess" is my JSONP callback function, which should get called with the email of the authorized user, if the script succeeds. Otherwise, if the user needs to log in or authorize, it will not and the overlay will stay visible and block the iframe error from showing to the user.

    contentWindow.length

    Thanks to the comment left by TheMaster on this post, and his linked answer, I learned of another approach that works in this instance. Certain properties are exposed from the iframe, even in a cross-origin scenario, and one of them is {iframeElem}.contentWindow.length. This is a proxied value of window.length, which is the number of iframe elements within the window. Since Google Apps Script always wraps the returned HTML in an iframe (giving us double nested iframes), this value will be either 1, if the iframe loads, or 0, if it fails to. We can use this combination of factors to craft another approach, that does not need JSONP.

    Embedded HTML:

    <style>
    .appsWidgetWrapper {
        position: fixed;
    }
    .appsWidget {
        width: 100%;
        height: 100%;
        min-width: 300px;
        min-height: 300px;
        border: none !important;
    }
    .loggedOut {
        top: 0px;
        left: 0px;
        position: absolute;
        width: 100%;
        height: 100%;
        background-color: darksalmon;
        text-align: center;
    }
    </style>
    
    <!-- Script loaded as iframe widget with fallback -->
    <div class="appsWidgetWrapper">
        <iframe class="appsWidget" src="https://script.google.com/macros/s/SCRIPT_ID/exec"></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>
    
    <!-- Check iframe contentWindow.length -->
    <script>
        // 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');
            });
        });
    </script>
    

    Code.js:

    function doGet(e) {
        var email = Session.getActiveUser().getEmail();
    
        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);
        }
    }
    

    Full Demo Link:

    I've also posted the full code on Github, where the structure might be a little easier to see.