4

I have a setup which - as far as I can tell - is fairly common nowadays: a backend REST API that lives on its own domain, say myapi.com, and a single page frontend application that is served somewhere else, say myapp.com.

The SPA is a client to the API and the API requires users to authenticate before they can do things.

The backend API is using cookies to store session data for some allowed origins among which myapp.com. This is in order to have a safe bus to transmit and store auth data without having to worry about it client-side.

In Chrome, Opera and Firefox, this works just fine: an API call is made to authenticate the user, Cookies are returned and stored in the browser in order to then be pushed together with the next call.

Safari, on the other hand, does receive the cookies but refuses to store them:

Auth call

First call

I suspect Safari sees the API domain as a 3rd party cookie domain and therefore blocks the cookies from being stored.

Is this the expected behaviour in Safari? If so, what are some best practices to get around it?

coconup
  • 815
  • 8
  • 16
  • Hi, I have run into this exact issue, using express-session. Did you get the accepted solution working with javascript/express by any chance? – alexr89 Jun 19 '20 at 14:58

1 Answers1

3

Perpetuating a tradition of answering your own question on this one.

TL;DR this is desired behaviour in Safari. The only way to get around it is to bring the user to a webpage hosted on the API's domain (myapi.com in the question) and set a cookie from there - anything really, you can write a small poem in the cookie if you like.

After this is done, the domain will be "whitelisted" and Safari will be nice to you and set your cookies in any subsequent call, even coming from clients on different domains.

This implies you can keep your authentication logic untouched and just introduce a dumb endpoint that would set a "seed" cookie for you. In my Ruby app this looks as follows:

class ServiceController < ActionController::Base
  def seed_cookie
    cookies[:s] = {value: 42, expires: 1.week, httponly: true} # value can be anything at all
    render plain: "Checking your browser"
  end
end

Client side, you might want to check if the browser making the request is Safari and defer your login logic after that ugly popup has been opened:

const doLogin = () => {
  if(/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
    const seedCookie = window.open(`http://myapi.com/seed_cookie`, "s", "width=1, height=1, bottom=0, left=0, toolbar=no, location=no, directories=no, status=no, menubar=no, scrollbars=no, resizable=no, copyhistory=no")
    setTimeout(() => {
      seedCookie.close();
      // your login logic;
    }, 500);
  } else {
    // your login logic;
  }
}

UPDATE: The solution above works fine for logging a user in, i.e. it correctly "whitelists" the API domain for the current browser session.

Unfortunately, though, it appears that a user refreshing the page will make the browser reset to the original state where 3rd party cookies for the API domain are blocked.

I found a good way to handle the case of a window refresh is to detect it in javascript upon page load and redirect the user to an API endpoint that does the same as the one above, just to then redirect the user to the original URL they were navigating to (the page being refreshed):

if(performance.navigation.type == 1 && /^((?!chrome|android).)*safari/i.test(navigator.userAgent)) {
  window.location.replace(`http://myapi.com/redirect_me`);
}

To complicate things, it turns out Safari won't store cookies if the response's HTTP status is a 30X (redirect). Thereby, a Safari-friendly solution involves setting the cookies and returning a 200 response together with a JS snippet that will handle the redirect within the browser.

In my case, being the backend a Rails app, this is how this endpoint looks like:

def redirect_me
  cookies[:s] = {value: 42, expires: 1.week, httponly: true}
  render body: "<html><head><script>window.location.replace('#{request.referer}');</script></head></html>", status: 200, content_type: 'text/html'
end
coconup
  • 815
  • 8
  • 16
  • Not only for Safari (which blocks 3rd party cookies by default). Chrome also has this setting which, when enabled, would block all 3rd party cookies. – Ioanna Apr 04 '19 at 08:25
  • Chrome works fine @Ioanna if you use the sameSite flag with the secure and httponly flags (EG: { httpOnly: true, secure: true, sameSite: 'none'}). However this solution you have posted seems quite painful coconup? It seems crazy that this is the only way to get safari to store cookies in this situation? I would have thought cors origin flag and cookie domain flag would give enough assurance that the cookie is legit – alexr89 Jun 19 '20 at 15:10
  • I've tried that with no luck. I don't know if it's a matter of SameSite/Secure configuration or if this trick is no longer possible. Does this still work for you in 2021? – 7hibault Mar 18 '21 at 07:05