Shopify / koa-shopify-auth

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

Session is not updated when logging out and logging in again and the requests to backend stop working #90

Closed ilugobayo closed 3 years ago

ilugobayo commented 3 years ago

Issue summary

The session isn't updated after logging out and logging in again to the admin dashboard and the app pages load but don't show anything since the data required isn't retrieved because the requests to the backend fail.

Expected behavior

After logging out and logging in to the admin dashboard and open the app from the apps menu, the session should be updated and the the app should load after retrieving the data and display any of the pages as follows:

Screenshot from 2021-04-07 05-02-45

Actual behavior

The app loads but the page shows a custom error banner which means that the retrieval of the data from the backend failed, one of the banners is as follows:

Untitled

Directly going to one of the other pages that are not index.js ('/') sends me to the auth process again, adds a new temporary session to the database and updates the actual session (in this case I also update the accessToken because I'm currently testing with online mode).

Untitled2

I'm not really sure if I'm missing something in my code, I've been reading a lot of documentation and seeing several examples trying to figure out how to have the new authentication completely implemented since it's the last thing the app review team told me to fix.

At this point I already have the CustomSessionStorage implementation and the custom API routes to my server with authenticatedFetch. I've read that you should check several things in the server which has me confused:

My code is currently as follows:

server.js

import "@babel/polyfill";
import dotenv from "dotenv";
import "isomorphic-fetch";
import shopifyAuth, { 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 jwt, { verify } from "jsonwebtoken";

const getSubscriptionUrl = require('./getSubscriptionUrl');
const bodyParser = require('koa-bodyparser');
const fs = require('fs');
const stringInject = require('stringinject');

const { receiveWebhook } = require('@shopify/koa-shopify-webhooks');

// helpers
const dashboardGetHelper = require('../getHelpers/dashboardGetHelper');
const storefrontSetHelper = require('../setHelpers/storefrontSetHelper');
const dashboardActionHelper = require('../actionHelpers/dashboardActionHelper');
const serverGetHelper = require('../getHelpers/serverGetHelper');
const serverSetHelper = require('../setHelpers/serverSetHelper');
const serverActionHelper = require('../actionHelpers/serverActionHelper');
const storefrontGetHelper = require('../getHelpers/storefrontGetHelper');
const storefrontActionHelper = require('../actionHelpers/storefrontActionHelper');

// database helpers
const dbSetHelper = require('../database/dbSetHelper');
const dbGetHelper = require('../database/dbGetHelper');
import SessionHandler from './handlers/sessionHandler';

// log messages in their own file
const logMessages = require('../util/logMessages');

// logger
const logger = require('../util/logger');

dotenv.config();
const port = parseInt(process.env.PORT, 10) || 3000;
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
const sessionStorage = new SessionHandler();

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.January21,
  IS_EMBEDDED_APP: true,
  IS_PRIVATE_APP: false,
  // This should be replaced with your preferred storage strategy
  //SESSION_STORAGE: new Shopify.Session.MemorySessionStorage(),
  SESSION_STORAGE: new Shopify.Session.CustomSessionStorage(
    sessionStorage.storeCallback,
    sessionStorage.loadCallback,
    sessionStorage.deleteCallback
  ),
});

const ACTIVE_SHOPIFY_SHOPS = {};

