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>"
+ "
"
+ "<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>"
+ "
"
+ "<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.