2020/12/09

サードパーティクッキーの問題を解決するApp Bridgeの新しい仕様、セッショントークンに対応する(Next.js版)

Photo by Matthew Henry from Burst

Shopifyアプリの開発

Shopify アプリの開発を行う際には、 Node.js + React で開発することが多いと思います。Shopify の開発者向けチュートリアルでもこのフレームワークが推奨されており、この手順通りに進めていくとアプリのスケルトンが簡単に作成でき、APIやSDKを一通り学ぶことが出来ます。

Build a Shopify App with Node and React

このチュートリアルのコードを拡張していくことで実際の製品版としてリリースすることまで可能です。

チュートリアルの問題点

しかし、このチュートリアルのコードはCookieを使ってセッションを管理しているため、サードパーティCookieの制限に抵触してしまい、ブラウザによっては正しく機能しません。これは、年々各ブラウザのセキュリティへの対応が厳しくなっているためです。

参考: Safariがサードパーティクッキーをデフォルトでブロック

そこでShopifyでもこの問題に対応するため、既存の仕組みとは異なるセッションの管理方法(本記事では Cookieless-Auth と呼ぶことにします)を提供しています。

埋め込みアプリの読み込みをすばやく確実におこなう方法

Authenticate your app using session tokens

この新しいセッションの管理方法もチュートリアルが提供されているのですが、サーバーサイドは Ruby on Rails で実装されているだけでなく Access Token の使用も想定していないので、Node.js で実装したアプリの場合、自前で実装する必要があります

そこで本記事では、Node.js の場合の Cookieless-Auth の実現方法についてまとめてみました。

前準備

前述のチュートリアルを一通り完了した時点のコードを起点として始めます。当該コードは下記のGitHubリポジトリに格納されていますので、適宜 Clone してください。

Shopify/shopify-demo-app-node-react

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

データの永続化

新しいセッション管理では、これまでCookieに格納していたデータ(Access Token 等)を別の方法で永続化するための仕組みが必要になります。

Authenticate your app using session tokens

今回は最も一般的な手法、RDBMSを利用しますのでそのために必要なライブラリをプロジェクトに組み込みます。 (本プロジェクトではPostgreSQLを採用しています) RDBMSを操作するライブラリに sequelize というORMラッパーツールを使用します。

$ npm install sequelize
$ npm install pg pg-hstore # RDBMSの種類によって読み替えてください
$ npm install --save-dev sequelize-cli

sequelize を初期化してプロジェクトに必要なファイルを追加します。

$ npx sequelize-cli init

config, migrations, models, seeders という4つのディレクトリが作成されたことを確認してください。 そしてconfig/config.json にデータベースに接続するための設定を記述してください。

次にデータを永続化するための入れ物(Model)を作成します。今回は Shop というエンティティにストアのドメイン(xxx.myshopify.com)とアクセストークンを保存しますので、その2つのフィールドを文字列型で作成するコマンドを叩きます。

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

実行すると、models/shop.js というファイルが追加されます。このファイルに Shop エンティティの情報が記述されます。今回は shop フィールドにUNIQUE属性を追加したいので、下記のように書き加えます。

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

PostgreSQLサーバにデータベースが作成されていて、config.js に有効な設定がされている状態でマイグレーションを実行してDBMS上にテーブルを作成します。これでプロジェクトで定義した Shop エンティティが利用できるようになります。

$ npx sequelize-cli db:migrate

プロジェクトに必要なパッケージを追加する

いよいよ実装に入りますが、まずは必要なライブラリをプロジェクトに追加します。

$ 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

本記事執筆時点では koa-shopify-auth-cookieless というパッケージが有効です。このパッケージを利用した実装例も作者によって公開されていますので、参考にしてください。こちらの実装例と記述を合わせるために下記の babel 関連のパッケージもインストールします。

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

プロジェクトのルートに index.js というファイルを作成し、下記を記述します。

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

起動スクリプトを変更します(server.js を index.js に変更)。

package.json(変更前)
    "dev": "node server.js",
    "build": "next build",
    "start": "NODE_ENV=production node server.js"
package.json(変更後)
    "dev": "node index.js",
    "build": "next build",
    "start": "NODE_ENV=production node index.js"

server.js を改修

server.js を改修していきます。

server.js(変更前)
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(変更後)
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");

koa-shopify-auth-cookieless のパッケージから必要な関数をインポートし、更に GraphQL のプロキシとして動作させるための関数を koa-shopify-graphql-proxy-cookieless からインポートしています。

続いて、createShopifyAuth メソッドのコールバック afterAuth を実装します。

server.js(変更前)
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(変更後)
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 つまりアプリの認証もしくはインストールが実行された直後に accessToken を DB に保存しています。変更前は Cookie に保存されたセッションに格納していたので、このことで Cookieless-Auth 後も Access Token を使用した GraphQL の呼び出しが実現されるというわけです。

次に GraphQLProxy を置き換えます。

server.js(変更前)
server.use(graphQLProxy({ version: ApiVersion.October20 }));
server.js(変更後)
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;
    }
  }
});

この変更により、アプリからの GraphQL の実行も Cookieless-Auth のセッション管理下になり、サーバ側の実装が完成します。

最後にセッションの検証ロジックを置き換えます。

server.js(変更前)
router.get('(.*)', verifyRequest(), async (ctx) => {
  await handle(ctx.req, ctx.res);
  ctx.respond = false;
  ctx.res.statusCode = 200;
});
server.js(変更後)
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;
});

変更前は、@shopify/koa-shopify-auth パッケージの verifyRequest 関数を利用していましたが、これを koa-shopify-auth-cookieless パッケージの verifyToken 関数に置き換えます。

更に、課金のための関数 getSubscriptionUrl を修正します。

server/getSubscriptionUtl.js(変更前)
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(変更後)
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
        ...
      )
    }`
  });
};

今まで Cookie に格納されていた shop キーを取得する必要があるため、課金処理後の returnUrl に Query String を付与します。

これでサーバー側の実装は完了しました。あとはフロントエンドで Cookie から値を取得していた部分を改修するだけです。

_app.js を改修

チュートリアルではクラスとして実装されていたのですが、Hook を使いたいので Function として実装し直します。変更点が多いので、ファイル全体を記載します。

pages/_app.js(変更前)
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(変更後)
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>
  );
}

ポイントはこの部分です。

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

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

router.query が利用可能になったときに shop プロパティを参照すると FQDN(xxx.myshopify.com)が取得できるので、フロントエンドではそれを利用することができます。 変更前のコードでは、Cookies.get(“shopOrigin”) で取得していた部分です。これによってフロントでも Cookieless を実現できました。

実行してみる

これで Cookieless-Auth の実装が完了しましたので、開発環境等で実行してみます。アプリの挙動は特に大きな変化が見られないかもしれませんが、今までうまく動いてなかったブラウザ環境(Safari等)でも問題なく動くことが確認できると思います。 忌々しい Third Party Cookie の警告ともオサラバです!!

まとめ

自分もそうですが、チュートリアルのコードの拡張のまま稼働しているアプリも多いかと思いますが、うまく動かない環境があると多少なりとも機会損失が発生しますので、なるべく早めに対応することをおすすめします。

今回実装したプロジェクトのリポジトリを下記にアップしましたので、是非ご利用ください。

taketsu/shopify-demo-app-node-react

最後までお読みいただきありがとうございました。