Upon visiting/refresh, the app checks for a refresh token in the cookie. If there is a valid one, an access token will be given by the Apollo Express Server. This works fine on my desktop but when using Chrome or Safari on the iPhone, the user gets sent to the login page on every refresh.
React App with Apollo Client
useEffect(() => {
fetchUser();
}, []);
const fetchUser = async () => {
const res = await fetch('https://website.com/token', {
method: 'POST',
credentials: 'include',
});
const { accessToken } = await res.json();
if (accessToken === '') {
setIsLoggedIn(false);
}
setAccessToken(accessToken);
setLoading(false);
};
Apollo Client also checks if whether the access token is valid
const authLink = setContext((_, { headers }) => {
const token = getAccessToken();
if (token) {
const { exp } = jwtDecode(token);
if (Date.now() <= exp * 1000) {
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : '',
},
};
}
}
fetch('https://website.com/token', {
method: 'POST',
credentials: 'include',
}).then(async (res) => {
const { accessToken } = await res.json();
setAccessToken(accessToken);
return {
headers: {
...headers,
authorization: accessToken ? `Bearer ${accessToken}` : '',
},
};
});
});
const client = new ApolloClient({
link: from([authLink.concat(httpLink)]),
cache: new InMemoryCache(),
connectToDevTools: true,
});
This handles the token link on the Express server
app.use('/token', cookieParser());
app.post('/token', async (req, res) => {
const token = req.cookies.rt;
if (!token) {
return res.send({ ok: false, accessToken: '' });
}
const user = await getUser(token);
if (!user) {
return res.send({ ok: false, accessToken: '' });
}
sendRefreshToken(res, createRefreshToken(user));
return res.send({ ok: true, accessToken: createAccessToken(user) });
});
And setting of the cookie
export const sendRefreshToken = (res, token) => {
res.cookie('rt', token, {
httpOnly: true,
path: '/token',
sameSite: 'none',
secure: true,
});
};
Same site is 'none' as the front end is on Netlify.
After a day of fiddling and researching, I have found the issue, and one solution when using a custom domain.
The issue is that iOS treats sameSite 'none' as sameSite 'strict'. I thought iOS Chrome would be different than Safari but it appears not.
If you use your front-end, hosted on Netlify, you will naturally have a different domain than your Heroku app back-end. Since I am using a custom domain, and Netlify provides free SSL, half of the work is done.
The only way to set a httpOnly cookie is to set the cookie to secure. The next step would be to set sameSite to 'none' but as mentioned above, this does not work with iOS.
Setting the domain property of the cookie will also not work because the domain property concerns the scope of the cookie and not the cookie origin. If the cookie came from a different domain (Heroku backend), then the frontend (on Netlify) will not be able to use it.
By default, on Heroku, the free dyno will give you a domain like 'your-app.herokuapp.com', which is great because it also includes free SSL. However, for the cookie to work, I added my custom domain that I use with Netlify. To be clear, Netlify already uses my apex custom domain, so I am adding a subdomain to Heroku (api.domain.com). Cookies do work for across the same domain and subdomains with sameSite 'strict'.
The final issue with this is that the custom domain with Heroku will not get SSL automatically, which is why I think it is worth it to upgrade to a $7/month hobby dyno to avoid managing the SSL manually. This I think is the only solution when using a custom domain.
On the other hand, for those who have the same issue and would like a free solution, you can forgo using a custom domain and host your static front-end with the back-end on Heroku.
Hopefully this will save some time for anyone deploying the back-end and front-end separately.