koa-shopify-auth icon indicating copy to clipboard operation
koa-shopify-auth copied to clipboard

OAuth process cannot complete in embedded app with "offline" access mode and CustomSessionStorage

Open bahadorify opened this issue 3 years ago • 1 comments

Issue summary

Embedded app authentication works fine with both online and offline access modes and MemorySessionStorage. However when switching to a CustomSessionStorage with Redis similar to the one explained here, while using offline access mode, it seems like an undefined session is being loaded during installation, which throws an error.

Expected behavior

The app should successfully authenticate as it does with MemorySessionStorage, when switching to CustomSessionStorage.

Actual behavior

The app first loads the following session

{
   id: 'offline_shopname.myshopify.com',
   shop: 'shopname.myshopify.com',
   state: '712381882386284',
   isOnline: 'false'
}

and right after this, the following error is thrown

InternalServerError: Cannot read property 'id' of undefined
       at Object.throw (/Users/node_modules/koa/lib/context.js:97:11)
       at /Users/node_modules/@shopify/koa-shopify-auth/dist/src/auth/index.js:100:42
       at step (/Users/node_modules/tslib/tslib.js:133:27)
       at Object.throw (/Users/node_modules/tslib/tslib.js:114:57)
       at rejected (/Users/node_modules/tslib/tslib.js:105:69)
       at processTicksAndRejections (internal/process/task_queues.js:93:5)

It seems like loadCallback is trying to get the id of an undefined session here which causes the error.

Steps to reproduce the problem

Create a public Shopify app with the following files (I am using ioredis library here): server.js

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 logger from "koa-logger";
import cors from "@koa/cors";
import next from "next";
import Router from "koa-router";

import {
  deleteCallback,
  loadCallback,
  storeCallback,
} from "./sessionHandler";
import {
  addActiveShop,
  deleteActiveShop,
  getActiveShops,
} from "./activeShopsHandler";
import { postAccessToken } from "./db/session";

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();

// custom session storage
const sessionStorage = new Shopify.Session.CustomSessionStorage(
  storeCallback,
  loadCallback,
  deleteCallback
);

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:\/\//, ""),
  API_VERSION: ApiVersion.October20,
  IS_EMBEDDED_APP: true,
  // This should be replaced with your preferred storage strategy
  // SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
  SESSION_STORAGE: 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.

app.prepare().then(async () => {
  const server = new Koa();
  server.use(cors());
  server.use(logger());
  const router = new Router();
  server.keys = [Shopify.Context.API_SECRET_KEY];
  const ACTIVE_SHOPIFY_SHOPS = await getActiveShops();
  server.use(
    createShopifyAuth({
      accessMode: "offline",
      async afterAuth(ctx) {
        try {
          // Access token and shop available in ctx.state.shopify
          const { shop, accessToken, scope } = ctx.state.shopify;

          // write accessToken to db
          await postAccessToken(shop, accessToken);

          ACTIVE_SHOPIFY_SHOPS[shop] = scope;
          await addActiveShop(shop);

          const response = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: "/webhooks",
            topic: "APP_UNINSTALLED",
            webhookHandler: async (topic, shop, body) => {
              delete ACTIVE_SHOPIFY_SHOPS[shop];
            },
          });

          const responseTransaction = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: `/transactions/order_created/${shop}`,
            topic: "ORDERS_CREATE",
          });

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

          // Redirect to app with shop parameter upon auth
          ctx.redirect(`/?shop=${shop}`);
        } catch (error) {
          console.log("Installation error", error);
        }
      },
    })
  );

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

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

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

  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, accessMode: "offline" }),
    async (ctx, next) => {
      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("(.*)", verifyRequest({ accessMode: "offline" }), handleRequest); // Everything else must have sessions

  server.use(router.allowedMethods());
  server.use(router.routes());
  server.listen(port, () => {
    console.log(`> Ready on http://localhost:${port}`);
  });
});

sessionHandler.js

import { Session } from "@shopify/shopify-api/dist/auth/session";
import redis from "./db";

export async function storeCallback(session) {
  try {
    const { id } = session;
    const res = await redis.hmset( id, { ...session });
    console.log("Wrote session to REDIS", id);
    return res;
  } catch (err) {
    throw new Error(err);
  }
}

export async function loadCallback(id) {
  try {
    console.log("loadCallback", id);
    const session = await redis.hgetall(id);
    const loadedSession = Object.assign(new Session(), { ...session });
    if (session) {
      console.log("Found session", id);
      console.log("And here it is", loadedSession);
      return loadedSession;
    } else {
      console.log("Session NOT FOUND", id);
      return undefined;
    }
  } catch (err) {
    throw new Error(err);
  }
}

export async function deleteCallback(id) {
  try {
    console.log("Deleting session", id);
    return await redis.del(id);
  } catch (err) {
    throw new Error(err);
  }
}

@paulomarg I would appreciate an answer, since this issue has blocked my development. Thank you.

bahadorify avatar Sep 07 '21 15:09 bahadorify

Hey. @bahadorify I had problems with a similar error. I was able to solve it in CustomSessionStorage mode by including a Promise of the storeCallback function. The storeCallback and loadCallback function need a Promise.

You may also find my shopify auth demo script helpful. Here Which solves the offline and online auth process in CustomSessionStorage Mode at the same time.

fotimo avatar Nov 26 '21 14:11 fotimo

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.

github-actions[bot] avatar Jan 30 '23 20:01 github-actions[bot]