Invalid OAuth Callback using both online and offline tokens

EugeneCXY
Excursionist
16 3 4

Hey, 

I've been stuck on this error for some time "Invalid OAuth callback". Took the day to try to debug it. I am using both offline and online tokens

EugeneCXY_0-1630391097968.png

The outcome of my debugging (Custom Session):

With custom session storage I found out that... it the state that is mismatched because of storeacallback called 2-4(inconsistently) times when first installed

here is an example if it getting called twice when I click test on development on Shopify partner (URL: /install/auth?shop=angiedesignsite.myshopify.com ) 

Console logged:

┃ -------------------- start storecallback-----------------------
┃ #####store session##### id={"id":"offline_angiedesignsite.myshopify.com","shop":"angiedesignsite.myshopify.com","state":"657591174910252","isOnline":false}
┃ -------------------- end storecallback-----------------------

┃ -------------------- start storecallback-----------------------
┃ #####store session##### id={"id":"offline_angiedesignsite.myshopify.com","shop":"angiedesignsite.myshopify.com","state":"012283685075465","isOnline":false}
┃ -------------------- end storecallback-----------------------


So this happens before adding app, my storecallback is called twice saving the correct state first (657591174910252) and then the wrong state (012283685075465)

After clicking add app the confirmation URL has the state (657591174910252) and it tries to loadcallback the first state "657591174910252" which fails because it saved the other state.

This only happens on the first install link clicked. After the first link has been clicked, It will not throw the oauth error. Meaning the state is called once after I have clicked on the install link 

 

The outcome of debugging (Memory session):

Using memory session storage I get back map is undefined and throws the same error "Invalid Oauth Callback". 

"TypeError: Cannot read property 'map' of undefined at new AuthScopes"

This is probably due to the storecallback inside memory being called multiple times

This only happens on the first install link clicked. After the first link has been clicked, It will not throw the oauth error. Meaning the state is called once after I have clicked on the install link 

 

I feel like I'm missing something very small here. My redirect URL is set to both /auth and install/auth 

here's my on server code: 

 

 

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";


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

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.April21,
  IS_EMBEDDED_APP: true,

  SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
  // SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
  //   storeCallback,
  //   loadCallback,
  //   deleteCallback
  // ),
});
const ACTIVE_SHOPIFY_SHOPS = {};
app.prepare().then(async () => {
  console.log("app prepare");
  const server = new Koa();
  const router = new Router();
  server.keys = [Shopify.Context.API_SECRET_KEY];

  // Using both "online" and "offline" Shopify API access modes

  server.use(
    createShopifyAuth({
      accessMode: "offline",
      prefix: "/install",
      async afterAuth(ctx) {
        // Access token and shop available in ctx.state.shopify
        const { shop, accessToken, scope } = ctx.state.shopify;

        ACTIVE_SHOPIFY_SHOPS[shop] = scope;

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

        if (!response.success) {
          console.log(
            `Failed to register APP_UNINSTALLED webhook: ${response.result}`
          );
        }
        ctx.redirect(`/auth?shop=${shop}`);
      },
    })
  );

  server.use(
    createShopifyAuth({
      async afterAuth(ctx) {
        // Online access mode access token and shop available in ctx.state.shopify
        const { shop } = ctx.state.shopify;

        ctx.redirect(
          `https://${shop}/admin/apps/${process.env.SHOPIFY_API_KEY}`
        );
      },
    })
  );

  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;

    if (ACTIVE_SHOPIFY_SHOPS[shop] === undefined) {
      console.log("redirecting to auth?shop=", shop);
      ctx.redirect(`/install/auth?shop=${shop}`);
    } else {
      // const session = await Shopify.Utils.loadCurrentSession(ctx.req, ctx.res);
      await handleRequest(ctx);
    }
  });

  //create special route for install

  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) => {
      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({
      authRoute: "/auth",
      fallbackRoute: "/install/auth",
    }),
    handleRequest
  ); //Everything else must have sessions

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

 

 

 

Replies 5 (5)

DavidT
Shopify Partner
23 1 6

Have you solved this yet? I'm getting the same issue with the session tokens storage callback being called multiple times, and then trying to load the first one that no longer exists. So my app fails to install. I would like to only have 1 record for each shop.

QuickEdit - Bulk Product Edit - Quick and easy bulk editor for products and variants.
SafeShip - Address Validator - International address validation and PO box blocking at checkout for Shopify Plus merchants.
EugeneCXY
Excursionist
16 3 4

Its an error on shopify's end, I think theyre looking into it. For now use incognito to do your developments, that should work

DavidT
Shopify Partner
23 1 6

So I think I might have fixed my issue. The problem was I wasn't returning a promise from the storage callback function (you also need this for the others). Before, the error would only happen occasionally, but 1 in 3 times at least. Now I've tested it several times and it hasn't had an error yet, so I think that was the issue.

 

Here's my working storage function:

export async function storeSession(session) {
  console.debug(`Storing session: '${session.id}'...`);
  let data = session; // Create a new object, so we can modify it
  data.onlineAccessInfo = JSON.stringify(session.onlineAccessInfo); // Convert object to JSON
  data.shop = data.shop.replace(/\/$/, ""); // Remove any trailing slash

  let domain_id = "";
  if (data.id.includes(data.shop)) {
    domain_id = data.id;
  }

  // Execute SQL query
  let query = new Promise((resolve, reject) => {
    db.run(
      `
      INSERT INTO shops
        (shop_url, session_id, domain_id, access_token, state, is_online, online_access_info, scope)
      VALUES
        (?, ?, ?, ?, ?, ?, ?, ?)
      ON CONFLICT(shop_url) DO UPDATE SET
        session_id=?, domain_id=?, access_token=?, state=?, is_online=?, online_access_info=?, scope=?
      `,
      [
        data.shop, // The shop url
        data.id,
        domain_id,
        data.accessToken,
        data.state,
        data.isOnline, // Is an online token
        data.onlineAccessInfo, // Token info for online token
        data.scope,
        // On Conflict Update params
        data.id,
        domain_id,
        data.accessToken,
        data.state,
        data.isOnline,
        data.onlineAccessInfo,
        data.scope,
      ],
      // Process result
      function (err) {
        err ? reject(err) : resolve(); // if error, reject promise; otherwise resolve
      }
    );
  });

// You need to return a Promise like this, using .then() .catch() syntax.
  return await query
    .then(() => {
      // resolved
      return true;
    })
    .catch((err) => {
      // rejected
      console.error(`Failed to store session '${data.id}'! SQL query failed: ` + err.message);
      return false;
    });
}

 

QuickEdit - Bulk Product Edit - Quick and easy bulk editor for products and variants.
SafeShip - Address Validator - International address validation and PO box blocking at checkout for Shopify Plus merchants.
AlexBystrytskyi
Visitor
1 0 0

Thanks. That`s help a lot! 

DavidT
Shopify Partner
23 1 6

Okay, now I ran into another issue caused because of this. It still loads a new session token multiple times, and then the later one overwrites the first one. I'm trying to use apollo to make graphql requests, but it's using a previous session token that gets overwritten in the database, so it fails to make the request because the session is invalid.

QuickEdit - Bulk Product Edit - Quick and easy bulk editor for products and variants.
SafeShip - Address Validator - International address validation and PO box blocking at checkout for Shopify Plus merchants.