Shopify Embedded Apps and Safari

‘Allow access’ page screen-shot

Apple recently released changes to Safari that broke our Shopify apps. Kudos to the Safari team for continuing to pave the way for privacy on the web, but their changes have developers scrambling to fix integrations.

Enhancements were added to Intelligent Tracking Prevention (ITP) in iOS and iPadOS 13.4 and Safari 13.1 on macOS (released March 24, 2020) to block all third-party cookies by default. This causes issues for many Shopify apps since embedded apps are displayed inside an iframe in Shopify’s admin interface and served from another domain. Most apps use a cookie to uniquely identify their session. Without access to their cookies, the apps don’t work.

Disabling the privacy option ‘Prevent cross-site tracking’ in Safari allows the apps to work, but we’re looking for a solution that doesn’t require users to change the default settings.

Storage Access API to the rescue

The Storage Access API provides a way for embedded, cross-origin content to gain access to browser storage that it would normally only have access to in a first-party context. All of the motivating use cases for the Safari team seem to be in support of authenticated embeds: for example, social network commenting widgets and third-party payment providers. But we can make use of it too.

The API allows embedded content to request access to its previously-set first-party cookies. The request can only be made as the result of a user gesture such as a tap or click. If storage access is granted, new cookies still cannot be set, but this gives us enough to create a work-around for Safari.

Our environment

Our Shopify apps use React (with Polaris) on the front-end and .NET Core (C#) on the backend, so we don’t use the Shopify-provided libraries (koa-shopify-auth for Node.js or shopify_app Ruby gem).

I did follow Shopify’s Build a Shopify App with Node and React tutorial and their approach doesn’t work in Safari. The tutorial uses the koa-shopify-auth library, which does make some use of Storage Access API, but it doesn’t seem that the new version of Safari is supported yet. In Safari, the app displays an Enable cookies page which puts the user into an endless redirect loop.

Update (May 1, 2020): Shopify released a new version of koa-shopify-auth today which fixes this issue.

Shopify tutorial yields ‘Enable cookies’ endless redirect loop

Steps to fix apps for Safari

These are the steps we took to fix our Shopify apps. You should follow these steps if you don’t use one of the Shopify-provided libraries (koa-shopify-auth for Node.js or shopify_app Ruby gem). A similar approach can be taken to patch those libraries until they are fixed.

1. Do not rely on a cookie for shop origin

Shopify App Bridge requires your API key and the shop name. Since we can’t rely on having access to cookies, the shop origin shouldn’t be set in a cookie.

Instead, your client-side code can pull the shop origin from the URL. This allows us to initialize app bridge successfully.

function getShopOriginFromUrl() {
var url = new URL(window.location.href);
return url.searchParams.get('shop');
}
const config = {
apiKey: process.env.REACT_APP_SHOPIFY_API_KEY,
shopOrigin: getShopOriginFromUrl(),
forceRedirect: true
};
class MyShopifyApp extends Component {
render() {
return (
<AppProvider i18n={translations}>
<Provider config={config}>
<App />
</Provider>
</AppProvider>
);
}
}
const root = document.getElementById('myShopifyApp');
ReactDOM.render(<MyShopifyApp />, root);

2. Check if cookies can be read

The first thing to do is to determine whether we have an issue reading cookies. If not, we can just render our app’s interface and go along our way.

We set a cookie server-side, named checkcookie, when our app is first rendered. Be sure to make your test cookie have a SameSite of None, be secure, and not be HTTP-only.

export default class App extends Component {
constructor(props) {
super(props);
this.state = {
hasCookieAccess: false
}
}
render() {
if (!this.state.hasCookieAccess) {
return (null);
}
// render app...
}
componentDidMount() {
const testCookie = Cookies.get('checkcookie');
if (typeof testCookie !== 'undefined') {
this.setState({ hasCookieAccess: true });
}
}
}

3. Redirect to a page in the top window

If our test cookie cannot be read, we assume that we haven’t successfully set our session identifier cookie either. We need to try to get cookies working in the browser.

We use app bridge to redirect to a page in the top window. That will allow us to set our session cookie as a first-party cookie. We pass the current app URL so that we can return back later.

export default class App extends Component {
static contextType = Context;
// ... componentDidMount() {
this.redirectIfNoCookieAccess();
}
redirectIfNoCookieAccess() {
const testCookie = Cookies.get('checkcookie');
if (typeof testCookie !== 'undefined') {
this.setState({ hasCookieAccess: true });
return;
}
const app = this.context;
const redirect = Redirect.create(app);
let url = window.location.origin
+ this.getApplicationPath(window.location.pathname)
+ '/setcookie';
redirect.dispatch(Redirect.Action.REMOTE, url
+ window.location.search
+ '&r=' + encodeURIComponent(window.location.href));
}
getApplicationPath(pathname) {
let pos = pathname.substring(1).indexOf("/");
if (pos === -1)
return pathname;
return pathname.substring(0, pos + 1);
}
}

