Shopify / koa-shopify-auth

DEPRECATED Middleware to authenticate a Koa application with Shopify
MIT License
80 stars 63 forks source link

Shopify session is not being renewed or created using Google Chrome #167

Closed dynamiclab-cl closed 1 year ago

dynamiclab-cl commented 2 years ago

Issue summary

This issue occurs on Google Chrome and after the app was installed.

When the Shopify session does not exist, or the session has expired, Google Chrome is not redirecting to the auth workflow to renew the session. Nevertheless, this works fine using Mozilla Firefox, we have not tested it in other browsers yet.

This happens when the app makes a request to the /graphql endpoint using the hook useQuery. As you can see below, in the server.js code, the /graphql endpoint is using the middleware verifyRequest({ returnHeader: true }).

BTW: This is an embedded react app, and it will be distributed as a public app. We also use redis as the session storage solution.

Expected behavior

If the session does not exist or is expired, the app should redirect to the authentication workflow regardless of the browser.

Actual behavior

When the app loads inside Shopify and the session was not present or expired, Google Chrome does not handle the redirection to the authentication process in order to renew the session. So the app loads but all the subsequent requests fail. This makes the app unsable.

We think that Google Chrome is not reading the redirections headers that verifyRequest middleware is injecting.

Steps to reproduce the problem

Using the following server.js file:

import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import createShopifyAuth, { verifyRequest } from "@shopify/koa-shopify-auth";
import Shopify, { ApiVersion } from "@shopify/shopify-api";
import Koa from "koa";
import next from "next";
import Router from "koa-router";
import koaBody from "koa-body";
import apiRoutes from "./routes/api";
import { getShopAccessToken, saveAccessToken } from "../services";
import helmet from "koa-helmet";
import { has } from "lodash";
import RedisStore from "./redis-store";
import verifyToken from "./verifyToken";

dotenv.config();
const port = parseInt(process.env.PORT, 10) || 8081;
const dev = process.env.NODE_ENV !== "production";
const app = next({
  dev,
});
const handle = app.getRequestHandler();
const sessionStorage =
  process.env.ENV === "development"
    ? new RedisStore(process.env.REDIS_URL)
    : new RedisStore(process.env.REDIS_URL, true);

Shopify.Context.initialize({
  API_KEY: process.env.SHOPIFY_API_KEY,
  API_SECRET_KEY: process.env.SHOPIFY_API_SECRET,
  SCOPES: process.env.SCOPES.split(","),
  HOST_NAME: process.env.HOST.replace(/https:\/\/|\/$/g, ""),
  API_VERSION: ApiVersion.October21,
  IS_EMBEDDED_APP: true,
  // This should be replaced with your preferred storage strategy
  // SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
  SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
    sessionStorage.storeCallback.bind(sessionStorage),
    sessionStorage.loadCallback.bind(sessionStorage),
    sessionStorage.deleteCallback.bind(sessionStorage)
  ),
});

// Storing the currently active shops in memory will force them to re-login when your server restarts. You should
// persist this object in your app.
let ACTIVE_SHOPIFY_SHOPS = {};

const shopHasSavedSettings = async (shop, accessToken) => {
  const client = new Shopify.Clients.Graphql(shop, accessToken);
  const r = await client.query({
    data: {
      query: `{
                shop {
                  id
                  metafield(namespace: "my-app-name-key", key: "settings") {
                    value
                  }
                }
              }`,
    },
  });

  return has(r, "data.shop.metafield.value");
};

