I am unable to get Safari to successfully apply Set-Cookie
from server responses when using the Fetch API (actually, via the fetch polyfill). The same code works correctly in FF and Chrome (I tested using both native and polyfill fetch
).
- The request is across domains;
- yes, I am setting
credentials: true
;
- the server does respond with a
Set-Cookie
header;
- subsequent requests are sent from Chrome and FF with cookie request headers, but Safari does not;
- the request uses HTTPS (the cert is self-signed and on a development domain but it seems to be accepted by Safari on regular requests); and
Does someone know what the problem might be?
I've read through the documentation and gone through many of the closed bug reports. Unless I missed something, I think maybe the problem is with the 'default browser behaviour' dealing with cookies and CORS -- and not with fetch (reading through the polyfill source code, it seems 100% ignorant of cookies). A few bug reports suggest a malformed server response can prevent cookies from being saved.
My code looks like this:
function buildFetch(url, init={}) {
let headers = Object.assign({}, init.headers || {}, {'Content-Type': 'application/json'});
let params = Object.assign({}, init, { credentials: 'include', headers });
return fetch(`${baseUrl}${url}`, params);
}
buildFetch('/remote/connect', {method: 'PUT', body: JSON.stringify({ code })})
.then(response => response.json())
.then(/* complete authentication */)
The actual authorization request is below. I am using cURL to get the exact request/response data, since Safari makes it hard to copy/paste it.
curl 'https://mydevserver:8443/api/v1/remote/connect' \
-v \
-XPUT \
-H 'Content-Type: application/json' \
-H 'Referer: http://localhost:3002/' \
-H 'Origin: http://localhost:3002' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8' \
--data-binary '{"token":"value"}'
* Trying 127.0.0.1...
* Connected to mydevserver (127.0.0.1) port 8443 (#0)
* TLS 1.2 connection using TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
* Server certificate: mydevserver
> PUT /api/v1/remote/connect HTTP/1.1
> Host: mydevserver:8443
> Accept: */*
> Content-Type: application/json
> Referer: http://localhost:3002/
> Origin: http://localhost:3002
> User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_5) AppleWebKit/602.4.8 (KHTML, like Gecko) Version/10.0.3 Safari/602.4.8
> Content-Length: 15
>
* upload completely sent off: 15 out of 15 bytes
< HTTP/1.1 200 OK
< Access-Control-Allow-Origin: http://localhost:3002
< Access-Control-Allow-Credentials: true
< Access-Control-Allow-Headers: Origin, X-Requested-With, Content-Type, Accept, Api-Key, Device-Key
< Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
< Access-Control-Expose-Headers: Date
< Content-Type: application/json; charset=utf-8
< Content-Length: 37
< Set-Cookie: express:sess=[SESSIONKEY]=; path=/; expires=Fri, 17 Feb 2017 15:30:01 GMT; secure; httponly
< Set-Cookie: express:sess.sig=[SIGNATURE]; path=/; expires=Fri, 17 Feb 2017 15:30:01 GMT; secure; httponly
< Date: Fri, 17 Feb 2017 14:30:01 GMT
< Connection: keep-alive
<
* Connection #0 to host mydevserver left intact
{"some":"normal","response":"payload"}
Answering my own question.
I find it pretty enraging that this is a "working as intended" behaviour of Safari, though I understand their motivation. XHR (and presumably native fetch when it lands natively) does not support the setting of third-party cookies at all. This failure is completely transparent because it is handled by the browser outside of the scripting context, so client-based solutions are not really going to be possible.
One recommended solution you will find here is to open a window or iframe to an HTML page on the API server and set a cookie there. At this point, 3rd party cookies will begin to work. This is pretty fugly and there is no guarantee that Safari won't at some point close that loophole.
My solution is to basically reimplement an authentication system that does what session-cookies do. Namely:
- Add a new header,
X-Auth: [token]
, where [token]
is a very small, short-lived JWT containing the information you require for your session (ideally only the user id -- something that is unlikely to mutate during the lifetime of your application -- but definitely not something like permissions if permissions can be changed during the session);
- Add
X-Auth
to Access-Control-Allow-Headers
;
- During sign-in, set the session cookie and the auth token with the payloads you require (both Safari and non-Safari users will get both the cookie and the auth header);
- On the client, look for the
X-Token
response header and echo it back as an X-Token
request header any time it sees it (you could achieve persistence by using local storage -- the token expires, so even if the value lives for years, it can't be redeemed after a certain point);
- On the server, for all requests for protected resources, check for the cookie and use it if it exists;
- Otherwise (if the cookie is absent -- because Safari didn't send it), look for the header token, verify and decode the token payload, update the current session with the provided info and then generate a new auth token and add it to the response headers;
- Proceed as normally.
Note that JWT (or anything similar) is intended to solve a completely different problem and should really never be used for session management because of the "replay" problem (think what could happen if a user had two windows open with their own header-state). In this case, however, they offer the transience and security you normally need. Bottom line is you should use cookies on browsers that support them, keep the session information as tiny as possible, keep your JWT as short-lived as possible, and build your server app to expect both accidental and malicious replay attacks.
FYI, trying this ~18 months later, this solution didn't work for me. Or, it seemed to, intermittently and for some users, which was really weird.
One recommended solution you will find here is to open a window or
iframe to an HTML page on the API server and set a cookie there. At
this point, 3rd party cookies will begin to work. This is pretty fugly
and there is no guarantee that Safari won't at some point close that
loophole.
Best guess is that whatever logic Safari is using internally depends on the order you manage to get the cookies, or something more complicated and opaque.
The solution I ultimately settled on, which might be an option if you're hitting this because you're serving a React App from a different host than the API, or otherwise control both sites, was to use DNS:
Our client was being served from www.company-name.com and our API was on company-name.herokuapp.com. By making a CNAME record api.company-name.com --> company-name.herokuapp.com, and using that subdomain of the same domain for the requests from the client to the API, Safari stopped considering it a "third-party" cookie.
The upside is that there's very little code involved, and it's all using well-established stuff... The downside is that you need some control/ownership over the API host if you're going to use https - they need a certificate that's valid for the client domain, or users will get a certificate warning - so this wouldn't work (at least not for something end-user-facing) if the API in question isn't yours or a partner's.