4. Set first-party cookie for session and redirect back to the app

When verifying the authenticity of this request, remember to strip the extra parameter that you added to the URL, in your HMAC calculation.

Our page just responds with an HTTP redirect back to the app URL that it was passed. Before redirecting back to the original app URL, we add an extra URL parameter. That gives us a hook to avoid getting into an endless redirect loop if something doesn’t work.

Cookies can be set in redirects, so our session identifier cookie is set as a first-party cookie here.

5. Render user activation page

Now we should have a first-party cookie set with our session identifier. However we need to explicitly request access via the Storage Access API. Doing this requires user activation such as a tap or click.

We display a page prompting the user to allow access to the app. App bridge does the work of putting our app back inside of an iframe in Shopify admin.

export default class App extends Component {
static contextType = Context;
constructor(props) {
super(props);
this.state = {
hasCookieAccess: false,
returnedFromRedirect: false
}
}
render() {
if (!this.state.hasCookieAccess
&& !this.state.returnedFromRedirect) {
return (null);
}
if (window.location === window.top.location) {
return (null);
}
if (!this.state.hasCookieAccess) {
return (
<UserActivate
hasCookieAccess={this.state.hasCookieAccess}
onChange={this.userActivateChange.bind(this)}
/>
);
}
// render app...
}
componentDidMount() {
var url = new URL(window.location.href);
if (url.searchParams.get('returnfromtop') !== '1') {
this.redirectIfNoCookieAccess();
return;
}
this.setState({ returnedFromRedirect: true });
}
// ... userActivateChange(field, value) {
this.setState({ [field]: value });
}
}

6. Request storage access

When the user clicks the Allow access button, we request storage access. Safari displays a browser prompt for the user to confirm, the first time a site requests access.

export default class UserActivate extends Component {
constructor(props) {
super(props);
this.activate = this.activate.bind(this);
}
render() {
return (
<Frame>
<Page>
<form className='allow-access-form'>
<Layout sectioned>
<Card title='Allow access' sectioned>
<p>This browser requires your permission to access this app. Please click the button to continue.</p>
<p><Button primary={true} onClick={this.activate}>Allow access</Button></p>
</Card>
</Layout>
</form>
</Page>
</Frame>
);
}
activate() {
if (document.hasStorageAccess()) {
this.props.onChange('hasCookieAccess', true);
}
var promise = document.requestStorageAccess();
promise.then(
function() {
this.props.onChange('hasCookieAccess', true);
},
function() {
console.log('ERROR: unable to get storage access.');
}
);
}
}

7. Render the app interface

If we get access, we re-render to finally display our app interface.

Screen-shot of our app interface
Finally able to render our app

App extensions: painful user experience

This approach works pretty well for many apps. Each time the user accesses your app, they are slightly inconvenienced by having to click a button to allow access.

However, if your app makes use of app extensions, like our apps do, this makes for a painful user experience. Each time an app extension link is clicked, your app loads and the user needs to go through the process of allowing access again.

There is nothing that can be done to alleviate this pain with the current version of Safari.

Hopeful for improvement

But I’m hopeful that the user experience will improve in the future. The Storage Access API is still listed as experimental technology. Only Safari and Firefox have browser support right now, with Firefox providing a much more lenient implementation.

Representatives from the different browser teams are working together on specification of the API as a Work Item of the Privacy Community Group. In my communication with members of the group, they have been open to feedback and possible change.

I hope this article helps others struggling with Safari support with their apps.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Stefan Leyhane

Stefan Leyhane

Head of Engineering and Product at Firmwater. Staying home and washing my hands lots.