koa-shopify-auth
koa-shopify-auth copied to clipboard
OAuth process cannot complete in embedded app with "offline" access mode and CustomSessionStorage
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.
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.
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.