Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Client doesn't work if third-party session storage is disabled #260

Open
asadovsky opened this issue Dec 22, 2016 · 52 comments
Open

Client doesn't work if third-party session storage is disabled #260

asadovsky opened this issue Dec 22, 2016 · 52 comments
Labels

Comments

@asadovsky
Copy link

In Chrome (55.0.2883.95), for privacy reasons, I set "block third-party cookies and site data".

With that, the code below produces this error:

Uncaught DOMException: Failed to read the 'sessionStorage' property from 'Window': Access is denied for this document.
at x.C (https://ssl.gstatic.com/accounts/o/644096210-idpiframe.js:12:285)
at Object.k.vb (https://ssl.gstatic.com/accounts/o/644096210-idpiframe.js:8:121)
at S.h.start (https://ssl.gstatic.com/accounts/o/644096210-idpiframe.js:50:170)
at Object.Bb [as startIdpIFrame] (https://ssl.gstatic.com/accounts/o/644096210-idpiframe.js:84:713)
at https://accounts.google.com/o/oauth2/iframe:1:413

Code:

    gapi.load('client', function() {
      gapi.client.init({
        clientId: clientId,
        scope: scope
      }).then(function() {
        gapi.auth2.getAuthInstance().signIn().then(function() {
          var user = gapi.auth2.getAuthInstance().currentUser.get();
          processContacts(user.getAuthResponse()['access_token']);
        });
      });

This can be fixed by adding accounts.google.com to the exceptions list, or by allowing third-party storage wholesale, but really, this shouldn't be necessary; the client should degrade gracefully in the case where the browser doesn't allow third-party access to local storage.

@bsittler
Copy link
Contributor

Thanks for reporting this issue. The issue is already known to us, but I'm not sure when or whether a fix will be available.

In the meantime, you may be able to work around this using the steps described in http://stackoverflow.com/questions/33200681/google-signin-not-working-in-safari-private-mode or something similar, although I have not tried that myself.

@mbloch
Copy link

mbloch commented Dec 29, 2016

@bsittler, I'm pretty sure that the workaround that you linked to does not help with the current issue.

(The workaround suggests replacing the #setItem() method of Storage.prototype when the browser is in private mode, because the native method throws an error, at least in Safari. The exception that @asadovsky reported above occurs when trying to access sessionStorage, i.e. before sessionStorage.setItem() could be called.)

A different workaround to avoid the "Access is denied" exception was proposed on the Chromium issue tracker (https://bugs.chromium.org/p/chromium/issues/detail?id=357625).

That workaround replaces localStorage and sessionStorage with proxy objects, using Object.defineProperty(window, 'localStorage', {value: /*your polyfill here*/ });.

This second workaround did not work for me -- the exception is still thrown. Could this be because the function that causes the error is running in an iframe, which has a different window object from the main page?

I'd appreciate any further suggestions :)

@bsittler
Copy link
Contributor

Correct, this error occurs in an IFRAME on a separate origin, so a polyfill won't work. I was intending to suggest that you could test for working storage and avoid loading the library if the storage does not work.

@mbloch
Copy link

mbloch commented Dec 29, 2016

Ah, gotcha, thanks for the quick response.

@TMSCH
Copy link
Contributor

TMSCH commented Feb 9, 2017

@mbloch @asadovsky we released a change that allows you to gracefully degrade under such environment, please have a look at the documentation: https://developers.google.com/identity/sign-in/web/reference#googleauththenoninit-onerror.

@mjgallag
Copy link

mjgallag commented Feb 28, 2017

@TMSCH do you mean gracefully degrade as in show an error message telling them they need to enable 3rd party cookies?

@TMSCH
Copy link
Contributor

TMSCH commented Feb 28, 2017

@mjgallag yes, as you are now able to detect the issue, you can tell them that their browser is unsupported (and list potential reasons). It's very difficult to accurately detect that 3rd party data is blocked from our library, which is why we haven't yet been able to release a proper fix.

@mjgallag
Copy link

@TMSCH got it, thanks for the quick reply!

@TMSCH
Copy link
Contributor

TMSCH commented Feb 28, 2017

@mjgallag you're welcome!

@joeldenning
Copy link

We are experiencing this issue as well. Using the idpiframe_initialization_failed error is really helpful for us to be able to message to our users what is happening. But even if we handle the error (gapi.auth2.getAuthInstance().then(success, err => {/*handle error*/})), the gapi library still throws a javascript error, which is problematic for the following reasons:

  1. What is thrown is an object, not an Error., which means there is no stacktrace associated with it. This is especially problematic for us since we log javascript errors to the server, but we don't have access to the stacktrace.
  2. The object is thrown inside of a setTimeout, which means that a window.onerror handler doesn't even have access the file, line, or column of the code that threw this error. So we can't filter out all errors originating from google.com domains, because that is lost with the setTimeout.

What do you guys think of updating the library code so that it doesn't actually throw the error, but only provide the error through the getAuthInstance().then(...) error handling? Or if not that, then perhaps converting it to be a real Error object instead of a plain object?

@TMSCH
Copy link
Contributor

TMSCH commented Mar 9, 2017

Hi @joeldenning!
Thanks for the feedback. We actually continued to throw the error to be consistent with previous behavior, which is probably not useful anymore.
However I'd like to make sure I observe the same error when third-party data is blocked. When I reproduce it, an error is thrown from within the IFrame, not from the client window, and so I also cannot catch it (although it is an instance of Error).
Is it your case? Or is the error thrown from the main window? Also, what is the error log?
Thanks!

@time-less-ness
Copy link

Would like to report we get same behaviour as @TMSCH. We cannot catch the error. Can't implement client-side OAuth2 when user doesn't allow 3rd party cookies/storage.

@TMSCH
Copy link
Contributor

TMSCH commented Mar 20, 2017

Hi @philovivero ! We recently pushed a changed to not throw the error from within the IFrame. Could you test again and let me know if that works? You should not observe any JS error, but be able to catch it in the promise. Thanks!
However, chrome with 3rd party cookies/storage disabled is still unsupported.

@time-less-ness
Copy link

I'm not sure I will be able to repro. I ripped out all that code. For now we've decided to just let customers who uncheck that get failures, and when we look into it again, will likely implement OAuth2 server-side, since we assume at least some percentage of customers will be privacy-minded.

@joeldenning
Copy link

@TMSCH I just checked today and the error still is getting thrown when the "Block third party cookies and site data" checkbox is checked in Chrome. Here are some screenshots:

The code inside of gapi:
screen shot 2017-03-21 at 10 41 49 am

The error:
screen shot 2017-03-21 at 10 42 09 am

One thing to note is that I am using ravenjs (the js client for Sentry), which patches setTimeout with its own implementation (in order to handle errors inside of setTimeouts better). That's why you see the raven.js code in the stacktrace. For a while, I thought that maybe because I'm patching setTimeout that that may be causing the error to surface when it otherwise would not, but after some investigation I don't think that's the case. In the first screenshot, gapi is still calling setTimeout(() => {throw a}) which is something that causes an error regardless of if setTimeout is patched or not. So I don't think it's related to that, but rather just that gapi is still throwing the error.

Was the change you pushed out recently something that would prevent this from happening? It looks like the iframe is communicating the error up to the main document through a port1 onmessage handler, and then the main document is still throwing the error.

@TMSCH
Copy link
Contributor

TMSCH commented Mar 21, 2017

@joeldenning are you also catching the error in the initialization promise? If you don't, it will get thrown again (that's the standard behavior of a promise). I tried not catching it and observed the same behavior than you.

gapi.auth2.init({...}).catch(function(error) {
  // That's where the IFrame initialization error should be handled.
  // If you don't catch the rejection of the promise here, the error will be thrown.
});

@joeldenning
Copy link

joeldenning commented Mar 22, 2017

@TMSCH Thanks, I got it work! Since gapi promises aren't real promises and don't follow the spec, the code sample you provided didn't work (It seems like you can't call .catch() on gapi promises). But if I catch it by providing two arguments to the .then(), it works.

gapi.auth2.init({...}).then(
  function success() {
  },
  function error(err) {
    // That's where the IFrame initialization error should be handled.
    // If you don't catch the rejection of the promise here, the error will be thrown.
  }
);

Thanks for your help with this and for pushing out the fix.

@TMSCH
Copy link
Contributor

TMSCH commented Mar 22, 2017

Oh yes, @joeldenning, you're right... Sorry about that. The return value is just a Thenable, there's no catch method. Glad you got it to work!

@bkoski
Copy link

bkoski commented Aug 11, 2017

It would be helpful if this was covered in the docs as a known issue and potential caveat.

We recently ran into this on a project during final testing, then had to scramble a server-side flow instead. (This seems like the only workaround, other than prompting users to change security settings in their browser?)

Would've been useful to know this upfront - perhaps the could be some kind of introduction that explains pros and cons of different flows?

@TMSCH
Copy link
Contributor

TMSCH commented Aug 11, 2017

@bkoski we will add this to the documentation, you're right, it should be mentioned. Server-side flow is indeed the most widely supported mechanism for now, as it doesn't rely on any storage or other characteristics of the browser. Keep in mind that such option in Chrome, block third party data, is not the default and a pretty advanced feature that most users won't have enabled.

@time-less-ness
Copy link

@TMSCH Privacy-conscious users enable it, and privacy-conscious tech geeks who set up systems for their less-technically-abled family and friends may also enable it. I never specifically enabled it on my system. I suspect one of the various privacy plugins I installed toggled it for me. I suspect there are many ways it becomes enabled where a non-advanced user will be the one suffering the consequence.

@bkoski I also do not know any other work-around other than implementing it server-side. I actually think it should be strongly recommended to never do client-side auth until the situation with Chrome (and other browsers?) changes.

@TMSCH
Copy link
Contributor

TMSCH commented Aug 11, 2017

@philovivero that's true. However, in recent browsers, the "Incognito", "InPrivate" or "Private" modes tend to have supplanted such option. It used to be very prominent in the browser's settings (I remember specifically IE), when now, it is relegated to a subcategory of advanced settings (eg Chrome), if not completely removed (I can't find the option in my version of Firefox).To be noted: in these modes and across browsers, gapi.auth2 works.

@nmlorg
Copy link

nmlorg commented Apr 26, 2018

Chrome with third-party data blocked disables cookies in IFrames. Cookies is a required feature to be able to securely authenticate the user. Without them, it is not possible to have a pure client side library that remains secure. This is why we won't support it. Single Sign-On JS libraries generally cannot be supported in such environment (Facebook login doesn't work neither).

So, for what it's worth, here is a quick implementation of a pure-client-side, pure-OAuth2 flow (that works with 3rd-party cookies disabled). If it serves to inspire this library's developers, wonderful; otherwise I can try to rigorize this and provide third-party support.

index.html:

<!DOCTYPE html>
<script src="https://apis.google.com/js/api.js"></script>
<script src="auth.js"></script>
<script>
const CLIENT_ID = '...';
const API_KEY = '...';
const SCOPES = ['https://www.googleapis.com/auth/calendar'];

googleAuthInit(CLIENT_ID, SCOPES).then(token => gapi.load('client:auth', () => gapiLoaded(token)));

function gapiLoaded(access_token) {
  gapi.auth.setToken({access_token});
  gapi.client.init({
      apiKey: API_KEY,
      discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
  }).then(() => {
    gapi.client.calendar.events.list({
        'calendarId': 'primary',
        'timeMin': '2018-04-24T00:00:00Z',
        'timeMax': '2018-04-26T00:00:00Z'}).then(response => {for (let event of response.result.items) console.log(event)});
  });
}
</script>

auth.js:

function googleAuthInit(client_id, scopes) {
  if (document.location.hash) {
    let params = {};
    for (let piece of document.location.hash.substr(1).split('&')) {
      let [k, v] = piece.split('=', 2);
      params[decodeURIComponent(k)] = v ? decodeURIComponent(v.replace(/[+]/g, ' ')) : '';
    }
    if (params.access_token && params.expires_in) {
      localStorage.setItem('access_token_expires', Date.now() + Number(params.expires_in) * 1000);
      localStorage.setItem('access_token', params.access_token);
      let base = window.location.href;
      let i = base.indexOf('#');
      if (i > -1)
        base = base.substring(0, i);
      window.history.replaceState(null, document.title, base + (params.state ? atob(params.state) : ''));
    }
  }

  function getAuthUri() {
    let base = window.location.href, state = '';
    let i = base.indexOf('#');
    if (i > -1) {
      state = base.substring(i);
      base = base.substring(0, i);
    }
    return 'https://accounts.google.com/o/oauth2/v2/auth' +
        '?client_id=' + encodeURIComponent(client_id) +
        '&redirect_uri=' + encodeURIComponent(base) +
        '&state=' + encodeURIComponent(btoa(state)) +
        '&response_type=' + encodeURIComponent('token') +
        '&scope=' + encodeURIComponent(scopes.join(' ')) +
        '&include_granted_scopes=' + encodeURIComponent('true');
  }

  let access_token_expires_in = Number(localStorage.getItem('access_token_expires')) - Date.now() - 1000;
  if (access_token_expires_in < 0) {
    window.location.replace(getAuthUri());
    return Promise.reject();
  }

  window.setTimeout(() => window.location.replace(getAuthUri()), access_token_expires_in);
  return Promise.resolve(localStorage.getItem('access_token'));
}

@nmlorg
Copy link

nmlorg commented Apr 27, 2018

Here's a drop-in version of auth.js that should work without changes to the [current] examples. It first tries upstream gapi.auth2.init, but catches idpiframe_initialization_failed and tries conventional OAuth2, then shoves the access token back in via gapi.auth.setToken and forces gapi.auth2.getAuthInstance().isSignedIn.get() to return true. I'd love for upstream gapi.auth2.init to do something similar (is the uncompiled code for at least the auth2 component available?), but if that's not possible I can keep working on this, just let me know!

(function() {

var patchedScriptOnload = false;
var patchedGapiLoad = false;
var patchedGapiAuth2Init = false;

tryPatchGapi();

function tryPatchGapi() {
  if (!window.gapi) {
    if (!patchedScriptOnload) {
      for (let script of document.getElementsByTagName('script')) {
        if (script.src.match(/^https:[/][/]apis[.]google[.]com[/]js[/]/)) {
          patchedScriptOnload = true;
          if (script.onload) {
            console.log('gapi-issue260: Installing onload patcher to', script);
            let scriptOnload = script.onload;
            script.onload = function(e) {
              tryPatchGapi();
              scriptOnload(e);
            };
          } else {
            console.log('gapi-issue260: Installing "load" event patcher to', script);
            script.addEventListener('load', tryPatchGapi);
          }
        }
      }
    }
    return;
  }

  if (!gapi.auth2) {
    if (!patchedGapiLoad) {
      patchedGapiLoad = true;
      console.log('gapi-issue260: Patching gapi.load.');
      let gapiLoad = gapi.load;
      gapi.load = function(targets, callback) {
        console.log('gapi-issue260: Watching gapi.load(', targets, ', ', callback, ').');
        if (callback instanceof Function) {
          gapiLoad(targets, () => {
            tryPatchGapi();
            callback();
          });
        } else {
          var newCallback = {
            callback: () => {
              tryPatchGapi();
              callback.callback();
            },
            onerror: callback.onerror,
          };
          gapiLoad(targets, newCallback);
        }
      };
    }
    return;
  }

  if (patchedGapiAuth2Init)
    return;
  patchedGapiAuth2Init = true;
  console.log('gapi-issue260: Patching gapi.auth2.init.');
  let gapiAuth2Init = gapi.auth2.init;
  gapi.auth2.init = function(params) {
    console.log('gapi-issue260: Watching gapi.auth2.init(', params, ').');
    return new Promise((resolve, reject) => {
      gapiAuth2Init(params).then(
          resolve,
          e => {
            if (e.error == 'idpiframe_initialization_failed') {
              console.log('gapi-issue260: Caught', e, '-- trying workaround.');
              workaround(params).then(access_token => {
                console.log('gapi-issue260: Success! Switching to gapi.auth and continuing.');
                gapi.auth.setToken({access_token});
                gapi.auth2.getAuthInstance().isSignedIn.get = () => true;
                resolve();
              });
            } else {
              reject(e);
            }
          });
    });
  };
}

function workaround(params) {
  if (document.location.hash) {
    let params = {};
    for (let piece of document.location.hash.substr(1).split('&')) {
      let [k, v] = piece.split('=', 2);
      params[decodeURIComponent(k)] = v ? decodeURIComponent(v.replace(/[+]/g, ' ')) : '';
    }
    if (params.access_token && params.expires_in) {
      localStorage.setItem('access_token_expires', Date.now() + Number(params.expires_in) * 1000);
      localStorage.setItem('access_token', params.access_token);
      let base = window.location.href;
      let i = base.indexOf('#');
      if (i > -1)
        base = base.substring(0, i);
      window.history.replaceState(null, document.title, base + (params.state ? atob(params.state) : ''));
    }
  }

  function getAuthUri() {
    let base = window.location.href;
    let state = '';
    let i = base.indexOf('#');
    if (i > -1) {
      state = base.substring(i);
      base = base.substring(0, i);
    }
    return 'https://accounts.google.com/o/oauth2/v2/auth' +
        '?client_id=' + encodeURIComponent(params.client_id) +
        '&redirect_uri=' + encodeURIComponent(base) +
        '&state=' + encodeURIComponent(btoa(state)) +
        '&response_type=token' +
        '&scope=' + encodeURIComponent(params.scope) +
        '&include_granted_scopes=true';
  }

  let access_token_expires_in = Number(localStorage.getItem('access_token_expires')) - Date.now() - 1000;
  if (access_token_expires_in < 0) {
    window.location.replace(getAuthUri());
    return Promise.reject();
  }

  window.setTimeout(() => window.location.replace(getAuthUri()), access_token_expires_in);
  return Promise.resolve(localStorage.getItem('access_token'));
}

})();

@UserDac
Copy link

UserDac commented Jun 14, 2018

Can Anyone help me with this Issue?

I have to find out the issue that why website does not load the page properly after sign in with google api but when i run the project it works fine on my localhost

@mrlubos
Copy link

mrlubos commented Aug 11, 2018

@UserDac Hi, can you be more specific regarding your issue? Can you describe what happens?

@abumalick
Copy link

We have 587 users that experienced this problem, it would be nice to fix this in the library 🛠.

@jsejcksn
Copy link

jsejcksn commented Apr 5, 2019

@TMSCH Any news on progress made in the last year?

@turbo2ltr
Copy link

Not a fix, but at least it won't fail silently.

I have found that a message is posted to the parent window that indicates that cookies are not enabled when the page is first loaded. Only tested in Chrome with Third Party cookies turned off.

method: "fireIdpEvent"
	params: 
		error: "Cookies are not enabled in current environment."

This is basic code and assumes if here is an error, that the error is a cookie error which I'm sure is not always the case, and I'm no JS expert... So modify as needed.

window.addEventListener('message', function(event) {
	if(typeof(event.data) != "undefined") {
		var msg_json = JSON.parse(event.data);
		if(typeof(msg_json.params) != "undefined") {
			if(typeof(msg_json.params.error) != "undefined"){
				// there is an error warn the user
				$('#cookie_warn').html('You must allow third-party cookies in your browser before you can log in');
			}
		}
	}
});

@gkiely
Copy link

gkiely commented Sep 21, 2019

@TMSCH @grant Any progress on this? Would be great to see a fix for this as part of the upcoming security audit.

@Yussur90
Copy link

Yussur90 commented Sep 30, 2019

@Gicminos
Copy link

Any news or progress related to this issue? Is there at least a confirmed workaround until the issue is not completely fixed? Thanks.

@admehta01
Copy link

I pasted a workaround with some old js code earlier (not sure if it still works):
#260 (comment)

My solution was to move everything to the backend:
https://developers.google.com/identity/sign-in/web/backend-auth

No issues with it so far.

@yuzhakovvv
Copy link

See my comment here. May help in your case

@jfbloom22
Copy link

This thread is a bit old so wanted to comment with what appears to be the current solution.

Google's documents this as a Known Issue.

Asking users to change their browser security settings is out of the question, so the only real solution is to abandon this JS library and implement server-side Auth 2.0 flow.

Suggestion: As more browsers move toward disabling 3rd party cookies by default, this client side auth library is going to work for fewer and fewer users. This known issue about 3rd party cookies should be added as a big red warning at the beginning of the installation instructions.

@mike-seekwell
Copy link

mike-seekwell commented Nov 5, 2020

Google Chrome incognito blocks 3rd party cookies by default now

@veeralpatel
Copy link

Any updates on this?
Apple seems to blocking third party cookies by default now too: https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/. It seems like a big deal to find a workaround that isn't replacing all client-side auth.

@veeralpatel
Copy link

It seems like the Firebase team has a workaround: firebase/firebase-js-sdk#1040

@CetinSert
Copy link

Hi everyone! We might have found a very simple workaround: pwa-builder/PWABuilder#3286 (comment) (confirmed from multiple websites).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests