2020/12/09

New specification of App Bridge to support session tokens, solving the problem of third party cookies(for Next.js)

Photo by Matthew Henry from Burst

Development of the Shopify app

When you develop a Shopify app, you’re likely to use Node.js + React. The framework is also recommended in Shopify’s developer tutorials, and if you follow these steps, you can easily create app skeletons and learn all about the API and SDK.

Build a Shopify App with Node and React

It is even possible to release it as a real product version by extending the code in this tutorial.

Problems of Tutorial

However, since the code in this tutorial uses cookies to manage the session, it violates the limitations of third-party cookies and does not work properly in some browsers.This is due to the fact that each year, each browser has become more and more demanding in terms of security.

FYI: Safari Blocks Third-Party Cookies by Default

So Shopify also addresses this issue by providing a different way of managing sessions (which we will call Cookieless-Auth in this article) than the existing mechanism.

How to Make Your Embedded Apps Load Quickly and Reliably Authenticate your app using session tokens

A tutorial is also provided on how to manage this new session, but since the server side is not only implemented in Ruby on Rails, but also does not assume the use of Access Token, you’ll have to implement it yourself if your app is implemented in Node.js.

So this article summarizes how to implement Cookieless-Auth in Node.js.

Preparation

Start with the code as a starting point for completing the aforementioned tutorial. The code in question is stored in the following GitHub repository and should be cloned accordingly.

Shopify/shopify-demo-app-node-react

$ git clone git@github.com:Shopify/shopify-demo-app-node-react.git

Data persistence

The new session management requires a mechanism for persisting data previously stored in cookies (e.g. Access Token) in a different way. Authenticate your app using session tokens

This time we will use the most common method: RDBMS, so we will incorporate the necessary libraries into the project. (This project uses PostgreSQL) We use an ORM wrapper tool called sequelize for the library that manipulates the RDBMS.

$ npm install sequelize
$ npm install pg pg-hstore # Replace by the type of RDBMS
$ npm install --save-dev sequelize-cli

Initialize sequelize and add the necessary files to the project.

$ npx sequelize-cli init

Make sure four directories are created: config, migrations, models, and seeders. Then write the configuration for connecting to the database in config/config.json.

Next, create a container (Model) to persist the data. This time we will store the store’s domain (xxx.myshopify.com) and access token in the entity Shop, so we’ll enter the command to create the two fields in the type of string.

$ npx sequelize-cli model:generate --name Shop --attributes shop:string,accessToken:string

This will add a file called models/shop.js. This file contains the information for the Shop entities. This time we want to add a UNIQUE attribute to the shop field, so we add the following

models/shop.js
Shop.init({
  shop: {
    type: DataTypes.STRING,
    unique: true // Added
  },
  accessToken: DataTypes.STRING
}, {
  sequelize,
  modelName: 'Shop',
});

With the database created on the PostgreSQL server and valid settings in config.js, run the migration to create the table on DBMS. Now the Shop entities defined in the project will be available.

$ npx sequelize-cli db:migrate

Add the necessary packages to the project

Now it’s time to start implementing, but first add the necessary libraries to the project.

$ npm install koa-shopify-auth-cookieless
$ npm install koa-shopify-graphql-proxy-cookieless
$ npm install @shopify/app-bridge-utils
$ npm install shopify-jwt-auth-verify
$ npm install jsonwebtoken

At the time of this writing, the package koa-shopify-auth-cookieless is currently useful. An example implementation using this package is also available from the author for your reference. We also install the following packages related to babel to match the description with this implementation example

$ npm install @babel/core @babel/polyfill @babel/preset-env @babel/register

Create a file named index.js in the root of your project and include the following.

require('@babel/register')({
  presets: ['@babel/preset-env'],
  ignore: ['node_modules']
});
module.exports = require('./server.js');

Change the startup script (change server.js to index.js).

package.json (Before)
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
package.json (After)
    "dev": "node index.js",
    "build": "next build",
    "start": "NODE_ENV=production node index.js"

Modify server.js

Let’s add the code to server.js.

server.js (Before)
require('isomorphic-fetch');
const dotenv = require('dotenv');
dotenv.config();
const Koa = require('koa');
const next = require('next');
const { default: createShopifyAuth } = require('@shopify/koa-shopify-auth');
const { verifyRequest } = require('@shopify/koa-shopify-auth');
const session = require('koa-session');
const { default: graphQLProxy } = require('@shopify/koa-shopify-graphql-proxy');
const { ApiVersion } = require('@shopify/koa-shopify-graphql-proxy');
const Router = require('koa-router');
const { receiveWebhook, registerWebhook } = require('@shopify/koa-shopify-webhooks');
const getSubscriptionUrl = require('./server/getSubscriptionUrl');
server.js (After)
import "@babel/polyfill";
import dotenv from "dotenv";
dotenv.config();
import "isomorphic-fetch";
import {
  createShopifyAuth,
  verifyToken,
  getQueryKey,
} from "koa-shopify-auth-cookieless";
import { graphQLProxy, ApiVersion } from "koa-shopify-graphql-proxy-cookieless";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
import { receiveWebhook, registerWebhook } from '@shopify/koa-shopify-webhooks';
import getSubscriptionUrl from './server/getSubscriptionUrl';
import isVerified from "shopify-jwt-auth-verify";
import db from './models';
const jwt = require("jsonwebtoken");