app.prepare().then(() => {
  const server = new Koa();
  const router = new Router();
  server.use(bodyParser());
  server.keys = [Shopify.Context.API_SECRET_KEY];

  server.use(
    shopifyAuth({
      //accessMode: 'offline',
      async afterAuth(ctx) {
        const { shop, accessToken, scope } = ctx.state.shopify;

        const installedStatus = await dbGetHelper.getInstalledStatus(shop);

        // checks if the app wasn't installed already
        if (installedStatus !== undefined && installedStatus !== null && (installedStatus === 0 || installedStatus === 2)) {
          await dbSetHelper.saveAccessTokenForShop(shop, scope, accessToken);
          await dbSetHelper.createBackfillDataRow(shop);
          await dbSetHelper.saveShopLanguage(shop, 'en');
          await serverSetHelper.createActiveMode(shop, accessToken);
          await serverSetHelper.createApiMode(shop, accessToken);
          await serverSetHelper.createBackfillMode(shop, accessToken);
          await serverSetHelper.createApiKeys(shop, accessToken);

          ACTIVE_SHOPIFY_SHOPS[shop] = scope;

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

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

          const responseCreate = await Shopify.Webhooks.Registry.register({
            shop,
            accessToken,
            path: "/webhooks/orders-create",
            topic: "ORDERS_CREATE",
          });

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

          const scriptTagStatus = await serverGetHelper.getScriptTags(shop, accessToken, `${process.env.HOST}/fingerprint.js`);

          // checks if there is already a script tag with the same src
          if (scriptTagStatus !== undefined && scriptTagStatus !== null && scriptTagStatus === 0) {
            const scriptTagBody = {
              'script_tag': {
                'event': 'onload',
                'src': `${process.env.HOST}/fingerprint.js`,
                'display_scope': 'online_store'
              }
            };

            fetch(`https://${shop}/admin/api/${process.env.API_VERSION}/script_tags.json`, {
              method: 'POST',
              credentials: 'include',
              body: JSON.stringify(scriptTagBody),
              headers: {
                'Content-Type': 'application/json',
                'X-Shopify-Access-Token': accessToken,
                'Accept': 'application/json'
              },
            });
          } else if (scriptTagStatus === undefined || scriptTagStatus === null) {
            logger.error(stringInject.default(logMessages.serverScriptTagStatusUndefined, [shop]));
          }
          const returnUrl = `https://${Shopify.Context.HOST_NAME}?shop=${shop}`;
          const subscriptionUrl = await getSubscriptionUrl(accessToken, shop, returnUrl);
          ctx.redirect(subscriptionUrl);
        } else if (installedStatus !== undefined && installedStatus !== null && installedStatus === 1) {
          await dbSetHelper.saveAccessTokenForShop(shop, scope, accessToken);
          ACTIVE_SHOPIFY_SHOPS[shop] = scope;
          ctx.redirect(`/?shop=${shop}`);
        } else if (installedStatus === undefined || installedStatus === null) {
          logger.error(stringInject.default(logMessages.serverInstalledStatusUndefined, [shop]));
        }
      },
    }),
  );

  const webhook = receiveWebhook({ secret: process.env.SHOPIFY_API_SECRET });

  router.get("/", async (ctx) => {
    if (ctx.url.includes("?charge_id=")) {
      await serverActionHelper.handlePaidStatus(ctx.query.shop);
    }
    const shop = ctx.query.shop;

    const installedStatus = await dbGetHelper.getInstalledStatus(shop);

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

  router.get('/api/bayonet/get-paid-status', verifyRequest(), async (ctx) => {
    const sessionToken = (ctx.headers.authorization).replace('Bearer ', '');
    var payloadToken = jwt.verify(sessionToken, Shopify.Context.API_SECRET_KEY, { algorithms: ['HS256'] });
    const shopDomain = payloadToken['dest'].substring(payloadToken['dest'].indexOf('https://') + 8, payloadToken['dest'].length);

    try {
      const paidStatus = await dbGetHelper.getPaidStatus(shopDomain);

      if (paidStatus !== undefined && paidStatus !== null) {
        console.log('we gonna return');
        console.log(paidStatus);
        ctx.body = {
          data: paidStatus
        };
      } else {
        ctx.body = {
          data: null
        };
        logger.error(stringInject.default(logMessages.serverPaidStatusUndefined, [shopDomain]));
      }
    } catch (err) {
      ctx.body = {
        data: null
      };
      logger.error(stringInject.default(logMessages.serverGetPaidStatusCatch, [shopDomain, err]))
    }
  });

  router.get('/api/bayonet/get-current-language', verifyRequest(), async (ctx) => {
    const sessionToken = (ctx.headers.authorization).replace('Bearer ', '');
    var payloadToken = jwt.verify(sessionToken, Shopify.Context.API_SECRET_KEY, { algorithms: ['HS256'] });
    const shopDomain = payloadToken['dest'].substring(payloadToken['dest'].indexOf('https://') + 8, payloadToken['dest'].length);

    try {
      const currentLanguage = await dbGetHelper.getShopCurrentLanguage(shopDomain);

      if (currentLanguage) {
        ctx.body = {
          data: currentLanguage
        };
      } else {
        ctx.body = {
          data: null
        };
        logger.error(stringInject.default(logMessages.serverGetLanguageUndefined, [shopDomain]));
      }
    } catch (err) {
      ctx.body = {
        data: null
      };
      logger.error(stringInject.default(logMessages.serverGetLanguageCatch, [shopDomain, err]));
    }
  });

  router.get('/api/bayonet/get-app-metadata', verifyRequest(), async (ctx) => {
    const sessionToken = (ctx.headers.authorization).replace('Bearer ', '');
    var payloadToken = jwt.verify(sessionToken, Shopify.Context.API_SECRET_KEY, { algorithms: ['HS256'] });
    const shopDomain = payloadToken['dest'].substring(payloadToken['dest'].indexOf('https://') + 8, payloadToken['dest'].length);

    try {
      const appMetadata = await dashboardGetHelper.getAppMetadata(shopDomain);
      if (appMetadata && appMetadata.length > 0) {
        ctx.body = {
          data: appMetadata
        };
      } else if (!appMetadata || appMetadata.length === 0) {
        ctx.body = {
          data: null
        };
        logger.error(stringInject.default(logMessages.serverGetAppMetadataUndefined, [shopDomain]));
      }
    } catch (err) {
      ctx.body = {
        data: null
      };
      logger.error(stringInject.default(logMessages.serverGetAppMetadataCatch, [shopDomain, err]));
    }
  });

  router.get('/api/bayonet/get-emailage-status', verifyRequest(), async (ctx) => {
    const sessionToken = (ctx.headers.authorization).replace('Bearer ', '');
    var payloadToken = jwt.verify(sessionToken, Shopify.Context.API_SECRET_KEY, { algorithms: ['HS256'] });
    const shopDomain = payloadToken['dest'].substring(payloadToken['dest'].indexOf('https://') + 8, payloadToken['dest'].length);

    try {
      const emailageStatus = await dbGetHelper.getEmailageStatus(shopDomain);

      if (emailageStatus !== undefined && emailageStatus !== null) {
        ctx.body = {
          data: emailageStatus
        };
      } else {
        ctx.body = {
          data: null
        };
        logger.error(stringInject.default(logMessages.serverGetEmailageStatusUndefined, [shopDomain]));
      }
    } catch (err) {
      ctx.body = {
        data: null
      };
      logger.error(stringInject.default(logMessages.serverGetEmailageStatusCatch, [shopDomain, err]));
    }
  });

  router.post("/webhooks/uninstalled", async (ctx) => {
    const shopDomain = ctx.request.header['x-shopify-shop-domain'];

    try {
      dbSetHelper.setUninstalledStatus(shopDomain);

      ctx.response.status = 200;
      ctx.response.message = 'success';
      ctx.body = {
        status: 'success'
      };
    } catch (err) {
      logger.error(stringInject.default(logMessages.serverAppUninstalledCatch, [shopDomain, err]));
    }
  });

  router.post("/webhooks/orders-create", async (ctx) => {
      const shopDomain = ctx.request.header['x-shopify-shop-domain'];
      const order = ctx.request.body;

      try {
        const analyzeResult = await serverActionHelper.analyzeNewOrder(shopDomain, order);

        if (analyzeResult !== undefined && analyzeResult !== null) {
          switch (analyzeResult) {
            case 0:
              ctx.response.status = 200;
              ctx.response.message = 'success';
              ctx.body = {
                status: 'success'
              };
              break;
            case -1:
              ctx.response.status = 200;
              ctx.response.message = 'success';
              ctx.body = {
                status: 'success'
              };
              logger.info(stringInject.default(logMessages.serverOrderCreateDeactivated, [order.id, shopDomain]));
              break;
            case -2:
              ctx.response.status = 200;
              ctx.response.message = 'success';
              ctx.body = {
                status: 'success'
              };
              logger.info(stringInject.default(logMessages.serverOrderCreateUnpaid, [order.id, shopDomain]));
              break;
            default:
              ctx.response.status = 200;
              ctx.response.message = 'success';
              ctx.body = {
                status: 'success'
              };
              logger.error(stringInject.default(logMessages.serverOrderCreateErrorCode, [order.id, shopDomain, analyzeResult]));
              break;
          }
        } else {
          ctx.response.status = 200;
          ctx.response.message = 'success';
          ctx.body = {
            status: 'success'
          };
          logger.error(stringInject.default(logMessages.serverOrderCreateUndefined, [order.id, shopDomain]));
        }
      } catch (err) {
        ctx.response.status = 200;
        ctx.response.message = 'success';
        ctx.body = {
          status: 'success'
        };
        logger.error(stringInject.default(logMessages.serverOrderCreateCatch, [order.id, shopDomain, err]));
      }
  });

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

  router.get("(/_next/static/.*)", handleRequest); // Static content is clear
  router.get("/_next/webpack-hmr", handleRequest); // Webpack content is clear
  router.get("(.*)", verifyRequest(), handleRequest); // Everything else must have sessions

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

  server.listen(port, () => {
    logger.info(`> Ready on http://localhost:${port}`);
  });
});

sessionHandler.js

import { Session } from '@shopify/shopify-api/dist/auth/session';
const dbSetHelper = require('../../database/dbSetHelper');
const dbGetHelper = require('../../database/dbGetHelper');

class SessionHandler {

  async storeCallback(session) {
    try {
      return await dbSetHelper.saveSessionForShop(session.id, JSON.stringify(session));
    } catch (err) {
      // throw errors, and handle them gracefully in your application
      throw new Error(err)
    }
  }

  async loadCallback(id) {
    try {
      var reply = await dbGetHelper.getSessionForShop(id);
      if (reply) {

        const parsedJson = JSON.parse(reply);
        var newSession = new Session(parsedJson['id']);
        Object.entries(parsedJson).forEach(([key, value]) => newSession[key] = value);

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

  async deleteCallback(id) {
    try {
      return dbSetHelper.deleteSessionForShop(id);
    } catch (err) {
      // throw errors, and handle them gracefully in your application
      throw new Error(err)
    }
  }
}

// Export the class
module.exports = SessionHandler

_app.js

import ApolloClient from "apollo-boost";
import { ApolloProvider } from "react-apollo";
import App from "next/app";
import { AppProvider } from "@shopify/polaris";
import { Provider, useAppBridge } from "@shopify/app-bridge-react";
import { authenticatedFetch, getSessionToken } from "@shopify/app-bridge-utils";
import "@shopify/polaris/dist/styles.css";
import translations from "@shopify/polaris/locales/en.json";
import ClientRouter from "../components/ClientRouter";

function MyProvider(props) {
  const app = useAppBridge();

  const client = new ApolloClient({
    fetch: authenticatedFetch(app),
    fetchOptions: {
      credentials: "include",
    },
  });

  const Component = props.Component;

  return (
    <ApolloProvider client={client}>
      <Component {...props} />
    </ApolloProvider>
  );
}

class MyApp extends App {

  render() {
    const { Component, pageProps, shopOrigin } = this.props;
    return (
      <AppProvider i18n={translations}>
        <Provider
          config={{
            apiKey: API_KEY,
            shopOrigin: shopOrigin,
            forceRedirect: true,
          }}
        >
          <ClientRouter/>
          <MyProvider Component={Component} {...pageProps} />
        </Provider>
      </AppProvider>
    );
  }
}

MyApp.getInitialProps = async ({ ctx }) => {
  return {
    shopOrigin: ctx.query.shop,
    API_KEY: process.env.SHOPIFY_API_KEY
  };
};

export default MyApp;

index.js

import React from 'react';
import {
  Frame,
  Layout,
  Page
} from '@shopify/polaris';
import LoadingPage from '../components/common/loadingPage';
import UnpaidBanner from '../components/common/unpaidBanner';
import MainBanner from '../components/indexPage/mainBanner';
import MessageBanner from '../components/common/messageBanner';
import KeysLayout from '../components/indexPage/keysLayout';
import ActionToggle from '../components/common/actionToggle';
import ToastMessage from '../components/common/toastMessage';
import { Redirect } from '@shopify/app-bridge/actions';
import { Context } from '@shopify/app-bridge-react';
import { withTranslation } from 'react-i18next';
import '../util/i18n';
import { authenticatedFetch } from "@shopify/app-bridge-utils";

class Index extends React.Component {
  static contextType = Context;

  constructor(props) {
    super(props);
    this.state = {
      loadingPage: true,
      apiMode: false,
      sandboxBayonetKey: null,
      liveBayonetKey: null,
      sandboxJsKey: null,
      liveJsKey: null,
      loadingApiMode: false,
      loadingSandboxBayonetKey: false,
      loadingSandboxJsKey: false,
      loadingLiveBayonetKey: false,
      loadingLiveJsKey: false,
      disabledApiMode: false,
      disabledSandboxBayonetKey: false,
      disabledSandboxJsKey: false,
      disabledLiveBayonetKey: false,
      disabledLiveJsKey: false,
      showToast: false,
      toastMessage: null,
      activeMode: false,
      loadingActiveMode: false,
      disabledActiveMode: false,
      lang: false,
      loadingLang: false,
      disabledLang: false,
      disabledSandboxBayonetText: false,
      disabledLiveBayonetText: false,
      disabledSandboxJsText: false,
      disabledLiveJsText: false,
      paid: true,
      showBannerPaid: false,
      showBannerLiveKeys: true,
      loadingChargeUrl: false,
      emailage: false,
      loadingEmailage: false,
      disabledEmailage: false,
      fetchError: false,
      fetchErrorMsg: ''
    };
  }

  async componentDidMount() {
    const paidStatus = await this.makeGetRequest('/api/bayonet/get-paid-status/');
    const configData = await this.makeGetRequest('/api/bayonet/get-app-metadata/');
    const currentLanguage = await this.makeGetRequest('/api/bayonet/get-current-language/');
    const emailageStatus = await this.makeGetRequest('/api/bayonet/get-emailage-status/');

    if (paidStatus && Object.keys(paidStatus).length > 0 && paidStatus.data !== undefined && paidStatus.data !== null &&
      configData && Object.keys(configData).length > 0 && configData.data && Object.keys(configData.data).length > 0 &&
      currentLanguage && Object.keys(currentLanguage).length > 0 && currentLanguage.data &&
      emailageStatus && Object.keys(emailageStatus).length > 0 && emailageStatus.data !== undefined && emailageStatus.data !== null) {
      this.setState({
        activeMode: configData.data[0] === 1 ? true : false,
        apiMode: configData.data[1] === 1 ? true : false,
        sandboxBayonetKey: configData.data[2] === '0' ? null : '**********',
        sandboxJsKey: configData.data[3] === '0' ? null : '**********',
        liveBayonetKey: configData.data[4] === '0' ? null : '**********',
        liveJsKey: configData.data[5] === '0' ? null : '**********',
        loadingPage: false,
        lang: currentLanguage.data === 'es' ? true : false,
        emailage: emailageStatus.data === 1 ? true : false,
        disabledEmailage: emailageStatus.data === 1 ? true : false
      });

      if (configData.data[4] !== '0' && configData.data[5] !== '0') {
        this.setState({ showBannerLiveKeys: false });
      }
      this.props.i18n.changeLanguage(currentLanguage.data);

      if (paidStatus.data === 0) {
        this.setState({
          disabledApiMode: true,
          disabledSandboxBayonetKey: true,
          disabledSandboxJsKey: true,
          disabledLiveBayonetKey: true,
          disabledLiveJsKey: true,
          disabledActiveMode: true,
          disabledSandboxBayonetText: true,
          disabledLiveBayonetText: true,
          disabledSandboxJsText: true,
          disabledLiveJsText: true,
          disabledEmailage: true,
          paid: false,
          showBannerPaid: true,
        });
      }
    } else if (!currentLanguage || Object.keys(currentLanguage).length === 0 || !currentLanguage.data) {
      this.enableFetchBanner('currentLanguageFetchError');
    } else if (!paidStatus || Object.keys(paidStatus).length === 0 ||
      paidStatus.data === undefined || paidStatus.data === null) {
      this.props.i18n.changeLanguage(currentLanguage.data);
      this.enableFetchBanner('paidFetchError');
    } else if (!configData || Object.keys(configData).length === 0 ||
      !configData.data || Object.keys(configData.data).length === 0) {
      this.props.i18n.changeLanguage(currentLanguage.data);
      this.enableFetchBanner('configDataFetchError');
    } else if (!emailageStatus || Object.keys(emailageStatus).length === 0 ||
      emailageStatus.data === undefined || emailageStatus.data === undefined) {
      this.props.i18n.changeLanguage(currentLanguage.data);
      this.enableFetchBanner('emailageFetchError');
    }
  }

  render() {
    const { t } = this.props;

    if (this.state.loadingPage) {
      return (
        <LoadingPage/>
      );
    }

    if (this.state.fetchError) {
      return (
        <Frame>
          <Page>
            <MessageBanner
              pMessageTitle={t("fetchErrorTitle")}
              pStatus={'critical'}
              pMessage={this.state.fetchErrorMsg}
              pShowBanner={this.state.fetchError}
            />
          </Page>
        </Frame>
      );
    }

    return (
      <Frame>
        <Page>
          <UnpaidBanner
            pShowBanner={this.state.showBannerPaid}
            pLoading={this.state.loadingChargeUrl}
            handleChargePage={this.handleRecurringChargePage.bind(this)}
          />
          <MainBanner />
          <ToastMessage
            pShowToast={this.state.showToast}
            pToastMessage={this.state.toastMessage}
            handleToast={this.toggleToast.bind(this)}
          />
          <br></br>
          <Layout>
            {/*Language Toggle*/}
            <ActionToggle
              pCurrentStatus={this.state.lang}
              pLoading={this.state.loadingLang}
              pDisabled={this.state.disabledLang}
              handlerFunction={this.handleLang.bind(this)}
              pTitle={t("langSectionTitle")}
              pDescription={t("langSectionDescription")}
              pLabel={t("langStatusLabel")}
              pButtonText={this.state.lang ? t('langButtonEn') : t('langButtonEs')}
              pLegendText={this.state.lang ? t('langStatusEs') : t('langStatusEn')}
            />
            {/*Active Mode Toggle*/}
            <ActionToggle
              pCurrentStatus={this.state.activeMode}
              pLoading={this.state.loadingActiveMode}
              pDisabled={this.state.disabledActiveMode}
              handlerFunction={this.handleActiveMode.bind(this)}
              pTitle={t("activateSectionTitle")}
              pDescription={t("activateSectionDescription")}
              pLabel={t("activateStatusLabel")}
              pButtonText={this.state.activeMode ? t('deactivateApp') : t('activateApp')}
              pLegendText={this.state.activeMode ? t('activated') : t('deactivated')}
            />
            {/*API Mode Toggle*/}
            <ActionToggle
              pCurrentStatus={this.state.apiMode}
              pLoading={this.state.loadingApiMode}
              pDisabled={this.state.disabledApiMode}
              handlerFunction={this.handleApiMode.bind(this)}
              pTitle={t("apiModeSectionTitle")}
              pDescription={t("apiModeSectionDescription")}
              pLabel={t("apiModeStatusLabel")}
              pButtonText={this.state.apiMode ? t('apiButtonSandbox') : t('apiButtonLive')}
              pLegendText={this.state.apiMode ? t('apiStatusLive') : t('apiStatusSandbox')}
            />
            <KeysLayout
              //sandbox keys
              pSandboxBayonetKey={this.state.sandboxBayonetKey}
              pSandboxJsKey={this.state.sandboxJsKey}
              pLoadingSandboxBayonetKey={this.state.loadingSandboxBayonetKey}
              pLoadingSandboxJsKey={this.state.loadingSandboxJsKey}
              pDisabledSandboxBayonetKey={this.state.disabledSandboxBayonetKey}
              pDisabledSandboxJsKey={this.state.disabledSandboxJsKey}
              pDisabledSandboxBayonetText={this.state.disabledSandboxBayonetText}
              pDisabledSandboxJsText={this.state.pDisabledSandboxJsText}
              handleSandboxBayonetKey={this.handleSandboxBayonetKey.bind(this)}
              handleSandboxJsKey={this.handleSandboxJsKey.bind(this)}
              //live keys
              pLiveBayonetKey={this.state.liveBayonetKey}
              pLiveJsKey={this.state.liveJsKey}
              pLoadingLiveBayonetKey={this.state.loadingLiveBayonetKey}
              pLoadingLiveJsKey={this.state.loadingLiveJsKey}
              pDisabledLiveBayonetKey={this.state.disabledLiveBayonetKey}
              pDisabledLiveJsKey={this.state.disabledLiveJsKey}
              pDisabledLiveBayonetText={this.state.disabledLiveBayonetText}
              pDisabledLiveJsText={this.state.pDisabledLiveJsText}
              handleLiveBayonetKey={this.handleLiveBayonetKey.bind(this)}
              handleLiveJsKey={this.handleLiveJsKey.bind(this)}
              pShowBanner={this.state.showBannerLiveKeys}
              //common props
              handleChange={this.handleChange.bind(this)}
              handleLabelAction={this.handleLabelAction.bind(this)}
            />
            {/*Emailage Toggle*/}
            <ActionToggle
              pCurrentStatus={this.state.emailage}
              pLoading={this.state.loadingEmailage}
              pDisabled={this.state.disabledEmailage}
              handlerFunction={this.handleEmailage.bind(this)}
              pTitle={t("emailageSectionTitle")}
              pDescription={t("emailageSectionDescription")}
              pLabel={t("emailageStatusLabel")}
              pButtonText={this.state.emailage ? t('emailageButtonRequested') : t('emailageButtonNotRequested')}
              pLegendText={this.state.emailage ? t('emailageStatusRequested') : t('emailageStatusNotRequested')}
            />
          </Layout>
        </Page>
      </Frame>
    );
  }

makeGetRequest = async (endpoint) => {
    const app = this.context;
    const result = await authenticatedFetch(app)(endpoint,
      {
        method: 'GET',
        headers: {
          'Accept': 'application/json',
        },
      })
      .then(resp => resp.json())
      .then(json => {
        return json;
      });

    return result;
  };

  makePutPostRequest = async (endpoint, method, requestBody) => {
    const app = this.context;
    const result = await authenticatedFetch(app)(endpoint,
      {
        method: method,
        headers: {
          'Accept': 'application/json',
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(requestBody)
      })
      .then(resp => resp.json())
      .then(json => {
        return json;
      });

    return result;
  };
}

export default withTranslation()(Index);
paulomarg commented 3 years ago

Hey @ilugobayo, I think you're getting that error because when you call your /api endpoints that use verifyRequest, it's actually returning an HTTP 302 when a session expires (you can probably follow the ngrok logs to check that), which breaks if you're using authenticatedFetch. To properly handle expired sessions, the app needs to handle that case by calling an App Bridge Redirect action to /auth, rather than that 302 redirect.

This was a bug in our example app, which was recently fixed (in koa-shopify-auth v4.1.2+). You can fix it by doing the following:

  1. Passing in the returnHeader flag when verifying the request in your /api endpoints, like verifyRequest({ returnHeader: true }), as in: https://github.com/Shopify/shopify-app-node/blob/master/server/server.js#L93
  2. Handling that header in your client side (probably in make*Request). You can see how we handled that in our example app: https://github.com/Shopify/shopify-app-node/blob/master/pages/_app.js#L11

Could you please try that out to see if it fixes your problem? If it doesn't we can investigate whether it's a bug in our package.

Hope this helps!

ilugobayo commented 3 years ago

Hello again @paulomarg

I tried what you suggested without success, I added the headers option in my endpoints verifyRequest({ returnHeader: true }) and tried to get the X-Shopify-API-Request-Failure-Reauthorize header in the frontend and it always gives me null:

makeGetRequest = async (endpoint) => {
    const app = this.context;
    const result = await authenticatedFetch(app)(endpoint,
      {
        method: 'GET',
        headers: {
          'Accept': 'application/json',
        },
      })
      .then(resp => console.log(resp.headers.get("X-Shopify-API-Request-Failure-Reauthorize")))
      .then(json => {
        return json;
      });

    return result;
  };

Screenshot from 2021-04-09 13-56-25

Screenshot from 2021-04-09 14-04-37

Also, my ngrok log shows 200 for every request

Screenshot from 2021-04-09 14-11-24

I even checked if that header was present where I make the request to the Shopify Admin, which is something like this:

async function getShopMetafields(shopDomain, accessToken) {
  try {
    const metafields = await fetch(`https://${shopDomain}/admin/api/${process.env.API_VERSION}/metafields.json`,
      {
        method: 'GET',
        headers: {
          'Content-Type': 'application/json',
          'X-Shopify-Access-Token': accessToken
        },
      })
      .then(response =>
        response.json().then(data => ({
          data: data,
          limitHeader: response.headers.get('X-Shopify-Shop-Api-Call-Limit')
        },
        console.log(response.headers.get("X-Shopify-API-Request-Failure-Reauthorize")))
        ).then(res => {
          var container = [];
          const bits = res.limitHeader.split("/");
          container.push(res.data);
          if (bits.length === 2) {
            container.push(bits[0] / bits[1]);
          }
          return container;
        }));

    return metafields;
  } catch (err) {
    logger.error(stringInject.default(logMessages.serverGHelperShopMetafieldsCatch, [shopDomain, err]));
  }
}

but since the accessToken is invalid, I get no response, therefore, no header, tried this as well with Postman, same result.

Last night I made a more structured comment after my debugging, maybe that can give you more details about my issue, here.

Do you think that the session token not actually being expired is affecting? I mean, its expire date is still in the future, therefore, it technically isn't expired yet and maybe the verifyRequest() function considers it a valid session.

paulomarg commented 3 years ago

Hey @ilugobayo, I think you're absolutely right - the session isn't expired for us in this scenario, even though the access token is no longer valid, so we're not triggering the re-auth.

We'll fix this, great catch!

ilugobayo commented 3 years ago

Thank you @paulomarg! I'll be waiting for the update!

michelarteta commented 3 years ago

@paulomarg any update on this issue? Do you have a timeline?

imran-arshad commented 3 years ago

Hi guys, any updates on when this fix will be merged?

nolandg commented 3 years ago

Is the workflow and basic app skeleton presented here way off the beaten path for "normal" "modern" Shopify apps? What's the current recommended way of doing things? I'm running into exactly these problems and confused how any app is working using the current examples. Is everyone just using offline session when they "shouldn't" be? Or is this Koa stuff just a weird side shoot and isn't really given much love by Shopify and we should find something else?

nolandg commented 3 years ago

I think my comment here is relevent to this? https://github.com/Shopify/koa-shopify-auth/issues/64#issuecomment-822932605

nolandg commented 3 years ago

My current workaround that appears to be working is modifying the modified fetch function as below.

The problem though is this is pretty bad ux for my customer. My app is usually interacted with by arriving via an admin link. If the token is invalid for whatever reason (user logged out then back in again) my app appears to be broken on the first use. My customer clicks on Orders, finds the order they want, clicks More Actions, clicks Generate Load Slip, gets to my app. My app's frontend graphql request gets a 401, redirects to /auth and then dumps the user at my apps /index page and my app has no idea what admin link was originally clicked or for what Order. The app bridge redirect doesn't allow query parameters and I don't even know if the auth flow would preserve them. And on the backend, the koa-shopify-auth takes over the /auth endpoint so I can't really control what's sent back to the redirect unless I override that.

What is the solution to redirecting after authenticating? Am I missing something really obvious here? Thanks!

import { authenticatedFetch } from '@shopify/app-bridge-utils';
import { Redirect } from '@shopify/app-bridge/actions';

const userLoggedInFetch = (app) => {
  const fetchFunction = authenticatedFetch(app);

  return async (uri, options) => {
    const response = await fetchFunction(uri, options);

    if (
      response.headers.get('X-Shopify-API-Request-Failure-Reauthorize') === '1'
      || response.status === 401 // <-------------- catches the 401s
    ) {
      const authUrlHeader = response.headers.get(
        'X-Shopify-API-Request-Failure-Reauthorize-Url',
      );

      const redirect = Redirect.create(app);
      redirect.dispatch(Redirect.Action.APP, authUrlHeader || `/auth`);
      return null;
    }

    return response;
  };
};

export { userLoggedInFetch };
adiled commented 3 years ago

@nolandg the last diagnosis is it, the app department pushes for no cookie implementation for new apps, while it's a new release for node stack and there's basically one maintainer on this middleware. I've been here for months, unable to ship the app because of this SPOF.

mllemango commented 3 years ago

Hi all, I've deployed #94 with version 4.1.3, please upgrade and try the fix! Let us know if you still run into any issues.