Invalid OAuth callback after install, but page refresh solves it. Why?

I’m using…

  • customSessionStorage with PostgreSQL
  • offline tokens, I think… I removed the default things that specified online tokens:
    in auth.js:
app.get("use-online-tokens")​

in /server/index.js:

app.set("use-online-tokens", USE_ONLINE_TOKENS);

There are two issues happening right now:

  • After clicking install, I’m directed to my app url outside of shopify (not embedded) and quickly get an Invalid OAuth callback error. If I refresh that page, the authorization proceeds and I’m directed to my app embedded in Shopify admin.
  • …but then it keeps looping the redirect out of shopify, then back to my embedded app, then back out to auth again, then back into embedded app.

My impression is that I’ve only implemented customSessionStorage halfway… my auth middleware (/middleware/auth.js) is still basically the default generated by the Shopify CLI when creating a node app skeleton.

Here’s my auth.js:

import { Shopify } from "@shopify/shopify-api";

import topLevelAuthRedirect from "../helpers/top-level-auth-redirect.js";

export default function applyAuthMiddleware(app) {
  app.get("/auth", async (req, res) => {
    if (!req.signedCookies[app.get("top-level-oauth-cookie")]) {
      return res.redirect(
        `/auth/toplevel?${new URLSearchParams(req.query).toString()}`
      );
    }

    const redirectUrl = await Shopify.Auth.beginAuth(
      req,
      res,
      req.query.shop,
      "/auth/callback",
      app.get("use-offline-tokens")
    );

    res.redirect(redirectUrl);
  });

  app.get("/auth/toplevel", (req, res) => {
    res.cookie(app.get("top-level-oauth-cookie"), "1", {
      signed: true,
      httpOnly: true,
      sameSite: "strict",
    });

    res.set("Content-Type", "text/html");

    res.send(
      topLevelAuthRedirect({
        apiKey: Shopify.Context.API_KEY,
        hostName: Shopify.Context.HOST_NAME,
        host: req.query.host,
        query: req.query,
      })
    );
  });

  app.get("/auth/callback", async (req, res) => {
    try {
      const session = await Shopify.Auth.validateAuthCallback(
        req,
        res,
        req.query
      );

      const host = req.query.host;
      app.set(
        "active-shopify-shops",
        Object.assign(app.get("active-shopify-shops"), {
          [session.shop]: session.scope,
        })
      );

      const response = await Shopify.Webhooks.Registry.register({
        shop: session.shop,
        accessToken: session.accessToken,
        topic: "APP_UNINSTALLED",
        path: "/webhooks",
      });

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

      // Redirect to app with shop parameter upon auth
      res.redirect(`/?shop=${session.shop}&host=${host}`);
    } catch (e) {
      switch (true) {
        case e instanceof Shopify.Errors.InvalidOAuthError:
          res.status(400);
          res.send(e.message);
          break;
        case e instanceof Shopify.Errors.CookieNotFound:
        case e instanceof Shopify.Errors.SessionNotFound:
          // This is likely because the OAuth session cookie expired before the merchant approved the request
          res.redirect(`/auth?shop=${req.query.shop}`);
          break;
        default:
          res.status(500);
          res.send(e.message);
          break;
      }
    }
  });
}

App.jsx is still the default generated for Shopify CLI node app.

Here’s my custom-session-storage.js:

import { Session } from '@shopify/shopify-api/dist/auth/session/index.js';
import db from '../models/index.js';

class CustomSessionStorage {
  storeCallback = async (session) => {
    try {
      // Inside our try, we use the `setAsync` method to save our session.
      // This method returns a boolean (true if successful, false if not)
      const [instance] = await db.Session.upsert({
        sessionId: session.id,
        sessionData: JSON.stringify(session),
      });

      return Boolean(instance);
    } catch (err) {
      // throw errors, and handle them gracefully in your application
      throw new Error(err);
    }
  };

  loadCallback = async (id) => {
    try {
      // Inside our try, we use `getAsync` to access the method by id
      // If we receive data back, we parse and return it
      // If not, we return `undefined`
      let instance = await db.Session.findOne({
        where: {
          sessionId: id,
        },
      });
      if (instance) {
        const sessionObj = JSON.parse(instance.sessionData);
        // console.log('sessionObj: ', sessionObj);
        return Session.cloneSession(sessionObj, sessionObj.sessionId);
        // return JSON.parse(instance.sessionData);
      } else {
        return undefined;
      }
    } catch (err) {
      throw new Error(err);
    }
  };

  /*
    The deleteCallback takes in the id, and uses the redis `del` method to delete it from the store
    If the session can be deleted, return true
    Otherwise, return false
  */
  deleteCallback = async (id) => {
    try {
      // Inside our try, we use the `delAsync` method to delete our session.
      // This method returns a boolean (true if successful, false if not)
      const numDestroyed = await db.Session.destroy({
        where: {
          sessionId: id,
        },
      });
      return numDestroyed > 0;
    } catch (err) {
      throw new Error(err);
    }
  };
}

// Export the class
export default CustomSessionStorage;

I wish I could close my own thread when there are no replies.
I have learned much since then and I now feel this question and the information presented is partially not relevant to my issue, and it could mislead others on the wrong path to solving their own issue if they are beginner level.

My issue seems to be caused by having much of my auth.js code incorrect considering what I’m trying to accomplish, and it’s causing users to be redirected back to /admin/oauth/authorize after they were already authorized previously.

3 Likes

Hi @buildpath

I have hope to help me to solve this issue. It’s like yours that’s why i am wondering how did you resolved it??

I haven’t changed auth.js and it still as it generated from node cli app.

So the issue started when I have implemented a Redis for CustomSessionStorage I got this error and really can’t solve. I have checked customsessionstorage, if there is overwrite but no overwrites for session data stored by storeCallback , loadCallback all the same states and timestamp. Although hmac is same but it never get validated at all. I have explained this issue in detail in this issue https://github.com/Shopify/shopify-api-node/issues/308#issuecomment-1204710174

Any help any hint is highly appreciated

Thanks & Regards