It imports the necessary functions from the package koa-shopify-auth-cookieless and then imports a function from koa-shopify-graphql-proxy-cookieless to act as a proxy for GraphQL.

Then implement the callback of the createShopifyAuth method afterAuth.

server.js (Before)
async afterAuth(ctx) {
  const { shop, accessToken } = ctx.session;
  ctx.cookies.set("shopOrigin", shop, {
    httpOnly: false,
    secure: true,
    sameSite: 'none'
  });
  const registration = await registerWebhook({
    address: `${HOST}/webhooks/products/create`,
    topic: 'PRODUCTS_CREATE',
    accessToken,
    shop,
    apiVersion: ApiVersion.October20
  });

  if (registration.success) {
    console.log('Successfully registered webhook!');
  } else {
    console.log('Failed to register webhook', registration.result);
  }
  await getSubscriptionUrl(ctx, accessToken, shop);
}
server.js (After)
async afterAuth(ctx) {
  const shopKey = ctx.state.shopify.shop;
  const accessToken = ctx.state.shopify.accessToken;
  await db.Shop.findOrCreate({
    where: { shop: shopKey },
    defaults: {
      accessToken: accessToken
    }
  }).then(([newShop, created]) => {
    if (created) {
      console.log("created.", shopKey, accessToken);
    } else {
      newShop.update({
        accessToken: accessToken
      }).then(() => {
        console.log("updated.", shopKey, accessToken);
      });
    }
  });
  const registration = await registerWebhook({
    address: `${HOST}/webhooks/products/create`,
    topic: 'PRODUCTS_CREATE',
    accessToken: accessToken,
    shop: shopKey,
    apiVersion: ApiVersion.October20
  });

  if (registration.success) {
    console.log('Successfully registered webhook!');
  } else {
    console.log('Failed to register webhook', registration.result);
  }
  await getSubscriptionUrl(ctx, accessToken, shopKey);
}

AfterAuth, i.e., right after the authentication or installation of the app has been performed, we store the accessToken in the DB. This means that even after Cookieless-Auth, GraphQL calls using the Access Token are still possible, since before the change, they were stored in a cookie-stored session.

Next, replace GraphQLProxy.

server.js (Before)
server.use(graphQLProxy({ version: ApiVersion.October20 }));
server.js (After)
router.post("/graphql", async (ctx, next) => {
  const bearer = ctx.request.header.authorization;
  const secret = process.env.SHOPIFY_API_SECRET_KEY;
  const valid = isVerified(bearer, secret);
  if (valid) {
    const token = bearer.split(" ")[1];
    const decoded = jwt.decode(token);
    const shop = new URL(decoded.dest).host;
    const dbShop = await db.Shop.findOne({ where: { shop: shop } });
    if (dbShop) {
      const accessToken = dbShop.accessToken;
      const proxy = graphQLProxy({
        shop: shop,
        password: accessToken,
        version: ApiVersion.October20,
      });
      await proxy(ctx, next);
    } else {
      ctx.res.statusCode = 403;
    }
  }
});

With this change, GraphQL execution from the app will also be under Cookieless-Auth session management, completing the server-side implementation.

Lastly, replace the validation logic of the session.

server.js (Before)
router.get('(.*)', verifyRequest(), async (ctx) => {
  await handle(ctx.req, ctx.res);
  ctx.respond = false;
  ctx.res.statusCode = 200;
});
server.js (After)
router.get('/', async (ctx, next) => {
  const shop = getQueryKey(ctx, "shop");
  const dbShop = await db.Shop.findOne({ where: { shop: shop } });
  const token = dbShop && dbShop.accessToken;
  ctx.state = { shopify: { shop: shop, accessToken: token } };
  await verifyToken(ctx, next);
});

router.get('/(.*)', async (ctx) => {
  await handle(ctx.req, ctx.res);
  ctx.respond = false;
  ctx.res.statusCode = 200;
});

Before the change, we used the verifyRequest function in the @shopify/koa-shopify-auth package to replace it with the verifyToken function in the koa-shopify-auth-cookieless package.

Additionally, we will modify the function getSubscriptionUrl for billing.