app.prepare().then(async () => {
  const server = new Koa();
  const router = new Router();
  const active_shops = await sessionStorage.client.get("ACTIVE_SHOPIFY_SHOPS");

  if (active_shops) {
    ACTIVE_SHOPIFY_SHOPS = { ...JSON.parse(active_shops) };
  }

  const setContentSecurityHeader = (ctx, next) => {
    // Cookie is set after auth
    if (ctx.cookies.get("shopOrigin")) {
      return helmet.contentSecurityPolicy({
        directives: {
          defaultSrc: helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
          frameAncestors: [
            `https://${ctx.cookies.get("shopOrigin")}`,
            "https://admin.shopify.com",
          ],
        },
      })(ctx, next);
    } else {
      // Before auth => no cookie set...
      return helmet.contentSecurityPolicy({
        directives: {
          defaultSrc: helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc,
          frameAncestors: [
            `https://${ctx.query.shop}`,
            "https://admin.shopify.com",
          ],
        },
      })(ctx, next);
    }
  };

  server.use(setContentSecurityHeader);
  server.keys = [Shopify.Context.API_SECRET_KEY];

  server.use(
    createShopifyAuth({
      accessMode: "offline",
      prefix: "/install",
      async afterAuth(ctx) {
        const { shop, accessToken, scope } = ctx.state.shopify;
        const host = ctx.query.host;
        // Save shop access_token for webhook consumption
        await saveAccessToken(shop, accessToken);
        ctx.redirect(`/?shop=${shop}&host=${host}`);
      },
    })
  );

  server.use(
    createShopifyAuth({
      async afterAuth(ctx) {
        // Access token and shop available in ctx.state.shopify
        const { shop, accessToken, scope } = ctx.state.shopify;
        // set shopOrigin cookie, so it can be used for click jacking header
        ctx.cookies.set("shopOrigin", shop, {
          httpOnly: false,
          secure: true,
          sameSite: "none",
        });

        const host = ctx.query.host;
        ACTIVE_SHOPIFY_SHOPS[shop] = scope;
        const r = await sessionStorage.client.set(
          "ACTIVE_SHOPIFY_SHOPS",
          JSON.stringify(ACTIVE_SHOPIFY_SHOPS)
        );

        const response = await Shopify.Webhooks.Registry.register({
          shop,
          accessToken,
          path: "/webhooks",
          topic: "APP_UNINSTALLED",
          webhookHandler: async (topic, shop, body) => {
            console.log("APP_UNINSTALLED triggered");
            delete ACTIVE_SHOPIFY_SHOPS[shop];
            const r = await sessionStorage.client.set(
              "ACTIVE_SHOPIFY_SHOPS",
              JSON.stringify(ACTIVE_SHOPIFY_SHOPS)
            );
          },
        });

        if (!response.APP_UNINSTALLED.success) {
          console.log(
            `Failed to register APP_UNINSTALLED webhook: ${response.APP_UNINSTALLED.result}`
          );
        }

        const hasSettings = await shopHasSavedSettings(shop, accessToken);

        if (!hasSettings) {
          return ctx.redirect(`/install/auth?shop=${shop}`);
        } else {
          // Redirect to app with shop parameter upon auth
          ctx.redirect(`/?shop=${shop}&host=${host}`);
        }
      },
    })
  );

  const handleRequest = async (ctx) => {
    await handle(ctx.req, ctx.res);
    ctx.respond = false;
    ctx.res.statusCode = 200;
  };

  router.post("/webhooks", async (ctx) => {
    try {
      await Shopify.Webhooks.Registry.process(ctx.req, ctx.res);
      console.log(`Webhook processed, returned status code 200`);
    } catch (error) {
      console.log(`Failed to process webhook: ${error}`);
    }
  });

  router.post(
    "/graphql",
    verifyRequest({ returnHeader: true }),
    async (ctx, next) => {
      try {
        // * This request fails when the user install the plugin
        const session = await Shopify.Utils.loadCurrentSession(
          ctx.req,
          ctx.res
        );
        const client = new Shopify.Clients.Graphql(
          session.shop,
          session.accessToken
        );
        const response = await client.query({ data: ctx.request.body });
        ctx.body = response.body;
        ctx.status = 200;
      } catch (error) {
        await Shopify.Utils.graphqlProxy(ctx.req, ctx.res);
      }
    }
  );

  router.get("(/_next/static/.*)", handleRequest); // Static content is clear
  router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear

  router.get("(.*)", async (ctx) => {
    const shop = ctx.query.shop;

    try {
      const data = await getShopAccessToken(shop);
      const accessToken = data.access_token;
      await shopHasSavedSettings(shop, accessToken);
    } catch (error) {
      console.log(error);
      if (error.code == 401) {
        delete ACTIVE_SHOPIFY_SHOPS[shop];
        const r = await sessionStorage.client.set(
          "ACTIVE_SHOPIFY_SHOPS",
          JSON.stringify(ACTIVE_SHOPIFY_SHOPS)
        );
      }
    }

    // This shop hasn't been seen yet, go through OAuth to create a session
    if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
        return ctx.redirect(`/auth?shop=${shop}`);
    } else {
      console.log("handleRequest");
      await handleRequest(ctx);
    }
  });

  server.use(koaBody());

  server.use(router.allowedMethods());
  server.use(router.routes());

  // Te lo llevo custom api routes
  server.use(apiRoutes.routes());
  server.use(apiRoutes.allowedMethods());

  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});
  1. Start the local server
  2. Install the plugin
  3. Open the plugin, the plugin will load successfully, the session was created.
  4. Connect to the redis local instance redis-cli -h localhost -p 6379
  5. List all the keys inside redis: keys *
  6. Get the data of your session: get [YOUR_SESSION_KEY]
  7. Delete your session using del [YOUR_SESSION_KEY] or modify the expiration date using set [YOUR_SESSION_KEY] [PAYLOAD] where payload is the modified content of get [YOUR_SESSION_KEY].
  8. Reload the app. The app will start failing for each subsequent request because the user was not redirected to the authentication workflow.

