Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Frontend/Backend separation: Safari not storing cookies from API which is hosted on a separate domain than its Frontend SPA client

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?

like image 586
coconup Avatar asked Sep 21 '18 15:09

coconup


1 Answers

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
like image 77
coconup Avatar answered Oct 22 '22 00:10

coconup