server/getSubscriptionUtl.js (Before)
const getSubscriptionUrl = async (ctx, accessToken, shop) => {
  const query = JSON.stringify({
    query: `mutation {
      appSubscriptionCreate(
        name: "Super Duper Plan"
        returnUrl: "${process.env.HOST}"
        test: true
        ...
      )
    }`
  });
};
server/getSubscriptionUtl.js (After)
import { redirectQueryString } from "koa-shopify-auth-cookieless";
const getSubscriptionUrl = async (ctx, accessToken, shop) => {
  const redirectQuery = redirectQueryString(ctx);
  const query = JSON.stringify({
    query: `mutation {
      appSubscriptionCreate(
        name: "Super Duper Plan"
        returnUrl: "${process.env.HOST}/?${redirectQuery}"
        test: true
        ...
      )
    }`
  });
};

Since we need to retrieve the shop key that was previously stored in a cookie, we add a Query String to the returnUrl after the billing process.

The implementation on the server side is now complete. All we need to do now is to modify the front end to get the values from the cookies.

Modify _app.js

This was implemented as a class in the tutorial, but we want to use hooks, so we will re-implement it as a function. I will re-implement it as a function.

pages/_app.js (Before)
import App from 'next/app';
import Head from 'next/head';
import { AppProvider } from '@shopify/polaris';
import { Provider } from '@shopify/app-bridge-react';
import Cookies from "js-cookie";
import '@shopify/polaris/dist/styles.css';
import translations from '@shopify/polaris/locales/en.json';
import ApolloClient from 'apollo-boost';
import { ApolloProvider } from 'react-apollo';
import ClientRouter from '../components/ClientRouter';

const client = new ApolloClient({
  fetchOptions: {
    credentials: 'include',
  },
});

class MyApp extends App {
  render() {
    const { Component, pageProps } = this.props;
    const config = { apiKey: API_KEY, shopOrigin: Cookies.get("shopOrigin"), forceRedirect: true };

    return (
      <React.Fragment>
        <Head>
          <title>Sample App</title>
          <meta charSet="utf-8" />
        </Head>
        <Provider config={config}>
          <ClientRouter />
          <AppProvider i18n={translations}>
            <ApolloProvider client={client}>
              <Component {...pageProps} />
            </ApolloProvider>
          </AppProvider>
        </Provider>
      </React.Fragment>
    );
  }
}

export default MyApp;
pages/_app.js (After)
import Head from 'next/head';
import { createHttpLink } from 'apollo-link-http';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { AppProvider } from '@shopify/polaris';
import { Provider } from '@shopify/app-bridge-react';
import '@shopify/polaris/dist/styles.css';
import translations from '@shopify/polaris/locales/en.json';
import createApp from '@shopify/app-bridge';
import { ApolloClient } from 'apollo-client';
import { ApolloProvider } from '@apollo/react-hooks';
import { authenticatedFetch } from '@shopify/app-bridge-utils';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';
import ClientRouter from '../components/ClientRouter';

export default function MyApp({ Component, pageProps }) {
  const router = useRouter();
  const [shop, setShop] = useState(null);

  useEffect(() => {
    if (router.asPath !== router.route) {
      setShop(router.query.shop);
    }
  }, [router]);

  if (!shop) return null;

  const app = createApp({
    apiKey: API_KEY,
    shopOrigin: shop,
    forceRedirect: true,
  });
  const link = new createHttpLink({
    credentials: "omit",
    fetch: authenticatedFetch(app),
  });
  const client = new ApolloClient({
    link: link,
    cache: new InMemoryCache(),
  });
  const config = { apiKey: API_KEY, shopOrigin: shop, forceRedirect: true };
  return (
    <React.Fragment>
      <Head>
        <title>Sample App</title>
        <meta charSet="utf-8" />
      </Head>
      <Provider config={config}>
        <ClientRouter />
        <AppProvider i18n={translations}>
          <ApolloProvider client={client}>
            <Component {...pageProps} />
          </ApolloProvider>
        </AppProvider>
      </Provider>
    </React.Fragment>
  );
}

The important thing is this part.

pages/_app.js
const router = useRouter();
const [shop, setShop] = useState(null);

useEffect(() => {
  if (router.asPath !== router.route) {
    setShop(router.query.shop);
  }
}, [router]);

If you refer to the shop property when router.query becomes available, you will get the FQDN (xxx.myshopify.com), which you can use on the front end. In the previous code, we used Cookies.get(“shopOrigin”) to retrieve it. This allows us to achieve Cookieless on the front end as well.

Try to execute

Now that the implementation of Cookieless-Auth is complete, let’s run it in the development environment. The behavior of the application may not show any significant changes, but I think we can confirm that it works without any problems in the browser environment (e.g., Safari) where it did not work well until now. Say goodbye to the damned Third Party Cookie warning!

Summary

Like me, I’m sure there are many apps that are running with the tutorial code extensions in place, but if you have an environment that doesn’t work well, you’ll lose some opportunities, so I recommend that you take action as soon as possible.

We have uploaded the repository of the project we implemented this time, so please use it.

taketsu/shopify-demo-app-node-react

Thank you for reading to the end.