Also here is our package.json file:

{
  "name": "shopify-app-node",
  "version": "1.0.0",
  "description": "Shopify's node app for CLI tool",
  "scripts": {
    "test": "jest",
    "dev": "cross-env NODE_ENV=development nodemon ./server/index.js --watch ./server/index.js",
    "build": "NEXT_TELEMETRY_DISABLED=1 next build",
    "start": "cross-env NODE_ENV=production node ./server/index.js"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/Shopify/shopify-app-node.git"
  },
  "author": "Shopify Inc.",
  "license": "MIT",
  "bugs": {
    "url": "https://github.com/shopify/shopify-app-node/issues"
  },
  "dependencies": {
    "@babel/core": "7.12.10",
    "@babel/polyfill": "^7.6.0",
    "@babel/preset-env": "^7.12.11",
    "@babel/register": "^7.12.10",
    "@shopify/app-bridge-react": "^2.0.2",
    "@shopify/app-bridge-utils": "^2.0.2",
    "@shopify/koa-shopify-auth": "^5.0.3",
    "@shopify/polaris": "7.4.0",
    "apollo-boost": "^0.4.9",
    "axios": "^0.24.0",
    "cross-env": "^7.0.3",
    "dotenv": "^8.2.0",
    "graphql": "^14.5.8",
    "isomorphic-fetch": "^3.0.0",
    "koa": "^2.13.1",
    "koa-body": "^4.2.0",
    "koa-helmet": "^6.1.0",
    "koa-router": "^10.0.0",
    "koa-session": "^6.1.0",
    "lodash": "^4.17.21",
    "moment": "^2.29.1",
    "next": "^10.0.4",
    "next-env": "^1.1.0",
    "node-fetch": "^2.6.1",
    "react": "^16.10.1",
    "react-apollo": "^3.1.3",
    "react-dom": "^16.10.1",
    "redis": "^4.0.6",
    "webpack": "^4.44.1"
  },
  "devDependencies": {
    "@babel/plugin-transform-runtime": "^7.12.10",
    "@babel/preset-stage-3": "^7.0.0",
    "autoprefixer": "^10.4.0",
    "babel-jest": "26.6.3",
    "babel-register": "^6.26.0",
    "enzyme": "3.11.0",
    "enzyme-adapter-react-16": "1.15.6",
    "husky": "^4.3.6",
    "jest": "26.6.3",
    "lint-staged": "^10.5.4",
    "nodemon": "^2.0.7",
    "postcss": "^8.4.4",
    "prettier": "2.2.1",
    "react-addons-test-utils": "15.6.2",
    "react-test-renderer": "16.14.0",
    "tailwindcss": "^3.0.1"
  },
  "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{js,css,json,md}": [
      "prettier --write"
    ]
  },
  "engines": {
    "node": "16.x"
  }
}
ertugrul59 commented 2 years ago

I have the same issue happening after v4.0.0

towfiqi commented 2 years ago

Same Issue!

towfiqi commented 2 years ago

@ertugrul59 @dynamiclab-cl Were you guys able to fix this issue?

ertugrul59 commented 2 years ago

I have moved from koa auth package to express, it takes more time but much efficient way, it fixed my issue.

Link: https://github.com/Shopify/shopify-app-template-node

github-actions[bot] commented 1 year ago

Note that this repo is no longer maintained and this issue will not be reviewed. Prefer the official JavaScript API library. If you still want to use Koa, see simple-koa-shopify-auth for a potential community solution.